mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
c4a9f0bd8a
**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.
167 lines
6.1 KiB
JavaScript
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();
|
|
}
|