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
30 changes: 11 additions & 19 deletions .github/workflows/publish-opencode.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
name: Publish OpenCode plugin

# The ArchDev CLI release creates the v<version> tag on main. This publishes
# the generated plugins/opencode package as @archastro/archdev-opencode at the
# same version. Tags named archdev-old-v<version> (the TypeScript CLI) do not
# match.
# The ArchDev CLI release creates the v<version> 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<version> tags do not match.

on:
push:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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: |
Expand Down
38 changes: 38 additions & 0 deletions scripts/opencode-release-version.mjs
Original file line number Diff line number Diff line change
@@ -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<semver>');
version = ref.slice('refs/tags/v'.length);
assert.match(version, semver, 'Publishing requires a version tag: refs/tags/v<semver>');
} 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);
38 changes: 25 additions & 13 deletions tools/plugin-gen/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<version>` on main. Install commands pin
that tag.
The CLI release process creates `v<version>` 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@<version>` 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@<version>` 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.<run>.<attempt>` 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<version>` 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.

Expand Down
130 changes: 130 additions & 0 deletions tools/plugin-gen/test/release.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> = {}, 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"));
});
Loading