Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions .github/workflows/publish-editor-surface.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
10 changes: 6 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
60 changes: 35 additions & 25 deletions packages/freecut-editor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <merged-staging-sha>
```

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-<version>.tgz` to npmjs. Run
`npm run publish:editor-surface:npmjs -- --ref <sha> --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
Expand Down
5 changes: 3 additions & 2 deletions packages/freecut-editor/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion provenance/dependency-inventory.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"generatedFrom": "package.json",
"packageName": "freecut",
"packageVersion": "0.0.0",
"packageJsonSha256": "d585b800f4a701f45a5415e588c282fbb9067cbebc78316add16397052a6a493",
"packageJsonSha256": "0fcaa186e39d48d5e4e95339c0a853543aeea2a8bbbb54691a51502eb4d3bd5b",
"lockfile": {
"path": "package-lock.json",
"lockfileVersion": 3,
Expand Down
2 changes: 1 addition & 1 deletion provenance/freecut-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
],
"dependencies": {
"packageJson": "package.json",
"packageJsonSha256": "d585b800f4a701f45a5415e588c282fbb9067cbebc78316add16397052a6a493",
"packageJsonSha256": "0fcaa186e39d48d5e4e95339c0a853543aeea2a8bbbb54691a51502eb4d3bd5b",
"lockfile": "package-lock.json",
"lockfileVersion": 3,
"lockfileSha256": "b4a86741ce7891da1f63df01b6fdd4ed507e8887d5097c6a0f93fc2ea6f3420e",
Expand Down
6 changes: 3 additions & 3 deletions scripts/package-editor-surface.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
135 changes: 135 additions & 0 deletions scripts/publish-editor-surface-npmjs.mjs
Original file line number Diff line number Diff line change
@@ -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 <merged-staging-sha> (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
}
}
69 changes: 69 additions & 0 deletions scripts/publish-editor-surface-npmjs.test.mjs
Original file line number Diff line number Diff line change
@@ -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/)
})
Loading