fix: a regression anchor needs a published release; an unmoved exception tag reruns only with its recorded reason; a version may end the sentence

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 07:27:04 +00:00
1 parent 8260497ca9
commit 40a8df86de
4 files changed
+29 -13

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@
**A fix for a regression on stable can skip the 72-hour wait, like a security fix.** Garry, asked whether non-security breakage on stable may bypass the twice-weekly cadence and the floor, answered on 2026-10-28: "Regression". Until now a fix for something the last stable release did right, and the current one breaks, waited up to 72 hours on every team install. Now `--regression "#n: worked in v<X>: why"` with `--ref` passes the floor once the tool ties it to the release, and the claim is public in the tag and the notes.
### Added
- **Regression cuts in `scripts/release-cut.ts`: `--regression "<reason>"` with `--ref <fix sha>`** (workflow input `regression`). The reason must name exactly one earlier stable release `v<X>` and at least one `#<n>`. The cut is refused with `REFUSED_REGRESSION`, red on every trigger, unless `v<X>` is an annotated release tag strictly behind the current stable (the current stable itself never counts), `#<n>` carries the `regression` label (a maintainer's declaration; a `security` label does not count, nor the reverse) and a released commit references `#<n>`. That the behavior really worked in `v<X>` stays attested. The record matches a security cut. The sanitized reason and evidence go into the tag (`Regression:` / `Verified:`), the notes (`(regression release)`, `**Regression release:** …`) and the plan (`REGRESSION:`, `RIDING_ALONG:`); a half-done cut finishes from its tag; an unmoved regression tag must verify again.
- **Regression cuts in `scripts/release-cut.ts`: `--regression "<reason>"` with `--ref <fix sha>`** (workflow input `regression`). The reason must name exactly one earlier stable release `v<X>` and at least one `#<n>`. The cut is refused with `REFUSED_REGRESSION`, red on every trigger, unless `v<X>` is an annotated release tag strictly behind the current stable with a published GitHub Release (the current stable itself never counts, nor a tag a half-done cut left behind), `#<n>` carries the `regression` label (a maintainer's declaration; a `security` label does not count, nor the reverse) and a released commit references `#<n>`. That the behavior really worked in `v<X>` stays attested. The record matches a security cut. The sanitized reason and evidence go into the tag (`Regression:` / `Verified:`), the notes (`(regression release)`, `**Regression release:** …`) and the plan (`REGRESSION:`, `RIDING_ALONG:`); a half-done cut finishes from its tag; an unmoved exception tag must verify again with the reason it recorded (this now holds for security cuts too).
### Changed
- **One exception per cut.** `--security` and `--regression` together are `REFUSED_REGRESSION`. A regression cut passes a dated floor only; `FLOOR_UNKNOWN` (an undatable or unpublished stable) still yields to a security cut alone. The floor counts from any exception release, so the next scheduled cut may be held. `REFUSED_FLOOR` names both ways past it.
+11 -7
View File
@@ -159,7 +159,7 @@ 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
with `security`, `ref` and the reason its tag recorded, and verifies. The floor then counts from the security
release, and the next regular cut's notes list only the versions after it.
## Regression releases
@@ -180,15 +180,18 @@ The reason must name exactly one release `v<X>` and at least one `#<n>`.
All of these must hold, or the cut is `REFUSED_REGRESSION` with what it
checked:
1. `v<X>` is an annotated release tag strictly behind the current `stable`,
an earlier stable release team installs actually ran. The current stable
1. `v<X>` is an annotated release tag strictly behind the current `stable`
whose GitHub Release is published (not a draft): an earlier stable release
team installs actually ran, which a tag left behind by a half-done cut is
not. The current stable
itself does not count, since a fix for something it still does right needs
no exception.
2. `#<n>` carries the `regression` label (a `security` label does not count,
nor the reverse) and a commit in `stable`..`ref` references it (the squash
subject `(#<n>)` does).
**Verified:** the release tag and its place behind `stable`, the label, and
**Verified:** the release tag, its place behind `stable` and its published
release, the label, and
that the labelled issue or PR is in the released span. **Attested, not
verified:** that the behavior really worked in `v<X>` and that this change
restores it; the label and the public reason carry that claim.
@@ -199,7 +202,8 @@ rides along (`RIDING_ALONG:`), and the sanitized reason and evidence go into
the tag message (`Regression:` / `Verified:`) and the notes
(``**Regression release:** `<reason>` (verified: <evidence>)``). A half-done
regression cut finishes from its tag's record, and a regression tag that never
moved `stable` is refused until it runs again with `regression` and verifies.
moved `stable` is refused until it runs again with `regression` and the reason
its tag recorded, and verifies.
Unlike a security cut, a regression cut does not pass `FLOOR_UNKNOWN`. The
floor counts from the regression release, so the next scheduled cut may be
held.
@@ -307,8 +311,8 @@ is exercised against real check runs before it merges.
| `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 `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 |
| `REFUSED_REGRESSION: ...` | `regression` without `ref` or beside `security`, a reason without one earlier stable `v<X>` and a regression-labelled `#<n>` in the span, or a regression tag that never moved stable run without `regression`; exit 1 on every trigger |
| `REFUSED_SECURITY: ...` | `security` without `ref`, a reason the tool cannot tie to the released commits, a security tag that never moved stable run without `security`, or a rerun whose reason differs from the one its unmoved tag recorded; exit 1 on every trigger (the workflow stops `security` without `ref` before the script runs) |
| `REFUSED_REGRESSION: ...` | `regression` without `ref` or beside `security`, a reason without one earlier stable `v<X>` and a regression-labelled `#<n>` in the span, a regression tag that never moved stable run without `regression`, or a rerun whose reason differs from the one its unmoved tag recorded; exit 1 on every trigger (the workflow stops `regression` without `ref` before the script runs) |
| `FLOOR: clear|refused|unknown|none|not applied|bypassed by the security|regression exception (...)` | the floor verdict, in every plan |
| ``SECURITY: `<reason>` (verified: <evidence>)`` or ``REGRESSION: `<reason>` (verified: <evidence>)``, `RIDING_ALONG: <n> other versions ship with the fix` | an exception 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 |
+8 -3
View File
@@ -321,14 +321,15 @@ export function verifySecurity(o: { reason: string; git: Git; gh: Gh; repo: stri
* Ties a regression reason to the commits in (from, to]. A regression is a fix
* for behavior that worked in an earlier stable release. The reason names
* exactly one release v<X> and at least one #<n>; it is verified when v<X> is
* an annotated release tag strictly behind the current stable (so team installs
* ran it and have since moved past it), and issue or PR #<n> carries the
* an annotated release tag strictly behind the current stable with a published
* GitHub Release (so team installs ran it and have since moved past it; a tag a
* half-done cut left behind does not count), and issue or PR #<n> carries the
* regression label (a maintainer's declaration: labelling needs triage rights)
* and a commit in the span references #<n>. That the behavior really worked in
* v<X> stays attested, in public, by the label and the reason.
*/
export function verifyRegression(o: { reason: string; git: Git; gh: Gh; repo: string; from: string | null; to: string; stable: string | null }): { ok: true; evidence: string } | { ok: false; why: string } {
const releases = [...new Set([...o.reason.matchAll(/(?<![\w.])v(\d+(?:\.\d+)+)(?![\w.])/g)].map(m => 'v' + m[1]!))];
const releases = [...new Set([...o.reason.matchAll(/(?<![\w.])v(\d+(?:\.\d+)+)(?!\w|\.\d)/g)].map(m => 'v' + m[1]!))];
const issues = issueNumbers(o.reason);
if (releases.length !== 1 || issues.length === 0) return { ok: false, why: 'the reason must name exactly one earlier stable release v<X> the behavior worked in and the regression-labelled #<n> it fixes (named ' + releases.length + ' release' + (releases.length === 1 ? '' : 's') + ' and ' + issues.length + ' #<n>)' };
if (!o.stable) return { ok: false, why: 'there is no stable release yet, so nothing earlier can have worked' };
@@ -337,6 +338,9 @@ export function verifyRegression(o: { reason: string; git: Git; gh: Gh; repo: st
const worked = o.git(['rev-parse', '--verify', '-q', 'refs/tags/' + tag + '^{commit}']);
if (!type.ok || type.out !== 'tag' || !worked.ok) return { ok: false, why: tag + ' is not an annotated release tag here, so it cannot be the earlier stable release the behavior worked in' };
if (worked.out === o.stable || !o.git(['merge-base', '--is-ancestor', worked.out, o.stable]).ok) return { ok: false, why: tag + ' is not an earlier stable release: it must sit strictly behind the current stable ' + o.stable.slice(0, 12) + ', since a fix for something the current stable still does right needs no exception' };
const release = o.gh(['release', 'view', tag, '--repo', o.repo, '--json', 'isDraft']);
const published = release.status === 0 ? parseJson<{ isDraft?: boolean }>(release) : null;
if (!published || published.isDraft === true) return { ok: false, why: tag + ' has no published release, so team installs never ran it as stable' };
const labelled = labelledReference(o.gh, o.repo, spanCommits(o.git, o.from, o.to), issues, 'regression');
if (!labelled.ok) return { ok: false, why: 'nothing in ' + spanName(o.from, o.to) + ' ties the reason to the release: ' + labelled.why.join('; ') };
return { ok: true, evidence: labelled.evidence + '; ' + tag + ' (' + worked.out.slice(0, 12) + ') is an earlier stable release' };
@@ -483,6 +487,7 @@ export function planRelease(o: PlanOptions): PlanResult {
if (recorded && !exception && asked !== recorded.kind) return { ok: false, code: refusalFor(recorded.kind), message: 'tag ' + tag + ' records a ' + recorded.kind + ' cut that has not moved stable; run it again with --' + recorded.kind + ' and --ref ' + sha.slice(0, 12) + ' so the reason is verified' };
if (!exception && asked) {
const reason = sanitizeReason((asked === 'security' ? o.security : o.regression)!);
if (recorded && recorded.reason !== reason) return { ok: false, code: refusalFor(asked), message: 'tag ' + tag + ' records the ' + asked + ' reason "' + recorded.reason + '"; run it again with that same reason so the tag and the notes agree' };
const verified = asked === 'security' ? verifySecurity({ reason, git, gh, repo, from: before, to: sha }) : verifyRegression({ reason, git, gh, repo, from: before, to: sha, stable: prevStable });
if (!verified.ok) return { ok: false, code: refusalFor(asked), message: verified.why };
exception = { kind: asked, reason, evidence: verified.evidence };
+9 -2
View File
@@ -909,11 +909,12 @@ describe('release-cut: the regression exception', () => {
if (moved.at === null) throw new Error(moved.why);
const fix = f.release('1.0.5', 'Restores the update check.', { message: 'v1.0.5: restore the update check (#91)' });
git(f.seed, 'tag', 'v1.0.1', f.shas['1.0.1']!);
git(f.seed, 'push', '-q', 'origin', 'refs/tags/v1.0.1');
execFileSync('git', ['tag', '-a', 'v1.0.2', f.shas['1.0.2']!, '-m', 'gstack v1.0.2 (regular release)'], { 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.1', 'refs/tags/v1.0.2');
f.refresh();
const now = () => moved.at + H;
const reg = (ref: string | undefined, reason: string, extra: { security?: string } = {}) => planRelease({ repo: f.repo, ref, git: f.git, gh: f.gh, now, regression: reason, ...extra });
const reason = '#91: worked in v1.0.3: the update check stopped running';
const reason = '#91: the update check stopped running; it worked in v1.0.3.';
const refused = (r: ReturnType<typeof reg>) => { expect(r.ok).toBe(false); return r.ok ? '' : (expect(r.code).toBe('REFUSED_REGRESSION'), r.message); };
expect(refused(reg(undefined, reason))).toBe('--regression needs --ref <the fix commit>: a regression cut releases up to the fix, never whatever is newest');
@@ -924,6 +925,8 @@ describe('release-cut: the regression exception', () => {
expect(refused(reg(fix, '#91: worked in v1.0.4'))).toBe('v1.0.4 is not an earlier stable release: it must sit strictly behind the current stable ' + f.shas['1.0.4']!.slice(0, 12) + ', since a fix for something the current stable still does right needs no exception');
expect(refused(reg(fix, '#91: worked in v9.9.9'))).toBe('v9.9.9 is not an annotated release tag here, so it cannot be the earlier stable release the behavior worked in');
expect(refused(reg(fix, '#91: worked in v1.0.1'))).toBe('v1.0.1 is not an annotated release tag here, so it cannot be the earlier stable release the behavior worked in');
// An annotated tag a half-done cut left behind, with no published release, never ran as stable.
expect(refused(reg(fix, '#91: worked in v1.0.2'))).toBe('v1.0.2 has no published release, so team installs never ran it as stable');
f.state.api = { 'repos/o/r/issues/91': { labels: [{ name: 'security' }] }, 'repos/o/r/issues/92': { labels: [{ name: 'regression' }] } };
expect(refused(reg(fix, reason))).toEndWith('#91: no regression label');
expect(refused(reg(fix, '#92: worked in v1.0.3'))).toEndWith('#92: no commit in the span references it');
@@ -969,6 +972,10 @@ describe('release-cut: the regression exception', () => {
const forged = planRelease({ repo: f.repo, ref: later, git: f.git, gh: f.gh, now: () => moved.at + 3 * H, security: 'CVE-2026-2: x' });
expect(forged.ok).toBe(false);
if (!forged.ok) expect(forged).toMatchObject({ code: 'REFUSED_REGRESSION', message: 'tag v1.0.6 records a regression cut that has not moved stable; run it again with --regression and --ref ' + later.slice(0, 12) + ' so the reason is verified' });
// A different reason than the tag recorded would publish notes the tag (and a later retry) contradicts.
const other = planRelease({ repo: f.repo, ref: later, git: f.git, gh: f.gh, now: () => moved.at + 3 * H, regression: '#93: worked in v1.0.4: other' });
expect(other.ok).toBe(false);
if (!other.ok) expect(other).toMatchObject({ code: 'REFUSED_REGRESSION', message: 'tag v1.0.6 records the regression reason "#93: forged"; run it again with that same reason so the tag and the notes agree' });
});
// Value: protects=an undatable stable and the very first cut stay closed to a regression claim (FLOOR_UNKNOWN is security-only; nothing earlier worked