mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-10 18:50:18 +01:00
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:
1 parent
906ef7df60
commit
251186e8a6
4 files changed
+39
-30
No files matched your search
+1
-1
@@ -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
@@ -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
@@ -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' };
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user