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..1ed8566 --- /dev/null +++ b/scripts/publish-if-needed.mjs @@ -0,0 +1,50 @@ +#!/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} …`); +// --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}`); 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);