Merge pull request 'ci: tag the release commit and link the package to the repository' (#18) from ci/tag-and-link-release into main

Reviewed-on: haylan/fastcrw-opencode-websarch#18
This commit is contained in:
haylan committed 2026-10-03 18:46:00 +00:00
commit 55b3229b26
4 files changed
+170 -12

No files matched your search

+3 -1
View File
@@ -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 }}
+9 -1
View File
@@ -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<version>`, 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.
+70 -9
View File
@@ -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/<owner>/npm), TOKEN (needs write:package).
// Publishes the latest CHANGELOG.md version to a 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.
// 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<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"
}
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<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))
+88 -1
View File
@@ -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<string, [number, unknown?]>) {
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/,
)
})