mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-10 02:30:39 +01:00
wip: changelog, coverage cases for tag-before-stable and multi-tag
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
6fb12f2281
commit
76b3e34526
2 files changed
+60
No files matched your search
@@ -1,5 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
**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.
|
||||
|
||||
### 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.
|
||||
- **Release notes say `(regular release)`, `(hotfix release)` or `(security release)`** instead of "weekly release", and the next regular cut after a security release lists only the versions since it.
|
||||
- **The stable push is pinned to the stable the plan read** (`--force-with-lease=refs/heads/stable:<sha>`), so two overlapping cuts cannot both move it.
|
||||
- **Docs:** `docs/releases.md` gains "The 72-hour floor" and "Security releases" (what is verified and what is attested) and the new outcome codes; the cadence sentence in `docs/install.md` and the release section of `docs/recommended-harness.md` follow.
|
||||
|
||||
## [1.91.284.0] - 2026-10-27
|
||||
|
||||
**Five small takes from tstack, in gstack's words.** Theo Browne's tstack (MIT, 2026-10-24) is nine short PR-landing skills; its best ideas that gstack lacked were a verb for an aging pull request, a Codex picker that says what each skill is for, a written grant for unattended sessions, a repo-level rule for porting a contributor's PR, and a visible author line on agent-posted comments. Each lands here as gstack prose and gstack code; no sentence is copied (NOTICE.md). Approved by Garry, 2026-10-26: "Do it, send" (yes to the opt-in attribution callout, off by default).
|
||||
|
||||
@@ -621,6 +621,11 @@ describe('release-cut: the 72-hour floor', () => {
|
||||
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');
|
||||
}
|
||||
// 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)' });
|
||||
|
||||
git(f.seed, 'push', '-q', '--delete', 'origin', 'refs/tags/v1.0.2');
|
||||
git(f.work, 'tag', '-d', 'v1.0.2');
|
||||
@@ -638,11 +643,46 @@ describe('release-cut: the 72-hour floor', () => {
|
||||
const base = { git: f.git, gh: f.gh, repo: f.repo, security: false, scheduled: false, now: Date.now() };
|
||||
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.
|
||||
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' });
|
||||
});
|
||||
|
||||
// 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
|
||||
// fast-forward that succeeds over a move made after planning; why_new=cuts never raced before the floor made the move time matter; seam=none (real bare origin);
|
||||
// no_claim=none beyond protects
|
||||
// Value: protects=a cut whose tag was pushed long before stable moved (a failed stable push, retried later) is still held by the floor and is dated by
|
||||
// its release, not by the old tag; fails_when=the old tag time lets the next cut through early or the retry skips the floor; why_new=the half-done tests
|
||||
// cover a missing release only; seam=gh release view is faked; no_claim=none beyond protects
|
||||
test('a tag pushed earlier without moving stable is held by the floor, and once promoted its release publish time dates the move', () => {
|
||||
const f = fixture();
|
||||
const at = cutFirst(f);
|
||||
const hot = f.release('1.0.4', 'Later work.');
|
||||
execFileSync('git', ['tag', '-a', 'v1.0.4', hot, '-m', 'gstack v1.0.4 (regular release)'], { cwd: f.seed, timeout: 30_000, env: { ...process.env, GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@x', GIT_COMMITTER_DATE: '2026-01-01T00:00:00Z' } });
|
||||
git(f.seed, 'push', '-q', 'origin', 'refs/tags/v1.0.4');
|
||||
f.refresh();
|
||||
const held = planRelease({ repo: f.repo, ref: hot, git: f.git, gh: f.gh, now: () => at + 10 * H });
|
||||
expect(held.ok).toBe(false);
|
||||
if (!held.ok) { expect(held.code).toBe('REFUSED_FLOOR'); expect(held.plan!.tagExists).toBe('same'); }
|
||||
const promo = planRelease({ repo: f.repo, ref: hot, git: f.git, gh: f.gh, now: () => at + 72 * H });
|
||||
expect(promo.ok, JSON.stringify(promo)).toBe(true);
|
||||
if (!promo.ok) return;
|
||||
const cut = cutRelease(promo.plan, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base });
|
||||
expect(cut.lines).toEqual(['TAG: v1.0.4 already names ' + hot.slice(0, 12), 'STABLE: ' + f.shas['1.0.3']!.slice(0, 12) + ' -> ' + hot.slice(0, 12), 'RELEASE: v1.0.4 published https://github.com/o/r/releases/tag/v1.0.4']);
|
||||
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' });
|
||||
f.release('1.0.5', 'Even later.');
|
||||
f.refresh();
|
||||
const next = planRelease({ repo: f.repo, git: f.git, gh: viaRelease, now: () => at + 100 * H });
|
||||
expect(next.ok).toBe(false);
|
||||
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'); }
|
||||
});
|
||||
|
||||
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 });
|
||||
@@ -799,6 +839,12 @@ describe.skipIf(process.platform === 'win32')('release-cut: refusals at the work
|
||||
expect(noRef.status).toBe(1);
|
||||
expect(noRef.stdout.split('\n')[0]).toBe('REFUSED_SECURITY: --security needs --ref <the fix commit>: a security cut releases up to the fix, never whatever is newest');
|
||||
expect(noRef.stdout).toContain('::error title=release-cut REFUSED_SECURITY::');
|
||||
// Value: protects=a refusal annotation stays one workflow command: % CR and LF in the message are escaped as %25 %0D %0A; fails_when=the escaping is
|
||||
// dropped, so caller-supplied text in --ref or a git error splits the ::error:: line and starts a new workflow command; why_new=every other annotation
|
||||
// message here is plain text; seam=none; no_claim=GitHub's rendering of the decoded annotation
|
||||
const escaped = run({ GITHUB_ACTIONS: 'true' }, '--ref', 'no%such\r\nref');
|
||||
expect(escaped.status).toBe(1);
|
||||
expect(escaped.stdout.split('\n').find(l => l.startsWith('::error'))).toStartWith('::error title=release-cut REFUSED::REFUSED: --ref no%25such%0D%0Aref is not a commit here: ');
|
||||
expect(run({}, '--security').status).toBe(2);
|
||||
expect(run({}, '--security', '--dry-run').status).toBe(2);
|
||||
|
||||
|
||||
Reference in new issue
Block a user