From e4f7ce9cfc4830079df0a87b1ba53ef4fc4aa96c Mon Sep 17 00:00:00 2001 From: daniellee-ux <54412342+daniellee-ux@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:11:15 +0800 Subject: [PATCH 1/2] ci: publish releases from GitHub Actions via npm trusted publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Releases needed an interactive 2FA OTP at the keyboard, so publishing could not be automated. npm's trusted publishing (OIDC) removes both the OTP and any stored NPM_TOKEN: the workflow exchanges a short-lived GitHub OIDC token for publish rights, and provenance is attested automatically. Pushing a `v*` tag cut from main now runs `.github/workflows/release.yml`, which re-runs the CI gates (publishing can't be undone, so it doesn't trust that the tag points at a green commit) and then publishes both packages in the required order, with three small scripts: - check-release-versions.mjs — tag == package versions, core and wrapper in lockstep, and the wrapper's caret range actually covers the core version being published (a stale ^0.3.0 would strand `npm create opencanva@latest` on the old core). - publish-if-needed.mjs — skips a version already on the registry, so a run that failed halfway can be re-run via workflow_dispatch. - wait-for-registry.mjs — gates the wrapper publish on core being resolvable, or `npm create opencanva@latest` fails with ETARGET in that window. One-time setup outside this repo: npmjs.com needs a Trusted Publisher on BOTH @opencanva/core and create-opencanva, pointing at this repository and the workflow filename release.yml. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 72 ++++++++++++++++++++++++++++++ scripts/check-release-versions.mjs | 45 +++++++++++++++++++ scripts/publish-if-needed.mjs | 44 ++++++++++++++++++ scripts/wait-for-registry.mjs | 48 ++++++++++++++++++++ 4 files changed, 209 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 scripts/check-release-versions.mjs create mode 100644 scripts/publish-if-needed.mjs create mode 100644 scripts/wait-for-registry.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e758b38 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,72 @@ +name: Release + +# Publishes both packages to npm with **trusted publishing** (OIDC) — no npm +# token, no 2FA OTP. npmjs.com must have a Trusted Publisher configured for +# @opencanva/core AND create-opencanva, both pointing at this repo and this +# exact workflow filename (release.yml). +# +# Trigger by pushing a version tag cut from main: git tag v0.4.0 && git push origin v0.4.0 +# (workflow_dispatch is there to re-run a half-finished release without re-tagging.) + +on: + push: + tags: ['v*'] + workflow_dispatch: + +concurrency: + group: release + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + # OIDC token for trusted publishing — without this npm falls back to + # token auth and the publish fails. + id-token: write + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + # Trusted publishing needs Node >= 22.14.0. + node-version: 22 + registry-url: https://registry.npmjs.org + cache: npm + + - name: Upgrade npm + # Node 22 ships npm 10.x; trusted publishing needs npm >= 11.5.1. + run: npm install -g npm@latest + + - name: Install dependencies + run: npm ci + + # Publishing is irreversible — re-run the same gates CI runs on main + # rather than trusting that the tag points at a green commit. + - name: Typecheck (core) + run: npm run typecheck + + - name: Typecheck (demo) + run: npx tsc --noEmit -p apps/demo + + - name: Check committed skills/template/lockfile are in sync + run: npm run check:sync + + - name: Check the tag matches the package versions + run: node scripts/check-release-versions.mjs "${{ github.ref_type == 'tag' && github.ref_name || '' }}" + + # Order matters: create-opencanva depends on @opencanva/core with a caret + # range, so the wrapper must not go out as `latest` before the core + # version it pins is resolvable — `npm create opencanva@latest` would fail + # with ETARGET. Publish core, wait for the registry, then the wrapper. + - name: Publish @opencanva/core + run: node scripts/publish-if-needed.mjs @opencanva/core + + - name: Wait for @opencanva/core on the registry + run: node scripts/wait-for-registry.mjs @opencanva/core + + - name: Publish create-opencanva + run: node scripts/publish-if-needed.mjs create-opencanva diff --git a/scripts/check-release-versions.mjs b/scripts/check-release-versions.mjs new file mode 100644 index 0000000..33d0073 --- /dev/null +++ b/scripts/check-release-versions.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node +// Release guard for .github/workflows/release.yml. A publish can't be undone, so +// assert the three things that have historically gone wrong at release time: +// +// 1. the tag says one version and package.json says another, +// 2. create-opencanva's version drifts from core's, +// 3. create-opencanva still pins the PREVIOUS core minor — a caret on ^0.3.0 +// does not cover 0.4.0, so `npm create opencanva@latest` keeps scaffolding +// the old core (see the publish-order note in AGENTS.md history). +// +// Usage: node scripts/check-release-versions.mjs [vX.Y.Z] (empty tag = skip 1) +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +const ROOT = path.resolve(import.meta.dirname, '..'); +const read = (rel) => JSON.parse(readFileSync(path.join(ROOT, rel), 'utf8')); + +const core = read('packages/core/package.json'); +const wrapper = read('packages/create-opencanva/package.json'); +const tag = (process.argv[2] ?? '').trim(); +const errors = []; + +if (tag) { + const want = tag.replace(/^v/, ''); + if (want !== core.version) errors.push(`tag ${tag} != @opencanva/core@${core.version}`); + if (want !== wrapper.version) errors.push(`tag ${tag} != create-opencanva@${wrapper.version}`); +} + +if (core.version !== wrapper.version) { + errors.push(`version drift: @opencanva/core@${core.version} vs create-opencanva@${wrapper.version}`); +} + +const range = wrapper.dependencies?.['@opencanva/core']; +if (range !== `^${core.version}`) { + errors.push( + `create-opencanva depends on @opencanva/core "${range}" — expected "^${core.version}", ` + + `or fresh projects get the old core`, + ); +} + +if (errors.length) { + console.error(`✗ release version check failed:\n - ${errors.join('\n - ')}`); + process.exit(1); +} +console.log(`✓ release versions consistent: ${core.version}${tag ? ` (tag ${tag})` : ''}`); diff --git a/scripts/publish-if-needed.mjs b/scripts/publish-if-needed.mjs new file mode 100644 index 0000000..23bb7df --- /dev/null +++ b/scripts/publish-if-needed.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +// Publishes one workspace package unless that exact version is already on the +// registry — so a release run that failed halfway (e.g. the wrapper step) can be +// re-run with workflow_dispatch without tripping over "cannot publish over the +// previously published version". +// +// Auth comes from GitHub Actions OIDC (trusted publishing): no NPM_TOKEN, no OTP. +// +// Usage: node scripts/publish-if-needed.mjs <@scope/name | name> +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +const ROOT = path.resolve(import.meta.dirname, '..'); +const DIRS = { '@opencanva/core': 'packages/core', 'create-opencanva': 'packages/create-opencanva' }; + +const name = process.argv[2]; +const dir = DIRS[name]; +if (!dir) { + console.error(`Unknown package "${name}". Known: ${Object.keys(DIRS).join(', ')}`); + process.exit(1); +} + +const { version } = JSON.parse(readFileSync(path.join(ROOT, dir, 'package.json'), 'utf8')); + +let live = null; +try { + live = execFileSync('npm', ['view', `${name}@${version}`, 'version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); +} catch { + // E404 (nothing published under this version, or the package is brand new) — + // the expected path on a real release. +} + +if (live === version) { + console.log(`↷ ${name}@${version} is already on the registry — skipping publish.`); + process.exit(0); +} + +console.log(`→ publishing ${name}@${version} …`); +execFileSync('npm', ['publish', '-w', name, '--access', 'public'], { cwd: ROOT, stdio: 'inherit' }); +console.log(`✓ published ${name}@${version}`); diff --git a/scripts/wait-for-registry.mjs b/scripts/wait-for-registry.mjs new file mode 100644 index 0000000..38cb0ed --- /dev/null +++ b/scripts/wait-for-registry.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +// Blocks until a just-published version actually resolves on the registry. +// create-opencanva pins @opencanva/core with a caret range, so publishing the +// wrapper before core is resolvable would tag a `latest` that fails to install +// (ETARGET) for anyone running `npm create opencanva@latest` in that window. +// +// Usage: node scripts/wait-for-registry.mjs <@scope/name> [attempts] +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +const ROOT = path.resolve(import.meta.dirname, '..'); +const DIRS = { '@opencanva/core': 'packages/core', 'create-opencanva': 'packages/create-opencanva' }; + +const name = process.argv[2]; +const attempts = Number(process.argv[3] ?? 20); +const dir = DIRS[name]; +if (!dir) { + console.error(`Unknown package "${name}". Known: ${Object.keys(DIRS).join(', ')}`); + process.exit(1); +} + +const { version } = JSON.parse(readFileSync(path.join(ROOT, dir, 'package.json'), 'utf8')); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +for (let i = 1; i <= attempts; i++) { + try { + // --prefer-online: skip npm's metadata cache, which would happily keep + // serving the pre-publish document for the whole loop. + const live = execFileSync('npm', ['view', `${name}@${version}`, 'version', '--prefer-online'], { + encoding: 'utf8', + // A pre-publish E404 is the normal case here — keep npm's error dump out + // of the log so the polling lines stay readable. + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + if (live === version) { + console.log(`✓ ${name}@${version} is live on the registry (attempt ${i}).`); + process.exit(0); + } + } catch { + // not there yet + } + console.log(`… waiting for ${name}@${version} (attempt ${i}/${attempts})`); + await sleep(5000); +} + +console.error(`✗ ${name}@${version} never appeared on the registry — not publishing anything that depends on it.`); +process.exit(1); From 8e72ab9ba13105dc213ccd2585b44fb79de47354 Mon Sep 17 00:00:00 2001 From: daniellee-ux <54412342+daniellee-ux@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:12:38 +0800 Subject: [PATCH 2/2] ci: pass --provenance explicitly on release publishes npm documents provenance as automatic under trusted publishing, but it is reported often enough as not applied that stating it beats discovering the gap in a failed release. Both packages' `repository` fields already point at this repo, which is what provenance verifies against. Co-Authored-By: Claude Opus 5 --- scripts/publish-if-needed.mjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/publish-if-needed.mjs b/scripts/publish-if-needed.mjs index 23bb7df..1ed8566 100644 --- a/scripts/publish-if-needed.mjs +++ b/scripts/publish-if-needed.mjs @@ -40,5 +40,11 @@ if (live === version) { } console.log(`→ publishing ${name}@${version} …`); -execFileSync('npm', ['publish', '-w', name, '--access', 'public'], { cwd: ROOT, stdio: 'inherit' }); +// --provenance is documented as implicit under trusted publishing, but reports of +// it NOT being applied are common enough that passing it explicitly is cheaper +// than a failed release. It needs package.json `repository` to match this repo. +execFileSync('npm', ['publish', '-w', name, '--access', 'public', '--provenance'], { + cwd: ROOT, + stdio: 'inherit', +}); console.log(`✓ published ${name}@${version}`);