fix: the floor dates stable from its v<VERSION> tag, so a stray extra tag cannot stall cuts silently

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:43:57 +00:00
1 parent 906ef7df60
commit 251186e8a6
4 files changed
+39 -30

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@
**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. The floor fails closed, so 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.
- **The 72-hour floor in `scripts/release-cut.ts`.** Every cut, scheduled or by hand, measures stable's last move from the annotated `v<VERSION>` tag on stable (or its GitHub Release's publish time when later; any other tag on that commit is ignored) 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, so a stable no annotated `v<VERSION>` 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
+7 -5
View File
@@ -66,9 +66,11 @@ tag before it.
`stable` moves at most once every 72 hours (Garry, 2026-10-27). Every cut,
scheduled or by hand, with or without `ref`, checks when `stable` last moved:
the creation time of the newest annotated `v*` tag pointing at `stable`, or
the publish time of that tag's GitHub Release when it is later (a cut whose
`stable` push was retried after the tag was pushed is dated by its release).
the creation time of the annotated `v<VERSION>` tag (VERSION read at `stable`)
pointing at `stable`, or the publish time of that tag's GitHub Release when it
is later (a cut whose `stable` push was retried after the tag was pushed is
dated by its release). Any other tag on that commit is ignored, so a hand-made
extra tag can neither date the move nor stall later cuts.
A cut inside the floor changes nothing and prints, as its first two lines:
```
@@ -96,7 +98,7 @@ it considers anything newer.
The floor fails closed when it cannot date the move. Three cases are
`FLOOR_UNKNOWN`, red on every trigger, and only a verified security cut gets
past them, as it gets past the floor. They are a `stable` that no annotated tag dates
past them, as it gets past the floor. They are a `stable` that no annotated `v<VERSION>` 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
@@ -260,7 +262,7 @@ 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: ...` | 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 |
| `FLOOR_UNKNOWN: ...` | stable's last move cannot be dated (no annotated `v<VERSION>` 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|unknown|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 |
+19 -15
View File
@@ -37,9 +37,9 @@
* and exits 0.
*
* The floor: stable moves at most once per FLOOR_HOURS (72). The last move is
* the newest annotated v* tag pointing at stable, or its GitHub Release's
* the annotated v<VERSION> tag pointing at stable, or its GitHub Release's
* publish time when that is later; a cut inside the floor is REFUSED_FLOOR
* with NEXT_ALLOWED, and a stable with no annotated tag is FLOOR_UNKNOWN. A
* with NEXT_ALLOWED, and a stable without that tag is FLOOR_UNKNOWN. A
* scheduled run (GITHUB_EVENT_NAME=schedule) gets SCHEDULED_GRACE_HOURS (3) of
* slack for cron start jitter; nothing else does. Finishing a half-done cut is
* not a new move. The one exception is a security cut: --security <reason>
@@ -224,23 +224,27 @@ 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. 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.
* When stable last moved: the annotated v<VERSION> tag a cut made on it (VERSION
* read at stable), or that tag's release publish time when later. Any other tag
* on that commit is ignored, so a hand-made extra tag cannot date the move or
* pin later runs to stable. 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 view = gh(['release', 'view', newest.tag, '--repo', repo, '--json', 'publishedAt']);
const version = git(['show', stable + ':VERSION']);
const tag = 'v' + (version.ok ? version.out.trim() : '<VERSION>');
const listed = version.ok ? git(['for-each-ref', '--points-at=' + stable, '--format=%(objecttype)%09%(creatordate:unix)', 'refs/tags/' + tag]) : null;
const [type, created] = listed?.ok && listed.out ? listed.out.split('\t') : [];
if (type !== 'tag') return { at: null, why: 'no annotated ' + tag + ' tag points at stable ' + stable.slice(0, 12) + ', so its last move cannot be dated' };
const at = Number(created) * 1000;
const view = gh(['release', 'view', 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' };
if (/not found|HTTP 404/i.test(view.stderr)) return { tag, at, published: false };
return { at: null, why: 'cannot read the release for ' + 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 };
return { tag, at: Number.isFinite(published) ? Math.max(at, published) : at, published: true };
}
/** The floor verdict for moving stable from prevStable to sha. A refusal carries the message and the NEXT_ALLOWED text. */
@@ -252,7 +256,7 @@ export function floorStatus(o: { git: Git; gh: Gh; repo: string; prevStable: str
const moved = stableMovedAt(o.git, o.gh, o.repo, o.prevStable);
if (moved.at === null) {
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' };
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<VERSION> 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' };
+12 -9
View File
@@ -606,9 +606,9 @@ describe('release-cut: the 72-hour floor', () => {
if (!handInGrace.ok) expect(handInGrace.code).toBe('REFUSED_FLOOR');
});
// Value: protects=the floor's anchor is the newest annotated v* tag on stable or its later release publish time, a stable it cannot date fails closed,
// and the first cut and a finishing cut are not held; fails_when=a lightweight tag or the commit date stands in for the move, a delayed release is ignored,
// or FLOOR_UNKNOWN passes; why_new=no floor existed; seam=gh release view is faked; no_claim=none beyond protects
// Value: protects=the floor's anchor is the annotated v<VERSION> tag on stable or its later release publish time, a stable it cannot date fails closed,
// and the first cut and a finishing cut are not held; fails_when=a lightweight tag, an extra tag or the commit date stands in for the move, a delayed
// release is ignored, or FLOOR_UNKNOWN passes; why_new=no floor existed; seam=gh release view is faked; no_claim=none beyond protects
test('stableMovedAt: annotated tags only, the later release publish time wins; an undatable stable is FLOOR_UNKNOWN; the first and a finishing cut skip the floor', () => {
const f = fixture();
git(f.seed, 'tag', 'v1.0.2', f.shas['1.0.2']!);
@@ -618,14 +618,14 @@ describe('release-cut: the 72-hour floor', () => {
expect(unknown.ok).toBe(false);
if (!unknown.ok) {
expect(unknown.code).toBe('FLOOR_UNKNOWN');
expect(unknown.message).toBe('no annotated v* tag points at stable ' + f.shas['1.0.2']!.slice(0, 12) + ', so its last move cannot be dated; only a --security cut can move it until one does');
expect(unknown.next).toBe('none until an annotated v* tag dates the release stable points at');
expect(unknown.message).toBe('no annotated v1.0.2 tag points at stable ' + f.shas['1.0.2']!.slice(0, 12) + ', so its last move cannot be dated; only a --security cut can move it until one does');
expect(unknown.next).toBe('none until an annotated v<VERSION> tag dates the release stable points at');
}
// Value: protects=FLOOR_UNKNOWN's promise that a verified --security cut still moves an undatable stable; fails_when=the unknown branch returns before
// the security check, stranding a security fix until someone hand-tags stable; why_new=the security test always starts from a dated stable; seam=none;
// no_claim=verifySecurity (floorStatus takes the verified flag)
expect(floorStatus({ git: f.git, gh: f.gh, repo: f.repo, prevStable: f.shas['1.0.2']!, sha: f.shas['1.0.3']!, security: true, scheduled: false, now: pastFloor() }))
.toEqual({ verdict: 'security', line: 'FLOOR: bypassed by the security exception (no annotated v* tag points at stable ' + f.shas['1.0.2']!.slice(0, 12) + ', so its last move cannot be dated)' });
.toEqual({ verdict: 'security', line: 'FLOOR: bypassed by the security exception (no annotated v1.0.2 tag points at stable ' + f.shas['1.0.2']!.slice(0, 12) + ', so its last move cannot be dated)' });
git(f.seed, 'push', '-q', '--delete', 'origin', 'refs/tags/v1.0.2');
git(f.work, 'tag', '-d', 'v1.0.2');
@@ -644,11 +644,14 @@ describe('release-cut: the 72-hour floor', () => {
expect(floorStatus({ ...base, prevStable: null, sha: f.shas['1.0.3']! }).verdict).toBe('first');
expect(floorStatus({ ...base, prevStable: f.shas['1.0.2']!, sha: f.shas['1.0.2']! }).verdict).toBe('finishing');
// Two annotated tags on stable: the newer one dates the move.
// A newer hand-made annotated tag on stable, with no release, neither dates the move nor pins later runs to stable (a silent NOTHING_NEW stall).
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', published: false });
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 pastStray = planRelease({ repo: f.repo, git: f.git, gh: late, now: () => Date.parse('2026-10-25T10:00:00Z') });
expect(pastStray.ok, JSON.stringify(pastStray)).toBe(true);
if (pastStray.ok) expect(pastStray.plan.tag).toBe('v1.0.3');
});
// 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
@@ -940,7 +943,7 @@ describe.skipIf(process.platform === 'win32')('release-cut: refusals at the work
f.refresh();
const unknown = run({ GITHUB_EVENT_NAME: 'schedule' });
expect(unknown.status).toBe(1);
expect(unknown.stdout.split('\n')[0]).toStartWith('FLOOR_UNKNOWN: no annotated v* tag points at stable ' + f.shas['1.0.4']!.slice(0, 12));
expect(unknown.stdout.split('\n')[0]).toStartWith('FLOOR_UNKNOWN: no annotated v1.0.4 tag points at stable ' + f.shas['1.0.4']!.slice(0, 12));
expect(stable()).toBe(f.shas['1.0.4']);
});
});