From d8551e3dbb746c5ff51c2358c5942ca02eef2f24 Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 1 May 2026 12:52:41 -0400 Subject: [PATCH 1/2] 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. --- scripts/publish.mts | 131 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 102 insertions(+), 29 deletions(-) diff --git a/scripts/publish.mts b/scripts/publish.mts index 4587bf40..09761030 100644 --- a/scripts/publish.mts +++ b/scripts/publish.mts @@ -5,12 +5,14 @@ */ import { existsSync, promises as fs } from 'node:fs' +import os from 'node:os' import path from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' import type { FlagValues } from '@socketsecurity/lib/argv/flags' import { parseArgs } from '@socketsecurity/lib/argv/parse' +import { safeDelete, safeDeleteSync } from '@socketsecurity/lib/fs' import type { Logger } from '@socketsecurity/lib/logger' import { getDefaultLogger } from '@socketsecurity/lib/logger' import type { @@ -233,6 +235,34 @@ async function validateBuildArtifacts(): Promise { /** * Publish a single package. */ +/** + * Stage publishable files into a fresh os.tmpdir() subdir. Returns + * the path of the staged copy. The caller publishes from there + * instead of the working tree, so an interrupted publish leaves + * `git status` clean. + */ +async function stageForPublish(): Promise { + const stageRoot = await fs.mkdtemp( + path.join(os.tmpdir(), `socket-packageurl-js-publish-${process.pid}-`), + ) + await fs.cp(rootPath, stageRoot, { + recursive: true, + dereference: true, + filter: src => { + const base = path.basename(src) + return ( + base !== 'node_modules' && + base !== '.git' && + base !== '.gitignore' && + base !== '.gitkeep' && + !base.startsWith('.pnpm') && + base !== 'pnpm-lock.yaml' + ) + }, + }) + return stageRoot +} + async function publishPackage(options: PublishOptions = {}): Promise { const { access = 'public', dryRun = false, otp, tag = 'latest' } = options @@ -253,42 +283,85 @@ async function publishPackage(options: PublishOptions = {}): Promise { } logger.done('Version check complete') - // Prepare publish args. - const publishArgs: string[] = ['publish', '--access', access, '--tag', tag] - - // Add provenance attestation in CI only. `npm publish --provenance` - // requires the GitHub Actions OIDC id-token endpoint; running locally - // fails with "Provenance generation in GitHub Actions requires - // 'id-token: write' permission". Gated so local non-dry-run publishes - // (emergency cases) still work. - if (!dryRun && process.env['GITHUB_ACTIONS'] === 'true') { - publishArgs.push('--provenance') + // Stage to os.tmpdir() so the working tree never mutates during + // publish. Cleanup is unconditional via try/finally + signal + // handlers — a SIGINT mid-publish leaves no residue. + logger.progress('Staging package contents') + const stageRoot = await stageForPublish() + const cleanup = (): void => { + try { + safeDeleteSync(stageRoot) + } catch { + /* swallow during teardown */ + } } + process.once('SIGINT', () => { + logger.warn('SIGINT — cleaning up staging root') + cleanup() + process.exit(130) + }) + process.once('SIGTERM', () => { + logger.warn('SIGTERM — cleaning up staging root') + cleanup() + process.exit(143) + }) + logger.done(`Staged to ${stageRoot}`) - if (dryRun) { - publishArgs.push('--dry-run') - } + try { + // Prepare publish args. Use pnpm publish (matches the fleet's + // package manager) with --no-git-checks (the staged tmpdir has + // no git history) and --ignore-scripts (the source's + // prepublishOnly guard exists to refuse direct working-tree + // publishes; this orchestrated publish is the legitimate path). + const publishArgs: string[] = [ + 'publish', + '--access', + access, + '--tag', + tag, + '--no-git-checks', + '--ignore-scripts', + ] + + // Add provenance attestation in CI only. `pnpm publish + // --provenance` requires the GitHub Actions OIDC id-token + // endpoint; running locally fails with "Provenance generation + // in GitHub Actions requires 'id-token: write' permission". + // Gated so local non-dry-run publishes (emergency cases) still + // work. + if (!dryRun && process.env['GITHUB_ACTIONS'] === 'true') { + publishArgs.push('--provenance') + } - if (otp) { - publishArgs.push('--otp', otp) - } + if (dryRun) { + publishArgs.push('--dry-run') + } - // Publish. - logger.progress(dryRun ? 'Running dry-run publish' : 'Publishing to npm') - const publishCode: number = await runCommand('npm', publishArgs) + if (otp) { + publishArgs.push('--otp', otp) + } - if (publishCode !== 0) { - logger.failed('Publish failed') - return false - } + // Publish from the staged copy, not the working tree. + logger.progress(dryRun ? 'Running dry-run publish' : 'Publishing to npm') + const publishCode: number = await runCommand('pnpm', publishArgs, { + cwd: stageRoot, + }) - if (dryRun) { - logger.done('Dry-run publish complete') - } else { - logger.done(`Published ${packageName}@${version} to npm`) - } + if (publishCode !== 0) { + logger.failed('Publish failed') + return false + } - return true + if (dryRun) { + logger.done('Dry-run publish complete') + } else { + logger.done(`Published ${packageName}@${version} to npm`) + } + + return true + } finally { + await safeDelete(stageRoot) + } } /** From 812bceb24e8c77de52d45cd429230b2b7328b45f Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 1 May 2026 12:57:47 -0400 Subject: [PATCH 2/2] chore(publish): add publishConfig {access:public, provenance:true} Same shape as socket-lib + socket-tui. Pins provenance to the package manifest so it survives any future direct publish path. --- package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/package.json b/package.json index c66d2115..9430d63c 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,10 @@ "version": "1.4.2", "packageManager": "pnpm@11.0.3", "license": "MIT", + "publishConfig": { + "access": "public", + "provenance": true + }, "description": "Socket.dev optimized package override for packageurl-js", "keywords": [ "Socket.dev",