Compare commits
2
Commits
6e7080d687
...
f16536fffd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f16536fffd | ||
|
|
626ad4156d |
No files matched your search
@@ -16,5 +16,7 @@ jobs:
|
|||||||
- run: npm run build
|
- run: npm run build
|
||||||
- run: node scripts/release.ts
|
- run: node scripts/release.ts
|
||||||
env:
|
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 }}
|
TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||||
@@ -82,4 +82,12 @@ npm run smoke -- "query" # one real search against CRW_API_URL (manual)
|
|||||||
|
|
||||||
### Releasing
|
### 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
@@ -1,5 +1,8 @@
|
|||||||
// Publishes the latest CHANGELOG.md version to a Gitea npm registry without `npm publish`.
|
// Publishes the latest CHANGELOG.md version to a Gitea npm registry without `npm publish`, tags the
|
||||||
// Env: REGISTRY_URL (e.g. https://host/api/packages/<owner>/npm), TOKEN (needs write:package).
|
// 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 { execFileSync } from "node:child_process"
|
||||||
import { createHash } from "node:crypto"
|
import { createHash } from "node:crypto"
|
||||||
import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
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() {
|
async function main() {
|
||||||
const registry = process.env.REGISTRY_URL?.replace(/\/+$/, "")
|
const { SERVER_URL, REPOSITORY, SHA, TOKEN: token } = process.env
|
||||||
const token = process.env.TOKEN
|
if (!SERVER_URL || !REPOSITORY || !SHA || !token) {
|
||||||
if (!registry || !token) throw new Error("REGISTRY_URL and TOKEN must be set")
|
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"))
|
const version = latestVersion(readFileSync("CHANGELOG.md", "utf8"))
|
||||||
// The version is rewritten in the staged copy only; the repo keeps its dev version.
|
// 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 })
|
const existing = await fetch(url, { headers })
|
||||||
if (existing.ok && isPublished(await existing.json(), version)) {
|
if (existing.ok && isPublished(await existing.json(), version)) {
|
||||||
console.log(`${pkg.name}@${version} is already published, skipping`)
|
console.log(`${pkg.name}@${version} is already published, skipping publish`)
|
||||||
return
|
} else if (!existing.ok && existing.status !== 404) {
|
||||||
}
|
|
||||||
if (!existing.ok && existing.status !== 404) {
|
|
||||||
throw new Error(`registry lookup failed: ${existing.status} ${await existing.text()}`)
|
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 })
|
rmSync(".release", { recursive: true, force: true })
|
||||||
mkdirSync(".release/package", { recursive: true })
|
mkdirSync(".release/package", { recursive: true })
|
||||||
writeFileSync(".release/package/package.json", JSON.stringify(pkg, null, 2))
|
writeFileSync(".release/package/package.json", JSON.stringify(pkg, null, 2))
|
||||||
|
|||||||
+88
-1
@@ -1,6 +1,13 @@
|
|||||||
import assert from "node:assert/strict"
|
import assert from "node:assert/strict"
|
||||||
import { test } from "node:test"
|
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`
|
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].data, tarball.toString("base64"))
|
||||||
assert.equal(body._attachments[file].length, tarball.length)
|
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/,
|
||||||
|
)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user