From 023e012eda784e035145ac8461b96307857da930 Mon Sep 17 00:00:00 2001 From: haylan Date: Wed, 14 Oct 2026 12:15:14 +0200 Subject: [PATCH] feat: changelog-driven versioning and idempotent Gitea registry release scripts --- CHANGELOG.md | 32 +++++++++ scripts/release.ts | 150 +++++++++++++++++++++++++++++++++++++++ scripts/sync-upstream.ts | 7 +- scripts/version.ts | 9 ++- test/release.test.ts | 134 ++++++++++++++++++++++++++++++++++ 5 files changed, 326 insertions(+), 6 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 scripts/release.ts create mode 100644 test/release.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..456ae34 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +All notable changes to this project are documented in this file. The latest `## [x.y.z]` heading is +the release source of truth (scripts/release.ts). Upstream syncs record the mattpocock/skills +revision they carry. + +Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); semver, with upstream +content changes bumping minor automatically (never major). + +## [Unreleased] + +## [0.1.0] - 2026-10-14 + +### Added + +- OpenCode v2 plugin `haylan.pocock` injecting all 31 vendored Matt Pocock skills + (mattpocock/skills `49dd158`, v1.3.1) via `ctx.skill.transform`; the 16 user-invoked skills are + hidden from model auto-selection (`autoinvoke: false`) and runnable as slash commands. +- Generated V2-native agents: `pocock` (primary orchestrator running the grill → spec → tickets → + parallel-worktree-build → review → retro flow), `pocock-worker` (worktree-locked test-first + implementer with skill allow-list), `pocock-reviewer` (read-only code-review axis executor). +- `opencode-pocock init [-g] [--force]` copying agent files into `.opencode/agents/` or + `~/.config/opencode/agents/` with a version stamp the plugin checks (missing/stale agents print + a one-line fix; no silent repo writes). +- Opt-in `guardrails` plugin option porting upstream `git-guardrails-claude-code` to a v2 + `ctx.tool.hook("execute.before")` shell block (dangerous git patterns, faithful to the upstream + script). +- Nightly Gitea Actions workflow: upstream sync (early exit on no change), regeneration, format / + typecheck / unit / integration / drift tests, changelog-driven minor bump, idempotent publish to + the Gitea npm registry with tag + package link, generated changes committed back to main. +- Test suite (`node --test` on TypeScript): frontmatter parser, manifest generation, skill/agent + drift gates, plugin fake-ctx integration, init behavior, release/version helpers. diff --git a/scripts/release.ts b/scripts/release.ts new file mode 100644 index 0000000..85e6271 --- /dev/null +++ b/scripts/release.ts @@ -0,0 +1,150 @@ +// Publishes the latest CHANGELOG.md version to the Gitea npm registry without `npm publish`, tags +// the commit `v` and links the package to this repository. Every step is idempotent, so a +// rerun repairs a release that is only partly done. (Adapted from fastcrw-opencode-websearch.) +// Env: SERVER_URL, REPOSITORY (owner/repo), SHA (from the workflow), TOKEN (needs write:package +// and write:repository). +import { execFileSync } from "node:child_process" +import { createHash } from "node:crypto" +import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { latestVersion } from "./version.ts" + +export { latestVersion } + +export const encodeName = (name: string) => name.replace("/", "%2F") + +export const isPublished = (packument: { versions?: Record }, version: string) => + Boolean(packument.versions?.[version]) + +export function publishBody(pkg: { name: string; version: string }, tarball: Buffer): object { + const { name, version } = pkg + const file = `${name}-${version}.tgz` + return { + _id: name, + name, + "dist-tags": { latest: version }, + versions: { + [version]: { + ...pkg, + _id: `${name}@${version}`, + dist: { + shasum: createHash("sha1").update(tarball).digest("hex"), + integrity: `sha512-${createHash("sha512").update(tarball).digest("base64")}`, + }, + }, + }, + _attachments: { + [file]: { + content_type: "application/octet-stream", + data: tarball.toString("base64"), + length: tarball.length, + }, + }, + } +} + +type Api = { base: string; headers: Record; fetchFn?: typeof fetch } + +async function call(api: Api, method: string, path: string, body?: object) { + return (api.fetchFn ?? fetch)(`${api.base}${path}`, { + method, + headers: { ...api.headers, "Content-Type": "application/json" }, + body: body && JSON.stringify(body), + }) +} + +const fail = async (what: string, res: Response) => + new Error(`${what} failed: ${res.status} ${await res.text()}`) + +export async function ensureTag(api: Api, repo: string, version: string, sha: string) { + const tag = `v${version}` + const existing = await call(api, "GET", `/repos/${repo}/tags/${tag}`) + if (existing.ok) return "exists" + if (existing.status !== 404) throw await fail("tag lookup", existing) + const res = await call(api, "POST", `/repos/${repo}/tags`, { + tag_name: tag, + target: sha, + message: `Release ${version}`, + }) + if (!res.ok) throw await fail("tag creation", res) + return "created" +} + +export async function ensureLinked( + api: Api, + owner: string, + name: string, + version: string, + repoName: string, +) { + const pkg = `/packages/${owner}/npm/${encodeURIComponent(name)}` + const existing = await call(api, "GET", `${pkg}/${version}`) + if (!existing.ok) throw await fail("package lookup", existing) + if (((await existing.json()) as { repository?: unknown }).repository) return "already linked" + const res = await call(api, "POST", `${pkg}/-/link/${repoName}`) + if (!res.ok) throw await fail("package link", res) + return "linked" +} + +// Everything the published tarball must contain (mirrors package.json "files" + metadata). +const STAGED = ["dist", "skills", "agents", "manifest.json", "README.md", "LICENSE", "NOTICE"] + +async function main() { + const { SERVER_URL, REPOSITORY, SHA, TOKEN: token } = process.env + if (!SERVER_URL || !REPOSITORY || !SHA || !token) { + throw new Error("SERVER_URL, REPOSITORY, SHA and TOKEN must be set") + } + const [owner, repoName] = REPOSITORY.split("/") + const server = SERVER_URL.replace(/\/+$/, "") + const registry = `${server}/api/packages/${owner}/npm` + + const version = latestVersion(readFileSync("CHANGELOG.md", "utf8")) + // The version is rewritten in the staged copy only; the repo keeps its dev version. + const pkg = { ...JSON.parse(readFileSync("package.json", "utf8")), version } + const url = `${registry}/${encodeName(pkg.name)}` + const headers = { Authorization: `Bearer ${token}` } + + const existing = await fetch(url, { headers }) + if (existing.ok && isPublished(await existing.json(), version)) { + console.log(`${pkg.name}@${version} is already published, skipping publish`) + } else if (!existing.ok && existing.status !== 404) { + throw new Error(`registry lookup failed: ${existing.status} ${await existing.text()}`) + } else { + await publish(pkg, url, headers) + } + + const api = { base: `${server}/api/v1`, headers } + console.log(`tag v${version}: ${await ensureTag(api, REPOSITORY, version, SHA)}`) + console.log(`package link: ${await ensureLinked(api, owner, pkg.name, version, repoName)}`) +} + +async function publish( + pkg: { name: string; version: string }, + url: string, + headers: Record, +) { + const { version } = pkg + rmSync(".release", { recursive: true, force: true }) + mkdirSync(".release/package", { recursive: true }) + writeFileSync(".release/package/package.json", JSON.stringify(pkg, null, 2)) + for (const item of STAGED) { + cpSync(item, `.release/package/${item}`, { recursive: true }) + } + execFileSync("tar", ["-czf", "package.tgz", "-C", ".release", "package"], { cwd: "." }) + const tarball = readFileSync("package.tgz") + + const res = await fetch(url, { + method: "PUT", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify(publishBody(pkg, tarball)), + }) + if (!res.ok) throw new Error(`publish failed: ${res.status} ${await res.text()}`) + console.log(`published ${pkg.name}@${version}`) +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((err) => { + console.error(err.message) + process.exit(1) + }) +} diff --git a/scripts/sync-upstream.ts b/scripts/sync-upstream.ts index 28e566a..ffbf65b 100644 --- a/scripts/sync-upstream.ts +++ b/scripts/sync-upstream.ts @@ -155,7 +155,12 @@ async function main() { upstream_version: upstreamPkg.version, } writeFileSync(path.join(SNAPSHOT_DIR, "UPSTREAM.json"), JSON.stringify(meta, null, 2) + "\n") - const state: UpstreamState = { ...meta, synced_at: new Date().toISOString() } + const state: UpstreamState & { previous_commit?: string | null; last_diff?: SnapshotDiff } = { + ...meta, + synced_at: new Date().toISOString(), + previous_commit: recorded?.commit ?? null, + last_diff: diff, + } writeFileSync(STATE_PATH, JSON.stringify(state, null, 2) + "\n") const fresh = recordNewSkills(CLASSIFICATION_PATH, diff.added) diff --git a/scripts/version.ts b/scripts/version.ts index d441f96..07549d6 100644 --- a/scripts/version.ts +++ b/scripts/version.ts @@ -19,8 +19,8 @@ export function latestVersion(changelog: string): string { } export function bumpMinor(version: string): string { - const [major, minor, patch] = version.split(".") - if (patch.includes("-")) return `${major}.${minor}.${patch}` // prerelease: keep as-is + if (version.includes("-")) return version // prerelease: keep as-is + const [major, minor] = version.split(".") return `${major}.${Number(minor) + 1}.0` } @@ -56,7 +56,8 @@ export function insertEntry(changelog: string, entry: SyncEntry): string { const i = changelog.indexOf(marker) if (i === -1) throw new Error("CHANGELOG.md has no `## [Unreleased]` heading") const after = i + marker.length - return `${changelog.slice(0, after)}\n\n${entry.body}\n${changelog.slice(after)}` + const rest = changelog.slice(after).replace(/^\n+/, "") + return `${changelog.slice(0, after)}\n\n${entry.body}\n\n${rest}` } function main() { @@ -82,8 +83,6 @@ function main() { return } writeFileSync(changelogPath, insertEntry(changelog, entry)) - state.released_commit = target.commit - writeFileSync(path.join(root, ".opencode/state/upstream.json"), JSON.stringify(state, null, 2) + "\n") console.log(`prepared release ${entry.version} for upstream ${target.commit.slice(0, 9)}`) } diff --git a/test/release.test.ts b/test/release.test.ts new file mode 100644 index 0000000..a8d2274 --- /dev/null +++ b/test/release.test.ts @@ -0,0 +1,134 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { encodeName, ensureLinked, ensureTag, isPublished, publishBody } from "../scripts/release.ts" +import { buildEntry, bumpMinor, insertEntry, latestVersion } from "../scripts/version.ts" + +const CHANGELOG = `# Changelog + +## [Unreleased] + +## [1.4.0] - 2026-09-01 + +### Changed + +- Upstream sync: mattpocock/skills \`aaaaaaaaa\` -> \`bbbbbbbbb\`. + +## [0.1.0] - 2026-08-01 +` + +test("latestVersion finds the first release heading", () => { + assert.equal(latestVersion(CHANGELOG), "1.4.0") + assert.throws(() => latestVersion("# empty\n"), /no release heading/) +}) + +test("bumpMinor: minor bump, prerelease untouched", () => { + assert.equal(bumpMinor("1.4.0"), "1.5.0") + assert.equal(bumpMinor("0.1.0"), "0.2.0") + assert.equal(bumpMinor("1.5.0-rc.1"), "1.5.0-rc.1") +}) + +test("buildEntry: minor bump + upstream diff lines; null when commit already released", () => { + const entry = buildEntry({ + changelog: CHANGELOG, + fromCommit: "bbbbbbbbb123", + toCommit: "ccccccccc456", + toVersion: "1.4.0", + date: "2026-10-15", + diff: { added: ["engineering/new"], removed: [], changed: ["engineering/tdd"] }, + }) + assert.equal(entry?.version, "1.5.0") + assert.match(entry!.body, /^## \[1\.5\.0\] - 2026-10-15/) + assert.match(entry!.body, /`bbbbbbbbb` -> `ccccccccc`/) + assert.match(entry!.body, /added: engineering\/new/) + assert.match(entry!.body, /changed: engineering\/tdd/) + + const again = buildEntry({ + changelog: CHANGELOG + entry!.body, + fromCommit: "b", + toCommit: "ccccccccc456", + toVersion: "1.4.0", + date: "2026-10-15", + diff: { added: [], removed: [], changed: [] }, + }) + assert.equal(again, null, "idempotent for an already-released upstream commit") +}) + +test("insertEntry places the new version under Unreleased", () => { + const entry = { version: "1.5.0", date: "2026-10-15", body: "## [1.5.0] - 2026-10-15" } + const out = insertEntry(CHANGELOG, entry) + assert.match(out, /## \[Unreleased\]\n\n## \[1\.5\.0\] - 2026-10-15\n\n## \[1\.4\.0\]/) + assert.throws(() => insertEntry("# nothing\n", entry), /no `## \[Unreleased\]` heading/) +}) + +test("publish helpers: name encoding, published check, body hashes", () => { + assert.equal(encodeName("@haylan/opencode-pocock"), "@haylan%2Fopencode-pocock") + assert.equal(isPublished({ versions: { "1.0.0": {} } }, "1.0.0"), true) + assert.equal(isPublished({ versions: {} }, "1.0.0"), false) + assert.equal(isPublished({}, "1.0.0"), false) + + const body = publishBody({ name: "@haylan/opencode-pocock", version: "1.0.0" }, Buffer.from("tar")) as { + "dist-tags": Record + versions: Record + _attachments: Record + } + assert.equal(body["dist-tags"].latest, "1.0.0") + assert.equal(body.versions["1.0.0"].dist.shasum.length, 40) + assert.match(body.versions["1.0.0"].dist.integrity, /^sha512-/) + const att = body._attachments["@haylan/opencode-pocock-1.0.0.tgz"] + assert.equal(att.data, Buffer.from("tar").toString("base64")) + assert.equal(att.length, 3) +}) + +function fakeFetch(routes: Record, seen: string[]) { + return (async (url: string | URL | Request, init?: RequestInit) => { + const u = String(url) + seen.push(`${init?.method ?? "GET"} ${u}`) + const match = Object.entries(routes).find(([suffix]) => u.endsWith(suffix)) + const route = match?.[1] ?? { status: 404 } + return new Response(route.body !== undefined ? JSON.stringify(route.body) : null, { + status: route.status, + }) + }) as typeof fetch +} + +test("ensureTag: creates on 404, no-ops when it exists, fails loudly otherwise", async () => { + const base = "https://gitea.test/api/v1" + const creating = { + base, + headers: {}, + fetchFn: (async (_url: string | URL | Request, init?: RequestInit) => { + if ((init?.method ?? "GET") === "GET") return new Response(null, { status: 404 }) + return new Response(null, { status: 201 }) + }) as typeof fetch, + } + assert.equal(await ensureTag(creating, "o/r", "1.0.0", "sha123"), "created") + + const exists = { + base, + headers: {}, + fetchFn: (async () => new Response(null, { status: 200 })) as typeof fetch, + } + assert.equal(await ensureTag(exists, "o/r", "1.0.0", "sha123"), "exists") + + const broken = { + base, + headers: {}, + fetchFn: (async () => new Response("boom", { status: 500 })) as typeof fetch, + } + await assert.rejects(() => ensureTag(broken, "o/r", "1.0.0", "sha123"), /tag lookup failed: 500/) +}) + +test("ensureLinked: links once, then reports already linked", async () => { + let linked = false + const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => { + const method = init?.method ?? "GET" + if (method === "GET") { + return Response.json(linked ? { repository: { id: 1 } } : {}, { status: 200 }) + } + linked = true + return new Response(null, { status: 204 }) + }) as typeof fetch + const api = { base: "https://gitea.test/api/v1", headers: {}, fetchFn } + assert.equal(await ensureLinked(api, "haylan", "@haylan/opencode-pocock", "0.2.0", "r"), "linked") + assert.equal(await ensureLinked(api, "haylan", "@haylan/opencode-pocock", "0.2.0", "r"), "already linked") +})