feat: changelog-driven versioning and idempotent Gitea registry release scripts
This commit is contained in:
1 parent
f10b885d1c
commit
023e012eda
5 files changed
+326
-6
No files matched your search
@@ -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.
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -155,7 +155,12 @@ async function main() {
|
|||||||
upstream_version: upstreamPkg.version,
|
upstream_version: upstreamPkg.version,
|
||||||
}
|
}
|
||||||
writeFileSync(path.join(SNAPSHOT_DIR, "UPSTREAM.json"), JSON.stringify(meta, null, 2) + "\n")
|
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")
|
writeFileSync(STATE_PATH, JSON.stringify(state, null, 2) + "\n")
|
||||||
|
|
||||||
const fresh = recordNewSkills(CLASSIFICATION_PATH, diff.added)
|
const fresh = recordNewSkills(CLASSIFICATION_PATH, diff.added)
|
||||||
|
|||||||
+4
-5
@@ -19,8 +19,8 @@ export function latestVersion(changelog: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function bumpMinor(version: string): string {
|
export function bumpMinor(version: string): string {
|
||||||
const [major, minor, patch] = version.split(".")
|
if (version.includes("-")) return version // prerelease: keep as-is
|
||||||
if (patch.includes("-")) return `${major}.${minor}.${patch}` // prerelease: keep as-is
|
const [major, minor] = version.split(".")
|
||||||
return `${major}.${Number(minor) + 1}.0`
|
return `${major}.${Number(minor) + 1}.0`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +56,8 @@ export function insertEntry(changelog: string, entry: SyncEntry): string {
|
|||||||
const i = changelog.indexOf(marker)
|
const i = changelog.indexOf(marker)
|
||||||
if (i === -1) throw new Error("CHANGELOG.md has no `## [Unreleased]` heading")
|
if (i === -1) throw new Error("CHANGELOG.md has no `## [Unreleased]` heading")
|
||||||
const after = i + marker.length
|
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() {
|
function main() {
|
||||||
@@ -82,8 +83,6 @@ function main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeFileSync(changelogPath, insertEntry(changelog, entry))
|
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)}`)
|
console.log(`prepared release ${entry.version} for upstream ${target.commit.slice(0, 9)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
Reference in new issue
Block a user