Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/scripts/release-wait.mjs
Original file line number Diff line number Diff line change
@@ -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");
}
83 changes: 83 additions & 0 deletions .github/scripts/release-wait.test.mjs
Original file line number Diff line number Diff line change
@@ -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 });
});
9 changes: 8 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
25 changes: 24 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion native/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading