Repository navigation
ci: publish releases from GitHub Actions via npm trusted publishing #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 || '' }}" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
|
|
||
| # 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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})` : ''}`); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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}`); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a tag points at a commit whose init template is invalid, this sequence proceeds from the demo typecheck directly to
check:syncand publishing, even though.github/workflows/ci.ymlalso runs the template TypeScript check andscripts/check-template-scaffold.mjsisolation check. Those checks catch template dependency and packed-scaffold failures that the core/demo checks cannot, so the workflow's stated defense against releasing an unverified tag still permits publishing a brokenopencanva init; include both existing CI steps before the publish steps.Useful? React with 👍 / 👎.