diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..7cfd22c --- /dev/null +++ b/.gitea/workflows/release.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index b947077..7fa41cd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ node_modules/ dist/ +.release/ +package.tgz diff --git a/scripts/release.ts b/scripts/release.ts new file mode 100644 index 0000000..2f9883d --- /dev/null +++ b/scripts/release.ts @@ -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//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 }, 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) + }) +} diff --git a/scripts/smoke.ts b/scripts/smoke.ts new file mode 100644 index 0000000..893ba84 --- /dev/null +++ b/scripts/smoke.ts @@ -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 = { "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)) diff --git a/test/release.test.ts b/test/release.test.ts new file mode 100644 index 0000000..1d09668 --- /dev/null +++ b/test/release.test.ts @@ -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) +})