From bdaaa61348ef665e0c74d9e98590dd6c9a2555f4 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Fri, 21 Aug 2026 20:31:16 -0700 Subject: [PATCH 1/4] chore(release): freecut-editor-surface 0.3.2 Version bump so the 0.3.2 npmjs publish is reproducible from a real repo commit (provenance gate). Content delta vs 0.3.1 is PR #13: host bridge round-trip stability, host-mode timeline shortcuts, and the host-mode cross-origin audio fix. --- packages/freecut-editor/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/freecut-editor/package.json b/packages/freecut-editor/package.json index a702a3d65..a15ee71d8 100644 --- a/packages/freecut-editor/package.json +++ b/packages/freecut-editor/package.json @@ -1,6 +1,6 @@ { "name": "@quantfive/freecut-editor-surface", - "version": "0.3.1", + "version": "0.3.2", "description": "The host-backed FreeCut browser editor surface.", "license": "MIT", "repository": { From e445b29f595feef1f37e61d311821ac0c92c116b Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Fri, 21 Aug 2026 21:09:11 -0700 Subject: [PATCH 2/4] chore(release): document and script the manual npmjs release path The package manifest keeps publishConfig pointed at GitHub Packages (the CI tag/dispatch target, enforced by package-editor-surface.mjs), but CodePress consumes @quantfive/freecut-editor-surface from npmjs and the repo has no NPM_TOKEN secret, so npmjs releases are a manual maintainer step. Add scripts/publish-editor-surface-npmjs.mjs (root script publish:editor-surface:npmjs) that runs the provenance + deterministic pack + fresh-consumer smoke preflight and then publishes the exact tarball to https://registry.npmjs.org with --access=public; --dry-run validates without publishing. Document both release targets in the package README and refresh the provenance package.json checksums. --- package.json | 9 +-- packages/freecut-editor/README.md | 36 ++++++++++-- provenance/dependency-inventory.json | 2 +- provenance/freecut-baseline.json | 2 +- scripts/publish-editor-surface-npmjs.mjs | 72 ++++++++++++++++++++++++ 5 files changed, 109 insertions(+), 12 deletions(-) create mode 100644 scripts/publish-editor-surface-npmjs.mjs diff --git a/package.json b/package.json index 1f84b65e2..35ff051a8 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,12 @@ "verify:provenance": "node scripts/package-reproducible.mjs --verify-only", "package:reproducible": "node scripts/package-reproducible.mjs", "package:editor-surface": "node scripts/package-editor-surface.mjs", + "publish:editor-surface:npmjs": "node scripts/publish-editor-surface-npmjs.mjs", "build:perf": "vp build --mode perf", - "lint": "vp lint src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs scripts/test-editor-surface-consumer.mjs scripts/qa-head-binding.mjs scripts/qa-browser-check.mjs scripts/qa-redaction-check.mjs scripts/qa-redaction-check.test.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts", - "lint:fix": "vp lint src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs scripts/test-editor-surface-consumer.mjs scripts/qa-head-binding.mjs scripts/qa-browser-check.mjs scripts/qa-redaction-check.mjs scripts/qa-redaction-check.test.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts --fix", - "format": "vp fmt src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs scripts/test-editor-surface-consumer.mjs scripts/qa-head-binding.mjs scripts/qa-browser-check.mjs scripts/qa-redaction-check.mjs scripts/qa-redaction-check.test.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts package.json packages/freecut-editor/package.json .oxlintrc.json .oxfmtrc.json", - "format:check": "vp fmt src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs scripts/test-editor-surface-consumer.mjs scripts/qa-head-binding.mjs scripts/qa-browser-check.mjs scripts/qa-redaction-check.mjs scripts/qa-redaction-check.test.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts package.json packages/freecut-editor/package.json .oxlintrc.json .oxfmtrc.json --check", + "lint": "vp lint src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs scripts/publish-editor-surface-npmjs.mjs scripts/test-editor-surface-consumer.mjs scripts/qa-head-binding.mjs scripts/qa-browser-check.mjs scripts/qa-redaction-check.mjs scripts/qa-redaction-check.test.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts", + "lint:fix": "vp lint src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs scripts/publish-editor-surface-npmjs.mjs scripts/test-editor-surface-consumer.mjs scripts/qa-head-binding.mjs scripts/qa-browser-check.mjs scripts/qa-redaction-check.mjs scripts/qa-redaction-check.test.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts --fix", + "format": "vp fmt src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs scripts/publish-editor-surface-npmjs.mjs scripts/test-editor-surface-consumer.mjs scripts/qa-head-binding.mjs scripts/qa-browser-check.mjs scripts/qa-redaction-check.mjs scripts/qa-redaction-check.test.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts package.json packages/freecut-editor/package.json .oxlintrc.json .oxfmtrc.json", + "format:check": "vp fmt src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs scripts/publish-editor-surface-npmjs.mjs scripts/test-editor-surface-consumer.mjs scripts/qa-head-binding.mjs scripts/qa-browser-check.mjs scripts/qa-redaction-check.mjs scripts/qa-redaction-check.test.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts package.json packages/freecut-editor/package.json .oxlintrc.json .oxfmtrc.json --check", "check:boundaries": "node scripts/check-feature-boundaries.mjs", "check:deps-contracts": "node scripts/check-deps-contract-boundaries.mjs", "check:legacy-lib-imports": "node scripts/check-legacy-lib-imports.mjs", diff --git a/packages/freecut-editor/README.md b/packages/freecut-editor/README.md index 3565a6d37..53199138e 100644 --- a/packages/freecut-editor/README.md +++ b/packages/freecut-editor/README.md @@ -46,12 +46,16 @@ npm ci --ignore-scripts npm run package:editor-surface ``` -The command writes a deterministic tarball to `artifacts/`. The package is -published to the `quantfive` GitHub Packages npm registry by the -manual/tagged workflow in `.github/workflows/publish-editor-surface.yml`. -The first GitHub Packages publication defaults to private; verify that -visibility remains private and grant `quantfive/codepress` read access under -**Manage Actions access** before CodePress installs it. +The command writes a deterministic tarball to `artifacts/`. There are two +release targets for that tarball: + +**GitHub Packages (CI).** The default target. `publishConfig.registry` in +this manifest points at `https://npm.pkg.github.com` (enforced by +`scripts/package-editor-surface.mjs`), and the manual/tagged workflow in +`.github/workflows/publish-editor-surface.yml` publishes there with its +`GITHUB_TOKEN`. The first GitHub Packages publication defaults to private; +verify that visibility remains private and grant `quantfive/codepress` read +access under **Manage Actions access** before CodePress installs it. For an authorized local publication, authenticate with a GitHub classic PAT that has `write:packages` and run: @@ -74,6 +78,26 @@ CodePress should route the `@quantfive` scope to GitHub Packages and provide //npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN} ``` +**npmjs (manual, CodePress consumption).** CodePress installs this package +from the public npm registry, and this repository has no `NPM_TOKEN` secret, +so npmjs releases are a manual maintainer step run from a clean checkout of +the merged staging commit: + +```bash +npm ci --ignore-scripts +npm run publish:editor-surface:npmjs +``` + +`scripts/publish-editor-surface-npmjs.mjs` runs the preflight (provenance +verification, deterministic pack, and a fresh-consumer install + smoke of the +exact tarball) and then publishes +`artifacts/freecut-editor-surface-.tgz` to +`https://registry.npmjs.org` with `--access=public`, using the maintainer's +local npm auth. The registry override is command-line only: the manifest +keeps targeting GitHub Packages and the CI path is unchanged. Run +`npm run publish:editor-surface:npmjs -- --dry-run` to validate without +publishing. Versions 0.3.1 and 0.3.2 are released this way. + It can then install the exact published version and keep it pinned in its lockfile: diff --git a/provenance/dependency-inventory.json b/provenance/dependency-inventory.json index 01634da1d..570d16641 100644 --- a/provenance/dependency-inventory.json +++ b/provenance/dependency-inventory.json @@ -3,7 +3,7 @@ "generatedFrom": "package.json", "packageName": "freecut", "packageVersion": "0.0.0", - "packageJsonSha256": "d585b800f4a701f45a5415e588c282fbb9067cbebc78316add16397052a6a493", + "packageJsonSha256": "fc5f67bdb87dd2cd411e2ab83c05c4066547c6db9be8130420ffec6864e04da1", "lockfile": { "path": "package-lock.json", "lockfileVersion": 3, diff --git a/provenance/freecut-baseline.json b/provenance/freecut-baseline.json index 421eb09c2..70f749370 100644 --- a/provenance/freecut-baseline.json +++ b/provenance/freecut-baseline.json @@ -34,7 +34,7 @@ ], "dependencies": { "packageJson": "package.json", - "packageJsonSha256": "d585b800f4a701f45a5415e588c282fbb9067cbebc78316add16397052a6a493", + "packageJsonSha256": "fc5f67bdb87dd2cd411e2ab83c05c4066547c6db9be8130420ffec6864e04da1", "lockfile": "package-lock.json", "lockfileVersion": 3, "lockfileSha256": "b4a86741ce7891da1f63df01b6fdd4ed507e8887d5097c6a0f93fc2ea6f3420e", diff --git a/scripts/publish-editor-surface-npmjs.mjs b/scripts/publish-editor-surface-npmjs.mjs new file mode 100644 index 000000000..402148061 --- /dev/null +++ b/scripts/publish-editor-surface-npmjs.mjs @@ -0,0 +1,72 @@ +import fs from 'node:fs' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const PACKAGE_ROOT = path.join(ROOT, 'packages/freecut-editor') +const packageJson = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')) +const NPMJS_REGISTRY = 'https://registry.npmjs.org' +const artifactName = `freecut-editor-surface-${packageJson.version}.tgz` +const artifactPath = path.join(ROOT, 'artifacts', artifactName) +const dryRun = process.argv.includes('--dry-run') + +function fail(message) { + throw new Error(`[editor-surface-npmjs] ${message}`) +} + +function assertCondition(condition, message) { + if (!condition) fail(message) +} + +function run(command, args) { + const result = spawnSync(command, args, { + cwd: ROOT, + env: { ...process.env, TZ: 'UTC', LC_ALL: 'C' }, + stdio: 'inherit', + }) + assertCondition(result.status === 0, `${command} ${args.join(' ')} failed`) +} + +function npmCommand() { + return process.platform === 'win32' ? 'npm.cmd' : 'npm' +} + +// Manual public-npm release path. The package manifest intentionally keeps +// publishConfig pointed at the private GitHub Packages registry (enforced by +// scripts/package-editor-surface.mjs); this script overrides the registry on +// the command line only, so the CI tag/dispatch path is untouched. CodePress +// consumes the package from npmjs. +function main() { + assertCondition( + packageJson.name === '@quantfive/freecut-editor-surface', + 'unexpected package name', + ) + // Preflight: provenance inventories, deterministic pack, and a fresh + // consumer install+smoke of the exact tarball (the consumer script runs + // package:editor-surface itself). + run(npmCommand(), ['run', 'verify:provenance']) + run(npmCommand(), ['run', 'test:editor-surface:consumer']) + assertCondition(fs.existsSync(artifactPath), `artifact does not exist: ${artifactPath}`) + + const args = [ + 'publish', + artifactPath, + `--registry=${NPMJS_REGISTRY}`, + // The scoped package must stay publicly installable for CodePress; the + // flag applies to this publish only and does not modify the manifest. + '--access=public', + ] + if (dryRun) args.push('--dry-run') + run(npmCommand(), args) + console.log( + `[editor-surface-npmjs] ${dryRun ? 'dry-run validated' : 'published'} ${packageJson.name}@${packageJson.version} -> ${NPMJS_REGISTRY}`, + ) +} + +try { + main() +} catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 +} From c821b57d1e2cec83ab0eb73b03f2726a2eea49ae Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Fri, 21 Aug 2026 21:20:52 -0700 Subject: [PATCH 3/4] chore(release): make public npmjs the canonical release target Per maintainer direction the editor surface is a public package now. publishConfig targets https://registry.npmjs.org with public access and package-editor-surface.mjs enforces exactly that; the tag/dispatch workflow publishes to npmjs with NODE_AUTH_TOKEN from the NPM_TOKEN repo secret (a repo admin must add it before tag publishes work). The manual maintainer path (publish:editor-surface:npmjs) stays as the fallback and drops its registry/access overrides now that the manifest is canonical. README release docs updated; GitHub Packages is no longer a target. --- .github/workflows/publish-editor-surface.yml | 18 ++++-- packages/freecut-editor/README.md | 61 +++++++------------- packages/freecut-editor/package.json | 3 +- scripts/package-editor-surface.mjs | 6 +- scripts/publish-editor-surface-npmjs.mjs | 31 +++++----- 5 files changed, 56 insertions(+), 63 deletions(-) diff --git a/.github/workflows/publish-editor-surface.yml b/.github/workflows/publish-editor-surface.yml index f8f95d9e7..a72ef986a 100644 --- a/.github/workflows/publish-editor-surface.yml +++ b/.github/workflows/publish-editor-surface.yml @@ -1,5 +1,12 @@ name: Publish FreeCut editor surface +# Publishes @quantfive/freecut-editor-surface to the public npmjs registry +# (the canonical release target; publishConfig in packages/freecut-editor +# points there and scripts/package-editor-surface.mjs enforces it). Requires +# an NPM_TOKEN repo secret (npm automation token with publish rights on the +# @quantfive scope); until a repo admin adds it, use the manual maintainer +# path instead: npm run publish:editor-surface:npmjs + on: workflow_dispatch: push: @@ -34,7 +41,7 @@ jobs: with: node-version: '22.14.0' cache: npm - registry-url: https://npm.pkg.github.com + registry-url: https://registry.npmjs.org - name: Select pinned npm run: npm install --global npm@11.8.0 @@ -69,13 +76,16 @@ jobs: test -f "${artifact}" npm run test:editor-surface:consumer -- --artifact "${artifact}" - - name: Publish private package to GitHub Packages + - name: Publish public package to npmjs shell: bash env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | set -euo pipefail package_version="$(node -p "require('./packages/freecut-editor/package.json').version")" artifact="artifacts/freecut-editor-surface-${package_version}.tgz" test -f "${artifact}" - npm publish "./${artifact}" --registry=https://npm.pkg.github.com + # publishConfig in the manifest already targets npmjs with public + # access; the explicit flags keep this step robust against manifest + # drift (the pack gate also asserts them). + npm publish "./${artifact}" --registry=https://registry.npmjs.org --access=public diff --git a/packages/freecut-editor/README.md b/packages/freecut-editor/README.md index 53199138e..b4d11284e 100644 --- a/packages/freecut-editor/README.md +++ b/packages/freecut-editor/README.md @@ -46,42 +46,22 @@ npm ci --ignore-scripts npm run package:editor-surface ``` -The command writes a deterministic tarball to `artifacts/`. There are two -release targets for that tarball: - -**GitHub Packages (CI).** The default target. `publishConfig.registry` in -this manifest points at `https://npm.pkg.github.com` (enforced by -`scripts/package-editor-surface.mjs`), and the manual/tagged workflow in -`.github/workflows/publish-editor-surface.yml` publishes there with its -`GITHUB_TOKEN`. The first GitHub Packages publication defaults to private; -verify that visibility remains private and grant `quantfive/codepress` read -access under **Manage Actions access** before CodePress installs it. - -For an authorized local publication, authenticate with a GitHub classic PAT -that has `write:packages` and run: - -```bash -NODE_AUTH_TOKEN="$GITHUB_CLASSIC_PAT" npm publish \ - artifacts/freecut-editor-surface-0.3.0.tgz \ - --registry=https://npm.pkg.github.com -``` - -Do not commit a token. The workflow uses its `GITHUB_TOKEN` with -`packages: write`; CodePress CI uses its `GITHUB_TOKEN` with `packages: read` -after the repository has been granted package access. - -CodePress should route the `@quantfive` scope to GitHub Packages and provide -`NODE_AUTH_TOKEN` in CI: - -```ini -@quantfive:registry=https://npm.pkg.github.com -//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN} -``` - -**npmjs (manual, CodePress consumption).** CodePress installs this package -from the public npm registry, and this repository has no `NPM_TOKEN` secret, -so npmjs releases are a manual maintainer step run from a clean checkout of -the merged staging commit: +The command writes a deterministic tarball to `artifacts/`. The canonical +release target is the **public npmjs registry**: `publishConfig` in this +manifest points at `https://registry.npmjs.org` with `"access": "public"` +(enforced by `scripts/package-editor-surface.mjs`). GitHub Packages is no +longer a release target for this package. + +**CI (tag or manual dispatch).** The workflow in +`.github/workflows/publish-editor-surface.yml` verifies provenance, builds +the deterministic tarball, smoke-tests it as an installed consumer, and +publishes to npmjs with `NODE_AUTH_TOKEN` from the `NPM_TOKEN` repo secret +(npm automation token with publish rights on the `@quantfive` scope). A repo +admin must add that secret before tag publishes work; until then, use the +manual path below. + +**Manual (maintainer).** Run from a clean checkout of the merged staging +commit with local npm auth: ```bash npm ci --ignore-scripts @@ -91,14 +71,13 @@ npm run publish:editor-surface:npmjs `scripts/publish-editor-surface-npmjs.mjs` runs the preflight (provenance verification, deterministic pack, and a fresh-consumer install + smoke of the exact tarball) and then publishes -`artifacts/freecut-editor-surface-.tgz` to -`https://registry.npmjs.org` with `--access=public`, using the maintainer's -local npm auth. The registry override is command-line only: the manifest -keeps targeting GitHub Packages and the CI path is unchanged. Run +`artifacts/freecut-editor-surface-.tgz` to npmjs. Run `npm run publish:editor-surface:npmjs -- --dry-run` to validate without publishing. Versions 0.3.1 and 0.3.2 are released this way. -It can then install the exact published version and keep it pinned in its +Do not commit a token. + +Consumers install the exact published version and keep it pinned in their lockfile: ```bash diff --git a/packages/freecut-editor/package.json b/packages/freecut-editor/package.json index a15ee71d8..9b590e293 100644 --- a/packages/freecut-editor/package.json +++ b/packages/freecut-editor/package.json @@ -29,7 +29,8 @@ "./package.json": "./package.json" }, "publishConfig": { - "registry": "https://npm.pkg.github.com" + "access": "public", + "registry": "https://registry.npmjs.org" }, "devDependencies": { "@testing-library/jest-dom": "6.9.1", diff --git a/scripts/package-editor-surface.mjs b/scripts/package-editor-surface.mjs index cb78143fd..3e09024af 100644 --- a/scripts/package-editor-surface.mjs +++ b/scripts/package-editor-surface.mjs @@ -87,10 +87,10 @@ function verifyPackageInputs() { assertCondition(packageJson.name === '@quantfive/freecut-editor-surface', 'unexpected package name') assertCondition(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(packageJson.version), 'package version must be semver') assertCondition( - publishConfig.registry === 'https://npm.pkg.github.com', - 'package must target the private GitHub Packages npm registry', + publishConfig.registry === 'https://registry.npmjs.org', + 'package must target the public npmjs registry', ) - assertCondition(publishConfig.access !== 'public', 'package must not enable public npm access') + assertCondition(publishConfig.access === 'public', 'package must enable public npm access') assertCondition(packageJson.exports?.['./style.css'] === './dist/style.css', 'style export must be stable') assertCondition(packageJson.peerDependencies?.react, 'React must remain a peer dependency') assertCondition(packageJson.peerDependencies?.['react-dom'], 'React DOM must remain a peer dependency') diff --git a/scripts/publish-editor-surface-npmjs.mjs b/scripts/publish-editor-surface-npmjs.mjs index 402148061..849702e20 100644 --- a/scripts/publish-editor-surface-npmjs.mjs +++ b/scripts/publish-editor-surface-npmjs.mjs @@ -32,16 +32,26 @@ function npmCommand() { return process.platform === 'win32' ? 'npm.cmd' : 'npm' } -// Manual public-npm release path. The package manifest intentionally keeps -// publishConfig pointed at the private GitHub Packages registry (enforced by -// scripts/package-editor-surface.mjs); this script overrides the registry on -// the command line only, so the CI tag/dispatch path is untouched. CodePress -// consumes the package from npmjs. -function main() { +// Manual public-npmjs release path (fallback for the tag/dispatch CI +// workflow, which needs the NPM_TOKEN repo secret). publishConfig in the +// manifest is the canonical npmjs target (enforced by +// scripts/package-editor-surface.mjs); this script adds the release +// preflight and publishes the exact smoke-tested tarball with the +// maintainer's local npm auth. +function assertPublicNpmjsTarget() { assertCondition( packageJson.name === '@quantfive/freecut-editor-surface', 'unexpected package name', ) + assertCondition( + packageJson.publishConfig?.registry === NPMJS_REGISTRY && + packageJson.publishConfig?.access === 'public', + 'publishConfig must target the public npmjs registry', + ) +} + +function main() { + assertPublicNpmjsTarget() // Preflight: provenance inventories, deterministic pack, and a fresh // consumer install+smoke of the exact tarball (the consumer script runs // package:editor-surface itself). @@ -49,14 +59,7 @@ function main() { run(npmCommand(), ['run', 'test:editor-surface:consumer']) assertCondition(fs.existsSync(artifactPath), `artifact does not exist: ${artifactPath}`) - const args = [ - 'publish', - artifactPath, - `--registry=${NPMJS_REGISTRY}`, - // The scoped package must stay publicly installable for CodePress; the - // flag applies to this publish only and does not modify the manifest. - '--access=public', - ] + const args = ['publish', artifactPath] if (dryRun) args.push('--dry-run') run(npmCommand(), args) console.log( From 27133c50f5768f6a96c8ab4dbc23f03c67fdb6b6 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Fri, 21 Aug 2026 21:55:11 -0700 Subject: [PATCH 4/4] chore(release): guard npmjs publish to the merged staging revision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer finding: the manual publish script rebuilt and preflighted without checking the worktree or release ref, so uncommitted source could ship as the official public version. The script now requires an explicit --ref (or --ref HEAD) and refuses before any build step — in dry-run mode too — unless the tree is clean, HEAD equals --ref, and --ref is an ancestor of origin/staging. Guard behavior is covered by scripts/publish-editor-surface-npmjs.test.mjs (node --test, 7 cases: clean/match/ancestor acceptance, dirty tree, HEAD mismatch, non-staging ref, missing ref, unresolvable ref) wired as test:publish-editor-surface-guards. README documents the --ref flow; provenance checksums refreshed for the root manifest change. --- package.json | 9 ++- packages/freecut-editor/README.md | 21 ++++-- provenance/dependency-inventory.json | 2 +- provenance/freecut-baseline.json | 2 +- scripts/publish-editor-surface-npmjs.mjs | 74 +++++++++++++++++-- scripts/publish-editor-surface-npmjs.test.mjs | 69 +++++++++++++++++ 6 files changed, 157 insertions(+), 20 deletions(-) create mode 100644 scripts/publish-editor-surface-npmjs.test.mjs diff --git a/package.json b/package.json index 35ff051a8..0c4883e4e 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,12 @@ "package:reproducible": "node scripts/package-reproducible.mjs", "package:editor-surface": "node scripts/package-editor-surface.mjs", "publish:editor-surface:npmjs": "node scripts/publish-editor-surface-npmjs.mjs", + "test:publish-editor-surface-guards": "node --test scripts/publish-editor-surface-npmjs.test.mjs", "build:perf": "vp build --mode perf", - "lint": "vp lint src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs scripts/publish-editor-surface-npmjs.mjs scripts/test-editor-surface-consumer.mjs scripts/qa-head-binding.mjs scripts/qa-browser-check.mjs scripts/qa-redaction-check.mjs scripts/qa-redaction-check.test.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts", - "lint:fix": "vp lint src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs scripts/publish-editor-surface-npmjs.mjs scripts/test-editor-surface-consumer.mjs scripts/qa-head-binding.mjs scripts/qa-browser-check.mjs scripts/qa-redaction-check.mjs scripts/qa-redaction-check.test.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts --fix", - "format": "vp fmt src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs scripts/publish-editor-surface-npmjs.mjs scripts/test-editor-surface-consumer.mjs scripts/qa-head-binding.mjs scripts/qa-browser-check.mjs scripts/qa-redaction-check.mjs scripts/qa-redaction-check.test.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts package.json packages/freecut-editor/package.json .oxlintrc.json .oxfmtrc.json", - "format:check": "vp fmt src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs scripts/publish-editor-surface-npmjs.mjs scripts/test-editor-surface-consumer.mjs scripts/qa-head-binding.mjs scripts/qa-browser-check.mjs scripts/qa-redaction-check.mjs scripts/qa-redaction-check.test.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts package.json packages/freecut-editor/package.json .oxlintrc.json .oxfmtrc.json --check", + "lint": "vp lint src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs scripts/publish-editor-surface-npmjs.mjs scripts/publish-editor-surface-npmjs.test.mjs scripts/test-editor-surface-consumer.mjs scripts/qa-head-binding.mjs scripts/qa-browser-check.mjs scripts/qa-redaction-check.mjs scripts/qa-redaction-check.test.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts", + "lint:fix": "vp lint src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs scripts/publish-editor-surface-npmjs.mjs scripts/publish-editor-surface-npmjs.test.mjs scripts/test-editor-surface-consumer.mjs scripts/qa-head-binding.mjs scripts/qa-browser-check.mjs scripts/qa-redaction-check.mjs scripts/qa-redaction-check.test.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts --fix", + "format": "vp fmt src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs scripts/publish-editor-surface-npmjs.mjs scripts/publish-editor-surface-npmjs.test.mjs scripts/test-editor-surface-consumer.mjs scripts/qa-head-binding.mjs scripts/qa-browser-check.mjs scripts/qa-redaction-check.mjs scripts/qa-redaction-check.test.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts package.json packages/freecut-editor/package.json .oxlintrc.json .oxfmtrc.json", + "format:check": "vp fmt src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs scripts/publish-editor-surface-npmjs.mjs scripts/publish-editor-surface-npmjs.test.mjs scripts/test-editor-surface-consumer.mjs scripts/qa-head-binding.mjs scripts/qa-browser-check.mjs scripts/qa-redaction-check.mjs scripts/qa-redaction-check.test.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts package.json packages/freecut-editor/package.json .oxlintrc.json .oxfmtrc.json --check", "check:boundaries": "node scripts/check-feature-boundaries.mjs", "check:deps-contracts": "node scripts/check-deps-contract-boundaries.mjs", "check:legacy-lib-imports": "node scripts/check-legacy-lib-imports.mjs", diff --git a/packages/freecut-editor/README.md b/packages/freecut-editor/README.md index b4d11284e..1717c6e6e 100644 --- a/packages/freecut-editor/README.md +++ b/packages/freecut-editor/README.md @@ -61,19 +61,26 @@ admin must add that secret before tag publishes work; until then, use the manual path below. **Manual (maintainer).** Run from a clean checkout of the merged staging -commit with local npm auth: +commit with local npm auth, naming that commit explicitly: ```bash npm ci --ignore-scripts -npm run publish:editor-surface:npmjs +npm run publish:editor-surface:npmjs -- --ref ``` -`scripts/publish-editor-surface-npmjs.mjs` runs the preflight (provenance -verification, deterministic pack, and a fresh-consumer install + smoke of the -exact tarball) and then publishes +`scripts/publish-editor-surface-npmjs.mjs` refuses to package anything else: +before the preflight it requires a clean worktree (`git status --porcelain` +empty), HEAD equal to `--ref`, and `--ref` an ancestor of `origin/staging` +— so the public artifact is always reproducible from the merged staging +revision, never from uncommitted or unrelated source. The guards run in +`--dry-run` too. After the guards it runs the preflight (provenance +verification, deterministic pack, and a fresh-consumer install + smoke of +the exact tarball) and then publishes `artifacts/freecut-editor-surface-.tgz` to npmjs. Run -`npm run publish:editor-surface:npmjs -- --dry-run` to validate without -publishing. Versions 0.3.1 and 0.3.2 are released this way. +`npm run publish:editor-surface:npmjs -- --ref --dry-run` to validate +without publishing; guard behavior is covered by +`npm run test:publish-editor-surface-guards`. Versions 0.3.1 and 0.3.2 are +released this way. Do not commit a token. diff --git a/provenance/dependency-inventory.json b/provenance/dependency-inventory.json index 570d16641..5825b5a0c 100644 --- a/provenance/dependency-inventory.json +++ b/provenance/dependency-inventory.json @@ -3,7 +3,7 @@ "generatedFrom": "package.json", "packageName": "freecut", "packageVersion": "0.0.0", - "packageJsonSha256": "fc5f67bdb87dd2cd411e2ab83c05c4066547c6db9be8130420ffec6864e04da1", + "packageJsonSha256": "0fcaa186e39d48d5e4e95339c0a853543aeea2a8bbbb54691a51502eb4d3bd5b", "lockfile": { "path": "package-lock.json", "lockfileVersion": 3, diff --git a/provenance/freecut-baseline.json b/provenance/freecut-baseline.json index 70f749370..c23d79b8c 100644 --- a/provenance/freecut-baseline.json +++ b/provenance/freecut-baseline.json @@ -34,7 +34,7 @@ ], "dependencies": { "packageJson": "package.json", - "packageJsonSha256": "fc5f67bdb87dd2cd411e2ab83c05c4066547c6db9be8130420ffec6864e04da1", + "packageJsonSha256": "0fcaa186e39d48d5e4e95339c0a853543aeea2a8bbbb54691a51502eb4d3bd5b", "lockfile": "package-lock.json", "lockfileVersion": 3, "lockfileSha256": "b4a86741ce7891da1f63df01b6fdd4ed507e8887d5097c6a0f93fc2ea6f3420e", diff --git a/scripts/publish-editor-surface-npmjs.mjs b/scripts/publish-editor-surface-npmjs.mjs index 849702e20..2108be4a0 100644 --- a/scripts/publish-editor-surface-npmjs.mjs +++ b/scripts/publish-editor-surface-npmjs.mjs @@ -1,12 +1,13 @@ import fs from 'node:fs' import path from 'node:path' -import { spawnSync } from 'node:child_process' -import { fileURLToPath } from 'node:url' +import { execFileSync, spawnSync } from 'node:child_process' +import { fileURLToPath, pathToFileURL } from 'node:url' const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const PACKAGE_ROOT = path.join(ROOT, 'packages/freecut-editor') const packageJson = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')) const NPMJS_REGISTRY = 'https://registry.npmjs.org' +const STAGING_REF = 'origin/staging' const artifactName = `freecut-editor-surface-${packageJson.version}.tgz` const artifactPath = path.join(ROOT, 'artifacts', artifactName) const dryRun = process.argv.includes('--dry-run') @@ -32,6 +33,55 @@ function npmCommand() { return process.platform === 'win32' ? 'npm.cmd' : 'npm' } +function git(cwd, args) { + return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim() +} + +function resolveCommit(cwd, ref) { + try { + return git(cwd, ['rev-parse', '--verify', `${ref}^{commit}`]) + } catch { + fail(`--ref does not resolve to a commit: ${ref}`) + } +} + +/** + * Release guards: the public npmjs artifact must be packaged from the exact + * merged staging revision, never from uncommitted or unrelated source. Runs + * before any preflight/build step, in dry-run mode too. + * Exported for scripts/publish-editor-surface-npmjs.test.mjs. + */ +export function assertReleaseRevision(cwd, refArg) { + assertCondition( + typeof refArg === 'string' && refArg.length > 0, + 'release requires an explicit --ref (or --ref HEAD)', + ) + assertCondition( + git(cwd, ['status', '--porcelain']) === '', + 'working tree must be clean; refusing to package uncommitted source', + ) + const head = git(cwd, ['rev-parse', 'HEAD']) + const release = refArg === 'HEAD' ? head : resolveCommit(cwd, refArg) + assertCondition( + head === release, + `HEAD (${head}) does not match --ref ${release}; check out the release commit first`, + ) + try { + execFileSync('git', ['merge-base', '--is-ancestor', release, STAGING_REF], { cwd }) + } catch { + fail(`--ref ${release} is not an ancestor of ${STAGING_REF}; publish only the merged staging revision`) + } + return release +} + +function readArg(name) { + const index = process.argv.indexOf(name) + if (index < 0) return null + const value = process.argv[index + 1] + assertCondition(value && !value.startsWith('--'), `${name} requires a value`) + return value +} + // Manual public-npmjs release path (fallback for the tag/dispatch CI // workflow, which needs the NPM_TOKEN repo secret). publishConfig in the // manifest is the canonical npmjs target (enforced by @@ -52,6 +102,10 @@ function assertPublicNpmjsTarget() { function main() { assertPublicNpmjsTarget() + const release = assertReleaseRevision(ROOT, readArg('--ref')) + console.log( + `[editor-surface-npmjs] release revision ${release} verified (clean tree, HEAD match, ${STAGING_REF} ancestor)`, + ) // Preflight: provenance inventories, deterministic pack, and a fresh // consumer install+smoke of the exact tarball (the consumer script runs // package:editor-surface itself). @@ -67,9 +121,15 @@ function main() { ) } -try { - main() -} catch (error) { - console.error(error instanceof Error ? error.message : error) - process.exitCode = 1 +const invokedDirectly = + typeof process.argv[1] === 'string' && + import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href + +if (invokedDirectly) { + try { + main() + } catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + } } diff --git a/scripts/publish-editor-surface-npmjs.test.mjs b/scripts/publish-editor-surface-npmjs.test.mjs new file mode 100644 index 000000000..6d88bdd5c --- /dev/null +++ b/scripts/publish-editor-surface-npmjs.test.mjs @@ -0,0 +1,69 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { execFileSync } from 'node:child_process' +import { assertReleaseRevision } from './publish-editor-surface-npmjs.mjs' + +function git(cwd, args) { + return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim() +} + +function commitFile(cwd, name, content) { + fs.writeFileSync(path.join(cwd, name), content) + git(cwd, ['add', name]) + git(cwd, ['commit', '-q', '-m', name]) + return git(cwd, ['rev-parse', 'HEAD']) +} + +// Two-commit fixture: A <- B, with origin/staging pointing at B. +function initRepo() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'editor-surface-npmjs-guards-')) + git(dir, ['init', '-q', '-b', 'main']) + git(dir, ['config', 'user.email', 'test@example.com']) + git(dir, ['config', 'user.name', 'Test']) + const shaA = commitFile(dir, 'a.txt', 'a') + const shaB = commitFile(dir, 'b.txt', 'b') + git(dir, ['update-ref', 'refs/remotes/origin/staging', shaB]) + return { dir, shaA, shaB } +} + +test('accepts a clean tree whose HEAD matches --ref on origin/staging', () => { + const { dir, shaB } = initRepo() + assert.equal(assertReleaseRevision(dir, shaB), shaB) +}) + +test('accepts --ref HEAD for a clean staging-tip checkout', () => { + const { dir, shaB } = initRepo() + assert.equal(assertReleaseRevision(dir, 'HEAD'), shaB) +}) + +test('rejects a dirty worktree even when HEAD matches --ref', () => { + const { dir, shaB } = initRepo() + fs.writeFileSync(path.join(dir, 'a.txt'), 'uncommitted') + assert.throws(() => assertReleaseRevision(dir, shaB), /working tree must be clean/) +}) + +test('rejects when HEAD does not match --ref', () => { + const { dir, shaA } = initRepo() + assert.throws(() => assertReleaseRevision(dir, shaA), /does not match --ref/) +}) + +test('rejects a ref that is not an ancestor of origin/staging', () => { + const { dir } = initRepo() + git(dir, ['checkout', '-q', '--orphan', 'unrelated']) + git(dir, ['rm', '-q', '-rf', '.']) + const orphan = commitFile(dir, 'orphan.txt', 'orphan') + assert.throws(() => assertReleaseRevision(dir, orphan), /not an ancestor of origin\/staging/) +}) + +test('rejects a missing --ref argument', () => { + const { dir } = initRepo() + assert.throws(() => assertReleaseRevision(dir, null), /requires an explicit --ref/) +}) + +test('rejects an unresolvable --ref', () => { + const { dir } = initRepo() + assert.throws(() => assertReleaseRevision(dir, 'deadbeef'.repeat(5)), /does not resolve/) +})