Skip to content

Commit d8551e3

Browse files
committed
refactor(publish): stage to os.tmpdir() before pnpm publish
Working tree never mutates during publish; the staged copy is what `pnpm publish` runs against. Eliminates a class of "interrupted publish leaves dirty git status" incidents: - Run `pnpm publish:ci` against the live tree. - Operator hits Ctrl-C mid-publish (or runner times out). - Old behavior: tree was being modified in-place; recovery awkward. - New behavior: tmpdir cleanup unconditional via try/finally + SIGINT/SIGTERM signal handlers; tree stays clean throughout. Switches from `npm publish` to `pnpm publish` (matches the fleet's package manager). Adds two flags required for tmpdir publishing: - `--no-git-checks`: the staged tmpdir has no git history; pnpm's default would refuse to publish without one. - `--ignore-scripts`: the prepublishOnly guard in package.json exists to refuse direct `pnpm publish` runs from the working tree. The orchestrated publish already validated upstream, so the guard's purpose is moot for the staged copy. Local validated: `node scripts/publish.mts --dry-run --force` runs through cleanly with working tree staying clean throughout.
1 parent 4535ed5 commit d8551e3

1 file changed

Lines changed: 102 additions & 29 deletions

File tree

scripts/publish.mts

Lines changed: 102 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
*/
66

77
import { existsSync, promises as fs } from 'node:fs'
8+
import os from 'node:os'
89
import path from 'node:path'
910
import process from 'node:process'
1011
import { fileURLToPath } from 'node:url'
1112

1213
import type { FlagValues } from '@socketsecurity/lib/argv/flags'
1314
import { parseArgs } from '@socketsecurity/lib/argv/parse'
15+
import { safeDelete, safeDeleteSync } from '@socketsecurity/lib/fs'
1416
import type { Logger } from '@socketsecurity/lib/logger'
1517
import { getDefaultLogger } from '@socketsecurity/lib/logger'
1618
import type {
@@ -233,6 +235,34 @@ async function validateBuildArtifacts(): Promise<boolean> {
233235
/**
234236
* Publish a single package.
235237
*/
238+
/**
239+
* Stage publishable files into a fresh os.tmpdir() subdir. Returns
240+
* the path of the staged copy. The caller publishes from there
241+
* instead of the working tree, so an interrupted publish leaves
242+
* `git status` clean.
243+
*/
244+
async function stageForPublish(): Promise<string> {
245+
const stageRoot = await fs.mkdtemp(
246+
path.join(os.tmpdir(), `socket-packageurl-js-publish-${process.pid}-`),
247+
)
248+
await fs.cp(rootPath, stageRoot, {
249+
recursive: true,
250+
dereference: true,
251+
filter: src => {
252+
const base = path.basename(src)
253+
return (
254+
base !== 'node_modules' &&
255+
base !== '.git' &&
256+
base !== '.gitignore' &&
257+
base !== '.gitkeep' &&
258+
!base.startsWith('.pnpm') &&
259+
base !== 'pnpm-lock.yaml'
260+
)
261+
},
262+
})
263+
return stageRoot
264+
}
265+
236266
async function publishPackage(options: PublishOptions = {}): Promise<boolean> {
237267
const { access = 'public', dryRun = false, otp, tag = 'latest' } = options
238268

@@ -253,42 +283,85 @@ async function publishPackage(options: PublishOptions = {}): Promise<boolean> {
253283
}
254284
logger.done('Version check complete')
255285

256-
// Prepare publish args.
257-
const publishArgs: string[] = ['publish', '--access', access, '--tag', tag]
258-
259-
// Add provenance attestation in CI only. `npm publish --provenance`
260-
// requires the GitHub Actions OIDC id-token endpoint; running locally
261-
// fails with "Provenance generation in GitHub Actions requires
262-
// 'id-token: write' permission". Gated so local non-dry-run publishes
263-
// (emergency cases) still work.
264-
if (!dryRun && process.env['GITHUB_ACTIONS'] === 'true') {
265-
publishArgs.push('--provenance')
286+
// Stage to os.tmpdir() so the working tree never mutates during
287+
// publish. Cleanup is unconditional via try/finally + signal
288+
// handlers — a SIGINT mid-publish leaves no residue.
289+
logger.progress('Staging package contents')
290+
const stageRoot = await stageForPublish()
291+
const cleanup = (): void => {
292+
try {
293+
safeDeleteSync(stageRoot)
294+
} catch {
295+
/* swallow during teardown */
296+
}
266297
}
298+
process.once('SIGINT', () => {
299+
logger.warn('SIGINT — cleaning up staging root')
300+
cleanup()
301+
process.exit(130)
302+
})
303+
process.once('SIGTERM', () => {
304+
logger.warn('SIGTERM — cleaning up staging root')
305+
cleanup()
306+
process.exit(143)
307+
})
308+
logger.done(`Staged to ${stageRoot}`)
267309

268-
if (dryRun) {
269-
publishArgs.push('--dry-run')
270-
}
310+
try {
311+
// Prepare publish args. Use pnpm publish (matches the fleet's
312+
// package manager) with --no-git-checks (the staged tmpdir has
313+
// no git history) and --ignore-scripts (the source's
314+
// prepublishOnly guard exists to refuse direct working-tree
315+
// publishes; this orchestrated publish is the legitimate path).
316+
const publishArgs: string[] = [
317+
'publish',
318+
'--access',
319+
access,
320+
'--tag',
321+
tag,
322+
'--no-git-checks',
323+
'--ignore-scripts',
324+
]
325+
326+
// Add provenance attestation in CI only. `pnpm publish
327+
// --provenance` requires the GitHub Actions OIDC id-token
328+
// endpoint; running locally fails with "Provenance generation
329+
// in GitHub Actions requires 'id-token: write' permission".
330+
// Gated so local non-dry-run publishes (emergency cases) still
331+
// work.
332+
if (!dryRun && process.env['GITHUB_ACTIONS'] === 'true') {
333+
publishArgs.push('--provenance')
334+
}
271335

272-
if (otp) {
273-
publishArgs.push('--otp', otp)
274-
}
336+
if (dryRun) {
337+
publishArgs.push('--dry-run')
338+
}
275339

276-
// Publish.
277-
logger.progress(dryRun ? 'Running dry-run publish' : 'Publishing to npm')
278-
const publishCode: number = await runCommand('npm', publishArgs)
340+
if (otp) {
341+
publishArgs.push('--otp', otp)
342+
}
279343

280-
if (publishCode !== 0) {
281-
logger.failed('Publish failed')
282-
return false
283-
}
344+
// Publish from the staged copy, not the working tree.
345+
logger.progress(dryRun ? 'Running dry-run publish' : 'Publishing to npm')
346+
const publishCode: number = await runCommand('pnpm', publishArgs, {
347+
cwd: stageRoot,
348+
})
284349

285-
if (dryRun) {
286-
logger.done('Dry-run publish complete')
287-
} else {
288-
logger.done(`Published ${packageName}@${version} to npm`)
289-
}
350+
if (publishCode !== 0) {
351+
logger.failed('Publish failed')
352+
return false
353+
}
290354

291-
return true
355+
if (dryRun) {
356+
logger.done('Dry-run publish complete')
357+
} else {
358+
logger.done(`Published ${packageName}@${version} to npm`)
359+
}
360+
361+
return true
362+
} finally {
363+
await safeDelete(stageRoot)
364+
}
292365
}
293366

294367
/**

0 commit comments

Comments
 (0)