From 3e6733b83bac96244d4a080ab3762a2b90f19d25 Mon Sep 17 00:00:00 2001 From: Kei Ito Date: Tue, 13 Oct 2026 03:44:10 +0000 Subject: [PATCH 1/4] ci: release typing to npm and JSR from GitHub Releases Co-Authored-By: Codex --- .github/workflows/release.yml | 104 ++++++++--------- cli/release-jsr.test.ts | 212 ++++++++++++++++++++++++++++++++++ cli/release-jsr.ts | 147 +++++++++++++++++++++++ package.json | 5 +- 4 files changed, 413 insertions(+), 55 deletions(-) create mode 100644 cli/release-jsr.test.ts create mode 100644 cli/release-jsr.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 95285729..2179e2f6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,67 +1,63 @@ +# npm Trusted Publisher: nlibjs / typing / release.yml; environment empty. +# Release and recovery: https://github.com/nlibjs/actions#release-procedure +name: Release on: - push: - branches: [main] - paths: [package.json] + release: + types: [published] workflow_dispatch: -concurrency: release + inputs: + action: + description: Operation + type: choice + options: [verify, stage] + default: verify + required: true + tag: + description: "Published Release tag for retry, e.g. v1.2.3" + type: string + default: "" +permissions: + contents: read +concurrency: + group: release-${{ github.event.release.tag_name || inputs.tag || github.ref }} + cancel-in-progress: false jobs: - Release: - runs-on: ubuntu-latest + Verify: + if: github.event_name == 'workflow_dispatch' && inputs.action == 'verify' permissions: - contents: write # create the tag and the GitHub Release - id-token: write # npm Trusted Publishing - outputs: - released: ${{ steps.version.outputs.released }} - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - id: version - run: | - tag="v$(node -p 'require("./package.json").version')" - echo "tag=$tag" >> "$GITHUB_OUTPUT" - if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then - echo "released=true" >> "$GITHUB_OUTPUT" - fi - - if: steps.version.outputs.released != 'true' - uses: actions/setup-node@v7 - with: - node-version: 26.x - package-manager-cache: false - registry-url: https://registry.npmjs.org - - if: steps.version.outputs.released != 'true' - run: npm install --global npm@11.21.0 - - if: steps.version.outputs.released != 'true' - run: npm ci - - if: steps.version.outputs.released != 'true' - run: npm run build - # Trusted Publishing (OIDC); a maintainer approves the staged version on npmjs.com. - - if: steps.version.outputs.released != 'true' - run: | - name=$(node -p 'require("./package.json").name') - version=$(node -p 'require("./package.json").version') - npm view "$name@$version" version >/dev/null 2>&1 || npm stage publish --provenance - - if: steps.version.outputs.released != 'true' - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ steps.version.outputs.tag }} - run: | - awk -v h="## $TAG " 'index($0,h)==1{f=1;next} /^## v/{if(f)exit} f' CHANGELOG.md > notes.md - gh release create "$TAG" --target "$GITHUB_SHA" --title "$TAG" --notes-file notes.md + contents: read + uses: nlibjs/actions/.github/workflows/npm-release.yml@fb8c1ebe8e807f896242c67e6dc407c71e4e34dd + with: + action: verify + Stage: + if: (github.event_name == 'release' && !github.event.release.prerelease) || inputs.action == 'stage' + permissions: + contents: read + actions: read + id-token: write + uses: nlibjs/actions/.github/workflows/npm-release.yml@fb8c1ebe8e807f896242c67e6dc407c71e4e34dd + with: + action: stage + tag: ${{ github.event.release.tag_name || inputs.tag }} PublishJsr: - needs: Release - if: needs.Release.outputs.released != 'true' + if: (github.event_name == 'release' && !github.event.release.prerelease) || inputs.action == 'stage' runs-on: ubuntu-latest permissions: contents: read id-token: write steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: 26.x - cache: npm - registry-url: https://registry.npmjs.org + package-manager-cache: false - run: npm ci - - run: npm run build - - run: npx jsr publish + - run: npm run release:prepare + - run: npm run release:jsr + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} diff --git a/cli/release-jsr.test.ts b/cli/release-jsr.test.ts new file mode 100644 index 00000000..0406907c --- /dev/null +++ b/cli/release-jsr.test.ts @@ -0,0 +1,212 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test, { type TestContext } from "node:test"; +import { releaseJsr } from "./release-jsr.ts"; + +async function fixture(t: TestContext) { + const cwd = await mkdtemp(join(tmpdir(), "typing-jsr-")); + t.after(() => rm(cwd, { recursive: true, force: true })); + const pkg = { + name: "@nlib/typing", + version: "1.2.3", + description: "Test", + repository: "https://github.com/nlibjs/typing", + exports: { ".": {}, "./package.json": "./package.json" }, + }; + const config = { + name: pkg.name, + version: pkg.version, + description: pkg.description, + exports: { ".": "./src/mod.ts" }, + publish: { + include: ["LICENSE.txt", "README.md", "src/**/*"], + exclude: ["**/*.test.ts"], + }, + }; + await mkdir(join(cwd, "src")); + for (const [file, bytes] of Object.entries({ + "package.json": JSON.stringify(pkg), + "jsr.json": JSON.stringify(config), + "LICENSE.txt": "license", + "README.md": "readme", + "src/mod.ts": "export {};", + "src/mod.test.ts": "excluded", + })) + await writeFile(join(cwd, file), bytes); + const manifest: Record = {}; + for (const file of ["jsr.json", "LICENSE.txt", "README.md", "src/mod.ts"]) { + const bytes = await readFile(join(cwd, file)); + manifest[`/${file}`] = { + size: bytes.length, + checksum: `sha256-${createHash("sha256").update(bytes).digest("hex")}`, + }; + } + const env = { + GITHUB_REPOSITORY: "nlibjs/typing", + GITHUB_SHA: "a".repeat(40), + RELEASE_TAG: "v1.2.3", + GITHUB_EVENT_NAME: "release", + GH_TOKEN: "test", + }; + const f = { + cwd, + env, + config, + manifest, + publishes: 0, + status: 200, + remote: { exports: config.exports, manifest } as unknown, + versions: {} as Record, + sha: env.GITHUB_SHA, + annotated: false, + draft: false, + tagReads: 0, + }; + const options = { + cwd, + env, + publish: () => { + f.publishes++; + }, + fetcher: (async (input, init) => { + const url = String(input); + if (url.startsWith("https://api.github.com/")) { + assert.equal( + new Headers(init?.headers).get("Authorization"), + "Bearer test", + ); + if (url.includes("/releases/")) + return Response.json({ + tag_name: env.RELEASE_TAG, + draft: f.draft, + prerelease: false, + }); + if (url.includes("/git/ref/")) f.tagReads++; + return Response.json({ + object: { + type: f.annotated && url.includes("/git/ref/") ? "tag" : "commit", + sha: f.sha, + }, + }); + } + assert.equal(new Headers(init?.headers).has("Authorization"), false); + if (url.endsWith("/meta.json")) + return Response.json({ + scope: "nlib", + name: "typing", + githubRepository: { owner: "nlibjs", name: "typing" }, + versions: f.versions, + }); + return f.status === 200 + ? Response.json(f.remote) + : new Response("", { status: f.status }); + }) as typeof fetch, + }; + return { f, options }; +} + +test("matching JSR content is a no-op, including annotated tags and excluded tests", async (t) => { + const { f, options } = await fixture(t); + f.annotated = true; + await releaseJsr(options); + assert.equal(f.publishes, 0); +}); +test("only explicit absence with matching package identity permits publication", async (t) => { + const { f, options } = await fixture(t); + f.status = 404; + await releaseJsr(options); + assert.equal(f.publishes, 1); + assert.equal(f.tagReads, 2); + f.versions["1.2.3"] = { yanked: true }; + await assert.rejects(releaseJsr(options), /exists without readable/); + assert.equal(f.publishes, 1); +}); +test("source, metadata, and published content conflicts never publish", async (t) => { + const { f, options } = await fixture(t); + for (const remote of [ + null, + false, + {}, + { exports: {}, manifest: f.manifest }, + { exports: f.config.exports, manifest: {} }, + ]) { + f.remote = remote; + await assert.rejects(releaseJsr(options)); + } + f.sha = "b".repeat(40); + await assert.rejects(releaseJsr(options), /tag moved/); + f.sha = f.env.GITHUB_SHA; + f.draft = true; + await assert.rejects(releaseJsr(options)); + assert.equal(f.publishes, 0); +}); +test("lookup failures and ambiguous responses never permit publication", async (t) => { + const { f, options } = await fixture(t); + for (const status of [403, 429, 500, 302]) { + f.status = status; + await assert.rejects(releaseJsr(options), /HTTP/); + } + await assert.rejects( + releaseJsr({ + ...options, + fetcher: async () => { + throw new Error("network"); + }, + }), + /network/, + ); + assert.equal(f.publishes, 0); +}); +test("tag changes during absence checks stop before publication", async (t) => { + const { f, options } = await fixture(t); + f.status = 404; + const fetcher = options.fetcher; + await assert.rejects( + releaseJsr({ + ...options, + fetcher: async (url, init) => { + if (String(url).endsWith("/meta.json")) f.sha = "b".repeat(40); + return fetcher(url, init); + }, + }), + /tag moved/, + ); + assert.equal(f.publishes, 0); +}); +test("local preparation checks metadata and exports without registry access", async (t) => { + const { f, options } = await fixture(t); + const local = { + ...options, + check: true, + fetcher: async () => { + throw new Error("unexpected lookup"); + }, + }; + await releaseJsr(local); + await writeFile( + join(f.cwd, "jsr.json"), + JSON.stringify({ ...f.config, version: "9.9.9" }), + ); + await assert.rejects(releaseJsr(local)); + await writeFile( + join(f.cwd, "jsr.json"), + JSON.stringify({ ...f.config, exports: { ".": "../escape.ts" } }), + ); + await assert.rejects(releaseJsr(local), /Missing export/); + assert.equal(f.publishes, 0); +}); +test("symlinks in published source are rejected", async (t) => { + const { f, options } = await fixture(t); + await symlink("../README.md", join(f.cwd, "src/link.ts")); + await assert.rejects(releaseJsr({ ...options, check: true }), /symlink/); +}); diff --git a/cli/release-jsr.ts b/cli/release-jsr.ts new file mode 100644 index 00000000..2121d788 --- /dev/null +++ b/cli/release-jsr.ts @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { glob, lstat, readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +export async function releaseJsr({ + cwd = process.cwd(), + check = false, + env = process.env, + fetcher = fetch, + publish = (): void => { + execFileSync("npx", ["--yes", "jsr@0.14.3", "publish"], { + cwd, + stdio: "inherit", + }); + }, +} = {}) { + const json = async (file: string) => + JSON.parse(await readFile(resolve(cwd, file), "utf8")); + const pkg = await json("package.json"), + config = await json("jsr.json"); + assert.equal(pkg.name, "@nlib/typing"); + assert.equal(pkg.repository, "https://github.com/nlibjs/typing"); + assert.match(pkg.version, /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/); + for (const key of ["name", "version", "description"]) + assert.equal(config[key], pkg[key], key); + assert.deepEqual(config.publish, { + include: ["LICENSE.txt", "README.md", "src/**/*"], + exclude: ["**/*.test.ts"], + }); + assert.deepEqual( + Object.keys(config.exports).sort(), + Object.keys(pkg.exports) + .filter((k) => k !== "./package.json") + .sort(), + ); + const manifest: Record = {}; + for await (const file of glob( + ["LICENSE.txt", "README.md", "jsr.json", "src/**/*"], + { cwd, exclude: ["src/**/*.test.ts"] }, + )) { + const stat = await lstat(resolve(cwd, file)); + assert.ok(!stat.isSymbolicLink(), `JSR symlink: ${file}`); + if (stat.isDirectory()) continue; + assert.ok(stat.isFile(), file); + const bytes = await readFile(resolve(cwd, file)); + manifest[`/${file}`] = { + size: bytes.length, + checksum: `sha256-${createHash("sha256").update(bytes).digest("hex")}`, + }; + } + for (const path of Object.values(config.exports)) { + assert.equal(typeof path, "string"); + assert.ok( + Object.hasOwn(manifest, String(path).replace(/^\.\//, "/")), + `Missing export: ${path}`, + ); + } + if (check) return; + assert.equal(env.GITHUB_REPOSITORY, "nlibjs/typing"); + assert.match(env.GITHUB_SHA || "", /^[a-f0-9]{40}$/); + assert.equal(env.RELEASE_TAG, `v${pkg.version}`); + assert.ok( + ["release", "workflow_dispatch"].includes(env.GITHUB_EVENT_NAME || ""), + ); + const request = async (url: string, github = false) => { + const response = await fetcher(url, { + headers: github + ? { + Authorization: `Bearer ${env.GH_TOKEN}`, + Accept: "application/vnd.github+json", + } + : { Accept: "application/json" }, + redirect: "error", + }); + if (response.status === 404 && !github) return null; + assert.ok(response.ok, `${url}: HTTP ${response.status}`); + const data = await response.json(); + assert.ok( + data && typeof data === "object" && !Array.isArray(data), + `Invalid metadata: ${url}`, + ); + return data; + }; + const source = async () => { + const api = "https://api.github.com/repos/nlibjs/typing"; + const release = await request( + `${api}/releases/tags/${env.RELEASE_TAG}`, + true, + ); + assert.equal(release.tag_name, env.RELEASE_TAG); + assert.equal(release.draft, false); + assert.equal(release.prerelease, false); + let { object } = await request( + `${api}/git/ref/tags/${env.RELEASE_TAG}`, + true, + ); + for (let depth = 0; object.type === "tag" && depth < 20; depth++) { + assert.match(object.sha, /^[a-f0-9]{40}$/); + ({ object } = await request(`${api}/git/tags/${object.sha}`, true)); + } + assert.equal(object.type, "commit"); + assert.equal( + object.sha, + env.GITHUB_SHA, + "Release tag moved or wrong dispatch commit", + ); + }; + await source(); + const base = "https://jsr.io/@nlib/typing"; + const remote = await request(`${base}/${pkg.version}_meta.json`); + if (remote !== null) { + assert.deepEqual(remote.exports, config.exports, "JSR exports differ"); + assert.deepEqual( + remote.manifest, + manifest, + "JSR published content differs", + ); + console.log(`JSR ${pkg.name}@${pkg.version} already matches`); + return; + } + const metadata = await request(`${base}/meta.json`); + assert.ok(metadata, "Cannot confirm JSR package identity"); + assert.equal(metadata.scope, "nlib"); + assert.equal(metadata.name, "typing"); + assert.deepEqual(metadata.githubRepository, { + owner: "nlibjs", + name: "typing", + }); + assert.ok( + metadata.versions && + typeof metadata.versions === "object" && + !Array.isArray(metadata.versions), + ); + assert.ok( + !Object.hasOwn(metadata.versions, pkg.version), + "JSR version exists without readable content", + ); + await source(); + publish(); +} + +if (import.meta.url === pathToFileURL(resolve(process.argv[1] || "")).href) { + await releaseJsr({ check: process.argv.includes("--check") }); +} diff --git a/package.json b/package.json index 714b79b4..309cb80c 100644 --- a/package.json +++ b/package.json @@ -219,7 +219,10 @@ "version:changelog": "npx @nlib/changelog --output CHANGELOG.md", "version:add-changelog": "git add CHANGELOG.md", "version:sync-version": "node cli/sync-version.ts", - "version:add-jsr": "git add jsr.json" + "version:add-jsr": "git add jsr.json", + "release:prepare": "npm run build && node cli/release-jsr.ts --check && git diff --exit-code -- package.json jsr.json src", + "release:jsr": "node cli/release-jsr.ts", + "test:release": "node --test cli/release-jsr.test.ts" }, "devDependencies": { "@biomejs/biome": "2.5.4", From 09c9b4233feb910dfc83ed4ada9cd9b6b2c71436 Mon Sep 17 00:00:00 2001 From: Kei Ito Date: Tue, 13 Oct 2026 23:07:49 +0000 Subject: [PATCH 2/4] fix: use release workflow with main helper pin Co-Authored-By: Codex --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2179e2f6..522499fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ jobs: if: github.event_name == 'workflow_dispatch' && inputs.action == 'verify' permissions: contents: read - uses: nlibjs/actions/.github/workflows/npm-release.yml@fb8c1ebe8e807f896242c67e6dc407c71e4e34dd + uses: nlibjs/actions/.github/workflows/npm-release.yml@53324e92eb61920ce5d274a42eb23f73dd98a383 with: action: verify Stage: @@ -35,7 +35,7 @@ jobs: contents: read actions: read id-token: write - uses: nlibjs/actions/.github/workflows/npm-release.yml@fb8c1ebe8e807f896242c67e6dc407c71e4e34dd + uses: nlibjs/actions/.github/workflows/npm-release.yml@53324e92eb61920ce5d274a42eb23f73dd98a383 with: action: stage tag: ${{ github.event.release.tag_name || inputs.tag }} From 4d6b23bf5794ace9329c1b4f56eff7847c1fbdd9 Mon Sep 17 00:00:00 2001 From: Kei Ito Date: Tue, 13 Oct 2026 23:11:07 +0000 Subject: [PATCH 3/4] ci: publish JSR after successful npm staging Co-Authored-By: Codex --- .github/workflows/release.yml | 9 +- cli/release-jsr.test.ts | 212 ---------------------------------- cli/release-jsr.ts | 147 ----------------------- package.json | 4 +- 4 files changed, 3 insertions(+), 369 deletions(-) delete mode 100644 cli/release-jsr.test.ts delete mode 100644 cli/release-jsr.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 522499fc..3ef29795 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,7 +40,7 @@ jobs: action: stage tag: ${{ github.event.release.tag_name || inputs.tag }} PublishJsr: - if: (github.event_name == 'release' && !github.event.release.prerelease) || inputs.action == 'stage' + needs: Stage runs-on: ubuntu-latest permissions: contents: read @@ -49,15 +49,10 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ github.sha }} - fetch-depth: 0 persist-credentials: false - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: 26.x package-manager-cache: false - run: npm ci - - run: npm run release:prepare - - run: npm run release:jsr - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} + - run: npx --yes jsr@0.14.3 publish diff --git a/cli/release-jsr.test.ts b/cli/release-jsr.test.ts deleted file mode 100644 index 0406907c..00000000 --- a/cli/release-jsr.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; -import { - mkdir, - mkdtemp, - readFile, - rm, - symlink, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import test, { type TestContext } from "node:test"; -import { releaseJsr } from "./release-jsr.ts"; - -async function fixture(t: TestContext) { - const cwd = await mkdtemp(join(tmpdir(), "typing-jsr-")); - t.after(() => rm(cwd, { recursive: true, force: true })); - const pkg = { - name: "@nlib/typing", - version: "1.2.3", - description: "Test", - repository: "https://github.com/nlibjs/typing", - exports: { ".": {}, "./package.json": "./package.json" }, - }; - const config = { - name: pkg.name, - version: pkg.version, - description: pkg.description, - exports: { ".": "./src/mod.ts" }, - publish: { - include: ["LICENSE.txt", "README.md", "src/**/*"], - exclude: ["**/*.test.ts"], - }, - }; - await mkdir(join(cwd, "src")); - for (const [file, bytes] of Object.entries({ - "package.json": JSON.stringify(pkg), - "jsr.json": JSON.stringify(config), - "LICENSE.txt": "license", - "README.md": "readme", - "src/mod.ts": "export {};", - "src/mod.test.ts": "excluded", - })) - await writeFile(join(cwd, file), bytes); - const manifest: Record = {}; - for (const file of ["jsr.json", "LICENSE.txt", "README.md", "src/mod.ts"]) { - const bytes = await readFile(join(cwd, file)); - manifest[`/${file}`] = { - size: bytes.length, - checksum: `sha256-${createHash("sha256").update(bytes).digest("hex")}`, - }; - } - const env = { - GITHUB_REPOSITORY: "nlibjs/typing", - GITHUB_SHA: "a".repeat(40), - RELEASE_TAG: "v1.2.3", - GITHUB_EVENT_NAME: "release", - GH_TOKEN: "test", - }; - const f = { - cwd, - env, - config, - manifest, - publishes: 0, - status: 200, - remote: { exports: config.exports, manifest } as unknown, - versions: {} as Record, - sha: env.GITHUB_SHA, - annotated: false, - draft: false, - tagReads: 0, - }; - const options = { - cwd, - env, - publish: () => { - f.publishes++; - }, - fetcher: (async (input, init) => { - const url = String(input); - if (url.startsWith("https://api.github.com/")) { - assert.equal( - new Headers(init?.headers).get("Authorization"), - "Bearer test", - ); - if (url.includes("/releases/")) - return Response.json({ - tag_name: env.RELEASE_TAG, - draft: f.draft, - prerelease: false, - }); - if (url.includes("/git/ref/")) f.tagReads++; - return Response.json({ - object: { - type: f.annotated && url.includes("/git/ref/") ? "tag" : "commit", - sha: f.sha, - }, - }); - } - assert.equal(new Headers(init?.headers).has("Authorization"), false); - if (url.endsWith("/meta.json")) - return Response.json({ - scope: "nlib", - name: "typing", - githubRepository: { owner: "nlibjs", name: "typing" }, - versions: f.versions, - }); - return f.status === 200 - ? Response.json(f.remote) - : new Response("", { status: f.status }); - }) as typeof fetch, - }; - return { f, options }; -} - -test("matching JSR content is a no-op, including annotated tags and excluded tests", async (t) => { - const { f, options } = await fixture(t); - f.annotated = true; - await releaseJsr(options); - assert.equal(f.publishes, 0); -}); -test("only explicit absence with matching package identity permits publication", async (t) => { - const { f, options } = await fixture(t); - f.status = 404; - await releaseJsr(options); - assert.equal(f.publishes, 1); - assert.equal(f.tagReads, 2); - f.versions["1.2.3"] = { yanked: true }; - await assert.rejects(releaseJsr(options), /exists without readable/); - assert.equal(f.publishes, 1); -}); -test("source, metadata, and published content conflicts never publish", async (t) => { - const { f, options } = await fixture(t); - for (const remote of [ - null, - false, - {}, - { exports: {}, manifest: f.manifest }, - { exports: f.config.exports, manifest: {} }, - ]) { - f.remote = remote; - await assert.rejects(releaseJsr(options)); - } - f.sha = "b".repeat(40); - await assert.rejects(releaseJsr(options), /tag moved/); - f.sha = f.env.GITHUB_SHA; - f.draft = true; - await assert.rejects(releaseJsr(options)); - assert.equal(f.publishes, 0); -}); -test("lookup failures and ambiguous responses never permit publication", async (t) => { - const { f, options } = await fixture(t); - for (const status of [403, 429, 500, 302]) { - f.status = status; - await assert.rejects(releaseJsr(options), /HTTP/); - } - await assert.rejects( - releaseJsr({ - ...options, - fetcher: async () => { - throw new Error("network"); - }, - }), - /network/, - ); - assert.equal(f.publishes, 0); -}); -test("tag changes during absence checks stop before publication", async (t) => { - const { f, options } = await fixture(t); - f.status = 404; - const fetcher = options.fetcher; - await assert.rejects( - releaseJsr({ - ...options, - fetcher: async (url, init) => { - if (String(url).endsWith("/meta.json")) f.sha = "b".repeat(40); - return fetcher(url, init); - }, - }), - /tag moved/, - ); - assert.equal(f.publishes, 0); -}); -test("local preparation checks metadata and exports without registry access", async (t) => { - const { f, options } = await fixture(t); - const local = { - ...options, - check: true, - fetcher: async () => { - throw new Error("unexpected lookup"); - }, - }; - await releaseJsr(local); - await writeFile( - join(f.cwd, "jsr.json"), - JSON.stringify({ ...f.config, version: "9.9.9" }), - ); - await assert.rejects(releaseJsr(local)); - await writeFile( - join(f.cwd, "jsr.json"), - JSON.stringify({ ...f.config, exports: { ".": "../escape.ts" } }), - ); - await assert.rejects(releaseJsr(local), /Missing export/); - assert.equal(f.publishes, 0); -}); -test("symlinks in published source are rejected", async (t) => { - const { f, options } = await fixture(t); - await symlink("../README.md", join(f.cwd, "src/link.ts")); - await assert.rejects(releaseJsr({ ...options, check: true }), /symlink/); -}); diff --git a/cli/release-jsr.ts b/cli/release-jsr.ts deleted file mode 100644 index 2121d788..00000000 --- a/cli/release-jsr.ts +++ /dev/null @@ -1,147 +0,0 @@ -import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { glob, lstat, readFile } from "node:fs/promises"; -import { resolve } from "node:path"; -import { pathToFileURL } from "node:url"; - -export async function releaseJsr({ - cwd = process.cwd(), - check = false, - env = process.env, - fetcher = fetch, - publish = (): void => { - execFileSync("npx", ["--yes", "jsr@0.14.3", "publish"], { - cwd, - stdio: "inherit", - }); - }, -} = {}) { - const json = async (file: string) => - JSON.parse(await readFile(resolve(cwd, file), "utf8")); - const pkg = await json("package.json"), - config = await json("jsr.json"); - assert.equal(pkg.name, "@nlib/typing"); - assert.equal(pkg.repository, "https://github.com/nlibjs/typing"); - assert.match(pkg.version, /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/); - for (const key of ["name", "version", "description"]) - assert.equal(config[key], pkg[key], key); - assert.deepEqual(config.publish, { - include: ["LICENSE.txt", "README.md", "src/**/*"], - exclude: ["**/*.test.ts"], - }); - assert.deepEqual( - Object.keys(config.exports).sort(), - Object.keys(pkg.exports) - .filter((k) => k !== "./package.json") - .sort(), - ); - const manifest: Record = {}; - for await (const file of glob( - ["LICENSE.txt", "README.md", "jsr.json", "src/**/*"], - { cwd, exclude: ["src/**/*.test.ts"] }, - )) { - const stat = await lstat(resolve(cwd, file)); - assert.ok(!stat.isSymbolicLink(), `JSR symlink: ${file}`); - if (stat.isDirectory()) continue; - assert.ok(stat.isFile(), file); - const bytes = await readFile(resolve(cwd, file)); - manifest[`/${file}`] = { - size: bytes.length, - checksum: `sha256-${createHash("sha256").update(bytes).digest("hex")}`, - }; - } - for (const path of Object.values(config.exports)) { - assert.equal(typeof path, "string"); - assert.ok( - Object.hasOwn(manifest, String(path).replace(/^\.\//, "/")), - `Missing export: ${path}`, - ); - } - if (check) return; - assert.equal(env.GITHUB_REPOSITORY, "nlibjs/typing"); - assert.match(env.GITHUB_SHA || "", /^[a-f0-9]{40}$/); - assert.equal(env.RELEASE_TAG, `v${pkg.version}`); - assert.ok( - ["release", "workflow_dispatch"].includes(env.GITHUB_EVENT_NAME || ""), - ); - const request = async (url: string, github = false) => { - const response = await fetcher(url, { - headers: github - ? { - Authorization: `Bearer ${env.GH_TOKEN}`, - Accept: "application/vnd.github+json", - } - : { Accept: "application/json" }, - redirect: "error", - }); - if (response.status === 404 && !github) return null; - assert.ok(response.ok, `${url}: HTTP ${response.status}`); - const data = await response.json(); - assert.ok( - data && typeof data === "object" && !Array.isArray(data), - `Invalid metadata: ${url}`, - ); - return data; - }; - const source = async () => { - const api = "https://api.github.com/repos/nlibjs/typing"; - const release = await request( - `${api}/releases/tags/${env.RELEASE_TAG}`, - true, - ); - assert.equal(release.tag_name, env.RELEASE_TAG); - assert.equal(release.draft, false); - assert.equal(release.prerelease, false); - let { object } = await request( - `${api}/git/ref/tags/${env.RELEASE_TAG}`, - true, - ); - for (let depth = 0; object.type === "tag" && depth < 20; depth++) { - assert.match(object.sha, /^[a-f0-9]{40}$/); - ({ object } = await request(`${api}/git/tags/${object.sha}`, true)); - } - assert.equal(object.type, "commit"); - assert.equal( - object.sha, - env.GITHUB_SHA, - "Release tag moved or wrong dispatch commit", - ); - }; - await source(); - const base = "https://jsr.io/@nlib/typing"; - const remote = await request(`${base}/${pkg.version}_meta.json`); - if (remote !== null) { - assert.deepEqual(remote.exports, config.exports, "JSR exports differ"); - assert.deepEqual( - remote.manifest, - manifest, - "JSR published content differs", - ); - console.log(`JSR ${pkg.name}@${pkg.version} already matches`); - return; - } - const metadata = await request(`${base}/meta.json`); - assert.ok(metadata, "Cannot confirm JSR package identity"); - assert.equal(metadata.scope, "nlib"); - assert.equal(metadata.name, "typing"); - assert.deepEqual(metadata.githubRepository, { - owner: "nlibjs", - name: "typing", - }); - assert.ok( - metadata.versions && - typeof metadata.versions === "object" && - !Array.isArray(metadata.versions), - ); - assert.ok( - !Object.hasOwn(metadata.versions, pkg.version), - "JSR version exists without readable content", - ); - await source(); - publish(); -} - -if (import.meta.url === pathToFileURL(resolve(process.argv[1] || "")).href) { - await releaseJsr({ check: process.argv.includes("--check") }); -} diff --git a/package.json b/package.json index 309cb80c..74c729e6 100644 --- a/package.json +++ b/package.json @@ -220,9 +220,7 @@ "version:add-changelog": "git add CHANGELOG.md", "version:sync-version": "node cli/sync-version.ts", "version:add-jsr": "git add jsr.json", - "release:prepare": "npm run build && node cli/release-jsr.ts --check && git diff --exit-code -- package.json jsr.json src", - "release:jsr": "node cli/release-jsr.ts", - "test:release": "node --test cli/release-jsr.test.ts" + "release:prepare": "npm run build && git diff --exit-code -- src" }, "devDependencies": { "@biomejs/biome": "2.5.4", From 31c2ad5de3d224594a46ba6c718a301c74833a7a Mon Sep 17 00:00:00 2001 From: Kei Ito Date: Tue, 13 Oct 2026 23:37:40 +0000 Subject: [PATCH 4/4] ci: rerun validation for simplified release workflow Co-Authored-By: Codex