From a703f1854ed7ad2b4a706344c772b33afb452737 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 22 Oct 2026 14:57:19 +0900 Subject: [PATCH] build: add np release entry point with exact-commit CI gate --- .github/scripts/release-wait.mjs | 50 ++++++++++++++++ .github/scripts/release-wait.test.mjs | 83 +++++++++++++++++++++++++++ .github/workflows/release.yml | 9 ++- CONTRIBUTING.md | 25 +++++++- native/android/build.gradle | 2 +- package.json | 6 +- 6 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 .github/scripts/release-wait.mjs create mode 100644 .github/scripts/release-wait.test.mjs diff --git a/.github/scripts/release-wait.mjs b/.github/scripts/release-wait.mjs new file mode 100644 index 0000000..3393fb4 --- /dev/null +++ b/.github/scripts/release-wait.mjs @@ -0,0 +1,50 @@ +import { setTimeout as delay } from "node:timers/promises"; + +// np pushes the version commit and tag together. Wait for main CI to catch up; +// resolveRelease and the publishing job still revalidate the exact candidate. +export async function waitForReleaseCI( + github, + context, + { sleep = delay, attempts = 110, interval = 30000, log = () => {} } = {}, +) { + if (context.eventName !== "push" || !context.ref.startsWith("refs/tags/v")) + throw new Error("Release CI waiting requires a release tag push"); + const repo = context.repo; + const { data: commit } = await github.rest.repos.getCommit({ + ...repo, + ref: context.ref, + }); + const { data: main } = await github.rest.repos.getBranch({ + ...repo, + branch: "main", + }); + const { data: comparison } = await github.rest.repos.compareCommits({ + ...repo, + base: commit.sha, + head: main.commit.sha, + }); + if (!["ahead", "identical"].includes(comparison.status)) + throw new Error("Release tag must refer to a commit on main"); + + for (let attempt = 0; attempt < attempts; attempt++) { + const runs = await github.paginate(github.rest.actions.listWorkflowRuns, { + ...repo, + workflow_id: "ci.yml", + head_sha: commit.sha, + event: "push", + branch: "main", + per_page: 100, + }); + const latest = runs.sort((a, b) => b.run_number - a.run_number)[0]; + if (latest?.status === "completed") { + if (latest.conclusion !== "success") + throw new Error(`Main Compatibility failed: ${latest.conclusion}`); + return; + } + log( + `Waiting for main Compatibility at ${commit.sha} (${attempt + 1}/${attempts})`, + ); + if (attempt + 1 < attempts) await sleep(interval); + } + throw new Error("Timed out waiting for main Compatibility"); +} diff --git a/.github/scripts/release-wait.test.mjs b/.github/scripts/release-wait.test.mjs new file mode 100644 index 0000000..15f4e3f --- /dev/null +++ b/.github/scripts/release-wait.test.mjs @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { waitForReleaseCI } from "./release-wait.mjs"; + +function fixture(sequence, comparison = "identical") { + let calls = 0; + let sleeps = 0; + const github = { + rest: { + repos: { + getCommit: async () => ({ data: { sha: "tagged" } }), + getBranch: async () => ({ data: { commit: { sha: "main" } } }), + compareCommits: async () => ({ data: { status: comparison } }), + }, + actions: { listWorkflowRuns: "runs" }, + }, + paginate: async (_, params) => { + assert.equal(params.head_sha, "tagged"); + assert.equal(params.event, "push"); + assert.equal(params.branch, "main"); + assert.equal(params.workflow_id, "ci.yml"); + return sequence[Math.min(calls++, sequence.length - 1)]; + }, + }; + const context = { + repo: { owner: "owner", repo: "repo" }, + eventName: "push", + ref: "refs/tags/v0.1.3", + }; + return { + run: () => + waitForReleaseCI(github, context, { + attempts: 3, + sleep: async () => { + sleeps++; + }, + }), + counts: () => ({ calls, sleeps }), + }; +} +const success = { run_number: 1, status: "completed", conclusion: "success" }; + +test("tag push waits for missing and in-progress main CI", async () => { + const f = fixture([ + [], + [{ run_number: 1, status: "in_progress" }], + [success], + ]); + await f.run(); + assert.deepEqual(f.counts(), { calls: 3, sleeps: 2 }); +}); +test("already successful CI needs no wait", async () => { + const f = fixture([[success]]); + await f.run(); + assert.deepEqual(f.counts(), { calls: 1, sleeps: 0 }); +}); +test("newest run takes precedence over an older success", async () => { + const f = fixture([ + [success, { run_number: 2, status: "queued" }], + [{ ...success, run_number: 2 }], + ]); + await f.run(); + assert.deepEqual(f.counts(), { calls: 2, sleeps: 1 }); +}); +test("failed or cancelled main CI refuses publication", async () => { + for (const conclusion of ["failure", "cancelled", "timed_out", "skipped"]) { + const f = fixture([ + [success, { run_number: 2, status: "completed", conclusion }], + ]); + await assert.rejects(f.run(), /Main Compatibility failed/); + assert.equal(f.counts().sleeps, 0); + } +}); +test("missing CI has a bounded wait", async () => { + const f = fixture([[]]); + await assert.rejects(f.run(), /Timed out/); + assert.deepEqual(f.counts(), { calls: 3, sleeps: 2 }); +}); +test("tags outside main fail before waiting", async () => { + const f = fixture([[success]], "diverged"); + await assert.rejects(f.run(), /commit on main/); + assert.deepEqual(f.counts(), { calls: 0, sleeps: 0 }); +}); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 90b2ffa..56d8f2f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: prepare: if: github.event_name != 'issue_comment' || (github.event.issue.pull_request && github.event.comment.body == '/beta') runs-on: ubuntu-24.04 - timeout-minutes: 10 + timeout-minutes: 70 permissions: contents: read actions: read @@ -28,6 +28,13 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Wait for the tagged main commit's Compatibility run + if: github.event_name == 'push' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { waitForReleaseCI } = await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/release-wait.mjs`); + await waitForReleaseCI(github, context, { log: core.info }); - name: Resolve release and require successful main CI id: plan uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eb17950..39260cc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -160,7 +160,30 @@ All 0.x versions are Preview releases, including plain versions published to `la Direct pushes and PR branches do not automatically publish beta packages. A newer main commit supersedes an older beta candidate. Beta and next releases do not change `latest`. -For a tagged release, update `package.json` to the intended version in a PR, merge it and wait for Compatibility on main to validate reuse or finish the necessary full run. Then tag that commit and push the tag, for example `git tag v0.1.3` followed by `git push origin v0.1.3`. The tag must match the package version and refer to a commit on main with successful CI. Already published versions cannot be republished; create a new version when changing package contents. +Use the same `np` entry point as the other repositories, from a clean, up-to-date +`main` checkout: + +```sh +git switch main +git pull --ff-only +npm run release +# Or select the increment without the version-selection prompt: +npm run release -- patch +``` + +`np --no-tests --no-publish --branch main` selects the version, creates the version +commit and Git tag, and pushes them. It never publishes to npm locally. Android's +source publication version is read from `package.json`, so no separate Gradle +version edit is needed. The tag-triggered Release workflow waits for Compatibility +to succeed on that exact main commit, then builds and publishes with Trusted +Publishing and provenance. Failed or missing CI prevents publication. The workflow +revalidates the candidate before publishing; local `--no-tests` does not bypass CI. + +A plain version such as `0.1.3` goes to `latest`; a prerelease version such as +`0.2.0-rc.1` goes to `next`. Existing versions and tags are immutable. If a release +version was already prepared and merged through a PR, wait for that main commit's +CI and push its matching tag instead of asking `np` to bump it again. Never retarget +an existing release tag. The [release workflow](.github/workflows/release.yml) builds without npm publishing credentials, then publishes the resulting tarball in a separate job with lifecycle scripts disabled. It uses [npm Trusted Publishing](https://docs.npmjs.com/trusted-publishers/) rather than an npm token. Configure the npm package's Trusted Publisher once with GitHub owner `rdlabo-dev`, repository `cf-workers-native`, workflow filename `release.yml`, no environment name, and permission to publish directly. No `NPM_TOKEN` secret is required. diff --git a/native/android/build.gradle b/native/android/build.gradle index 73368bb..f35ab03 100644 --- a/native/android/build.gradle +++ b/native/android/build.gradle @@ -2,7 +2,7 @@ apply plugin: 'com.android.library' apply plugin: 'maven-publish' group = 'io.github.rdlabo' -version = '0.1.3' +version = new groovy.json.JsonSlurper().parse(file('../../package.json')).version android { diff --git a/package.json b/package.json index 864d1d5..90709f9 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,8 @@ "format": "prettier --write .", "format:check": "prettier --check .", "prepack": "npm run build", - "release:prepare": "node scripts/prepare-local-release.mjs" + "release:prepare": "node scripts/prepare-local-release.mjs", + "release": "np --no-tests --no-publish --branch main" }, "dependencies": { "@babel/core": "^8.0.7", @@ -129,7 +130,8 @@ "typescript": "^7.0.2", "vite": "^7.3.7", "vitest": "^5.0.5", - "whatwg-url": "17.3.0" + "whatwg-url": "17.3.0", + "np": "^12.1.1" }, "peerDependencies": { "@capacitor/core": "^8.0.0",