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/package.json b/package.json index 1f84b65e2..0c4883e4e 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,13 @@ "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", + "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/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/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 3565a6d37..1717c6e6e 100644 --- a/packages/freecut-editor/README.md +++ b/packages/freecut-editor/README.md @@ -46,35 +46,45 @@ 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. - -For an authorized local publication, authenticate with a GitHub classic PAT -that has `write:packages` and run: +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, naming that commit explicitly: ```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} +npm ci --ignore-scripts +npm run publish:editor-surface:npmjs -- --ref ``` -It can then install the exact published version and keep it pinned in its +`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 -- --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. + +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 a702a3d65..9b590e293 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": { @@ -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/provenance/dependency-inventory.json b/provenance/dependency-inventory.json index 01634da1d..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": "d585b800f4a701f45a5415e588c282fbb9067cbebc78316add16397052a6a493", + "packageJsonSha256": "0fcaa186e39d48d5e4e95339c0a853543aeea2a8bbbb54691a51502eb4d3bd5b", "lockfile": { "path": "package-lock.json", "lockfileVersion": 3, diff --git a/provenance/freecut-baseline.json b/provenance/freecut-baseline.json index 421eb09c2..c23d79b8c 100644 --- a/provenance/freecut-baseline.json +++ b/provenance/freecut-baseline.json @@ -34,7 +34,7 @@ ], "dependencies": { "packageJson": "package.json", - "packageJsonSha256": "d585b800f4a701f45a5415e588c282fbb9067cbebc78316add16397052a6a493", + "packageJsonSha256": "0fcaa186e39d48d5e4e95339c0a853543aeea2a8bbbb54691a51502eb4d3bd5b", "lockfile": "package-lock.json", "lockfileVersion": 3, "lockfileSha256": "b4a86741ce7891da1f63df01b6fdd4ed507e8887d5097c6a0f93fc2ea6f3420e", 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 new file mode 100644 index 000000000..2108be4a0 --- /dev/null +++ b/scripts/publish-editor-surface-npmjs.mjs @@ -0,0 +1,135 @@ +import fs from 'node:fs' +import path from 'node:path' +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') + +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' +} + +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 +// 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() + 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). + 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] + 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}`, + ) +} + +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/) +})