Files
software-mansion__argent/scripts/sync-next-dist-tag.mjs
T
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

167 lines
6.1 KiB
JavaScript

#!/usr/bin/env node
/**
* Re-point the `next` npm dist-tag at the highest published version of
* @swmansion/argent — whether that highest version is a prerelease or a
* stable release.
*
* Why this exists: `npm publish` (stable) only moves `latest`, and
* `npm publish --tag next` only moves `next` when a *prerelease* is shipped.
* So once a prerelease was tagged `next`, every later stable release left
* `next` frozen on an old prerelease (e.g. next=0.7.0-next.4 while
* latest=0.11.0). Running this after every publish makes `next` self-heal to
* the true maximum, so users who track `next` always get the newest build.
*
* Idempotent: if `next` already points at the maximum, it does nothing.
*
* Usage:
* node scripts/sync-next-dist-tag.mjs [package] [--dry-run]
* package npm package name (default: @swmansion/argent)
* --dry-run compute and report, but do not mutate the dist-tag
* (also honoured via DRY_RUN=1). `npm dist-tag add` needs auth;
* use --dry-run to exercise the logic without NODE_AUTH_TOKEN.
*/
import { execFileSync } from "node:child_process";
import { argv as processArgv, env } from "node:process";
import { pathToFileURL } from "node:url";
const TAG = "next";
// --- SemVer 2.0.0 precedence (build metadata ignored) ------------------------
// Self-contained so this script has zero npm dependencies: it must run in a
// lightweight CI job without `npm ci`. Verified against the `semver` library in
// scripts/sync-next-dist-tag.test.mjs.
export function parseSemver(version) {
const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(
String(version).trim()
);
if (!m) return null;
return {
main: [Number(m[1]), Number(m[2]), Number(m[3])],
pre: m[4] ? m[4].split(".") : [],
};
}
function compareIdentifiers(a, b) {
const an = /^\d+$/.test(a);
const bn = /^\d+$/.test(b);
if (an && bn) return Math.sign(Number(a) - Number(b)); // numeric: compare value
if (an) return -1; // numeric identifiers rank lower than alphanumeric
if (bn) return 1;
return a < b ? -1 : a > b ? 1 : 0; // alphanumeric: ASCII order
}
/** Returns <0 if va < vb, 0 if equal, >0 if va > vb. */
export function compareSemver(va, vb) {
const a = parseSemver(va);
const b = parseSemver(vb);
for (let i = 0; i < 3; i++) {
if (a.main[i] !== b.main[i]) return Math.sign(a.main[i] - b.main[i]);
}
// A prerelease has lower precedence than the associated normal version.
if (a.pre.length === 0 && b.pre.length === 0) return 0;
if (a.pre.length === 0) return 1;
if (b.pre.length === 0) return -1;
const len = Math.min(a.pre.length, b.pre.length);
for (let i = 0; i < len; i++) {
const c = compareIdentifiers(a.pre[i], b.pre[i]);
if (c !== 0) return c;
}
// All shared identifiers equal: the longer set of fields has higher precedence.
return Math.sign(a.pre.length - b.pre.length);
}
/**
* Decide whether `next` should be re-pointed at `max`. Only ever *advances*:
* returns true iff `max` is strictly newer than the current tag, or the current
* tag is unset/unparseable.
*
* This never moves `next` backward. The post-publish sync can momentarily read a
* stale (CDN-cached) packument that omits the version just published — so the
* computed `max` can lag behind the freshly-tagged release. A plain
* `current === max` check would then re-point `next` at the older `max`,
* demoting the new build. Requiring a strict advance keeps the sync idempotent
* and self-healing without that regression.
*/
export function shouldAdvanceTag(max, current) {
if (current === undefined || current === null) return true;
if (parseSemver(current) === null) return true;
return compareSemver(max, current) > 0;
}
// --- npm registry I/O --------------------------------------------------------
function npmJson(args) {
const out = execFileSync("npm", [...args, "--json"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "inherit"],
});
return JSON.parse(out);
}
function getVersions(name) {
const raw = npmJson(["view", name, "versions"]);
// npm returns a bare string when a package has exactly one published version.
const list = Array.isArray(raw) ? raw : [raw];
return list.filter((v) => parseSemver(v) !== null);
}
function getDistTags(name) {
try {
return npmJson(["view", name, "dist-tags"]);
} catch {
return {};
}
}
// --- main --------------------------------------------------------------------
function main() {
const argv = processArgv.slice(2);
const dryRun = argv.includes("--dry-run") || env.DRY_RUN === "1";
// First real package-name arg. Skip flags (anything starting with "-", so a
// stray "-x" isn't handed to npm as a flag) and blank tokens (an empty/
// whitespace arg must NOT become the package name — `npm view ""` silently
// resolves to the squatted "undefined" package rather than erroring). When no
// usable arg is present we fall back to the scoped default below.
const pkg = argv.find((a) => a.trim() !== "" && !a.startsWith("-")) ?? "@swmansion/argent";
const versions = getVersions(pkg);
if (versions.length === 0) {
console.log(`No published versions found for ${pkg}; nothing to do.`);
return;
}
// Highest version overall, prerelease or stable.
const max = versions.slice().sort((x, y) => compareSemver(y, x))[0];
const current = getDistTags(pkg)[TAG];
console.log(`Package: ${pkg}`);
console.log(`Highest version: ${max}`);
console.log(`Current '${TAG}': ${current ?? "(unset)"}`);
if (!shouldAdvanceTag(max, current)) {
console.log(
`'${TAG}' (${current}) is already at or ahead of the highest published version (${max}); nothing to do.`
);
return;
}
if (dryRun) {
console.log(`[dry-run] would run: npm dist-tag add ${pkg}@${max} ${TAG}`);
return;
}
console.log(`Updating '${TAG}': ${current ?? "(unset)"} -> ${max}`);
execFileSync("npm", ["dist-tag", "add", `${pkg}@${max}`, TAG], {
stdio: "inherit",
});
console.log(`Done. '${TAG}' now points at ${max}.`);
}
// Run only when invoked directly, not when imported by the test.
if (processArgv[1] && import.meta.url === pathToFileURL(processArgv[1]).href) {
main();
}