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
72 changes: 72 additions & 0 deletions .github/workflows/release.yml
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
Comment on lines +55 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Run the template release gates before publishing

When a tag points at a commit whose init template is invalid, this sequence proceeds from the demo typecheck directly to check:sync and publishing, even though .github/workflows/ci.yml also runs the template TypeScript check and scripts/check-template-scaffold.mjs isolation 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 broken opencanva init; include both existing CI steps before the publish steps.

Useful? React with 👍 / 👎.


- name: Check the tag matches the package versions
run: node scripts/check-release-versions.mjs "${{ github.ref_type == 'tag' && github.ref_name || '' }}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require manual releases to target a version tag

When workflow_dispatch is launched with a branch selected, github.ref_type is not tag, so this expression passes an empty string and check-release-versions.mjs deliberately skips tag validation; checkout and both publish steps then operate on that branch's HEAD. The suggested half-finished-release path can therefore publish an untagged version—or the next version after main has advanced—instead of resuming the intended release. Require a tag ref or explicit version-tag input and validate it before publishing.

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
45 changes: 45 additions & 0 deletions scripts/check-release-versions.mjs
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})` : ''}`);
50 changes: 50 additions & 0 deletions scripts/publish-if-needed.mjs
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}`);
48 changes: 48 additions & 0 deletions scripts/wait-for-registry.mjs
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);
Loading