Files
software-mansion__argent/scripts/next-canary-version.mjs
Ignacy Łątka c4a9f0bd8a ci: publish a "next" canary on every push to main (#423)
**Update after team discussion:** the open question from the original
version of this PR ("should we fire `next` on every push to main?") got
a yes, so this now does exactly that — continuous canary publishing from
`main`. Original tag-sync wiring is still here too. Branch is merged up
to current `main`.

### what it does

every push to main publishes a canary prerelease under the `next`
dist-tag, so `next` always tracks the tip of main.

- **"Publish prerelease to npm (next)"** now also triggers on `push:
branches: [main]`. on that path it derives a version, stamps it into
`packages/argent/package.json`, and publishes `--tag next`. the manual
`workflow_dispatch` path is untouched (gated behind `if:
github.event_name == 'workflow_dispatch'`).
- **version scheme** = `<patch-bump of the highest published
version>-next.<counter>`. a prerelease of the next patch still outranks
the current `latest` (so `next` lands above it) but stays under the
eventual stable. the **counter resets per base**: next index after the
highest `<base>-next.<k>` already on npm, or 0 if the base has no
canaries yet — so each patch line starts at `.0`:

  ```
  0.13.1-next.0, 0.13.1-next.1, ...   ← latest is 0.13.0
       ... 0.13.1 ships stable ...
  0.13.2-next.0, 0.13.2-next.1, ...   ← counter restarts
  ```

lives in `scripts/next-canary-version.mjs` (+ tests) as a pure function
of the published-versions list, reusing the semver helpers from the sync
script.
- **`argent -v` reports the right thing.** the CLI reads its version
from `package.json` at runtime, so stamping is enough — and there's an
in-pipeline step that runs the built `argent --version` and fails the
publish if it doesn't match the version about to ship. verified
end-to-end against the real registry: computed `0.13.1-next.0`, stamped,
built, `argent --version` → `0.13.1-next.0`.
- **collisions self-heal.** the counter is registry-derived, so canary
publishes are serialized (a `concurrency` group) to kill the concurrent
race; the publish step additionally retries a version-exists collision
(npm read-after-write lag on back-to-back runs) by recomputing. any
other publish failure still fails fast.
- canary builds pull the floating `argent-main` / `radon-main` signed
binaries. sync workflow still fires after every publish (self-heals
`next`, and still does the real work after a *stable* release).

### stuff to sanity-check before merge

- this publishes to npm on **every** merge to main — a lot of versions +
a full build/publish run each time. fine if that's the intent (it's the
whole point), just calling it out.
- **native binaries on the canary path are the floating `argent-main`
tag** — if radon's binary build lags a native-source change on main, a
canary can ship fresh JS against slightly-older signed
dylibs/ax-service/APK with no signal (the *missing* case fails loud +
safe; the *stale* case is silent). there's no per-commit signed-binary
build to pin against, so this is inherent to canary-from-main rather
than something this PR can fix — flagging it as the one real "preview
channel can be skewed" caveat.
- during a rapid burst of merges, GitHub's concurrency supersedes
intermediate queued runs — published `-next.N` stay contiguous from 0,
but not literally every commit in a burst gets its own canary.
- like any push trigger, it only goes live once this is on main.
2026-07-01 18:01:53 +02:00

101 lines
4.2 KiB
JavaScript

#!/usr/bin/env node
/**
* Compute the version for a canary prerelease published on every push to main:
*
* <patch-bump of the highest STABLE published version>-next.<counter>
*
* e.g. with latest=0.13.0 and no canaries yet → `0.13.1-next.0`.
*
* Why patch-bump-of-stable: each main build is a preview of the next patch
* release. A prerelease of 0.13.1 still outranks the 0.13.0 release by SemVer
* precedence, so `next` always lands strictly above `latest`, while staying
* below the eventual 0.13.1 stable.
*
* The counter resets per base: it's the next index after the highest
* `<base>-next.<k>` already on npm, or 0 if this base has no canaries yet. So
* each new patch line starts at `.0` and increments per publish:
* 0.13.1-next.0, 0.13.1-next.1, ... then 0.13.1 ships stable ...
* 0.13.2-next.0, 0.13.2-next.1, ...
*
* Because the counter is read from the registry, the `concurrency` group in
* publish-next.yml serializes canary publishes so two runs can't read the same
* index at once. That removes the concurrent race but not npm's read-after-write
* lag (a freshly published version may not be visible to the next run's read for
* a few seconds), so the publish step ALSO retries on a version-exists collision
* by recomputing — see "Publish canary" in publish-next.yml. Either way a
* collision fails safe (npm 409, no bad artifact, no tag move).
*
* Zero npm dependencies (runs in a lightweight CI job without `npm ci`); the
* SemVer helpers are shared with sync-next-dist-tag.mjs.
*
* Usage:
* node scripts/next-canary-version.mjs print the canary version
* node scripts/next-canary-version.mjs --write also stamp it into
* packages/argent/package.json
*/
import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { argv as processArgv } from "node:process";
import { pathToFileURL } from "node:url";
import { compareSemver, parseSemver } from "./sync-next-dist-tag.mjs";
const PACKAGE = "@swmansion/argent";
const PKG_JSON = "packages/argent/package.json";
/**
* Pure version math, separated from npm I/O so it can be unit-tested.
* @param {string[]} versions published versions (unparseable entries ignored)
* @returns {string} the canary version, e.g. "0.13.1-next.0"
*/
export function computeCanaryVersion(versions) {
const valid = versions.filter((v) => parseSemver(v) !== null);
if (valid.length === 0) {
throw new Error("no parseable published versions found");
}
const stable = valid.filter((v) => parseSemver(v).pre.length === 0);
// Prefer the highest stable release and bump its patch. With no stable
// release yet, preview the highest prerelease's own target version (no bump).
const pool = stable.length > 0 ? stable : valid;
const { main } = parseSemver(pool.slice().sort((a, b) => compareSemver(b, a))[0]);
const [major, minor, patch] = main;
const base = stable.length > 0 ? [major, minor, patch + 1] : [major, minor, patch];
// Counter resets per base: next index after the highest existing
// `<base>-next.<k>`, or 0 if this base has no canaries yet.
let counter = 0;
for (const v of valid) {
const { main: m, pre } = parseSemver(v);
const sameBase = m[0] === base[0] && m[1] === base[1] && m[2] === base[2];
if (sameBase && pre.length === 2 && pre[0] === "next" && /^\d+$/.test(pre[1])) {
counter = Math.max(counter, Number(pre[1]) + 1);
}
}
return `${base[0]}.${base[1]}.${base[2]}-next.${counter}`;
}
function main() {
const write = processArgv.includes("--write");
const raw = JSON.parse(
execFileSync("npm", ["view", PACKAGE, "versions", "--json"], { encoding: "utf8" })
);
// npm returns a bare string when a package has exactly one published version.
const versions = Array.isArray(raw) ? raw : [raw];
const version = computeCanaryVersion(versions);
if (write) {
const pkg = JSON.parse(readFileSync(PKG_JSON, "utf8"));
pkg.version = version;
writeFileSync(PKG_JSON, JSON.stringify(pkg, null, 2) + "\n");
}
process.stdout.write(version + "\n");
}
// Run only when invoked directly, not when imported by the test.
if (processArgv[1] && import.meta.url === pathToFileURL(processArgv[1]).href) {
main();
}