feat: changelog-driven versioning and idempotent Gitea registry release scripts

This commit is contained in:
haylan committed 2026-10-14 12:15:14 +02:00
1 parent f10b885d1c
commit 023e012eda
5 files changed
+326 -6

No files matched your search

+32
View File
@@ -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.
+150
View File
@@ -0,0 +1,150 @@
// Publishes the latest CHANGELOG.md version to the Gitea npm registry without `npm publish`, tags
// the commit `v<version>` 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<string, unknown> }, 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<string, string>; 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<string, string>,
) {
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)
})
}
+6 -1
View File
@@ -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)
+4 -5
View File
@@ -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)}`)
}
+134
View File
@@ -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<string, string>
versions: Record<string, { dist: { shasum: string; integrity: string } }>
_attachments: Record<string, { data: string; length: number }>
}
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<string, { status: number; body?: unknown }>, 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")
})