mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 06:24:41 +01:00
v1.91.342.0 ship-policy: gstack versions its own PRs at merge time (changelog_fragments on; GSTA-154 P) (#3654)
* captain: train land per slot under changelog_fragments (skill and skills.md) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship-policy: turn changelog_fragments on for gstack (required checks, key version 1.91.336.0); mergeChangelogs keeps a stacked child's only line inside its released parent's list Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review fixes: a carried child entry never repeats a released parent line; min_gstack_version 1.91.340.0; required-check names as GitHub reports them Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
5532345a10
commit
53522b1bbc
21 files changed
+124
-21
No files matched your search
@@ -14,5 +14,6 @@
|
||||
"pr_count": { "default_pr_count": 1, "split_requires_owner": true },
|
||||
"maintainers": ["garrytan"],
|
||||
"train": { "gate_workflow": "evals.yml", "free_workflow": "free-tests.yml", "full_gate_check": "gstack/full-gate", "record_artifact": "gstack-train-record", "max_size": 3, "fill_hours": 1, "screen_selection": "gstack-paid", "trial_reservation_usd": 1.5 },
|
||||
"min_gstack_version": "1.91.299.0"
|
||||
"changelog_fragments": { "dir": "changelog.d", "required_checks": ["free-tests", "windows-free-tests", "Check VERSION is not stale vs queue", "quality", "check-freshness", "actionlint"] },
|
||||
"min_gstack_version": "1.91.340.0"
|
||||
}
|
||||
@@ -1,5 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## [1.91.342.0] - 2026-10-30
|
||||
|
||||
**gstack now versions its own PRs at merge time: a PR adds `changelog.d/<branch-slug>.md` instead of a version, and trains merge through `train land` instead of slot calls.** `.gstack/ship-policy.json` turns `changelog_fragments` on with the checks every PR runs as `required_checks`, and `min_gstack_version` moves to 1.91.340.0, the first release that reads `required_checks` and counts a stamped slot as merged only when GitHub reports it MERGED, so an older gstack refuses the file by name.
|
||||
|
||||
### Changed
|
||||
- **`changelog_fragments` is on for gstack** (`.gstack/ship-policy.json`, `changelog.d/README.md`): `required_checks` lists `free-tests`, `windows-free-tests`, `Check VERSION is not stale vs queue`, `quality`, `check-freshness` and `actionlint`. Path-filtered workflows stay out of the list, because a stamp commit of a PR outside their paths never gets those checks.
|
||||
- **`CHANGELOG_FRAGMENTS_KEY_VERSION` is 1.91.336.0**, the version #3594 landed at, in place of the placeholder 1.92.0.0.
|
||||
- **/captain teaches `train land`**: under `changelog_fragments` there is no slot call; the controller runs `gstack-ship-queue train land` per slot in order (`captain/SKILL.md.tmpl`, `docs/skills.md`).
|
||||
|
||||
### Fixed
|
||||
- **A stacked PR whose only own line sits inside its released parent's list merges its CHANGELOG instead of refusing** (`lib/changelog-check.ts`): `mergeChangelogs` filed the line as an addition to the parent's released entry and returned null, so #3613's slot needed a hand resolve. When that would leave the child nothing of its own, the inserted lines are now the child's entry; on #3613's real CHANGELOGs the result equals the resolve that shipped.
|
||||
|
||||
### Tests
|
||||
- `test/changelog-merge-damage.test.ts`: a child whose only own bullet sits at the top of the released parent's `### Changed` list keeps it as its entry and passes `ownEntryAboveMain`; a child with no line of its own, a reordered parent bullet and a duplicated one stay null.
|
||||
- `test/gstack-ship-policy.test.ts`: every `required_checks` name is a check a pull_request workflow without a path filter reports (a job's `name:`, or its id when it has none), and `min_gstack_version` is at least the key's introducing version.
|
||||
|
||||
## [1.91.341.0] - 2026-10-30
|
||||
|
||||
**gstack no longer leaves Ubicloud VMs running after a lane dies (GST-118).** A runner killed mid-run, a copy of the runner without a TTL, or a VM with no owner could keep billing until someone noticed. Now every VM names its owner and its lifetime, the next `up` anywhere destroys anything past its lifetime, and every run says in one line whether its VM is gone.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.341.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.91.342.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
@@ -261,6 +261,13 @@ Never merge out of order, never merge anything else while a train is merging, an
|
||||
skip the second verify: a mismatch there is what writes the freeze. When every slot has
|
||||
merged, `train prune --train "$NONCE"` deletes the train branch.
|
||||
|
||||
**Under `changelog_fragments`** (a stamped train: `train build` printed `S1:`..`Sk:` lines)
|
||||
there is no slot call and no lane stamp. Per slot, in `ORDER`, run
|
||||
`gstack-ship-queue train land --train "$NONCE" --pr "$PR"` until it exits 0. It pushes the
|
||||
stamped prefix S_i as a commit on [pin, `BASE`], waits for the policy's `required_checks`,
|
||||
merges with `--match-head-commit` and confirms `BASE` is S_i exactly. A rerun resumes from
|
||||
its journal, and any other `BASE` freezes the queue as above (docs/ship-policy.md#train-land).
|
||||
|
||||
**The tree rule.** A gate covers a tree, not a commit. A head whose diff from the gated
|
||||
prefix touches only `STAMP_FILES` reuses the gate; that is what lets the lane stamp after
|
||||
the gate ran. Anything else is a new tree and needs a new gate.
|
||||
|
||||
@@ -189,6 +189,13 @@ Never merge out of order, never merge anything else while a train is merging, an
|
||||
skip the second verify: a mismatch there is what writes the freeze. When every slot has
|
||||
merged, `train prune --train "$NONCE"` deletes the train branch.
|
||||
|
||||
**Under `changelog_fragments`** (a stamped train: `train build` printed `S1:`..`Sk:` lines)
|
||||
there is no slot call and no lane stamp. Per slot, in `ORDER`, run
|
||||
`gstack-ship-queue train land --train "$NONCE" --pr "$PR"` until it exits 0. It pushes the
|
||||
stamped prefix S_i as a commit on [pin, `BASE`], waits for the policy's `required_checks`,
|
||||
merges with `--match-head-commit` and confirms `BASE` is S_i exactly. A rerun resumes from
|
||||
its journal, and any other `BASE` freezes the queue as above (docs/ship-policy.md#train-land).
|
||||
|
||||
**The tree rule.** A gate covers a tree, not a commit. A head whose diff from the gated
|
||||
prefix touches only `STAMP_FILES` reuses the gate; that is what lets the lane stamp after
|
||||
the gate ran. Anything else is a new tree and needs a new gate.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# CHANGELOG fragments
|
||||
|
||||
A PR adds its CHANGELOG entry here as `changelog.d/<branch-slug>.md`: the entry text only, with no `## [x]` heading, and `bump: minor` or `bump: major` front matter only when the change warrants more than a patch. It never edits VERSION, `package.json`'s version or a released CHANGELOG section. The merge step (`gstack-ship-queue land`, or `train land` in a stamped train) assigns the version, folds every fragment into one CHANGELOG section and deletes the files. This README is skipped. See docs/ship-policy.md#changelog-fragments.
|
||||
+15
-1
@@ -90,10 +90,24 @@ hard-code:
|
||||
"stamp_patterns": ["_KEY_VERSION = '{version}'"],
|
||||
"changelog": "CHANGELOG.md",
|
||||
"history": "squash-ok",
|
||||
"stamp_sensitive_gates": ["tests"]
|
||||
"stamp_sensitive_gates": ["tests"],
|
||||
"changelog_fragments": { "dir": "changelog.d", "required_checks": ["free-tests", "windows-free-tests", "Check VERSION is not stale vs queue", "quality", "check-freshness", "actionlint"] },
|
||||
"min_gstack_version": "1.91.340.0"
|
||||
}
|
||||
```
|
||||
|
||||
gstack itself runs on [changelog fragments](#changelog-fragments): a PR adds
|
||||
`changelog.d/<branch-slug>.md` and never a version, and trains merge through
|
||||
[`train land`](#stamped-trains-under-changelog_fragments-train-land) instead
|
||||
of slot calls. `required_checks` names the pull_request checks that run on
|
||||
every PR (branch protection enforces only `free-tests`); a workflow with a path
|
||||
filter (Windows Setup E2E, make-pdf gate, Native ARM Setup Smoke, Dependency
|
||||
Review) is left out, because a stamp commit of a PR outside its paths never
|
||||
gets that check and `train land` would hold on it. `min_gstack_version` is
|
||||
1.91.340.0, the first release that reads `required_checks` and counts a
|
||||
stamped slot as merged only when GitHub reports it MERGED, so an older gstack
|
||||
refuses the file by name (`POLICY_GSTACK_TOO_OLD`).
|
||||
|
||||
`.gstack/ship-policy.example.json` shows every key set the way a serial-merge
|
||||
repo with a release script, docs mentions and a mirror would set them.
|
||||
|
||||
|
||||
+4
-2
@@ -45,7 +45,7 @@ Detailed guides for every gstack skill — philosophy, workflow, and examples.
|
||||
| [`/write-skill`](#write-skill) | **Skill Editor** | Write or improve an agent skill without bloat: edit ladder, size per level, skill-to-bin drift check, full diets with a before/after test. |
|
||||
| [`/test-audit`](#test-audit) | **Test Auditor** | Sweep existing tests for low-value, implementation-coupled or duplicate tests. Report-only unless you approve a batch. |
|
||||
| [`/landing-report`](#landing-report) | **Ship Queue Dashboard** | Read-only snapshot of the workspace-aware ship queue. Which version slots are claimed, which sibling workspaces have WIP. |
|
||||
| [`/captain`](#captain) | **Crew Controller** | Run many coding agents from one controller. Lanes build and send a READY block; the controller validates it, merges through trains (stamp on call, verify, merge with the expected head, verify again), keeps the digest, relays approvals in three fixed shapes and runs a daily flaky-fix pass. Also answers to `/crew`. |
|
||||
| [`/captain`](#captain) | **Crew Controller** | Run many coding agents from one controller. Lanes build and send a READY block; the controller validates it, merges through trains (stamp on call, verify, merge with the expected head, verify again; `train land` per slot under `changelog_fragments`), keeps the digest, relays approvals in three fixed shapes and runs a daily flaky-fix pass. Also answers to `/crew`. |
|
||||
| [`/benchmark-models`](#benchmark-models) | **Model Benchmark** | Side-by-side cross-model benchmark for skills (Claude vs GPT vs Gemini). Latency, tokens, cost, optional LLM-judged quality. |
|
||||
| [`/eval-plan`](#eval-plan) | **Eval Planner** | Preregister a paid eval (bars, arms, decoys, stop rules), dry-run it for $0 with the stub model, pilot three priced items, then approve the spend from the extrapolation table. |
|
||||
| | | |
|
||||
@@ -1467,7 +1467,9 @@ receipt at the pin and the logged flaky cases); a green PR page or a cheap test
|
||||
never merge evidence. The controller batches READY pins into a **merge train**, reads one
|
||||
full gate on the train head (an in-run flaky rerun once; a case that failed twice is
|
||||
real), then merges slot by slot: slot call, lane stamps on call with a commit that holds
|
||||
only tool output, `verify --train`, `gh pr merge --match-head-commit`, verify again. A red
|
||||
only tool output, `verify --train`, `gh pr merge --match-head-commit`, verify again. Under
|
||||
`changelog_fragments` there is no slot call: `train land` publishes each slot's stamped
|
||||
prefix and merges it. A red
|
||||
train is bisected on its red cases and rebuilt at most once; a backstop gate on every
|
||||
base push freezes the queue when a tree no train covered lands. Approvals have three
|
||||
shapes (the per-thread record, the plan ask with before → after and a "do it" reply, the
|
||||
|
||||
+17
-4
@@ -254,11 +254,21 @@ function fragments(removed: string, added: string): { old: string; new: string }
|
||||
* the child changed inside them becomes a correction bullet under the child's
|
||||
* own `### Gate integrity` (the convention #3453's entry used for its parent),
|
||||
* so no wording the gate carried is lost and the released entry below stays
|
||||
* byte for byte main's. Null when the shape is something else: no single
|
||||
* `[Unreleased]` on our side, no released entry of theirs the base lacks whose
|
||||
* text our section holds, or nothing of the child's own left.
|
||||
* byte for byte main's. A line the child inserted between two of the parent's
|
||||
* lines counts as an addition to the parent's entry, unless that would leave
|
||||
* the child nothing of its own: then the inserted lines are the child's entry
|
||||
* (#3613 put its only bullet at the top of #3595's `### Changed` list), and a
|
||||
* line meant as an amendment to the parent lands there too. Null when the
|
||||
* shape is something else: no single `[Unreleased]` on our side, no released
|
||||
* entry of theirs the base lacks whose text our section holds, nothing of the
|
||||
* child's own left either way, or a child entry that repeats a released
|
||||
* parent line (a reordered or duplicated bullet is a hand resolve).
|
||||
*/
|
||||
export function carryReleasedParent(ours: string, theirs: string, base: string): { text: string; notes: string[] } | null {
|
||||
return carryWith(ours, theirs, base, true) ?? carryWith(ours, theirs, base, false);
|
||||
}
|
||||
|
||||
function carryWith(ours: string, theirs: string, base: string, insertsJoinParent: boolean): { text: string; notes: string[] } | null {
|
||||
const a = parseChangelog(ours);
|
||||
const unreleased = a.sections.filter(s => s.unreleased);
|
||||
if (unreleased.length !== 1) return null;
|
||||
@@ -268,11 +278,13 @@ export function carryReleasedParent(ours: string, theirs: string, base: string):
|
||||
const notes: string[] = [];
|
||||
const bullets: string[] = [];
|
||||
let carried = false;
|
||||
const parentLines = new Set<string>();
|
||||
for (const parent of parseChangelog(theirs).sections.filter(s => !s.unreleased && !baseKeys.has(s.version!))) {
|
||||
const pl = parent.lines.slice(1);
|
||||
const ownBlocks = entryBlocks({ ...unreleased[0]!, lines: ['', ...lines] });
|
||||
if (!entryBlocks(parent).some(b => ownBlocks.includes(b))) continue;
|
||||
carried = true;
|
||||
for (const l of pl) if (content(l)) parentLines.add(l);
|
||||
// LCS over content lines only: blanks and sub-headings recur in both entries and would anchor wrongly.
|
||||
const lcs = Array.from({ length: pl.length + 1 }, () => new Uint32Array(lines.length + 1));
|
||||
for (let i = pl.length - 1; i >= 0; i--) for (let j = lines.length - 1; j >= 0; j--) {
|
||||
@@ -302,7 +314,7 @@ export function carryReleasedParent(ours: string, theirs: string, base: string):
|
||||
}
|
||||
}
|
||||
// Lines added between two of the parent's own lines, or after its last beside an edit there, belong to its entry; the rest are the child's.
|
||||
if (k > 0 && (k < hunks.length - 1 || h.removed.length)) for (const j of h.added) {
|
||||
if (insertsJoinParent && k > 0 && (k < hunks.length - 1 || h.removed.length)) for (const j of h.added) {
|
||||
parentAt.add(j);
|
||||
bullets.push(`- Addition to the ${v} entry below: ${strip(lines[j]!)}`);
|
||||
notes.push(`${v}: added "${strip(lines[j]!).slice(0, 60)}"`);
|
||||
@@ -320,6 +332,7 @@ export function carryReleasedParent(ours: string, theirs: string, base: string):
|
||||
});
|
||||
while (kept.length && !kept.at(-1)!.trim()) kept.pop();
|
||||
if (!kept.some(content)) return null;
|
||||
if (kept.some(l => content(l) && parentLines.has(l))) return null;
|
||||
if (bullets.length) {
|
||||
const at = kept.findIndex(l => GATE_INTEGRITY_RE.test(l));
|
||||
if (at < 0) kept.push('', '### Gate integrity', ...bullets);
|
||||
|
||||
+1
-1
@@ -180,7 +180,7 @@ export const TRAIN_KEYS_VERSION = '1.91.146.0';
|
||||
// its key, so a value a stamp did not move fails there.
|
||||
export const REVIEW_SCOPE_KEY_VERSION = '1.91.256.0';
|
||||
export const DOCS_CAPS_KEY_VERSION = '1.91.270.0';
|
||||
export const CHANGELOG_FRAGMENTS_KEY_VERSION = '1.92.0.0';
|
||||
export const CHANGELOG_FRAGMENTS_KEY_VERSION = '1.91.336.0';
|
||||
export const POLICY_KEY_INTRODUCED: Record<PolicyKey, string> = {
|
||||
bump: '1.91.73.0', queue_mode: '1.91.73.0', release_tool: '1.91.73.0', release_outputs: '1.91.73.0', stamp_paths: '1.91.73.0',
|
||||
changelog: '1.91.73.0', mirror: '1.91.73.0', history: '1.91.73.0', preregistration_shas: '1.91.73.0', stamp_sensitive_gates: '1.91.73.0',
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.91.341",
|
||||
"version": "1.91.342",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -12,7 +12,7 @@ import { mergePolicy, validatePolicy } from '../lib/ship-policy';
|
||||
import { buildReceipt } from '../lib/tree-receipt';
|
||||
import { BASE_POLICY, cleanup, makeFixtureRepo, readFile, restamp, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
|
||||
const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' };
|
||||
const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' };
|
||||
const repos: FixtureRepo[] = [];
|
||||
const fixture = () => { const r = makeFixtureRepo({ policy: POLICY }); repos.push(r); return r; };
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
|
||||
@@ -139,6 +139,24 @@ describe("a stacked child whose parent was released since the fork (GSTA-96, tra
|
||||
expect(carryReleasedParent(`# C\n\n## [Unreleased]\n\n- a\n\n## [Unreleased]\n\n**Parent paragraph.**\n\n### Changed\n- one\n\n## [1.0.0] - d\n\n- base\n`, theirs, base)).toBeNull();
|
||||
});
|
||||
|
||||
// Value: protects=a stacked child whose only own line sits inside the released parent's list merges instead of refusing; fails_when=the inserted line is filed as an addition to the parent and the merge returns null (#3613 slot 2, train 57, needed a hand resolve); why_new=every shape above has an own entry outside the parent's lines; seam=none; no_claim=where a line inserted beside the child's own entry goes
|
||||
test("#3613's shape: a child whose only own line sits inside the released parent's list keeps that line as its entry", () => {
|
||||
const released = '## [1.0.1] - d\n\n**Parent paragraph.**\n\n### Added\n- new thing\n\n### Changed\n- one\n- two\n';
|
||||
const theirs = `# C\n\n${released}\n## [1.0.0] - d\n\n- base\n`;
|
||||
const base = '# C\n\n## [Unreleased]\n\n**Parent paragraph.**\n\n### Added\n- new thing\n\n### Changed\n- one\n- two\n\n## [1.0.0] - d\n\n- base\n';
|
||||
const child = '# C\n\n## [Unreleased]\n\n**Parent paragraph.**\n\n### Added\n- new thing\n\n### Changed\n- mine\n- one\n- two\n\n## [1.0.0] - d\n\n- base\n';
|
||||
const r = carryReleasedParent(child, theirs, base)!;
|
||||
expect(r.notes).toEqual([]);
|
||||
expect(r.text).toBe('# C\n\n## [Unreleased]\n\n### Changed\n- mine\n\n## [1.0.0] - d\n\n- base\n');
|
||||
const merged = mergeChangelogs(child, theirs, base)!;
|
||||
expect(merged).toBe(`# C\n\n## [Unreleased]\n\n### Changed\n- mine\n\n${released}\n## [1.0.0] - d\n\n- base\n`);
|
||||
expect(ownEntryAboveMain(merged, theirs, new Set(['### Changed', '- mine', '- one', '- two', '- new thing', '### Added', '**Parent paragraph.**']))).toBeNull();
|
||||
expect(carryReleasedParent('# C\n\n## [Unreleased]\n\n**Parent paragraph.**\n\n### Added\n- new thing\n\n### Changed\n- one\n- two\n\n## [1.0.0] - d\n\n- base\n', theirs, base)).toBeNull();
|
||||
// A reordered or duplicated parent bullet is not the child's entry: it stays a hand resolve.
|
||||
expect(mergeChangelogs('# C\n\n## [Unreleased]\n\n**Parent paragraph.**\n\n### Added\n- new thing\n\n### Changed\n- two\n- one\n\n## [1.0.0] - d\n\n- base\n', theirs, base)).toBeNull();
|
||||
expect(mergeChangelogs('# C\n\n## [Unreleased]\n\n**Parent paragraph.**\n\n### Added\n- new thing\n\n### Changed\n- one\n- one\n- two\n\n## [1.0.0] - d\n\n- base\n', theirs, base)).toBeNull();
|
||||
});
|
||||
|
||||
// Value: protects=carryReleasedParent rewrites only a child that still holds the released parent's entry and keeps an existing Gate integrity section's neighbours in place; fails_when=an unrelated [Unreleased] or an already-stamped head is rewritten or the correction lands outside Gate integrity; why_new=the shapes test above never has a child without the parent's entry, a head without [Unreleased], or a Gate integrity section followed by another heading; seam=none; no_claim=the wording of corrections beyond one edited line
|
||||
test('shapes: a child that does not hold the released parent, or a head already stamped, is null; a correction joins the child\'s existing Gate integrity section without crossing the next heading', () => {
|
||||
const released = '## [1.0.1] - d\n\n**Parent paragraph.**\n\n### Changed\n- one\n- two\n- three\n';
|
||||
|
||||
@@ -8,8 +8,9 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { DEFAULT_POLICY, POLICY_KEYS, POLICY_KEY_INTRODUCED, REVIEW_SCOPE_KEY_VERSION, globToRegExp, matchesAny, validatePolicy } from '../lib/ship-policy';
|
||||
import { CHANGELOG_FRAGMENTS_KEY_VERSION, DEFAULT_POLICY, POLICY_KEYS, POLICY_KEY_INTRODUCED, REVIEW_SCOPE_KEY_VERSION, globToRegExp, matchesAny, validatePolicy } from '../lib/ship-policy';
|
||||
import { fillDue } from '../lib/ship-queue-train';
|
||||
import { cmpVersion, parseVersion } from '../lib/version-source';
|
||||
import { BASE_POLICY, cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
@@ -126,6 +127,27 @@ describe("gstack's own .gstack/ship-policy.json", () => {
|
||||
for (const wf of [raw.train.gate_workflow, raw.train.free_workflow]) expect(fs.existsSync(path.join(root, '.github/workflows', wf))).toBe(true);
|
||||
});
|
||||
|
||||
// Value: protects=train land on gstack itself: every changelog_fragments.required_checks name is a job of a pull_request workflow without a path filter, so a stamp commit always gets it; fails_when=a name is misspelled, names a path-filtered workflow's job, or names a job id GitHub reports under its `name:` instead, and train land holds every slot whose PR skips those paths; why_new=the key was off on gstack; seam=the policy file and .github/workflows; no_claim=that the checks pass
|
||||
test('turns changelog_fragments on with required checks every PR runs', () => {
|
||||
const root = path.join(import.meta.dir, '..');
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(root, '.gstack', 'ship-policy.json'), 'utf8'));
|
||||
expect(raw.changelog_fragments.dir).toBe('changelog.d');
|
||||
expect(fs.existsSync(path.join(root, 'changelog.d', 'README.md'))).toBe(true);
|
||||
expect(cmpVersion(parseVersion(raw.min_gstack_version)!, parseVersion(CHANGELOG_FRAGMENTS_KEY_VERSION)!)).toBeGreaterThanOrEqual(0);
|
||||
const jobs = new Set<string>();
|
||||
for (const f of fs.readdirSync(path.join(root, '.github/workflows')).filter(f => f.endsWith('.yml'))) {
|
||||
const text = fs.readFileSync(path.join(root, '.github/workflows', f), 'utf8');
|
||||
const on = text.slice(text.search(/^on:/m), text.search(/^jobs:/m));
|
||||
if (!/^\s+pull_request:/m.test(on) || /^\s+paths(-ignore)?:/m.test(on)) continue;
|
||||
const body = text.slice(text.search(/^jobs:/m));
|
||||
for (const m of body.matchAll(/^ ([A-Za-z0-9_-]+):\n((?: .*\n|\s*\n)*)/gm)) {
|
||||
const name = /^ name:\s*(.+)$/m.exec(m[2]!)?.[1]?.trim();
|
||||
jobs.add(name ?? m[1]!);
|
||||
}
|
||||
}
|
||||
expect(raw.changelog_fragments.required_checks.filter((n: string) => !jobs.has(n))).toEqual([]);
|
||||
});
|
||||
|
||||
// Value: protects=the fill rule the coordinator relies on: a train dispatches at 3 READY PRs or one hour after the first READY, whichever comes first
|
||||
// (2026-10-26: 5 PRs / 3 hours left READY PRs waiting most of a day); fails_when=the live block drifts back to a larger or slower fill; why_new=the values were unpinned; seam=the policy file; no_claim=fillDue's arithmetic (ship-queue-train-logic)
|
||||
test('fills a train at 3 READY PRs or one hour after the first READY, whichever comes first', () => {
|
||||
@@ -161,7 +183,7 @@ describe('gstack-ship-policy (bin)', () => {
|
||||
expect(r.out).toMatch(/^ship: release_tool: `node gen\.js` runs only inside gstack-restamp/m);
|
||||
expect(r.out).not.toMatch(/^ship: history/m);
|
||||
expect(r.out).not.toMatch(/^ship: changelog_fragments/m);
|
||||
const frag = fixture({ policy: { ...BASE_POLICY, changelog_fragments: { dir: 'changelog.d/' }, min_gstack_version: '1.92.0.0' } });
|
||||
const frag = fixture({ policy: { ...BASE_POLICY, changelog_fragments: { dir: 'changelog.d/' }, min_gstack_version: '1.91.336.0' } });
|
||||
expect(runBin(frag, 'gstack-ship-policy', ['show-effective', '--base', 'main']).out).toMatch(/^ship: changelog_fragments changelog\.d: Step 12 writes no version and Step 13 writes the entry as changelog\.d\/<branch-slug>\.md .*never an \[Unreleased\] section; lint it with `gstack-prose-lint changelog\.d\/<branch-slug>\.md --gate`/m);
|
||||
const strict = fixture({ policy: { ...BASE_POLICY, history: 'merge-only', queue_mode: 'claim-at-open', bump: 'ask', release_tool: null, preregistration_shas: ['0123456789abcdef0123456789abcdef01234567'] } });
|
||||
const lines = runBin(strict, 'gstack-ship-policy', ['show-effective', '--base', 'main']).out.split('\n').filter(l => l.startsWith('ship: '));
|
||||
|
||||
@@ -297,7 +297,7 @@ describe.skipIf(process.platform === 'win32')('clear --receipt under changelog_f
|
||||
test('the fold of a deleted fragment is a stamp; a fold that changed the text is not', () => {
|
||||
const repo = makeFixtureRepo({
|
||||
pregate: { ...BASE_PREGATE, remote_gate: GATE },
|
||||
baseFiles: { '.gstack/ship-policy.json': JSON.stringify({ ...POLICY, changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' }, null, 2) + '\n', VERSION: '1.0.0\n', 'CHANGELOG.md': '# Changelog\n\n## [1.0.0] - 2026-10-01\n\n- base\n' },
|
||||
baseFiles: { '.gstack/ship-policy.json': JSON.stringify({ ...POLICY, changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' }, null, 2) + '\n', VERSION: '1.0.0\n', 'CHANGELOG.md': '# Changelog\n\n## [1.0.0] - 2026-10-01\n\n- base\n' },
|
||||
});
|
||||
repos.push(repo);
|
||||
repo.git('checkout', '-q', 'feature');
|
||||
|
||||
@@ -17,7 +17,7 @@ import { resolveReleaseConflicts } from '../lib/ship-queue-land';
|
||||
import { mergePolicy } from '../lib/ship-policy';
|
||||
import { persist, repoIdentity } from '../lib/ship-queue-train-record';
|
||||
|
||||
const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' };
|
||||
const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' };
|
||||
const repos: FixtureRepo[] = [];
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
GREEN_GATE, RED_GATE, TRAIN_POLICY, line, nonceOf, openPr, queue, readyReceipt, shippedBody, trainFixture, trainJson, type TrainFixture,
|
||||
} from './helpers/ship-queue-train-fixture';
|
||||
|
||||
const POLICY = { ...TRAIN_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' };
|
||||
const POLICY = { ...TRAIN_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' };
|
||||
const fixtures: TrainFixture[] = [];
|
||||
afterEach(() => { while (fixtures.length) cleanup(fixtures.pop()!.repo); });
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { stampChain, stampMismatch, type StampChainOptions } from '../lib/ship-q
|
||||
import type { TrainSlot } from '../lib/ship-queue-train-record';
|
||||
import { BASE_POLICY, cleanup, makeFixtureRepo, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
|
||||
const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' };
|
||||
const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' };
|
||||
const repos: FixtureRepo[] = [];
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('the policy gate', () => {
|
||||
|
||||
// Value: protects=under changelog_fragments ready records the READY receipt land checks, only after every ready check passed; fails_when=land has no proof of the pregate; why_new=the case above covers the key off; seam=none; no_claim=the notes push to a real GitHub remote
|
||||
test('under changelog_fragments ready records a READY receipt; a failed pregate records none', () => {
|
||||
const fx = make({ ...BASE_POLICY, changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' });
|
||||
const fx = make({ ...BASE_POLICY, changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' });
|
||||
const head = openPr(fx, '1');
|
||||
fx.write(d => { d.ci[head] = [{ status: 'completed', conclusion: 'success' }]; });
|
||||
const tree = runBin(fx.repo, 'gstack-wtree', []).out.trim();
|
||||
|
||||
@@ -10,7 +10,7 @@ import * as path from 'node:path';
|
||||
import { BASE_POLICY, cleanup, makeFixtureRepo, ROOT, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
|
||||
const SCRIPT = path.join(ROOT, 'scripts', 'compare-pr-version.ts');
|
||||
const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' };
|
||||
const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' };
|
||||
const repos: FixtureRepo[] = [];
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
const fixture = () => { const r = makeFixtureRepo({ policy: POLICY, prefix: 'version-gate-frag-' }); repos.push(r); return r; };
|
||||
|
||||
Reference in new issue
Block a user