From 626ad4156d4e849c73f06a8c81437346fc45392c Mon Sep 17 00:00:00 2001 From: ArthurErlich Date: Sat, 3 Oct 2026 20:44:07 +0200 Subject: [PATCH] ci: tag the release commit and link the package to the repository Co-Authored-By: Claude Sonnet 5.5 --- .gitea/workflows/release.yml | 4 +- README.md | 10 +++- scripts/release.ts | 79 ++++++++++++++++++++++++++++---- test/release.test.ts | 89 +++++++++++++++++++++++++++++++++++- 4 files changed, 170 insertions(+), 12 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 7cfd22c..2af5b83 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -16,5 +16,7 @@ jobs: - run: npm run build - run: node scripts/release.ts env: - REGISTRY_URL: ${{ gitea.server_url }}/api/packages/${{ gitea.repository_owner }}/npm + SERVER_URL: ${{ gitea.server_url }} + REPOSITORY: ${{ gitea.repository }} + SHA: ${{ gitea.sha }} TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 0752fed..07509f7 100644 --- a/README.md +++ b/README.md @@ -82,4 +82,12 @@ npm run smoke -- "query" # one real search against CRW_API_URL (manual) ### Releasing -Releases are published by the manually started `release` workflow on Gitea Actions. It publishes the latest version heading in `CHANGELOG.md` (the repository keeps the dev version `0.0.0-dev` in `package.json`) and skips versions that already exist. To release: add a dated `## [x.y.z]` entry to `CHANGELOG.md`, merge it to `main`, then start the workflow. It needs a `PACKAGE_TOKEN` secret with `write:package` rights unless the job token already has them. +Releases are published by the manually started `release` workflow on Gitea Actions. It reads the latest version heading in `CHANGELOG.md` (the repository keeps the dev version `0.0.0-dev` in `package.json`) and then: + +1. publishes that version to the Gitea npm registry, unless it already exists; +2. tags the workflow's commit `v`, unless the tag already exists; +3. links the package to this repository, unless it is already linked. + +Every step skips itself when done, so starting the workflow again completes a release that only partly succeeded. + +To release: add a dated `## [x.y.z]` entry to `CHANGELOG.md`, merge it to `main`, then start the workflow. It needs a token with `write:package` and `write:repository` rights: the job token if it has them, otherwise a `PACKAGE_TOKEN` secret. diff --git a/scripts/release.ts b/scripts/release.ts index 2f9883d..958e1bd 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -1,5 +1,8 @@ -// Publishes the latest CHANGELOG.md version to a Gitea npm registry without `npm publish`. -// Env: REGISTRY_URL (e.g. https://host/api/packages//npm), TOKEN (needs write:package). +// Publishes the latest CHANGELOG.md version to a 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. +// 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" @@ -43,10 +46,57 @@ export function publishBody(pkg: { name: string; version: string }, tarball: Buf } } +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" +} + async function main() { - const registry = process.env.REGISTRY_URL?.replace(/\/+$/, "") - const token = process.env.TOKEN - if (!registry || !token) throw new Error("REGISTRY_URL and TOKEN must be set") + 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. @@ -56,13 +106,24 @@ async function main() { const existing = await fetch(url, { headers }) if (existing.ok && isPublished(await existing.json(), version)) { - console.log(`${pkg.name}@${version} is already published, skipping`) - return - } - if (!existing.ok && existing.status !== 404) { + 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)) diff --git a/test/release.test.ts b/test/release.test.ts index 1d09668..f12d90f 100644 --- a/test/release.test.ts +++ b/test/release.test.ts @@ -1,6 +1,13 @@ import assert from "node:assert/strict" import { test } from "node:test" -import { encodeName, isPublished, latestVersion, publishBody } from "../scripts/release.ts" +import { + encodeName, + ensureLinked, + ensureTag, + isPublished, + latestVersion, + publishBody, +} from "../scripts/release.ts" const changelog = (body: string) => `# Changelog\n\n${body}\n` @@ -42,3 +49,83 @@ test("publishBody builds the npm publish payload with the tarball attached", () assert.equal(body._attachments[file].data, tarball.toString("base64")) assert.equal(body._attachments[file].length, tarball.length) }) + +// Fake Gitea API: each route answers with a status and optional JSON body; calls are recorded. +function fakeApi(routes: Record) { + const calls: string[] = [] + const fetchFn = (async (url: string, init?: RequestInit) => { + const key = `${init?.method ?? "GET"} ${url.replace("https://git.test/api/v1", "")}` + calls.push(key + (init?.body ? ` ${init.body}` : "")) + const [status, body] = routes[key] ?? [500, { message: `unexpected ${key}` }] + return new Response(JSON.stringify(body ?? {}), { status }) + }) as typeof fetch + return { + calls, + api: { base: "https://git.test/api/v1", headers: { Authorization: "Bearer t" }, fetchFn }, + } +} + +test("ensureTag creates the tag when it does not exist", async () => { + const { calls, api } = fakeApi({ + "GET /repos/haylan/r/tags/v1.0.0": [404], + "POST /repos/haylan/r/tags": [201], + }) + assert.equal(await ensureTag(api, "haylan/r", "1.0.0", "abc123"), "created") + assert.deepEqual(JSON.parse(calls[1].replace("POST /repos/haylan/r/tags ", "")), { + tag_name: "v1.0.0", + target: "abc123", + message: "Release 1.0.0", + }) +}) + +test("ensureTag leaves an existing tag alone", async () => { + const { calls, api } = fakeApi({ "GET /repos/haylan/r/tags/v1.0.0": [200, { name: "v1.0.0" }] }) + assert.equal(await ensureTag(api, "haylan/r", "1.0.0", "abc123"), "exists") + assert.equal(calls.length, 1) +}) + +test("ensureTag throws on an unexpected API answer", async () => { + const { api } = fakeApi({ "GET /repos/haylan/r/tags/v1.0.0": [403, { message: "no" }] }) + await assert.rejects(ensureTag(api, "haylan/r", "1.0.0", "abc"), /403/) +}) + +const pkgPath = "/packages/haylan/npm/%40haylan%2Fopencode-fastcrw" + +test("ensureLinked links an unlinked package to the repository", async () => { + const { calls, api } = fakeApi({ + [`GET ${pkgPath}/1.0.0`]: [200, { repository: null }], + [`POST ${pkgPath}/-/link/r`]: [201], + }) + assert.equal( + await ensureLinked(api, "haylan", "@haylan/opencode-fastcrw", "1.0.0", "r"), + "linked", + ) + assert.equal(calls.length, 2) +}) + +test("ensureLinked skips a package that already has a repository", async () => { + const { calls, api } = fakeApi({ + [`GET ${pkgPath}/1.0.0`]: [200, { repository: { full_name: "haylan/r" } }], + }) + assert.equal( + await ensureLinked(api, "haylan", "@haylan/opencode-fastcrw", "1.0.0", "r"), + "already linked", + ) + assert.equal(calls.length, 1) +}) + +test("ensureLinked throws when the package lookup or link fails", async () => { + const lookup = fakeApi({ [`GET ${pkgPath}/1.0.0`]: [404] }) + await assert.rejects( + ensureLinked(lookup.api, "haylan", "@haylan/opencode-fastcrw", "1.0.0", "r"), + /404/, + ) + const link = fakeApi({ + [`GET ${pkgPath}/1.0.0`]: [200, {}], + [`POST ${pkgPath}/-/link/r`]: [403], + }) + await assert.rejects( + ensureLinked(link.api, "haylan", "@haylan/opencode-fastcrw", "1.0.0", "r"), + /403/, + ) +})