mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
088a9d09b1
`release-app.mjs` derives the next version from apps/<app>/package.json on the
CURRENT branch. When that branch is behind the app's released history the tag it
computes does not continue that line, in one of two ways:
* it already exists -> `git tag` aborts, but only after the release commit has
been made, leaving a junk commit on the branch;
* it is a different line (a minor/major bump off a stale base) -> nothing
collides, the tag becomes the HIGHEST for that app, and the Flux ImagePolicy
selects the highest semver rather than the most recent push. That stale build
is then what production runs.
apps/moderator is in exactly this state, measured 2026-08-17: 0.0.1 on main,
0.0.26 live, all 26 releases cut from `moderator-app-pages` — 211 commits and
+38,630 lines that never merged to main. `pnpm release:moderator` from main
collides on the existing 0.0.2 and aborts; `release:moderator:minor` computes
0.1.0, which does NOT exist, and would deploy main's stale copy to production.
One command, no collision.
So this does NOT bump moderator's version to 0.0.26. That is the obvious fix and
it is the wrong one: it removes the collision that is currently the only brake,
while leaving the app itself 211 commits stale. The real remedy is to land the
branch; the guard is what makes the trap loud until someone does.
Checked after `git pull --rebase` so the tag list is current, and before
`npm version` so a refusal leaves the tree exactly as it found it.
Coverage: 19 tests. The version arithmetic is unit-tested, and — because a guard
nothing calls is not a guard — release-app.mjs is also driven end-to-end as a
real process against a throwaway git repo with a local bare remote (offline; no
tags or commits touch this repository).
Mutation-tested, 4 mutants, each the narrowest expression that can be wrong:
invert the behind-comparison -> 5 tests fail
lexicographic highest-tag compare -> 6 tests fail
guard computed but never acted on -> ONLY the 2 behavioural tests fail,
which is what pins reachability
drop the unparseable-tag skip -> exactly 1 test fails
Baseline restored green (19/19) after the battery.
NOTE: scripts/**/*.test.ts runs in the `unit` vitest project, which is
`continue-on-error: true` in lint.yml — so these tests run but cannot currently
fail CI. That is tracked separately (868kp7fdr); it does not make them useless,
it makes them a local and post-fix gate.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
89 lines
4.4 KiB
JavaScript
89 lines
4.4 KiB
JavaScript
// Version arithmetic for scripts/release-app.mjs, kept separate so it is testable
|
|
// without shelling out to git or cutting a real release.
|
|
//
|
|
// ── The failure this exists to stop ─────────────────────────────────────────────
|
|
// `release-app.mjs` derives the next version from `apps/<app>/package.json` ON THE
|
|
// CURRENT BRANCH. If that branch is behind the app's released history, the tag it
|
|
// computes is wrong in one of two ways:
|
|
//
|
|
// * it ALREADY EXISTS -> `git tag` aborts, but only AFTER the release commit has
|
|
// been made, leaving a junk commit on the branch;
|
|
// * it does NOT exist but is a DIFFERENT LINE (a minor/major bump off a stale
|
|
// base) -> nothing collides, the tag becomes the HIGHEST for that app, and
|
|
// since the Flux ImagePolicy selects the highest semver in range rather than
|
|
// the most recently pushed, that stale build is what production runs.
|
|
//
|
|
// The second is the dangerous one and has no natural brake. Measured 2026-08-17:
|
|
// `apps/moderator` is 0.0.1 on `main` while 0.0.26 is live, because all 26 releases
|
|
// were cut from `moderator-app-pages` (211 commits / +38,630 lines never merged to
|
|
// main). `pnpm release:moderator` from main collides on the existing 0.0.2 and
|
|
// aborts — but `release:moderator:minor` computes 0.1.0, which does NOT exist, and
|
|
// would deploy main's stale copy of the app to production.
|
|
|
|
/** Parse a plain `x.y.z` version. Returns null for anything else (pre-release, junk). */
|
|
export function parseSemver(value) {
|
|
const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(value ?? '').trim());
|
|
if (!m) return null;
|
|
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) };
|
|
}
|
|
|
|
/** Compare two `x.y.z` strings. >0 if a is newer, <0 if b is newer, 0 if equal. */
|
|
export function compareSemver(a, b) {
|
|
const pa = parseSemver(a);
|
|
const pb = parseSemver(b);
|
|
if (!pa || !pb) throw new Error(`cannot compare non-semver versions: ${a} vs ${b}`);
|
|
return pa.major - pb.major || pa.minor - pb.minor || pa.patch - pb.patch;
|
|
}
|
|
|
|
/**
|
|
* Highest released version among `tags` carrying `tagPrefix`, or null if none.
|
|
* Tags that do not parse are ignored rather than throwing: an app's tag namespace
|
|
* can legitimately contain hand-cut oddities, and one of those must not be able to
|
|
* disable the guard for every subsequent release.
|
|
*/
|
|
export function highestTagVersion(tags, tagPrefix) {
|
|
let best = null;
|
|
for (const raw of tags) {
|
|
const tag = String(raw).trim();
|
|
if (!tag.startsWith(tagPrefix)) continue;
|
|
const version = tag.slice(tagPrefix.length);
|
|
if (!parseSemver(version)) continue;
|
|
if (best === null || compareSemver(version, best) > 0) best = version;
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/**
|
|
* Is this branch's package.json behind the app's released history?
|
|
*
|
|
* Equal is fine — that is the normal state right before a release. Ahead is fine
|
|
* too (someone bumped by hand). Only BEHIND is refused, because that is the state
|
|
* in which the computed tag does not continue the released line.
|
|
*/
|
|
export function releaseSkew({ currentVersion, tags, tagPrefix }) {
|
|
const highest = highestTagVersion(tags, tagPrefix);
|
|
if (highest === null) return { behind: false, current: currentVersion, highest: null };
|
|
if (!parseSemver(currentVersion)) {
|
|
throw new Error(`package.json version is not a plain x.y.z version: ${currentVersion}`);
|
|
}
|
|
return {
|
|
behind: compareSemver(currentVersion, highest) < 0,
|
|
current: currentVersion,
|
|
highest,
|
|
};
|
|
}
|
|
|
|
/** The refusal text. Separate from the check so a test can pin what an operator is told. */
|
|
export function skewMessage({ appDir, tagPrefix, current, highest, branch }) {
|
|
return [
|
|
`refusing to release: ${appDir}/package.json is ${current} on '${branch}', but ${tagPrefix}${highest} is already released.`,
|
|
`This branch is BEHIND the app's released history, so the tag this would cut does not continue that line.`,
|
|
`Either it collides with an existing tag (the release aborts half-done), or — for a minor/major bump — it becomes`,
|
|
`the highest tag for this app and Flux deploys THIS branch's code, because the ImagePolicy selects the highest`,
|
|
`semver rather than the most recent push.`,
|
|
``,
|
|
`Fix the branch, not the number: merge or rebase the branch that carries the releases, then release from there.`,
|
|
`Setting the version to ${highest} by hand removes the collision that is currently the only thing stopping this.`,
|
|
].join('\n');
|
|
}
|