From 8cd0013d11bf643811932b89514ffc7e6cdca7b5 Mon Sep 17 00:00:00 2001 From: Bruno <281018252+bruno-archastro@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:07:57 +0000 Subject: [PATCH] Derive OpenCode package release versions from CLI tags --- .github/workflows/publish-opencode.yml | 30 +++--- scripts/opencode-release-version.mjs | 38 ++++++++ tools/plugin-gen/README.md | 38 +++++--- tools/plugin-gen/test/release.test.ts | 130 +++++++++++++++++++++++++ 4 files changed, 204 insertions(+), 32 deletions(-) create mode 100644 scripts/opencode-release-version.mjs create mode 100644 tools/plugin-gen/test/release.test.ts diff --git a/.github/workflows/publish-opencode.yml b/.github/workflows/publish-opencode.yml index 64e6363..c9f83b2 100644 --- a/.github/workflows/publish-opencode.yml +++ b/.github/workflows/publish-opencode.yml @@ -1,9 +1,8 @@ name: Publish OpenCode plugin -# The ArchDev CLI release creates the v tag on main. This publishes -# the generated plugins/opencode package as @archastro/archdev-opencode at the -# same version. Tags named archdev-old-v (the TypeScript CLI) do not -# match. +# The ArchDev CLI release creates the v tag on main without changing +# the plugin catalog. Publish the tested bundle at the tag's version, preserving +# the catalog version in source. archdev-old-v tags do not match. on: push: @@ -18,6 +17,9 @@ on: permissions: contents: read +env: + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} + concurrency: group: publish-opencode-${{ github.ref }} cancel-in-progress: false @@ -30,24 +32,11 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - - name: Require a release commit from main and a tag matching the catalog - env: - DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} + - name: Require a release commit from main, consistent source, and a valid version tag run: | git fetch --no-tags origin main git merge-base --is-ancestor "$GITHUB_SHA" origin/main - node --input-type=module <<'JS' - import assert from 'node:assert/strict'; - import { readFileSync } from 'node:fs'; - const catalog = JSON.parse(readFileSync('catalog/archdev.json', 'utf8')); - const pkg = JSON.parse(readFileSync('plugins/opencode/package.json', 'utf8')); - assert.equal(pkg.name, '@archastro/archdev-opencode'); - assert.equal(pkg.version, catalog.version, 'plugins/opencode is out of date with the catalog'); - assert.match(pkg.version, /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/); - if (process.env.DRY_RUN !== 'true' || process.env.GITHUB_REF_TYPE === 'tag') { - assert.equal(process.env.GITHUB_REF, `refs/tags/v${pkg.version}`, 'Publishing requires a tag matching catalog/archdev.json'); - } - JS + node scripts/opencode-release-version.mjs test: needs: validate @@ -72,6 +61,9 @@ jobs: package-manager-cache: false - name: Pin npm with OIDC support run: npm install --global npm@11.19.0 + - name: Prepare the npm artifact version from the release tag + working-directory: . + run: node scripts/opencode-release-version.mjs --prepare - name: Use an unpublished version for dry-run registry checks if: github.event_name == 'workflow_dispatch' && inputs.dry_run run: | diff --git a/scripts/opencode-release-version.mjs b/scripts/opencode-release-version.mjs new file mode 100644 index 0000000..d328a9b --- /dev/null +++ b/scripts/opencode-release-version.mjs @@ -0,0 +1,38 @@ +// Run from the repository root. Validate committed source before stamping the +// npm artifact; CLI release tags do not update the plugin catalog on main. +import assert from 'node:assert/strict'; +import { readFileSync, writeFileSync } from 'node:fs'; + +assert.ok(process.argv.length === 2 || (process.argv.length === 3 && process.argv[2] === '--prepare'), + 'Usage: node scripts/opencode-release-version.mjs [--prepare]'); + +const numeric = '(?:0|[1-9]\\d*)'; +const prerelease = '(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)'; +// Absolute end, including for refs containing a trailing newline. Build metadata +// is deliberately excluded, as npm does not distinguish it in package versions. +const semver = new RegExp(`^${numeric}\\.${numeric}\\.${numeric}(?:-${prerelease}(?:\\.${prerelease})*)?(?![\\s\\S])`); +const catalog = JSON.parse(readFileSync('catalog/archdev.json', 'utf8')); +const packagePath = 'plugins/opencode/package.json'; +const pkg = JSON.parse(readFileSync(packagePath, 'utf8')); +assert.equal(pkg.name, '@archastro/archdev-opencode', 'Unexpected OpenCode package name'); +assert.equal(pkg.version, catalog.version, 'plugins/opencode is out of date with the catalog'); +assert.match(catalog.version, semver, 'Invalid catalog version'); + +const ref = process.env.GITHUB_REF || ''; +let version; +if (process.env.GITHUB_REF_TYPE === 'tag') { + assert.ok(ref.startsWith('refs/tags/v'), 'Publishing requires a version tag: refs/tags/v'); + version = ref.slice('refs/tags/v'.length); + assert.match(version, semver, 'Publishing requires a version tag: refs/tags/v'); +} else { + assert.ok(process.env.DRY_RUN === 'true' && process.env.GITHUB_REF_TYPE === 'branch' && ref.startsWith('refs/heads/'), + 'Publishing requires a version tag; only branch dry runs may use the catalog version'); + version = catalog.version; +} + +if (process.argv[2] === '--prepare') { + // Only the artifact's package version changes. Source consistency is checked + // against the catalog above, and the generated bundle is tested before this. + writeFileSync(packagePath, JSON.stringify({ ...pkg, version }, null, 2) + '\n'); +} +console.log(version); diff --git a/tools/plugin-gen/README.md b/tools/plugin-gen/README.md index 6a4bd14..f49c9e5 100644 --- a/tools/plugin-gen/README.md +++ b/tools/plugin-gen/README.md @@ -25,24 +25,36 @@ npm run e2e # installs each plugin with the real harness CLI (local; skip ``` The hook wiring mirrors the CLI's `src/ts/archdev/src/reviews/hooks/harnesses.ts` -in firstlanding: `hooks.spec`, the actions and the matchers must match the -release named by `version`. +in firstlanding: `hooks.spec`, the actions and the matchers must be supported +by the tagged CLI. The catalog's `version` versions the generated bundle; +it does not need to advance for CLI releases that leave that bundle unchanged. ## Release -Set `version` in the catalog to the CLI release, run `npm run build`, and -merge before the CLI release tags `v` on main. Install commands pin -that tag. +The CLI release process creates `v` on this repository's current main +without updating the catalog. Install commands pin that tag. Change the catalog +and run `npm run build` when the bundle needs updating, not for every CLI tag. -The tag push runs `.github/workflows/publish-opencode.yml`, which checks that -the tag matches the catalog, reruns the plugin CI, and publishes -`plugins/opencode` as `@archastro/archdev-opencode@` through npm -trusted publishing with provenance. Run it by hand with `dry_run` to pack and -validate without publishing. +The tag push runs `.github/workflows/publish-opencode.yml`. It requires a +commit from main, a valid version tag, and committed package/catalog consistency, +then reruns the plugin CI. Only after those checks pass does +`scripts/opencode-release-version.mjs --prepare` set the npm artifact's version +to the release tag's version. The catalog and bundled code/skills stay unchanged. +It publishes `@archastro/archdev-opencode@` through npm trusted +publishing with provenance. -One-time npm setup by an `@archastro` owner: publish the first version by hand -(`npm publish plugins/opencode --access public` from the release tag), then -connect this workflow with +Run the workflow by hand with `dry_run` to pack and validate without publishing. +On a branch, dry runs use the catalog version; on a tag, they use the tag version. +Both get an unpublished `-dryrun..` version before registry checks. +Non-dry-run publication requires a tag. Rerunning an old failed run still uses +that tag's old workflow; merging a workflow fix applies to future release tags, +not retroactively to existing tags. + +One-time npm setup by an `@archastro` owner: from the repository root at the +release tag, run `node scripts/opencode-release-version.mjs --prepare` with +`GITHUB_REF=refs/tags/v` and `GITHUB_REF_TYPE=tag`, then publish the +prepared package (`npm publish ./plugins/opencode --access public`). Connect +this workflow with `npm trust github @archastro/archdev-opencode --repository ArchAstro/archdev --file publish-opencode.yml` or the package's Trusted Publisher settings on npmjs.com. diff --git a/tools/plugin-gen/test/release.test.ts b/tools/plugin-gen/test/release.test.ts new file mode 100644 index 0000000..c84bae2 --- /dev/null +++ b/tools/plugin-gen/test/release.test.ts @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { after, test } from "node:test"; +import { fileURLToPath } from "node:url"; + +const repoRoot = fileURLToPath(new URL("../../../", import.meta.url)); +const script = path.join(repoRoot, "scripts/opencode-release-version.mjs"); +const scratch = mkdtempSync(path.join(tmpdir(), "archdev-opencode-release-")); +after(() => rmSync(scratch, { recursive: true, force: true })); + +function fixture(packageVersion = "0.49.7", catalogVersion = "0.49.7", name = "@archastro/archdev-opencode") { + const root = mkdtempSync(path.join(scratch, "fixture-")); + mkdirSync(path.join(root, "catalog")); + mkdirSync(path.join(root, "plugins/opencode/skills"), { recursive: true }); + writeFileSync(path.join(root, "catalog/archdev.json"), JSON.stringify({ version: catalogVersion })); + writeFileSync(path.join(root, "plugins/opencode/package.json"), JSON.stringify({ + name, version: packageVersion, type: "module", files: ["index.js", "skills"], + }, null, 2) + "\n"); + writeFileSync(path.join(root, "plugins/opencode/index.js"), "export const ArchDevPlugin = () => {};\n"); + writeFileSync(path.join(root, "plugins/opencode/skills/SKILL.md"), "# Fixture skill\n"); + return root; +} + +function resolve(root: string, env: Record = {}, prepare = false) { + return spawnSync(process.execPath, [script, ...(prepare ? ["--prepare"] : [])], { + cwd: root, + encoding: "utf8", + env: { ...process.env, GITHUB_REF: "refs/tags/v0.54.2", GITHUB_REF_TYPE: "tag", DRY_RUN: "false", ...env }, + }); +} + +function contents(root: string) { + return ["catalog/archdev.json", "plugins/opencode/package.json", "plugins/opencode/index.js", "plugins/opencode/skills/SKILL.md"] + .map((file) => readFileSync(path.join(root, file), "utf8")); +} + +test("CLI release tags can advance without a catalog bump, without changing source during validation", () => { + const root = fixture(); + const before = contents(root); + const result = resolve(root); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), "0.54.2"); + assert.deepEqual(contents(root), before); +}); + +test("preparation changes only the npm artifact version, including when versions already match", () => { + for (const version of ["0.49.7", "0.54.2"]) { + const root = fixture(version, version); + const before = contents(root); + const result = resolve(root, {}, true); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), "0.54.2"); + const after = contents(root); + assert.equal(after[0], before[0], "catalog must remain unchanged"); + assert.deepEqual(after.slice(2), before.slice(2), "bundled code and skills must remain unchanged"); + assert.deepEqual(JSON.parse(after[1]!), { ...JSON.parse(before[1]!), version: "0.54.2" }); + } +}); + +test("prerelease tags supply the full npm version", () => { + const result = resolve(fixture(), { GITHUB_REF: "refs/tags/v0.55.0-rc.1" }, true); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), "0.55.0-rc.1"); +}); + +test("branch dispatch is permitted only for a dry run and uses the catalog version", () => { + const root = fixture(); + const result = resolve(root, { GITHUB_REF: "refs/heads/main", GITHUB_REF_TYPE: "branch", DRY_RUN: "true" }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), "0.49.7"); + const refused = resolve(root, { GITHUB_REF: "refs/heads/main", GITHUB_REF_TYPE: "branch" }, true); + assert.notEqual(refused.status, 0); + assert.match(refused.stderr, /Publishing requires a version tag/); +}); + +test("tag dispatch dry runs still use the tag version", () => { + const result = resolve(fixture(), { DRY_RUN: "true" }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), "0.54.2"); +}); + +test("invalid release refs are refused before touching the package, even in dry runs", () => { + for (const ref of ["refs/heads/main", "refs/tags/vfoo", "refs/tags/v1.2", "refs/tags/v01.2.3", "refs/tags/v1.2.3-01", "refs/tags/v1.2.3-", "refs/tags/v1.2.3\n", "refs/tags/archdev-old-v0.54.2"]) { + const root = fixture(); + const before = contents(root); + const result = resolve(root, { GITHUB_REF: ref, DRY_RUN: "true" }, true); + assert.notEqual(result.status, 0, ref); + assert.deepEqual(contents(root), before); + } +}); + +test("missing GitHub refs cannot bypass tag validation", () => { + for (const env of [{ GITHUB_REF: "" }, { GITHUB_REF_TYPE: "", DRY_RUN: "true", GITHUB_REF: "" }]) { + const result = resolve(fixture(), env); + assert.notEqual(result.status, 0, result.stdout); + } +}); + +test("source package identity, catalog consistency, and valid catalog versions remain required", () => { + for (const [pkgVersion, catalogVersion, name, error] of [ + ["0.49.6", "0.49.7", "@archastro/archdev-opencode", /out of date with the catalog/], + ["0.49.7", "0.49.7", "another-package", /Unexpected OpenCode package name/], + ["not-a-version", "not-a-version", "@archastro/archdev-opencode", /Invalid catalog version/], + ] as const) { + const root = fixture(pkgVersion, catalogVersion, name); + const before = contents(root); + const result = resolve(root, {}, true); + assert.notEqual(result.status, 0); + assert.match(result.stderr, error); + assert.deepEqual(contents(root), before); + } +}); + +test("the packed npm artifact carries the release version, not the catalog version", () => { + const root = fixture(); + const prepared = resolve(root, {}, true); + assert.equal(prepared.status, 0, prepared.stderr); + const packed = spawnSync("npm", ["pack", "--json", "--ignore-scripts", "--cache", path.join(scratch, "npm-cache")], { + cwd: path.join(root, "plugins/opencode"), encoding: "utf8", + }); + assert.equal(packed.status, 0, packed.stderr); + const result = JSON.parse(packed.stdout)[0]; + assert.equal(result.name, "@archastro/archdev-opencode"); + assert.equal(result.version, "0.54.2"); + assert.equal(result.filename, "archastro-archdev-opencode-0.54.2.tgz"); + assert.ok(result.files.some((file: { path: string }) => file.path === "skills/SKILL.md")); +});