Skip to content

Commit 37be948

Browse files
committed
feat(publish): adopt pnpm stage for two-step publish + cascade pnpm@11.3.0 pin
Bumps the local pin to pnpm@11.3.0 (which introduced `pnpm stage`) and wires up a two-step CI + human publish workflow: 1. CI: `pnpm run publish:stage` — uploads all 9 packages (8 per-platform shims + umbrella) to npm staging via the existing OIDC trusted-publisher token. Nothing publicly visible yet. The captured stage IDs land in .stage-publish.json (gitignored). 2. Human: `pnpm run publish:approve` — reads the manifest and calls `pnpm stage approve <id>` for each entry. pnpm interactively prompts for 2FA OTP; the registry then promotes each staged tarball to its public dist-tag. Pass --otp <code> to skip the prompt, or --reject to discard. The benefit over direct publish: if anything goes wrong with one of the 9 stage uploads, none of them are publicly visible. `pnpm stage reject` cleans up server-side without leaving partial publishes for users to trip over. Direct (`pnpm run publish`) remains available for emergency hotfixes where no human review step is wanted. scripts/publish.mts grew a `--stage` flag that swaps publish → stage publish and writes the manifest; scripts/publish-approve.mts is the new human-facing script.
1 parent c640316 commit 37be948

5 files changed

Lines changed: 373 additions & 10 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,7 @@ npm-debug.log
8282
pnpm-debug.log
8383
*.tgz
8484
# ─── END fleet-canonical ────────────────────────────────────────
85+
86+
# socket-addon: staged-publish manifest produced by `pnpm run publish:stage`
87+
# and consumed by `pnpm run publish:approve`. Locally-relevant; never tracked.
88+
/.stage-publish.json

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55
"description": "NAPI .node binaries for @socketaddon/* packages. Downloads prebuilt artifacts from socket-btm GH Releases, verifies checksums, publishes to npm.",
66
"license": "MIT",
77
"type": "module",
8-
"packageManager": "pnpm@11.1.3",
8+
"packageManager": "pnpm@11.3.0",
99
"engines": {
1010
"node": ">=18.20.8",
11-
"pnpm": ">=11.1.3"
11+
"pnpm": ">=11.3.0"
1212
},
1313
"scripts": {
1414
"build": "echo 'no build — publish-only repo'",
@@ -22,8 +22,10 @@
2222
"lint": "node scripts/lint.mts",
2323
"prepare": "node scripts/install-git-hooks.mts",
2424
"publish": "node scripts/publish.mts",
25+
"publish:approve": "node scripts/publish-approve.mts",
2526
"publish:ci": "node scripts/publish.mts --tag ${DIST_TAG:-latest}",
2627
"publish:dry": "node scripts/publish.mts --dry-run",
28+
"publish:stage": "node scripts/publish.mts --stage --tag ${DIST_TAG:-latest}",
2729
"security": "node scripts/security.mts",
2830
"setup": "node .claude/hooks/setup-security-tools/index.mts",
2931
"test": "node scripts/test.mts",

pnpm-lock.yaml

Lines changed: 58 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/publish-approve.mts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/**
2+
* Approve `@socketaddon/*` packages previously uploaded to npm staging via
3+
* `scripts/publish.mts --stage`.
4+
*
5+
* Two-step publish workflow (CI + human):
6+
* 1. CI runs `pnpm run publish -- --stage` — uploads all 9 packages to npm
7+
* staging via OIDC trusted-publisher token. The tarballs sit server-side;
8+
* nothing is publicly visible yet. The stage IDs are written to
9+
* `.stage-publish.json` (committed-tree-root, gitignored).
10+
* 2. Human runs `pnpm run publish:approve` — this script reads the manifest
11+
* and calls `pnpm stage approve <id>` per package. `pnpm stage approve`
12+
* interactively prompts for 2FA OTP; the registry then promotes each
13+
* staged tarball to its public dist-tag.
14+
*
15+
* Why split: pnpm stage requires a human 2FA approval for the promote step.
16+
* The CI workflow can't supply OTP. Splitting the publish from the approve
17+
* keeps the actual public-promotion gated on a human's 2FA token without
18+
* sacrificing the OIDC + provenance the stage-publish leg supplies.
19+
*
20+
* If anything went wrong with one of the stage uploads (wrong file, wrong
21+
* version, wrong checksum), `pnpm stage reject <id>` discards the staged
22+
* tarball without it ever becoming public. Use that instead of `approve` for
23+
* the bad ones; re-run the stage upload for those packages only.
24+
*
25+
* Usage:
26+
* pnpm run publish:approve # approve all packages in manifest
27+
* pnpm run publish:approve -- --otp 123456
28+
* # pre-supply OTP (skips interactive
29+
* # prompt). Useful when batching.
30+
* pnpm run publish:approve -- --dry-run # report what would be approved,
31+
* # skip the actual registry calls.
32+
* pnpm run publish:approve -- --reject # reject (discard) all packages in
33+
* # manifest instead of approving.
34+
*/
35+
36+
import { readFileSync, existsSync, unlinkSync } from 'node:fs'
37+
import path from 'node:path'
38+
import process from 'node:process'
39+
import { fileURLToPath } from 'node:url'
40+
41+
import { getDefaultLogger } from '@socketsecurity/lib/logger'
42+
import { spawnSync } from '@socketsecurity/lib-stable/spawn/spawn'
43+
44+
import { errorMessage } from './lib/error-utils.mts'
45+
46+
// Windows: pnpm is a .cmd shim that requires shell invocation.
47+
const useShell = process.platform === 'win32'
48+
49+
const logger = getDefaultLogger()
50+
51+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
52+
const rootDir = path.resolve(__dirname, '..')
53+
54+
interface StagedPackage {
55+
name: string
56+
version: string
57+
stageId: string
58+
}
59+
60+
interface StageManifest {
61+
tag: string
62+
stagedAt: string
63+
packages: StagedPackage[]
64+
}
65+
66+
interface ApproveArgs {
67+
dryRun: boolean
68+
otp: string | undefined
69+
reject: boolean
70+
}
71+
72+
export function parseArgs(): ApproveArgs {
73+
const args = process.argv.slice(2)
74+
let otp: string | undefined
75+
for (let i = 0; i < args.length; i += 1) {
76+
const a = args[i]!
77+
if (a.startsWith('--otp=')) {
78+
otp = a.slice('--otp='.length)
79+
} else if (a === '--otp' && i + 1 < args.length) {
80+
otp = args[i + 1]!
81+
i += 1
82+
}
83+
}
84+
return {
85+
dryRun: args.includes('--dry-run'),
86+
otp,
87+
reject: args.includes('--reject'),
88+
}
89+
}
90+
91+
export function readManifest(manifestPath: string): StageManifest {
92+
if (!existsSync(manifestPath)) {
93+
throw new Error(
94+
`Manifest not found at ${path.relative(rootDir, manifestPath)}\n` +
95+
`Run \`pnpm run publish -- --stage\` first to upload packages to staging.`,
96+
)
97+
}
98+
const raw = readFileSync(manifestPath, 'utf8')
99+
return JSON.parse(raw) as StageManifest
100+
}
101+
102+
export function runStageCommand(
103+
subcommand: 'approve' | 'reject',
104+
stageId: string,
105+
otp: string | undefined,
106+
dryRun: boolean,
107+
): void {
108+
const args = ['stage', subcommand, stageId]
109+
if (otp) {
110+
args.push('--otp', otp)
111+
}
112+
if (dryRun) {
113+
logger.info(` [dry-run] pnpm ${args.join(' ')}`)
114+
return
115+
}
116+
const r = spawnSync('pnpm', args, {
117+
cwd: rootDir,
118+
shell: useShell,
119+
stdio: 'inherit',
120+
})
121+
if (r.status !== 0) {
122+
throw new Error(
123+
`pnpm stage ${subcommand} ${stageId} exited with status ${r.status}`,
124+
)
125+
}
126+
}
127+
128+
async function main(): Promise<void> {
129+
const { dryRun, otp, reject } = parseArgs()
130+
const manifestPath = path.join(rootDir, '.stage-publish.json')
131+
const manifest = readManifest(manifestPath)
132+
133+
const verb = reject ? 'reject' : 'approve'
134+
// Past-tense: 'approved' / 'rejected'. Present-participle: 'approving' /
135+
// 'rejecting'. Both follow the same drop-final-e + add-suffix rule.
136+
const verbing = reject ? 'rejecting' : 'approving'
137+
const verbed = reject ? 'rejected' : 'approved'
138+
logger.info(
139+
`${verb}: ${manifest.packages.length} package(s) staged at ${manifest.stagedAt} for tag=${manifest.tag}` +
140+
`${dryRun ? ' (dry-run)' : ''}` +
141+
`${otp ? ' (otp-from-flag)' : ''}`,
142+
)
143+
144+
let failed = 0
145+
for (let i = 0, { length } = manifest.packages; i < length; i += 1) {
146+
const pkg = manifest.packages[i]!
147+
logger.info(
148+
` ${pkg.name}@${pkg.version}${verbing} (id: ${pkg.stageId})`,
149+
)
150+
try {
151+
runStageCommand(reject ? 'reject' : 'approve', pkg.stageId, otp, dryRun)
152+
logger.success(` ${pkg.name}@${pkg.version}${verbed}`)
153+
} catch (e) {
154+
failed += 1
155+
logger.error(` ${pkg.name}@${pkg.version} — failed: ${errorMessage(e)}`)
156+
}
157+
}
158+
159+
if (failed > 0) {
160+
throw new Error(
161+
`${failed}/${manifest.packages.length} package(s) failed to ${verb}; leaving manifest in place for retry`,
162+
)
163+
}
164+
165+
// All succeeded — remove the manifest so a stale one doesn't get
166+
// re-approved on a future run. Skip in dry-run.
167+
if (!dryRun) {
168+
unlinkSync(manifestPath)
169+
logger.info(`Removed ${path.relative(rootDir, manifestPath)}`)
170+
}
171+
}
172+
173+
main().catch((e: unknown) => {
174+
logger.error(`publish-approve: ${errorMessage(e)}`)
175+
process.exitCode = 1
176+
})

0 commit comments

Comments
 (0)