fix: floor fails closed on undatable moves, recorded security evidence on finish, npm-only advisory match

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-28 00:21:14 +00:00
1 parent 76b3e34526
commit c8855d196c
5 files changed
+165 -41

No files matched your search

+1 -1
View File
@@ -91,7 +91,7 @@ jobs:
run: |
set -euo pipefail
if [ -n "$RELEASE_REF" ] && ! [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::ref must be the full 40-hex SHA of a commit on main, not '$RELEASE_REF'"
echo "::error::ref must be the full 40-hex SHA of a commit on main"
exit 1
fi
if [ -n "$SECURITY_REASON" ] && [ -z "$RELEASE_REF" ]; then
+2 -2
View File
@@ -5,8 +5,8 @@
**Stable moves twice a week, and never twice in 72 hours unless a security fix needs it.** Garry asked, 2026-10-27: "Do we have rules around stable where we update stable except for security fixes at most once every 72 hours?" There were none: the Monday cut and any hand-run cut moved team installs whenever someone ran it. Now the release cut runs Monday and Thursday at 14:00 UTC ("Ok twice a week"), refuses any move inside 72 hours of the last one, and lets a security fix through only when it can tie the fix to the commits being released.
### Added
- **The 72-hour floor in `scripts/release-cut.ts`.** Every cut, scheduled or by hand, measures stable's last move from the newest annotated `v*` tag on stable (or its GitHub Release's publish time when later) and refuses inside 72 hours. The refusal's first two lines are `REFUSED_FLOOR: stable moved to v<X> at <time>, <h> h ago; …` and `NEXT_ALLOWED: <time> for a hand-run cut; <time> for a scheduled run`, followed by the plan it would have run. Scheduled runs alone get 3 hours of grace for GitHub's late cron starts, so a Thursday run after a late Monday cut still cuts. A stable no annotated tag dates is `FLOOR_UNKNOWN`. A refused hand cut exits 1 with an error annotation; a refused scheduled run or dry run exits 0 with a notice, since the policy is working.
- **Security cuts: `--security "<reason>"` with `--ref <fix sha>`** (workflow inputs `security` and `ref`). The reason must name a `GHSA-…`, `CVE-…` or `#<n>`, and the cut passes the floor only when a commit in the span names that GHSA or CVE, `#<n>` carries the `security` label and a commit references it, or the GitHub Advisory Database lists a package that changes in the span's `package.json` or `bun.lock`. Anything else is `REFUSED_SECURITY`. The reason (one public line) goes into the tag message and the release notes as `**Security release:** …`, a retry reads it back from the tag, and the plan's `RIDING_ALONG:` line counts the other versions that ship with the fix.
- **The 72-hour floor in `scripts/release-cut.ts`.** Every cut, scheduled or by hand, measures stable's last move from the newest annotated `v*` tag on stable (or its GitHub Release's publish time when later) and refuses inside 72 hours. The refusal's first two lines are `REFUSED_FLOOR: stable moved to v<X> at <time>, <h> h ago; …` and `NEXT_ALLOWED: <time> for a hand-run cut; <time> for a scheduled run`, followed by the plan it would have run. Scheduled runs alone get 3 hours of grace for GitHub's late cron starts, so a Thursday run after a late Monday cut still cuts. The floor fails closed: a stable no annotated tag dates, a release that cannot be read, or a cut while stable's own release is unpublished is `FLOOR_UNKNOWN`, and a run without `--ref` finishes an unpublished cut before moving on. A refused hand cut exits 1 with an error annotation; a refused scheduled run or dry run exits 0 with a notice, since the policy is working.
- **Security cuts: `--security "<reason>"` with `--ref <fix sha>`** (workflow inputs `security` and `ref`). The reason must name a `GHSA-…`, `CVE-…` or `#<n>`, and the cut passes the floor only when a commit in the span names that GHSA or CVE, `#<n>` carries the `security` label and a commit references it, or the GitHub Advisory Database lists an npm package whose own entry changes in the span's `package.json` or `bun.lock`. Anything else is `REFUSED_SECURITY`. The reason (one public line) goes into the tag message and the release notes as `**Security release:** …`, finishing a half-done security cut reuses the reason and evidence its tag recorded (a security tag that never moved stable must verify again), and the plan's `RIDING_ALONG:` line counts the other versions that ship with the fix.
### Changed
- **`.github/workflows/weekly-release.yml` is now "Stable release" and runs Monday and Thursday** (`0 14 * * 1,4`). The file keeps its name so its run history and every `gh workflow run weekly-release.yml` stay valid. The cut job can read issue and pull-request labels for the security check.
+22 -9
View File
@@ -91,9 +91,16 @@ the floor verdict (`FLOOR:` line) and exits 0.
Two cases are not held. The first cut, before `stable` exists, has nothing to
measure. Finishing a half-done cut (`stable` already at the candidate, release
missing) is not a new move. A `stable` that no annotated tag dates is
`FLOOR_UNKNOWN`, red on every trigger: only this tool moves `stable` and it
tags first, so that state means someone moved it by hand. The floor binds the
missing) is not a new move, and a run without `ref` finishes that cut before
it considers anything newer.
The floor fails closed when it cannot date the move. These are
`FLOOR_UNKNOWN`, red on every trigger: a `stable` that no annotated tag dates
(only this tool moves `stable` and it tags first, so that state means someone
moved it by hand), a release that cannot be read (a GitHub API error is not
"no release"), and a `ref` cut while `stable`'s own release is still
unpublished (its tag may be days older than the push). A tag dated in the
future holds the floor from now, not from that date. The floor binds the
release tool, not a writer pushing `stable` directly; that would take a branch
ruleset limiting `stable` to the Actions bot.
@@ -120,8 +127,11 @@ The reason must name a `GHSA-…`, a `CVE-…` or an issue or pull request
2. issue or pull request `#<n>` carries the `security` label and a commit in
the span references `#<n>` (the squash subject `(#<n>)` does);
3. the GitHub Advisory Database entry for the GHSA or CVE (the data OSV
imports) lists a package whose line changes in the span's `package.json`
or `bun.lock`: a dependency bump that fixes a known advisory.
imports) lists an npm package whose own entry changes in the span's
`package.json` or `bun.lock` (a dependency line or a lockfile package key,
never a name listed inside another package's entry): a dependency bump that
fixes a known advisory. The direction of the bump and the patched range are
not checked.
**Verified:** the identifier exists in the reason, the label exists on the
issue, and the link between the identifier and a released commit or bumped
@@ -140,8 +150,11 @@ notes: `**Security release:** <reason> (verified: <evidence>)`. For an
embargoed fix, use path 2 with a neutral commit subject and a labelled issue
whose title says no more than the fix does, and keep advisory details out of
the reason until disclosure.
A security cut that stopped half-way is finished by the next run with the
same reason, read back from its tag. The floor then counts from the security
A security cut that stopped half-way after moving `stable` is finished by
the next run with the reason and evidence its tag recorded, without checking
them again. A tag that records a security cut but never moved `stable` gets
no such pass: the cut is refused (`REFUSED_SECURITY`) until it runs again
with `security` and `ref` and verifies. The floor then counts from the security
release, and the next regular cut's notes list only the versions after it.
## Channels
@@ -246,8 +259,8 @@ is exercised against real check runs before it merges.
| `REFUSED: tag v<X> already names <other sha>` | a version was stamped twice; needs a look before anything is cut |
| `REFUSED_FLOOR: stable moved to v<X> at <time>, <h> h ago; ...` | inside the 72-hour floor; nothing changed. Exit 1 by hand, 0 on the schedule or a dry run |
| `NEXT_ALLOWED: <time> for a hand-run cut; <time> for a scheduled run` | follows every `REFUSED_FLOOR` (and `FLOOR_UNKNOWN`, where it says why there is no time) |
| `FLOOR_UNKNOWN: no annotated v* tag points at stable <sha> ...` | stable's last move cannot be dated; exit 1 on every trigger |
| `REFUSED_SECURITY: ...` | `security` without `ref`, or a reason the tool cannot tie to the released commits; exit 1 on every trigger |
| `FLOOR_UNKNOWN: ...` | stable's last move cannot be dated (no annotated tag, an unreadable release, or an unpublished release under a `ref` cut); exit 1 on every trigger |
| `REFUSED_SECURITY: ...` | `security` without `ref`, a reason the tool cannot tie to the released commits, or a security tag that never moved stable run without `security`; exit 1 on every trigger |
| `FLOOR: clear|refused|none|not applied|bypassed by the security exception (...)` | the floor verdict, in every plan |
| `SECURITY: <reason> (verified: <evidence>)`, `RIDING_ALONG: <n> other versions ship with the fix` | a security cut's plan |
| `TAG:`, `STABLE:`, `RELEASE:` | what the cut did; a re-run reports each step as already done and finishes a step an earlier run did not reach |
+44 -20
View File
@@ -223,15 +223,24 @@ export function releaseNotes(n: NotesInput): string {
const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
/** When stable last moved: the newest annotated v* tag on it, or that tag's release publish time when later. Lightweight tags do not count. */
export function stableMovedAt(git: Git, gh: Gh, repo: string, stable: string): { at: number; tag: string } | { at: null; why: string } {
/**
* When stable last moved: the newest annotated v* tag on it, or that tag's
* release publish time when later. Lightweight tags do not count. published is
* false when the release does not exist yet (a half-done cut, whose tag may
* predate the stable push); a release that cannot be read leaves the move undated.
*/
export function stableMovedAt(git: Git, gh: Gh, repo: string, stable: string): { at: number; tag: string; published: boolean } | { at: null; why: string } {
const listed = git(['for-each-ref', '--points-at=' + stable, '--format=%(refname:short)%09%(objecttype)%09%(creatordate:unix)', 'refs/tags/v[0-9]*']);
const newest = (listed.ok && listed.out ? listed.out.split('\n') : []).map(l => l.split('\t')).filter(p => p[1] === 'tag')
.map(p => ({ tag: p[0]!, at: Number(p[2]) * 1000 })).sort((a, b) => b.at - a.at)[0];
if (!newest) return { at: null, why: 'no annotated v* tag points at stable ' + stable.slice(0, 12) + ', so its last move cannot be dated' };
const release = parseJson<{ publishedAt?: string }>(gh(['release', 'view', newest.tag, '--repo', repo, '--json', 'publishedAt']));
const published = Date.parse(release?.publishedAt ?? '');
return { tag: newest.tag, at: Number.isFinite(published) ? Math.max(newest.at, published) : newest.at };
const view = gh(['release', 'view', newest.tag, '--repo', repo, '--json', 'publishedAt']);
if (view.status !== 0) {
if (/not found|HTTP 404/i.test(view.stderr)) return { tag: newest.tag, at: newest.at, published: false };
return { at: null, why: 'cannot read the release for ' + newest.tag + ' (' + (view.stderr.trim().slice(0, 120) || 'gh exit ' + view.status) + '), so its last move cannot be dated' };
}
const published = Date.parse(parseJson<{ publishedAt?: string }>(view)?.publishedAt ?? '');
return { tag: newest.tag, at: Number.isFinite(published) ? Math.max(newest.at, published) : newest.at, published: true };
}
/** The floor verdict for moving stable from prevStable to sha. A refusal carries the message and the NEXT_ALLOWED text. */
@@ -245,6 +254,11 @@ export function floorStatus(o: { git: Git; gh: Gh; repo: string; prevStable: str
if (o.security) return { verdict: 'security', line: 'FLOOR: bypassed by the security exception (' + moved.why + ')' };
return { verdict: 'unknown', line: 'FLOOR: unknown', message: moved.why + '; only a --security cut can move it until one does', next: 'none until an annotated v* tag dates the release stable points at' };
}
if (!moved.published && !o.security) {
return { verdict: 'unknown', line: 'FLOOR: unknown', message: 'stable moved to ' + moved.tag + ' but its release is not published, so the tag cannot date the move; finish that cut first (a run without --ref finishes it)', next: 'once the release for ' + moved.tag + ' is published' };
}
// A tag dated in the future holds the floor from now, not from that date.
moved.at = Math.min(moved.at, o.now);
const need = FLOOR_HOURS - (o.scheduled ? SCHEDULED_GRACE_HOURS : 0);
const since = 'stable moved to ' + moved.tag + ' at ' + iso(moved.at) + ', ' + ((o.now - moved.at) / HOUR).toFixed(1) + ' h ago';
const next = iso(moved.at + FLOOR_HOURS * HOUR) + ' for a hand-run cut; ' + iso(moved.at + (FLOOR_HOURS - SCHEDULED_GRACE_HOURS) * HOUR) + ' for a scheduled run';
@@ -275,17 +289,19 @@ export function verifySecurity(o: { reason: string; git: Git; gh: Gh; repo: stri
const range = o.from ? [o.from + '..' + o.to] : ['--max-count=' + WINDOW_COMMITS, o.to];
const log = o.git(['log', '--format=%H%x1f%B%x1e', ...range]);
const commits = (log.ok ? log.out.split('\x1e') : []).map(c => c.trim().split('\x1f')).filter(c => c[0]).map(c => ({ sha: c[0]!.slice(0, 12), body: c[1] ?? '' }));
const changed = o.from ? o.git(['diff', '--unified=0', o.from, o.to, '--', 'package.json', 'bun.lock']).out.split('\n').filter(l => /^[+-](?![+-])/.test(l)) : [];
// Package names whose own entry changed: a package.json dependency line or a bun.lock package key, never a name listed inside another package's entry.
const changed = new Set((o.from ? o.git(['diff', '--unified=0', o.from, o.to, '--', 'package.json', 'bun.lock']).out.split('\n') : [])
.flatMap(l => { const m = /^[+-](?![+-])\s*"([^"]+)":\s*(?:"[^"]*"|\[\s*"([^"]+)@)/.exec(l); return m && (!m[2] || m[2] === m[1]) ? [m[1]!] : []; }));
const why: string[] = [];
for (const id of advisories) {
const named = commits.find(c => c.body.toUpperCase().includes(id.toUpperCase()));
if (named) return { ok: true, evidence: 'commit ' + named.sha + ' names ' + id };
const read = parseJson<unknown>(o.gh(['api', id.startsWith('GHSA') ? 'advisories/' + id : 'advisories?cve_id=' + id]));
const entries = (Array.isArray(read) ? read : read ? [read] : []) as Array<{ vulnerabilities?: Array<{ package?: { name?: string } }> }>;
const packages = [...new Set(entries.flatMap(e => e.vulnerabilities ?? []).map(v => v.package?.name).filter((n): n is string => Boolean(n)))];
const bumped = packages.find(p => changed.some(l => l.includes('"' + p + '"') || l.includes('"' + p + '@')));
const entries = (Array.isArray(read) ? read : read ? [read] : []) as Array<{ vulnerabilities?: Array<{ package?: { name?: string; ecosystem?: string } }> }>;
const packages = [...new Set(entries.flatMap(e => e.vulnerabilities ?? []).filter(v => v.package?.ecosystem?.toLowerCase() === 'npm').map(v => v.package?.name).filter((n): n is string => Boolean(n)))];
const bumped = packages.find(p => changed.has(p));
if (bumped) return { ok: true, evidence: 'advisory ' + id + ' lists ' + bumped + ', which changes in package.json/bun.lock' };
why.push(id + ': no commit names it' + (read ? ' and none of its ' + packages.length + ' advisory packages changes in package.json/bun.lock' : ' and the advisory database has no entry for it'));
why.push(id + ': no commit names it' + (read ? ' and none of its ' + packages.length + ' npm advisory packages changes in package.json/bun.lock' : ' and the advisory database has no entry for it'));
}
for (const n of issues) {
const ref = commits.find(c => new RegExp('(?<![\\w&])#' + n + '\\b').test(c.body));
@@ -297,11 +313,12 @@ export function verifySecurity(o: { reason: string; git: Git; gh: Gh; repo: stri
return { ok: false, why: 'nothing in ' + (o.from ? o.from.slice(0, 12) + '..' : 'up to ') + o.to.slice(0, 12) + ' ties the reason to the release: ' + why.join('; ') };
}
/** The security reason an earlier run wrote into this release's annotated tag, so a retry publishes the same notes. */
export function securityFromTag(git: Git, tag: string): string | null {
/** The security reason and evidence an earlier run wrote into this release's annotated tag, so a retry publishes the same notes. */
export function securityFromTag(git: Git, tag: string): Security | null {
const body = git(['for-each-ref', '--format=%(contents)', 'refs/tags/' + tag]);
const m = body.ok ? /^Security: (.+)$/m.exec(body.out) : null;
return m ? m[1]!.trim() : null;
const reason = body.ok ? /^Security: (.+)$/m.exec(body.out) : null;
if (!reason) return null;
return { reason: reason[1]!.trim(), evidence: /^Verified: (.+)$/m.exec(body.out)?.[1]!.trim() ?? 'recorded in tag ' + tag };
}
export interface Plan {
@@ -351,6 +368,9 @@ export function planRelease(o: PlanOptions): PlanResult {
const list = git(['rev-list', '--first-parent', '--max-count=' + (o.windowCommits ?? WINDOW_COMMITS), '--since=' + since, 'refs/remotes/origin/main']);
candidates = list.ok && list.out ? list.out.split('\n') : [];
if (candidates.length === 0) return { ok: false, code: 'NOTHING_NEW', message: 'no commit on origin/main in the last ' + windowDays + ' days' };
// A cut that moved stable but never published its release is finished before stable moves again.
const moved = prevStable ? stableMovedAt(git, gh, repo, prevStable) : null;
if (prevStable && moved && moved.at !== null && !moved.published) candidates = [prevStable];
}
// stable itself stays a candidate so a cut that stopped half-way (tag and
@@ -400,10 +420,13 @@ export function planRelease(o: PlanOptions): PlanResult {
const older = git(['rev-list', '--first-parent', '-1', '--before=' + before, sha]);
if (older.ok && older.out) prevVersion = git(['show', older.out + ':VERSION']).out.trim() || null;
}
// A retry of a security cut takes the reason from the tag the first run wrote.
const reason = (tagExists === 'same' ? securityFromTag(git, tag) : null) ?? (o.security === undefined ? null : sanitizeReason(o.security));
let security: Security | null = null;
if (reason !== null) {
// Finishing a security cut keeps the reason and evidence its tag recorded; a tag
// that recorded one but never moved stable needs --security verified again.
const recorded = tagExists === 'same' ? securityFromTag(git, tag) : null;
let security: Security | null = recorded && prevStable === sha ? recorded : null;
if (recorded && !security && o.security === undefined) return { ok: false, code: 'REFUSED_SECURITY', message: 'tag ' + tag + ' records a security cut that has not moved stable; run it again with --security and --ref ' + sha.slice(0, 12) + ' so the reason is verified' };
if (!security && o.security !== undefined) {
const reason = sanitizeReason(o.security);
const verified = verifySecurity({ reason, git, gh, repo, from: before, to: sha });
if (!verified.ok) return { ok: false, code: 'REFUSED_SECURITY', message: verified.why };
security = { reason, evidence: verified.evidence };
@@ -412,7 +435,7 @@ export function planRelease(o: PlanOptions): PlanResult {
const date = git(['show', '-s', '--format=%cs', sha]).out;
const notes = releaseNotes({ repo, version, date, prevVersion, prevTag, entries: changelog.ok ? changelogEntries(changelog.out) : [], hotfix: Boolean(o.ref), first: !before, security });
const floor = floorStatus({ git, gh, repo, prevStable, sha, security: Boolean(security), scheduled: Boolean(o.scheduled), now: o.now?.() ?? Date.now() });
const ridingAlong = Math.max(0, notes.split('\n').filter(l => l.startsWith('- ')).length - 1);
const ridingAlong = Math.max(0, notes.split('\n').filter(l => l.startsWith('- **')).length - 1);
const plan: Plan = { sha, version, tag, date, hotfix: Boolean(o.ref), via, prevStable, prevVersion, prevTag, tagExists, stableAt: prevStable === sha, notes, skipped, security, floor, ridingAlong };
if (floor.verdict === 'refused' || floor.verdict === 'unknown') return { ok: false, code: floor.verdict === 'refused' ? 'REFUSED_FLOOR' : 'FLOOR_UNKNOWN', message: floor.message!, next: floor.next, plan };
return { ok: true, plan };
@@ -437,6 +460,7 @@ export function cutRelease(p: Plan, o: { repo: string; git: Git; gh: Gh; notesDi
lines.push('TAG: ' + p.tag + ' already names ' + p.sha.slice(0, 12));
}
if (!p.stableAt) {
if (p.prevStable && !git(['merge-base', '--is-ancestor', p.prevStable, p.sha]).ok) return { ok: false, lines: [...lines, 'STABLE: refused: ' + p.sha.slice(0, 12) + ' does not descend from stable ' + p.prevStable.slice(0, 12)] };
// The lease pins origin's stable to the commit the floor and the ancestry check read; a move in between fails the push.
const push = git(['push', '--force-with-lease=refs/heads/stable:' + (p.prevStable ?? ''), 'origin', p.sha + ':refs/heads/stable']);
if (!push.ok) return { ok: false, lines: [...lines, 'STABLE: push failed (stable moved since this plan read it, or the push was rejected): ' + push.out] };
@@ -466,7 +490,7 @@ export function describePlan(p: Plan, dryRun: boolean): string[] {
'NOTES: ' + p.notes.split('\n').filter(l => l.startsWith('- ')).length + ' version lines, ' + p.notes.length + ' chars',
p.floor.line,
];
if (p.security) lines.push('SECURITY: ' + p.security.reason + ' (verified: ' + p.security.evidence + ')', 'RIDING_ALONG: ' + p.ridingAlong + ' other version' + (p.ridingAlong === 1 ? '' : 's') + ' ship with the fix');
if (p.security) lines.push('SECURITY: `' + p.security.reason + '` (verified: ' + p.security.evidence + ')', 'RIDING_ALONG: ' + p.ridingAlong + ' other version' + (p.ridingAlong === 1 ? '' : 's') + ' ship with the fix');
for (const s of p.skipped) lines.push('SKIPPED: ' + s);
if (dryRun) {
lines.push('DRY_RUN: nothing pushed, nothing created. The cut would run:',
+96 -9
View File
@@ -633,9 +633,9 @@ describe('release-cut: the 72-hour floor', () => {
execFileSync('git', ['tag', '-a', 'v1.0.2', f.shas['1.0.2']!, '-m', 'gstack v1.0.2'], { cwd: f.seed, timeout: 30_000, env: { ...process.env, GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@x', GIT_COMMITTER_DATE: '2026-10-20T10:00:00Z' } });
git(f.seed, 'push', '-q', 'origin', 'refs/tags/v1.0.2');
f.refresh();
expect(stableMovedAt(f.git, f.gh, f.repo, f.shas['1.0.2']!)).toEqual({ at: Date.parse('2026-10-20T10:00:00Z'), tag: 'v1.0.2' });
expect(stableMovedAt(f.git, f.gh, f.repo, f.shas['1.0.2']!)).toEqual({ at: Date.parse('2026-10-20T10:00:00Z'), tag: 'v1.0.2', published: false });
const late: Gh = (args) => args[0] === 'release' && args[1] === 'view' && args[2] === 'v1.0.2' ? { status: 0, stdout: '{"publishedAt":"2026-10-21T09:30:00Z"}', stderr: '' } : f.gh(args);
expect(stableMovedAt(f.git, late, f.repo, f.shas['1.0.2']!)).toEqual({ at: Date.parse('2026-10-21T09:30:00Z'), tag: 'v1.0.2' });
expect(stableMovedAt(f.git, late, f.repo, f.shas['1.0.2']!)).toEqual({ at: Date.parse('2026-10-21T09:30:00Z'), tag: 'v1.0.2', published: true });
const held = planRelease({ repo: f.repo, git: f.git, gh: late, now: () => Date.parse('2026-10-24T09:00:00Z') });
expect(held.ok).toBe(false);
if (!held.ok) expect(held.next).toStartWith('2026-10-24T09:30:00Z for a hand-run cut');
@@ -648,7 +648,7 @@ describe('release-cut: the 72-hour floor', () => {
execFileSync('git', ['tag', '-a', 'v1.0.2-rc', f.shas['1.0.2']!, '-m', 'again'], { cwd: f.seed, timeout: 30_000, env: { ...process.env, GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@x', GIT_COMMITTER_DATE: '2026-10-22T08:00:00Z' } });
git(f.seed, 'push', '-q', 'origin', 'refs/tags/v1.0.2-rc');
f.refresh();
expect(stableMovedAt(f.git, f.gh, f.repo, f.shas['1.0.2']!)).toEqual({ at: Date.parse('2026-10-22T08:00:00Z'), tag: 'v1.0.2-rc' });
expect(stableMovedAt(f.git, f.gh, f.repo, f.shas['1.0.2']!)).toEqual({ at: Date.parse('2026-10-22T08:00:00Z'), tag: 'v1.0.2-rc', published: false });
});
// Value: protects=the stable push is conditioned on the stable the floor read, so two overlapping cuts cannot both move it; fails_when=the push is a plain
@@ -675,7 +675,7 @@ describe('release-cut: the 72-hour floor', () => {
f.refresh();
const published = isoZ(at + 72 * H);
const viaRelease: Gh = (args) => args[0] === 'release' && args[1] === 'view' && args[2] === 'v1.0.4' ? { status: 0, stdout: JSON.stringify({ publishedAt: published }), stderr: '' } : f.gh(args);
expect(stableMovedAt(f.git, viaRelease, f.repo, hot)).toEqual({ at: Date.parse(published), tag: 'v1.0.4' });
expect(stableMovedAt(f.git, viaRelease, f.repo, hot)).toEqual({ at: Date.parse(published), tag: 'v1.0.4', published: true });
f.release('1.0.5', 'Even later.');
f.refresh();
const next = planRelease({ repo: f.repo, git: f.git, gh: viaRelease, now: () => at + 100 * H });
@@ -683,6 +683,52 @@ describe('release-cut: the 72-hour floor', () => {
if (!next.ok) { expect(next.code).toBe('REFUSED_FLOOR'); expect(next.message).toStartWith('stable moved to v1.0.4 at ' + published + ', 28.0 h ago'); }
});
// Value: protects=the floor fails closed when the move cannot be dated: a release that cannot be read is FLOOR_UNKNOWN, a stable whose release was never
// published is finished before stable moves again, and a future-dated tag holds from now; fails_when=a transient gh failure or a half-done cut lets the
// floor fall back to an old tag date; why_new=both adversarial reviews reproduced that bypass; seam=gh is faked; no_claim=none beyond protects
test('an unreadable release is FLOOR_UNKNOWN; an unpublished stable release is finished first; a future-dated tag holds from now', () => {
const f = fixture();
const at = cutFirst(f);
const hot = f.release('1.0.4', 'Later work.');
f.refresh();
const flaky: Gh = (args) => args[0] === 'release' && args[1] === 'view' ? { status: 1, stdout: '', stderr: 'HTTP 503: Service Unavailable' } : f.gh(args);
const unread = planRelease({ repo: f.repo, git: f.git, gh: flaky, now: () => at + 100 * H });
expect(unread.ok).toBe(false);
if (!unread.ok) { expect(unread.code).toBe('FLOOR_UNKNOWN'); expect(unread.message).toStartWith('cannot read the release for v1.0.3 (HTTP 503: Service Unavailable), so its last move cannot be dated'); }
// stable moved to 1.0.4 but its release failed: the next window run finishes 1.0.4 instead of moving on to 1.0.5.
f.state.createFails = 'v1.0.4';
const promo = planRelease({ repo: f.repo, ref: hot, git: f.git, gh: f.gh, now: () => at + 72 * H });
expect(promo.ok).toBe(true);
if (!promo.ok) return;
expect(cutRelease(promo.plan, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base }).ok).toBe(false);
delete f.state.createFails;
const newer = f.release('1.0.5', 'Even later.');
f.refresh();
const finish = planRelease({ repo: f.repo, git: f.git, gh: f.gh, now: () => at + 100 * H });
expect(finish.ok, JSON.stringify(finish)).toBe(true);
if (finish.ok) { expect(finish.plan.sha).toBe(hot); expect(finish.plan.floor.verdict).toBe('finishing'); }
const past = planRelease({ repo: f.repo, ref: newer, git: f.git, gh: f.gh, now: () => at + 200 * H });
expect(past.ok).toBe(false);
if (!past.ok) { expect(past.code).toBe('FLOOR_UNKNOWN'); expect(past.message).toBe('stable moved to v1.0.4 but its release is not published, so the tag cannot date the move; finish that cut first (a run without --ref finishes it)'); }
const future = floorStatus({ git: f.git, gh: (args) => args[0] === 'release' ? { status: 0, stdout: JSON.stringify({ publishedAt: '2099-01-01T00:00:00Z' }), stderr: '' } : f.gh(args), repo: f.repo, prevStable: hot, sha: newer, security: false, scheduled: false, now: at });
expect(future.verdict).toBe('refused');
expect(future.next).toStartWith(isoZ(at + 72 * H) + ' for a hand-run cut');
});
test('cutRelease refuses to push a stable that the candidate does not descend from', () => {
const f = fixture();
git(f.seed, 'push', '-q', 'origin', f.shas['1.0.2'] + ':refs/heads/stable');
f.refresh();
const planned = planRelease({ repo: f.repo, ref: f.shas['1.0.3'], git: f.git, gh: f.gh, security: undefined });
const plan = planned.ok ? planned.plan : planned.plan!;
const cut = cutRelease({ ...plan, sha: f.shas['1.0.1']!, tag: 'v1.0.1', stableAt: false, prevStable: f.shas['1.0.2']! }, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base });
expect(cut.ok).toBe(false);
expect(cut.lines.at(-1)).toBe('STABLE: refused: ' + f.shas['1.0.1']!.slice(0, 12) + ' does not descend from stable ' + f.shas['1.0.2']!.slice(0, 12));
expect(git(f.origin, 'rev-parse', 'refs/heads/stable')).toBe(f.shas['1.0.2']);
});
test('a stable that moved after planning fails the push with a lease error and stays where the other cut put it', () => {
const f = fixture();
const planned = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
@@ -731,8 +777,8 @@ describe('release-cut: the security exception', () => {
f.state.api = {
'repos/o/r/issues/77': { labels: [{ name: 'bug' }] },
'repos/o/r/issues/79': { labels: [{ name: 'Security' }] },
'advisories/GHSA-9999-xxxx-wwww': { vulnerabilities: [{ package: { name: 'left-pad' } }] },
'advisories?cve_id=CVE-2026-55555': [{ vulnerabilities: [{ package: { name: 'left-pad' } }] }],
'advisories/GHSA-9999-xxxx-wwww': { vulnerabilities: [{ package: { name: 'left-pad', ecosystem: 'npm' } }] },
'advisories?cve_id=CVE-2026-55555': [{ vulnerabilities: [{ package: { name: 'left-pad', ecosystem: 'npm' } }] }],
};
const noLabel = sec(labelled, '#77: summary injection');
if (!noLabel.ok) expect(noLabel.message).toEndWith('#77: no security label');
@@ -756,7 +802,7 @@ describe('release-cut: the security exception', () => {
expect(verified.plan.floor.verdict).toBe('security');
expect(verified.plan.ridingAlong).toBe(0);
expect(verified.plan.notes).toContain('## gstack v1.0.4 (security release)\n\n**Security release:** \`' + reason + '\` (verified: commit ' + fix.slice(0, 12) + ' names GHSA-2345-cfgh-jmpq).');
expect(describePlan(verified.plan, false)).toEqual(expect.arrayContaining(['SECURITY: ' + reason + ' (verified: commit ' + fix.slice(0, 12) + ' names GHSA-2345-cfgh-jmpq)', 'RIDING_ALONG: 0 other versions ship with the fix']));
expect(describePlan(verified.plan, false)).toEqual(expect.arrayContaining(['SECURITY: `' + reason + '` (verified: commit ' + fix.slice(0, 12) + ' names GHSA-2345-cfgh-jmpq)', 'RIDING_ALONG: 0 other versions ship with the fix']));
// The release step fails; a scheduled retry with no --security finishes it with the same reason, read back from the tag.
f.state.createFails = 'v1.0.4';
@@ -791,6 +837,47 @@ describe('release-cut: the security exception', () => {
expect(next.plan.notes).not.toContain('Security release');
});
// Value: protects=a finishing security cut keeps its tag's recorded evidence (no live re-check that a removed label or a gh failure can break), a tag that
// recorded a security cut but never moved stable cannot pass the floor without an explicit verified --security, and the advisory path counts only npm
// packages whose own entry changed; fails_when=a forged or stale tag bypasses the floor, finishing is blocked, or a transitive name verifies; why_new=adversarial findings
// 1, 4 and 5; seam=gh is faked; no_claim=none beyond protects
test('recorded evidence finishes a security cut; an unmoved security tag needs --security again; advisories match only npm packages whose own entry changed', () => {
const f = fixture();
const at = cutFirst(f);
const fix = f.release('1.0.4', 'Escapes the summary.', { message: 'v1.0.4 (#77)' });
f.refresh();
f.state.api = { 'repos/o/r/issues/77': { labels: [{ name: 'security' }] } };
const sec = planRelease({ repo: f.repo, ref: fix, git: f.git, gh: f.gh, now: () => at + H, security: '#77: summary injection' });
expect(sec.ok, JSON.stringify(sec)).toBe(true);
if (!sec.ok) return;
f.state.createFails = 'v1.0.4';
expect(cutRelease(sec.plan, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base }).ok).toBe(false);
delete f.state.createFails;
f.refresh();
f.state.api = {};
const finish = planRelease({ repo: f.repo, git: f.git, gh: f.gh, now: () => at + 2 * H, scheduled: true });
expect(finish.ok, JSON.stringify(finish)).toBe(true);
if (finish.ok) expect(finish.plan.security).toEqual(sec.plan.security);
// A security-recorded tag at a commit stable never reached.
const later = f.release('1.0.5', 'Later.', { message: 'v1.0.5 (#78)' });
execFileSync('git', ['tag', '-a', 'v1.0.5', later, '-m', 'gstack v1.0.5 (security release)\n\nSecurity: #78: forged\nVerified: nothing'], { cwd: f.seed, timeout: 30_000, env: { ...process.env, GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@x' } });
git(f.seed, 'push', '-q', 'origin', 'refs/tags/v1.0.5');
f.refresh();
const forged = planRelease({ repo: f.repo, ref: later, git: f.git, gh: f.gh, now: () => at + 3 * H, scheduled: true });
expect(forged.ok).toBe(false);
if (!forged.ok) expect(forged).toMatchObject({ code: 'REFUSED_SECURITY', message: 'tag v1.0.5 records a security cut that has not moved stable; run it again with --security and --ref ' + later.slice(0, 12) + ' so the reason is verified' });
const lock = '{\n "packages": {\n "app": ["app@1.0.0", "", { "dependencies": { "zod": "^3" } }, "sha512-x"],\n }\n}\n';
const bump = f.release('1.0.6', 'Bumps app.', { message: 'chore(deps): bump app', files: { 'bun.lock': lock } });
f.refresh();
const verify = (api: Record<string, unknown>, reason: string) => { f.state.api = api; return verifySecurity({ reason, git: f.git, gh: f.gh, repo: f.repo, from: later, to: bump }); };
expect(verify({ 'advisories/GHSA-2222-3333-4444': { vulnerabilities: [{ package: { name: 'zod', ecosystem: 'npm' } }] } }, 'GHSA-2222-3333-4444').ok).toBe(false);
expect(verify({ 'advisories/GHSA-2222-3333-4444': { vulnerabilities: [{ package: { name: 'app', ecosystem: 'pip' } }] } }, 'GHSA-2222-3333-4444').ok).toBe(false);
expect(verify({ 'advisories/GHSA-2222-3333-4444': { vulnerabilities: [{ package: { name: 'app', ecosystem: 'npm' } }] } }, 'GHSA-2222-3333-4444'))
.toEqual({ ok: true, evidence: 'advisory GHSA-2222-3333-4444 lists app, which changes in package.json/bun.lock' });
});
test('sanitizeReason keeps one public line: no control characters or backticks, at most 300 characters; #12a and &#13; are not issue numbers', () => {
expect(sanitizeReason(' CVE-2026-1234:\r\n a\tb \`c\` ')).toBe("CVE-2026-1234: a b 'c'");
expect(sanitizeReason('#1 ' + 'x'.repeat(400)).length).toBe(300);
@@ -806,12 +893,12 @@ describe.skipIf(process.platform === 'win32')('release-cut: refusals at the work
// refusal codes existed; seam=a gh shim on PATH; no_claim=GitHub's rendering of annotations
test('refusal lines, annotations and exit codes for hand, scheduled and dry runs', () => {
const f = fixture();
cutFirst(f);
const at = cutFirst(f);
f.release('1.0.4', 'Later work.');
f.refresh();
const shim = path.join(f.base, 'shim');
fs.mkdirSync(shim);
fs.writeFileSync(path.join(shim, 'gh'), '#!/usr/bin/env bash\ncase "$*" in\n *check-runs*) echo \'' + JSON.stringify([{ check_runs: GREEN }]) + '\' ;;\n *) exit 1 ;;\nesac\n', { mode: 0o755 });
fs.writeFileSync(path.join(shim, 'gh'), '#!/usr/bin/env bash\ncase "$*" in\n *check-runs*) echo \'' + JSON.stringify([{ check_runs: GREEN }]) + '\' ;;\n "release view v1.0.3 "*) echo \'{"publishedAt":"' + isoZ(at) + '"}\' ;;\n *) echo "release not found" >&2; exit 1 ;;\nesac\n', { mode: 0o755 });
const run = (env: Record<string, string>, ...args: string[]) => spawnSync(process.execPath, [...bunIsolationArgs(), 'run', path.join(path.resolve(import.meta.dir, '..'), 'scripts', 'release-cut.ts'), '--repo', f.repo, ...args],
{ cwd: f.work, encoding: 'utf8', timeout: 60_000, env: cliEnv(shim, env) });
const stable = () => git(f.origin, 'rev-parse', 'refs/heads/stable');