ci: add release workflow, release script and smoke script #15

Merged
haylan merged 2 commits from feat/release into main 2026-10-03 17:30:13 +00:00
5 changed files with 175 additions and 0 deletions
Showing only changes of commit 0712a932cc - Show all commits

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
name: release
on: workflow_dispatch
jobs:
release:
if: gitea.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- run: npm ci
- run: npm test
- run: npm run build
- run: node scripts/release.ts
env:
REGISTRY_URL: ${{ gitea.server_url }}/api/packages/${{ gitea.repository_owner }}/npm
TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.GITHUB_TOKEN }}
+2
View File
@@ -1,2 +1,4 @@
node_modules/
dist/
.release/
package.tgz
+87
View File
@@ -0,0 +1,87 @@
// 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).
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"
export function latestVersion(changelog: string): string {
const match = changelog.match(/^## \[(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\]/m)
if (!match) throw new Error("no release heading like `## [1.0.0]` found in CHANGELOG.md")
return match[1]
}
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,
},
},
}
}
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 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`)
return
}
if (!existing.ok && existing.status !== 404) {
throw new Error(`registry lookup failed: ${existing.status} ${await existing.text()}`)
}
rmSync(".release", { recursive: true, force: true })
mkdirSync(".release/package", { recursive: true })
writeFileSync(".release/package/package.json", JSON.stringify(pkg, null, 2))
cpSync("dist", ".release/package/dist", { 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)
})
}
+22
View File
@@ -0,0 +1,22 @@
// Manual check against a real fastCRW server: prints the raw response shape and mapped results.
// Usage: CRW_API_URL=http://crw.home:3000 [CRW_API_KEY=...] npm run smoke -- "query"
import { resolveConfig } from "../src/config.ts"
import { search } from "../src/search.ts"
const config = resolveConfig({}, process.env)
const query = process.argv[2] ?? "opencode"
const headers: Record<string, string> = { "Content-Type": "application/json" }
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`
const raw = await fetch(`${config.baseURL}/v1/search`, {
method: "POST",
headers,
body: JSON.stringify({ query, limit: config.limit }),
})
const body: any = await raw.json().catch(() => undefined)
console.log(
`HTTP ${raw.status}; data shape:`,
Array.isArray(body?.data) ? "data[]" : body?.data?.results ? "data.results" : "other",
)
console.log(await search(config, query, new AbortController().signal))
+44
View File
@@ -0,0 +1,44 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { encodeName, isPublished, latestVersion, publishBody } from "../scripts/release.ts"
const changelog = (body: string) => `# Changelog\n\n${body}\n`
test("latestVersion picks the first released heading and skips Unreleased", () => {
const text = changelog(
"## [Unreleased]\n\n- wip\n\n## [1.2.0] - 2026-10-10\n\n## [1.1.0] - 2026-10-01\n",
)
assert.equal(latestVersion(text), "1.2.0")
})
test("latestVersion accepts prerelease versions", () => {
assert.equal(latestVersion(changelog("## [2.0.0-beta.1] - 2026-10-10")), "2.0.0-beta.1")
})
test("latestVersion throws when there is no release", () => {
assert.throws(() => latestVersion(changelog("## [Unreleased]\n\n- wip")), /no release/)
})
test("encodeName escapes the scope slash", () => {
assert.equal(encodeName("@haylan/opencode-fastcrw"), "@haylan%2Fopencode-fastcrw")
})
test("isPublished checks the versions map of a packument", () => {
assert.equal(isPublished({ versions: { "1.0.0": {} } }, "1.0.0"), true)
assert.equal(isPublished({ versions: { "1.0.0": {} } }, "1.1.0"), false)
assert.equal(isPublished({}, "1.0.0"), false)
})
test("publishBody builds the npm publish payload with the tarball attached", () => {
const pkg = { name: "@haylan/opencode-fastcrw", version: "1.0.0", description: "d" }
const tarball = Buffer.from("tarball-bytes")
const body = publishBody(pkg, tarball) as any
const file = "@haylan/opencode-fastcrw-1.0.0.tgz"
assert.equal(body._id, "@haylan/opencode-fastcrw")
assert.deepEqual(body["dist-tags"], { latest: "1.0.0" })
assert.equal(body.versions["1.0.0"]._id, "@haylan/opencode-fastcrw@1.0.0")
assert.match(body.versions["1.0.0"].dist.shasum, /^[0-9a-f]{40}$/)
assert.match(body.versions["1.0.0"].dist.integrity, /^sha512-/)
assert.equal(body._attachments[file].data, tarball.toString("base64"))
assert.equal(body._attachments[file].length, tarball.length)
})