mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-04 15:57:33 +01:00
Merge origin/main into #3381 and stamp v1.91.194.0 (train slot 3)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
commit
e59477bb5a
59 files changed
+1597
-230
No files matched your search
@@ -238,12 +238,43 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Push runs check out main with `checkout -B main`, so the clone holds a
|
||||
# local main; PR runs are a detached merge ref and train dispatches sit on
|
||||
# train/<nonce>. A fixture that made its own main passed its PR and then
|
||||
# failed every push to main (#3237, run 38999168852). Give every run the
|
||||
# push layout so a PR run catches what the main push would.
|
||||
- name: Match main's local branch layout on PR and dispatch runs
|
||||
if: github.ref != 'refs/heads/main'
|
||||
run: git checkout -q -B main
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: 1.4.3
|
||||
bun-version: 1.4.4
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Read the Playwright version for the browser cache key
|
||||
id: playwright
|
||||
run: echo "version=$(node -p 'require("playwright-core/package.json").version')" >> "$GITHUB_OUTPUT"
|
||||
# Keyed on the Playwright version, which fixes the Chromium revision: a
|
||||
# lockfile bump that keeps Playwright reuses the browser, and a Playwright
|
||||
# bump never restores a stale revision.
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: linux-playwright-chromium-${{ steps.playwright.outputs.version }}
|
||||
# The cache restores the browser; --with-deps still runs apt for system
|
||||
# libraries. On 2026-10-21 apt stalled on an archive.ubuntu.com fetch and
|
||||
# held free-suite (12) for the whole 20-minute job timeout (run
|
||||
# 39641712129). Each attempt gets 5 minutes (a warm install takes about
|
||||
# 15 s) and the install alone is retried once. This retries infrastructure,
|
||||
# never a test.
|
||||
- name: Install Playwright Chromium
|
||||
id: playwright_install
|
||||
timeout-minutes: 5
|
||||
continue-on-error: true
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Retry the Playwright Chromium install once
|
||||
if: steps.playwright_install.outcome == 'failure'
|
||||
timeout-minutes: 5
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Configure the bundled Chromium sandbox helper
|
||||
run: |
|
||||
@@ -294,6 +325,15 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Push runs check out main with `checkout -B main`, so the clone holds a
|
||||
# local main; PR runs are a detached merge ref and train dispatches sit on
|
||||
# train/<nonce>. A fixture that made its own main passed its PR and then
|
||||
# failed every push to main (#3237, run 38999168852). Give every run the
|
||||
# push layout so a PR run catches what the main push would.
|
||||
- name: Match main's local branch layout on PR and dispatch runs
|
||||
if: github.ref != 'refs/heads/main'
|
||||
run: git checkout -q -B main
|
||||
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: 1.4.2
|
||||
@@ -314,16 +354,33 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Read the Playwright version for the browser cache key
|
||||
id: playwright
|
||||
run: echo "version=$(node -p 'require("playwright-core/package.json").version')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Keyed on the Playwright version, which fixes the Chromium revision: a
|
||||
# lockfile bump that keeps Playwright reuses the browser, and a Playwright
|
||||
# bump never restores a stale revision.
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: linux-playwright-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
linux-playwright-
|
||||
key: linux-playwright-chromium-${{ steps.playwright.outputs.version }}
|
||||
|
||||
# Cache restores browser binaries; install is still required for system
|
||||
# deps and is a fast no-op for already-present browsers.
|
||||
# The cache restores the browser; --with-deps still runs apt for system
|
||||
# libraries. On 2026-10-21 apt stalled on an archive.ubuntu.com fetch and
|
||||
# held free-suite (12) for the whole 20-minute job timeout (run
|
||||
# 39641712129). Each attempt gets 5 minutes (a warm install takes about
|
||||
# 15 s) and the install alone is retried once. This retries infrastructure,
|
||||
# never a test.
|
||||
- name: Install Playwright Chromium
|
||||
id: playwright_install
|
||||
timeout-minutes: 5
|
||||
continue-on-error: true
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Retry the Playwright Chromium install once
|
||||
if: steps.playwright_install.outcome == 'failure'
|
||||
timeout-minutes: 5
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Configure the bundled Chromium sandbox helper
|
||||
|
||||
+104
-1
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
## [1.91.194.0] - 2026-10-25
|
||||
|
||||
**A read-only repo can now opt in to Memorable recall, one remote at a time, and a corrupt policy store no longer turns the recall veto off.** #3359 (exGeni) asked for a way to keep a repo out of the brain while still letting its prompts reach Memorable: today the recall hook skips every `deny` and `read-only` repo, and `read-only` is also the tier people pick just to stop code ingest. Garry approved a per-remote flag on 2026-10-24: off by default, and a `deny` always wins. While building the negative controls we found that the hook's policy lookup went through `get`, which quarantines a corrupt store and answers "unset", so a truncated `gbrain-repo-policy.json` let recall run in every repo, read-only and deny included, and reset the store on the way.
|
||||
|
||||
@@ -28,6 +28,109 @@
|
||||
- One deliberate widening of a privacy veto, approved by Garry: a read-only remote with an explicit opt-in is recalled. Nothing else is loosened, and the corrupt-store path is tightened. Seventeen mutations of the new gate were run against the focused tests. Sixteen turn at least one test red: the hook ignoring the opt-in, the hook dropping deny, the CLI reporting the opt-in on any tier, an unkeyed opt-in, a corrupt store read as no policy, the second store ignored, an opt-in accepted on deny, the hook using the rewritten URL, an existsSync fast path, any JSON document accepted, a non-file path read as absent, the hook checking only the last origin URL, a missing path trusted without checking its ancestors, origin URLs split on newlines or trimmed, and the client fast path ignoring a dangling ancestor symlink. The seventeenth, the client reading unknown script output as no policy, stays green because the script can no longer produce such output. That check is a second layer and is left in place. No threshold, budget, timeout, trial count or case kind changed.
|
||||
- Known limit, unchanged by this PR: remotes that the store already treats as one key (`host:8443/x` and `host/8443/x`, or paths that differ only in case) share the opt-in exactly as they already share a tier.
|
||||
|
||||
## [1.91.193.0] - 2026-10-25
|
||||
|
||||
**Windows: the browse terminal agent stops respawning every 60 seconds and stops flashing a console when Bun came from npm (#2295; ported from #3357 by @adammartin0087).** The agent was spawned as a bare `bun`. With Bun installed through `npm install -g bun`, that name resolves to npm's `bun.cmd` shim and runs as `cmd.exe /c bun …`, so `windowsHide` hid only `cmd.exe` while `bun.exe` opened its own visible console, and the recorded pid was `cmd.exe`'s. The agent's startup check never matched its record, it exited after 2 seconds, and the watchdog respawned it.
|
||||
|
||||
### Fixed
|
||||
- **`browse/src/terminal-agent-control.ts`**: `spawnTerminalAgent` spawns `resolveBunExecutable()`, which on Windows is the real `bun.exe`: the running `bun.exe` itself, then `BUN_INSTALL\bin`, each absolute `PATH` entry (and, next to an npm `bun.cmd` shim, the `node_modules\bun\bin\bun.exe` it forwards to), then `USERPROFILE\.bun\bin`. With none found it falls back to `bun`, as before. macOS and Linux are unchanged.
|
||||
- **`browse/src/bun-child-config.ts`**, **`browse/src/cookie-import-native.ts`**: that lookup is `findWindowsBunExe()`, and the Windows native cookie-import worker now uses it instead of its own copy, so it also finds an npm-global Bun. It lives in `bun-child-config.ts` because that file is already in the native import's source-bound qualification set (`NATIVE_CODE_INPUTS`). The edit changes the hash of `cookie-import-native.ts` and `bun-child-config.ts`; the qualification list is empty today, so no receipt is invalidated.
|
||||
|
||||
### Tests
|
||||
- `browse/test/terminal-agent-bun-exe.test.ts` (from the PR) covers POSIX, the running `bun.exe`, a compiled `browse.exe`, the npm shim, lookup order and the fallback, plus a source check that the spawn site never passes a bare `'bun'`. New: the shared lookup returns null when nothing is installed, finds the npm-global `bun.exe` for the cookie worker, and `cookie-import-native.ts` has no candidate list of its own.
|
||||
- Not verified here: real Windows behaviour. The PR author tested on Windows 11; the `windows-free-tests` lane runs the new unit tests.
|
||||
|
||||
## [1.91.192.0] - 2026-10-25
|
||||
|
||||
**Merge trains stop refusing correct heads and stop passing damaged CHANGELOGs.** This week's trains hit five defects in the ship-queue tooling. #3331's READY was invisible to the coordinator because its lane's origin was `https://git.capy.ai/...`. #3345's pin replaced the released `## [1.91.179.0]` heading, and three things then failed. The Version Gate passed the pin. `mergeChangelogs` copied 179's body into `[Unreleased]`. `verify --train` refused the correct hand-resolved head at 593e61c. Separately, the train slotted the /cso docs PR (#3337) ahead of the catalog PR it describes (#3343). Each defect now has a regression test built from the real case.
|
||||
|
||||
### Fixed
|
||||
- **`lib/version-gate-train.ts`**: the unstamped-head check read CHANGELOG.md through `spawnSync`'s default 1 MiB buffer. gstack's CHANGELOG is 1.67 MB, so both reads failed and the check compared two empty strings and passed. That is how #3345's 1eb7092 got through: `classifyTrainHead(base a59b5e4, head 1eb7092)` returned `unstamped`. The buffer is now 64 MiB, a CHANGELOG that can't be read is refused, and a dropped released heading is named in the error ("CHANGELOG.md drops released heading 1.91.179.0"). `gstack-ship-queue ready` runs the same check and refuses with `QUEUE_NOT_READY`.
|
||||
- **`lib/changelog-check.ts`**: `mergeChangelogs(ours, theirs, base?)` returns null, meaning resolve by hand, when either side damaged released history. Damage means one of two things: a released heading the merge base carries is missing from that side (dropped or renamed), or a section only that side carries holds a paragraph of a released entry the other side has. The train merge, the gate-ahead recompute and `gstack-restamp --after` all pass the merge base. A damaged PR is now `QUEUE_TRAIN_CONFLICT` on CHANGELOG.md with the reason. Before, the damage went into T<i>.
|
||||
- **`lib/ship-queue-train-verify.ts`**: `verify --train` refused #3345's correct stamped head because T1's CHANGELOG was the damaged mechanical merge. The refusal printed the diff against the pin (main's files), not the diff against T1. A merged-in head is now accepted when it contains main and equals T<i> modulo stamps, with its CHANGELOG judged against main: main's header and sections must be unchanged, with only lines from the pin's own CHANGELOG diff above them. The refusal now names the real difference.
|
||||
- **`lib/ship-queue-train-record.ts`**: READY receipts, train records and freezes are keyed on origin normalized to `https://github.com/<owner>/<repo>`, whatever URL form the lane uses (github.com, git.capy.ai, scp, ssh, a token URL, with or without `.git`). The canonical key equals the github.com key existing records already use. Readers also accept the keys older versions wrote for the common URL forms, so #3331's `1fb5dd69740a8cf8` receipt is found. The stored `repo` URL never carries credentials.
|
||||
- **`lib/restamp.ts`**: its git reads use a 64 MiB buffer too. Before, `gstack-restamp --after` read each CHANGELOG conflict stage through the 1 MiB default.
|
||||
|
||||
### Added
|
||||
- **`gstack-ship-queue train build --order <pr>,...`**: an explicit slot order naming every pinned PR once. The default order (screened first, then READY order) is unchanged, and the train's later `NEXT:` rebuild lines keep the explicit order.
|
||||
|
||||
### Tests
|
||||
- `test/changelog-merge-damage.test.ts`: the GSTA-64 repro, plus #3345's real CHANGELOGs (pin 1eb7092, base a59b5e4, main 4a98e40, train T1 6755ab8, stamped de23f5d, under `test/fixtures/merge-train-3345/`). Negative controls: a correct hand resolve, a shared tentative stamp, and two `[Unreleased]` blocks are not damage. An invented line, an edited main section, a changed header and a duplicate heading are still refused.
|
||||
- `test/compare-pr-version.test.ts`: on a CHANGELOG over 1 MiB, #3345's edit is refused naming 1.91.179.0, an additive entry passes, and a corrupt CHANGELOG object is refused. All three failed before the fix.
|
||||
- `test/ship-queue-train.test.ts`: a git.capy.ai lane writes the owner/repo key, and a legacy-key receipt is admitted. A damaged PR is a CHANGELOG conflict, and ready refuses it. `--order` sets and records the order. A hand-resolved merged-in head verifies, while an invented CHANGELOG line, a head without main and a stray file are refused. `test/ship-queue-train-logic.test.ts`: URL normalization, #3331's real keys, `orderSlots` and `parseOrder`.
|
||||
|
||||
### Gate integrity
|
||||
- Version Gate unstamped check: **stronger**. It refuses a head it can't read and names dropped headings. Additive heads still pass at full size.
|
||||
- `mergeChangelogs` and the train CHANGELOG merge: **stronger**. Damage is now a conflict.
|
||||
- `verify --train` merged-in path: **narrower in one place, stronger elsewhere**. The head must now contain main. A CHANGELOG that differs from T<i> is accepted only as main's CHANGELOG plus lines the pin itself added. Every other file must still equal T<i> modulo stamps, and the paid gate never reads CHANGELOG text.
|
||||
- READY receipt lookup: **unchanged strength**. The head and CI binding are the same; only the repository key is normalized.
|
||||
|
||||
## [1.91.191.0] - 2026-10-25
|
||||
|
||||
**`gstack-version-bump write --changelog` moves the branch's `[Unreleased]` entry under the new version, and /ship Step 12 stops treating a provisional queue pick as a verified slot.** Stamping by hand meant writing VERSION with `gstack-version-bump write` and then renaming the `## [Unreleased]` heading yourself, because only `gstack-restamp` re-headed the CHANGELOG. Separately, when `gstack-next-version` could not read some open PRs' VERSION it returned `complete:false` and a provisional pick, and Step 12 printed the warning and then wrote that version as if the queue had been checked (left over from #3258, noted in GSTA-45).
|
||||
|
||||
### Fixed
|
||||
- **`bin/gstack-version-bump`**: `write` takes an opt-in `--changelog <path>`. It re-heads an existing `## [Unreleased]` section as `## [<version>] - <UTC date>` at the top, using the same `reheadChangelog` that `gstack-restamp` uses, so the result is byte-for-byte what a restamp produces. Only the heading changes; every entry is kept. A file with no `[Unreleased]` section, or no file, is left alone, so a released heading is never renamed. A move that would duplicate a version heading exits 2 before anything is written. The output gains a `changelog` field only when the flag is passed. `gstack-restamp` doesn't pass the flag, so under stamp-at-merge it still owns the re-heading, and a later restamp moves an entry this command headed as the branch's own stamp.
|
||||
- **`ship/SKILL.md.tmpl` Step 12**: an incomplete queue (`complete:false`) reruns the queue check once, then asks whether to use the provisional candidate (recommended, because Version Gate CI rereads the queue on every push) or stop, naming the PRs in `unknown_claims`. The write in item 4 passes `--changelog CHANGELOG.md`, so an `[Unreleased]` entry written early sits under the new version before Step 13 extends it.
|
||||
|
||||
### Tests
|
||||
- `test/gstack-version-bump.test.ts`: eight cases for `--changelog`. They cover the re-heading (checked with `normalizeChangelog` and `checkChangelog`), an entry below the predecessor after a main merge, reruns, a file with no `[Unreleased]`, a missing file, the duplicate-heading refusal, bad paths, and a real `gstack-restamp` run after the write (`already headed`, then `1.2.4.0 -> 1.2.5.0`). Seven of them failed before the fix.
|
||||
- `test/ship-version-sync.test.ts`: the rendered Step 12 has to handle `complete:false`, using the field names `gstack-next-version` emits. Its write command, run verbatim, has to re-head `[Unreleased]` and leave a released heading alone. Both tests failed before the fix.
|
||||
|
||||
## [1.91.190.0] - 2026-10-25
|
||||
|
||||
**The weekly model-policy freshness check reads OpenAI's deprecations page again (#3155).** It has been red since the 2026-10-19 run: OpenAI's page now writes some deprecated models as a display name followed by the backticked ids, for example ``GPT-4o (`gpt-4o-2024-08-06`, `gpt-4o-2024-11-20`)``, and the parser refused any model cell that did not start with a backtick, so every run stopped with `openai-lifecycle:unrecognized-deprecated-model-cell`. Left alone, the stored pinned-model evidence would have gone stale around 2026-11-11.
|
||||
|
||||
### Fixed
|
||||
- **`lib/model-policy-freshness-sources.ts`**: an OpenAI deprecated-model cell is accepted when it contains backticked model ids, wherever they sit in the cell, and each id becomes its own lifecycle row. A cell with no backticked id still fails with `unrecognized-deprecated-model-cell`. The live page (42,211 bytes, fetched 2026-10-24) now parses to 177 rows, including the five ids from its four display-name rows.
|
||||
|
||||
### Tests
|
||||
- `test/fixtures/model-policy-freshness/openai-lifecycle.md` gains a display-name row, and `test/model-policy-freshness.test.ts` checks that both of its ids come out with the row's shutdown date. With the old parser, 16 of the file's 21 tests fail on the new fixture.
|
||||
- Negative controls: the existing cell with its backticks removed, and the new display-name row with unticked ids, both still fail with `unrecognized-deprecated-model-cell`.
|
||||
|
||||
### Gate integrity
|
||||
- One accepted-input change to review: the OpenAI lifecycle parser accepts a model cell with text before its backticked ids. It is a weekly advisory check, not a required check. The negative controls above still fail closed, and no threshold, budget, timeout, trial count or case kind changed.
|
||||
|
||||
## [1.91.189.0] - 2026-10-25
|
||||
|
||||
**A stalled apt mirror can no longer hold a Free Tests shard for 20 minutes, PR runs now see main's branch layout, and gstack's install default moves to Bun 1.4.4.** On 2026-10-21, free-suite (12) in run 39641712129 sat in "Install Playwright Chromium" until the job timeout cancelled it. The browser had already come from cache: `--with-deps` was waiting on apt's fetch of noble-security from archive.ubuntu.com. No tests ran, and a whole merge train had to be rebuilt. Separately, a push to main checks out a local `main` while PR runs check out a detached merge ref, so a fixture that created its own `main` passed its PR and then failed every main push until #3237 (run 38999168852). And Capy images and gbrain now run Bun 1.4.4, which no CI lane tested, while the installer still downloaded 1.4.3.
|
||||
|
||||
### Fixed
|
||||
- **`.github/workflows/free-tests.yml`**: in free-suite and quick-bun-default, the Playwright install gets its own 5-minute timeout (a warm install takes about 15 s), and that install step alone gets one bounded retry, also 5 minutes. This retries infrastructure, never a test: a second failure fails the job. The browser cache is keyed on the Playwright version instead of `bun.lock`, with no prefix fallback, so a lockfile bump keeps the browser and a Playwright bump never restores a stale revision. quick-bun-default, which had no browser cache, now has one.
|
||||
- **`.github/workflows/free-tests.yml`**: free-suite and quick-bun-default run `git checkout -q -B main` right after checkout on every run whose ref is not main (PRs and train dispatches). Their clone then has the same local `main` a push run gets, so a PR run catches what the main push would.
|
||||
- **`bin/gstack-bun-version.sh`**: `GSTACK_BUN_DEFAULT` is 1.4.4, the Bun that Capy images ship, that gbrain pins, and that carries the CVE-2026-111497 fix. quick-bun-default runs the quick tier on it. The 1.4.2 minimum, engines.bun, Dockerfile.ci, every other CI lane and the verified archives (1.4.2, 1.4.3, 1.4.4) are unchanged. `docs/capy-install.md` and `docs/troubleshooting.md` name the new default.
|
||||
|
||||
### Tests
|
||||
- `test/free-tests-workflow-wiring.test.ts` requires the version-keyed cache, the 5-minute install, its single retry as the only `continue-on-error` step in each job, and the local-main step. Both new cases fail against the previous workflow.
|
||||
- `test/gstack-capy-install.test.ts` reads the install default from `GSTACK_BUN_DEFAULT` wherever it meant "the install default", so the next bump doesn't need a literal sweep, and so does the `test/runtime-pins.test.ts` case that reads gstack's own CI pins. `test/bun-resolve.test.ts` moves its gbrain-like project pin from 1.4.3 to 1.4.4, gbrain's real pin.
|
||||
|
||||
### Gate integrity
|
||||
- One gate edit to review: the Playwright install step in the required Free Tests lane is `continue-on-error`, and a retry step that is not marked that way repeats it. Verdict: not weakening. A shard still fails unless one of the two installs succeeds, and every test, threshold, timeout and trial count stays the same. The local-main step strengthens PR runs. No test assertion was removed.
|
||||
|
||||
## [1.91.188.0] - 2026-10-24
|
||||
|
||||
**Pregate maps extensionless .jsx imports to their tests (#3340).** On a Next.js or webpack JSX repo, `gstack-pregate` failed `lanes` with "has no test lane" for every component imported without an extension, so /ship's receipt refused and the only way out was shipping without one. The import resolver tried `.ts`, `.tsx`, `.js`, `.mjs` and `.cjs` but never `.jsx`, and a directory import found `index.ts` or `index.js` but not `index.jsx`, `index.tsx` or `index.mjs` (it fell back to selecting on the whole directory). Reported by adam-balazs.
|
||||
|
||||
### Fixed
|
||||
- **`lib/pregate/import-graph.ts`**: `RESOLVE_EXT` adds `.jsx`, `.mts`, `.cts`, `/index.tsx`, `/index.jsx` and `/index.mjs`. The existing entries keep their order, so a `.js` file still wins over a `.jsx` sibling. Relative, `paths` alias and `baseUrl` imports share the list, so all three resolve the new forms.
|
||||
|
||||
### Tests
|
||||
- `test/pregate-selection.test.ts` has three new cases: an extensionless import of a `.jsx`, `.mts` and `.cts` file; a directory import that reaches `index.jsx`, `index.tsx` and `index.mjs` and leaves the directory's other files unselected; and a jsconfig `paths` alias to a `.jsx` file, with a `.js` sibling that still wins. All three failed before the change.
|
||||
|
||||
### Docs
|
||||
- `docs/pregate.md` lists the extensions and index forms an extensionless import tries, in order.
|
||||
|
||||
## [1.91.187.0] - 2026-10-24
|
||||
|
||||
**Four docs now say what the code does: the unattended run contract, the gate reply grammar, the session-kind override and voice triggers.** All four were reported by readers who checked the docs against the code. `docs/unattended.md` promised that every workflow skill ends an unattended run with a run directory and a `GSTACK_RESULT` line, but only `/autoplan` has the steps and helpers that write one (#3281, thanks @exGeni). `gstack-gate --help` still taught the reply grammar from before 1.91.107.0, without `rec` or `as-is`, while the render it relays leads with `rec` (#3282, thanks @exGeni). `docs/OPENCLAW.md` said only `spawned` is honored as a `GSTACK_SESSION_KIND` override, but `unattended` is honored too (#3287, thanks @exGeni). The README said natural phrases activate the right skill, without saying that only `voice-triggers:` aliases reach a host and that the plain `triggers:` list does not (#2118, thanks @jinzheio).
|
||||
|
||||
### Fixed
|
||||
- **`docs/unattended.md`**: the terminal-line section lists who writes the line today: `/autoplan` (the only skill that writes a run directory), the workflow commands `gstack-pregate`, `gstack-regen`, `gstack-restamp` and `gstack-ship-queue` (each naming its own record), and a refused launch (`status=refused run=-`). For `/ship`, `/review`, `/qa` and the other workflow skills it says not to wait for a run directory and names each skill's own record to read instead. It also says `/eval-plan` has no `gate.json` writer yet and that `/cso` skips the shared unattended rules.
|
||||
- **`bin/gstack-gate --help`** and the `/autoplan` owner brief's answer hint (`lib/owner-brief.ts`) show the current grammar: `rec` (alias `all`), `rec except <tokens>`, `<id><option>` tokens and `as-is`. Display text only; the parser is unchanged and `all` still parses the same as `rec`.
|
||||
- **`docs/troubleshooting.md#gate-reply-unparsed`** gives the same grammar in its fix line.
|
||||
- **`docs/OPENCLAW.md`**: the `GSTACK_SESSION_KIND` section says `spawned` and `unattended` are honored, and links to `unattended.md`.
|
||||
- **`README.md`**: the voice input section says which phrases a host sees (`voice-triggers:` aliases, as router lines on Claude Code and in each description elsewhere) and that `triggers:` is metadata that does not make a skill activate.
|
||||
|
||||
## [1.91.186.0] - 2026-10-24
|
||||
|
||||
**Approving an autoplan run no longer fails one time in ten because its run id looks like a card number.** Today's daily flaky-fix pass found `test/capy-captain-flow.test.ts` failing once and passing on the serial retry in 7 of the 57 Free Tests runs from 2026-10-21 to 2026-10-23, one of them a push to main (39878113535). Every failure had the same assertion: `gstack-approvals import` refused the approval row with "field ask: pii.cc". The default autoplan run id is `run-YYYYMMDDHHMMSS`, the import writes it into the row's ask and source, and the credit-card rule saw those 14 digits as a card number whenever they passed the Luhn check, which a timestamp does about one time in ten. A real user's approval import failed the same way.
|
||||
|
||||
@@ -513,9 +513,18 @@ The sprint structure is what makes parallelism work. Without a process, ten agen
|
||||
|
||||
### Voice input (AquaVoice, Whisper, etc.)
|
||||
|
||||
gstack skills have voice-friendly trigger phrases. Say what you want naturally —
|
||||
"run a security check", "test the website", "do an engineering review" — and the
|
||||
right skill activates. You don't need to remember slash command names or acronyms.
|
||||
Many gstack skills list speech-to-text aliases (`voice-triggers:` in their
|
||||
templates): "security check" for `/cso`, "run QA" or "test the app" for `/qa`,
|
||||
"tech review" for `/plan-eng-review`. Say one of those naturally and the right
|
||||
skill can be picked without the slash command name. On Claude Code the aliases
|
||||
are routing lines in the root `/gstack` router; on Codex and the other hosts
|
||||
they are appended to each skill's description, which is what those hosts match
|
||||
on.
|
||||
|
||||
The separate `triggers:` list in a skill's frontmatter is gstack metadata, not a
|
||||
matching surface. Claude Code and Codex choose a skill from its name and
|
||||
description, so a phrase that appears only under `triggers:` does not activate
|
||||
the skill (Codex output drops the field; the GBrain host keeps it).
|
||||
|
||||
## Uninstall
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.186.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.91.194.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,
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
# at or above the minimum, and one CI job runs the quick tier on it.
|
||||
GSTACK_BUN_FLOOR="1.3.3"
|
||||
GSTACK_BUN_TESTED="1.4.2"
|
||||
GSTACK_BUN_DEFAULT="1.4.3"
|
||||
GSTACK_BUN_DEFAULT="1.4.4"
|
||||
|
||||
# gstack_bun_archive_sha256 TARGET [VERSION] — the SHA-256 of
|
||||
# https://github.com/oven-sh/bun/releases/download/bun-v$VERSION/bun-TARGET.zip
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ const HELP = `gstack-gate — one gate list, one reply grammar
|
||||
decisions <gate.json> [--reply "<text>" --gate-rev N] [--answered-at ISO]
|
||||
decisions.jsonl rows (pending, or applied when a reply is given)
|
||||
|
||||
Reply grammar: \`all\` · \`<id><option>\` tokens (\`d3b uc1a\`) · \`all except <tokens>\`. Bare yes/no is rejected.
|
||||
Reply grammar: \`rec\` (alias \`all\`) · \`rec except <tokens>\` · \`<id><option>\` tokens (\`d3b uc1a\`) · \`as-is\` (the plan as written). Bare yes/no is rejected.
|
||||
gate.json: {"schema_version":1,"run":"<id>","gate_rev":1,"items":[{"id":"d1","title":"…","kind":"auto|approval","recommended":"a","options":[{"key":"a","text":"…"},…]}]}
|
||||
${exitTableHelp()}`;
|
||||
|
||||
|
||||
+50
-1
@@ -25,8 +25,16 @@
|
||||
// the caller must handle), exit 2 on bad args / unresolvable base /
|
||||
// broken version source.
|
||||
//
|
||||
// write --version <X.Y.Z.W> [--version-path <p>] [--regen-digest]
|
||||
// write --version <X.Y.Z.W> [--version-path <p>] [--regen-digest] [--changelog <p>]
|
||||
// Validates the 4-digit pattern, writes VERSION + package.json.version.
|
||||
// --changelog <p> (opt-in): re-head the branch's `## [Unreleased]`
|
||||
// entry in <p> as `## [<version>] - <UTC date>` at the top, with
|
||||
// lib/changelog-check's reheadChangelog — the same function
|
||||
// gstack-restamp uses, so a later restamp finds the heading already in
|
||||
// place (or moves it again as the branch's own stamp). No [Unreleased]
|
||||
// section, or an absent file, is a no-op. Duplicate headings refuse with
|
||||
// exit 2 before anything is written. restamp never passes the flag: under
|
||||
// stamp-at-merge it stays the only owner of the re-heading.
|
||||
// --regen-digest (opt-in): additionally run the target repo's own
|
||||
// scripts/gen-agents-digest.ts when present, so the version-stamped
|
||||
// committed digest stays freshness-green through the bump. Executes
|
||||
@@ -67,6 +75,7 @@ import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
import { bunIsolationArgs } from "../lib/bun-isolation";
|
||||
import { checkChangelog, parseChangelog, reheadChangelog } from "../lib/changelog-check";
|
||||
import {
|
||||
extractVersion,
|
||||
isJsonVersionPath,
|
||||
@@ -404,6 +413,42 @@ function regenAgentsDigest(versionPath: string, optedIn: boolean): boolean | nul
|
||||
}
|
||||
}
|
||||
|
||||
interface ChangelogPlan { rel: string; text: string | null; detail: string }
|
||||
|
||||
/**
|
||||
* --changelog: computed before any write, so a CHANGELOG the re-heading
|
||||
* cannot handle (duplicate headings) refuses with nothing written. Only an
|
||||
* existing [Unreleased] section moves; released sections are never renamed.
|
||||
*/
|
||||
function planChangelog(cwd: string, args: string[], version: string): ChangelogPlan | null {
|
||||
if (!args.includes("--changelog")) return null;
|
||||
const rel = argVal(args, "--changelog");
|
||||
if (!rel || rel.startsWith("--")) fail("write: --changelog needs a path (for example --changelog CHANGELOG.md)", 2);
|
||||
assertRepoContained(cwd, rel, "--changelog");
|
||||
const p = join(cwd, rel);
|
||||
if (!existsSync(p)) return { rel, text: null, detail: `${rel} absent` };
|
||||
const text = readFileSync(p, "utf-8");
|
||||
if (!parseChangelog(text).sections.some((s) => s.unreleased)) return { rel, text: null, detail: "no [Unreleased] section" };
|
||||
const r = reheadChangelog(text, { from: null, to: version, date: new Date().toISOString().slice(0, 10), file: rel });
|
||||
if (r.error) fail(`write: ${r.error.path}: ${r.error.message}. Nothing written.`, 2);
|
||||
if (!r.moved) return { rel, text: null, detail: `already headed ${version}` };
|
||||
const errors = checkChangelog(r.text, version, rel);
|
||||
if (errors.length) fail(`write: ${errors[0]!.path}: ${errors[0]!.message}. Nothing written.`, 2);
|
||||
return { rel, text: r.text, detail: `Unreleased -> ${version}` };
|
||||
}
|
||||
|
||||
function writeChangelog(cwd: string, plan: ChangelogPlan | null): { changelog: { path: string; moved: boolean; detail: string } } | Record<string, never> {
|
||||
if (!plan) return {};
|
||||
if (plan.text !== null) {
|
||||
try {
|
||||
writeFileSync(join(cwd, plan.rel), plan.text);
|
||||
} catch {
|
||||
fail(`write: the version files were written but ${plan.rel} was not. Re-run write with the same --version and --changelog.`, 3);
|
||||
}
|
||||
}
|
||||
return { changelog: { path: plan.rel, moved: plan.text !== null, detail: plan.detail } };
|
||||
}
|
||||
|
||||
function cmdWrite(args: string[], cwd: string): void {
|
||||
const version = argVal(args, "--version");
|
||||
if (!version) fail("write requires --version <X.Y.Z.W>", 2);
|
||||
@@ -431,6 +476,7 @@ function cmdWrite(args: string[], cwd: string): void {
|
||||
// npm rejects forever — with no drift state to catch it (a JSON source is
|
||||
// self-consistent by construction).
|
||||
const jsonV = npmVersion(version!);
|
||||
const changelogPlan = planChangelog(cwd, args, jsonV);
|
||||
let manifestWritten = false;
|
||||
let lockSynced: string[] = [];
|
||||
try {
|
||||
@@ -466,6 +512,7 @@ function cmdWrite(args: string[], cwd: string): void {
|
||||
// here: a JSON-manifest version source implies no plain VERSION file
|
||||
// for the digest generator to stamp.
|
||||
agentsDigest: null,
|
||||
...writeChangelog(cwd, changelogPlan),
|
||||
}) + "\n",
|
||||
);
|
||||
return;
|
||||
@@ -473,6 +520,7 @@ function cmdWrite(args: string[], cwd: string): void {
|
||||
|
||||
const pkgPath = resolvePkgPath(cwd, argVal(args, "--package-json-path"));
|
||||
const hasPkg = existsSync(pkgPath);
|
||||
const changelogPlan = planChangelog(cwd, args, version!);
|
||||
writeFileSync(versionPath, version + "\n");
|
||||
const digestRegen = regenAgentsDigest(versionPath, args.includes("--regen-digest"));
|
||||
let lockSynced: string[] = [];
|
||||
@@ -511,6 +559,7 @@ function cmdWrite(args: string[], cwd: string): void {
|
||||
packageLock: lockSynced.length > 0,
|
||||
// null = not the gstack repo (no digest to regen); true/false = regen outcome
|
||||
agentsDigest: digestRegen,
|
||||
...writeChangelog(cwd, changelogPlan),
|
||||
}) + "\n",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,3 +33,41 @@ export function emptyBunConfig(platform: NodeJS.Platform = process.platform): st
|
||||
emptyConfig = file;
|
||||
return file;
|
||||
}
|
||||
|
||||
export interface BunExeLookup {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
execPath?: string;
|
||||
isBun?: boolean;
|
||||
isFile?: (p: string) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bun.exe a Windows parent spawns its bun children with, or null when
|
||||
* none is installed: the running bun.exe itself, then BUN_INSTALL\bin, each
|
||||
* absolute PATH entry (plus, beside npm's bun.cmd shim, the bun.exe it
|
||||
* forwards to), then USERPROFILE\.bun\bin. The terminal agent and the native
|
||||
* cookie-import worker (cookie-import-native.ts) both resolve through it; it
|
||||
* lives here because this file is part of the native import's source-bound
|
||||
* qualification (NATIVE_CODE_INPUTS), and terminal-agent-control.ts is not.
|
||||
*/
|
||||
export function findWindowsBunExe(opts: BunExeLookup = {}): string | null {
|
||||
const win = path.win32;
|
||||
const env = opts.env ?? process.env;
|
||||
const execPath = opts.execPath ?? process.execPath;
|
||||
const isBun = opts.isBun ?? !!process.versions.bun;
|
||||
const isFile = opts.isFile ?? ((p: string) => { try { return fs.statSync(p).isFile(); } catch { return false; } });
|
||||
// A compiled browse.exe also reports versions.bun, so check the name too.
|
||||
if (isBun && /^bun\.exe$/i.test(win.basename(execPath))) return execPath;
|
||||
const dirs = (env.PATH || env.Path || '').split(';').filter(d => d && win.isAbsolute(d));
|
||||
const candidates = [
|
||||
...(env.BUN_INSTALL ? [win.join(env.BUN_INSTALL, 'bin', 'bun.exe')] : []),
|
||||
...dirs.flatMap(d => [
|
||||
win.join(d, 'bun.exe'),
|
||||
// npm global install: PATH holds only the bun.cmd shim; the real
|
||||
// binary it forwards to lives under node_modules.
|
||||
...(isFile(win.join(d, 'bun.cmd')) ? [win.join(d, 'node_modules', 'bun', 'bin', 'bun.exe')] : []),
|
||||
]),
|
||||
...(env.USERPROFILE ? [win.join(env.USERPROFILE, '.bun', 'bin', 'bun.exe')] : []),
|
||||
];
|
||||
return candidates.find(isFile) ?? null;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { createRequire } from 'node:module';
|
||||
import { release } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { CookieImportError, normalizeCookieDomain, type PlaywrightCookie } from './cookie-import-browser';
|
||||
import { emptyBunConfig } from './bun-child-config';
|
||||
import { emptyBunConfig, findWindowsBunExe } from './bun-child-config';
|
||||
import { hashNativeFile, nativeCodeHashes, nativeCodeMatches, readNativeQualifications, type NativeQualifiedBuild } from './cookie-import-native-integrity';
|
||||
|
||||
type BrowserName = 'Chrome' | 'Chromium' | 'Brave' | 'Edge';
|
||||
@@ -107,14 +107,7 @@ export async function importNativeCookies(options: {
|
||||
} catch (error) {
|
||||
throw new CookieImportError('The native browser build could not be checked safely. Sign in manually in the gstack browser.', error instanceof Error && error.message === 'native_timeout' ? 'native_timeout' : 'native_unqualified');
|
||||
}
|
||||
const bunCandidates = [
|
||||
...(process.env.BUN_INSTALL ? [path.join(process.env.BUN_INSTALL, 'bin', 'bun.exe')] : []),
|
||||
...(process.env.PATH || process.env.Path || '').split(path.delimiter).filter(directory => path.isAbsolute(directory)).map(directory => path.join(directory, 'bun.exe')),
|
||||
...(process.env.USERPROFILE ? [path.join(process.env.USERPROFILE, '.bun', 'bin', 'bun.exe')] : []),
|
||||
];
|
||||
const bunExecutable = bunCandidates.find(candidate => {
|
||||
try { return statSync(candidate).isFile(); } catch { return false; }
|
||||
});
|
||||
const bunExecutable = findWindowsBunExe();
|
||||
if (!bunExecutable || !qualified.some(build => build.executableSha256 === executableSha256 && build.playwrightVersion === playwrightVersion)) {
|
||||
throw new CookieImportError('This browser build or supervisor runtime has not passed native qualification. Sign in manually in the gstack browser.', 'native_unqualified');
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import { safeUnlink, isProcessAlive } from './error-handling';
|
||||
import { restrictFilePermissions, mkdirSecure } from './file-permissions';
|
||||
import { atomicWriteSync } from '../../lib/fs-atomic';
|
||||
import { readPidCmdline, readPidStartTime } from './xvfb';
|
||||
import { emptyBunConfig } from './bun-child-config';
|
||||
import { emptyBunConfig, findWindowsBunExe, type BunExeLookup } from './bun-child-config';
|
||||
|
||||
function agentProcessInfo(pid: number): { startTime: string; commandLine: string } {
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) return { startTime: '', commandLine: '' };
|
||||
@@ -149,6 +149,22 @@ export function bunChildFlags(): string[] {
|
||||
return ['--no-env-file', `--config=${emptyBunConfig()}`];
|
||||
}
|
||||
|
||||
/**
|
||||
* The bun executable to spawn the agent with. On Windows a bare 'bun' under
|
||||
* the Node polyfill resolves (via cross-spawn) to npm's bun.cmd shim, run as
|
||||
* `cmd.exe /c bun ...`. That breaks two ways: windowsHide only hides cmd.exe,
|
||||
* and the detached cmd.exe has no console, so bun.exe allocates a fresh
|
||||
* visible one (a window that steals focus on every spawn); and proc.pid is
|
||||
* cmd.exe's, so the agent's startup check (record.pid === process.pid) never
|
||||
* matches and it exits after 2s, which the 60s watchdog then respawns.
|
||||
* Spawning bun.exe directly fixes both. Falls back to 'bun' when no .exe is
|
||||
* found, which is the old behavior.
|
||||
*/
|
||||
export function resolveBunExecutable(opts: BunExeLookup & { platform?: string } = {}): string {
|
||||
if ((opts.platform ?? process.platform) !== 'win32') return 'bun';
|
||||
return findWindowsBunExe(opts) ?? 'bun';
|
||||
}
|
||||
|
||||
export function resolveTerminalAgentScript(searchHints: { metaDir?: string; execPath?: string } = {}): string | null {
|
||||
const meta = searchHints.metaDir || __dirname;
|
||||
const exec = searchHints.execPath || process.execPath;
|
||||
@@ -208,7 +224,7 @@ export function spawnTerminalAgent(opts: {
|
||||
writeAgentRecord(stateDir, reservation);
|
||||
let proc: any;
|
||||
try {
|
||||
proc = (Bun as any).spawn(['bun', 'run', ...bunChildFlags(), script, `--agent-gen=${gen}`], {
|
||||
proc = (Bun as any).spawn([resolveBunExecutable(), 'run', ...bunChildFlags(), script, `--agent-gen=${gen}`], {
|
||||
cwd: opts.cwd || process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
|
||||
@@ -233,7 +233,7 @@ describe('cli.ts: sidebar-agent is no longer spawned', () => {
|
||||
'utf-8',
|
||||
);
|
||||
expect(CONTROL_SRC).toContain('terminal-agent.ts');
|
||||
expect(CONTROL_SRC).toMatch(/\.spawn\(\['bun',\s*'run',\s*\.\.\.bunChildFlags\(\),\s*script,\s*`--agent-gen=\$\{gen\}`\]/);
|
||||
expect(CONTROL_SRC).toMatch(/\.spawn\(\[resolveBunExecutable\(\),\s*'run',\s*\.\.\.bunChildFlags\(\),\s*script,\s*`--agent-gen=\$\{gen\}`\]/);
|
||||
expect(CONTROL_SRC).toContain('BROWSE_OWNER_PID: String(opts.ownerPid)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Windows terminal-agent console flash: under the Node polyfill a bare 'bun'
|
||||
* resolved to npm's bun.cmd shim and ran as `cmd.exe /c bun ...`. The detached
|
||||
* cmd.exe had no console, so bun.exe opened a visible one on every spawn, and
|
||||
* proc.pid was cmd.exe's, so the agent failed its startup record check and
|
||||
* exited, and the 60s watchdog respawned it. spawnTerminalAgent now spawns
|
||||
* bun.exe directly.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { findWindowsBunExe } from '../src/bun-child-config';
|
||||
import { resolveBunExecutable } from '../src/terminal-agent-control';
|
||||
|
||||
const files = (...paths: string[]) => (p: string) => paths.some(f => f.toLowerCase() === p.toLowerCase());
|
||||
|
||||
describe('resolveBunExecutable', () => {
|
||||
test('POSIX keeps bare bun', () => {
|
||||
expect(resolveBunExecutable({ platform: 'linux', isFile: () => true })).toBe('bun');
|
||||
expect(resolveBunExecutable({ platform: 'darwin', isFile: () => true })).toBe('bun');
|
||||
});
|
||||
|
||||
test('Windows under bun.exe reuses its own executable', () => {
|
||||
expect(resolveBunExecutable({
|
||||
platform: 'win32', isBun: true, execPath: 'C:\\Tools\\bun.exe', env: {}, isFile: () => false,
|
||||
})).toBe('C:\\Tools\\bun.exe');
|
||||
});
|
||||
|
||||
test('a compiled browse.exe is not mistaken for bun', () => {
|
||||
expect(resolveBunExecutable({
|
||||
platform: 'win32', isBun: true, execPath: 'C:\\gstack\\browse\\dist\\browse.exe',
|
||||
env: { PATH: 'C:\\bun\\bin' }, isFile: files('C:\\bun\\bin\\bun.exe'),
|
||||
})).toBe('C:\\bun\\bin\\bun.exe');
|
||||
});
|
||||
|
||||
test('npm global install resolves through the bun.cmd shim to the real bun.exe', () => {
|
||||
const npm = 'C:\\Users\\u\\AppData\\Roaming\\npm';
|
||||
expect(resolveBunExecutable({
|
||||
platform: 'win32', isBun: false, execPath: 'C:\\Program Files\\nodejs\\node.exe',
|
||||
env: { PATH: `C:\\Windows\\System32;${npm}` },
|
||||
isFile: files(`${npm}\\bun.cmd`, `${npm}\\node_modules\\bun\\bin\\bun.exe`),
|
||||
})).toBe(`${npm}\\node_modules\\bun\\bin\\bun.exe`);
|
||||
});
|
||||
|
||||
test('BUN_INSTALL wins over PATH, and USERPROFILE\\.bun is the last resort', () => {
|
||||
const isFile = files('C:\\bi\\bin\\bun.exe', 'C:\\p\\bun.exe', 'C:\\Users\\u\\.bun\\bin\\bun.exe');
|
||||
const base = { platform: 'win32', isBun: false, execPath: 'node.exe', isFile };
|
||||
expect(resolveBunExecutable({ ...base, env: { BUN_INSTALL: 'C:\\bi', PATH: 'C:\\p' } })).toBe('C:\\bi\\bin\\bun.exe');
|
||||
expect(resolveBunExecutable({ ...base, env: { PATH: 'C:\\p' } })).toBe('C:\\p\\bun.exe');
|
||||
expect(resolveBunExecutable({ ...base, env: { PATH: 'C:\\none', USERPROFILE: 'C:\\Users\\u' } })).toBe('C:\\Users\\u\\.bun\\bin\\bun.exe');
|
||||
});
|
||||
|
||||
test('falls back to bare bun when no bun.exe exists', () => {
|
||||
expect(resolveBunExecutable({
|
||||
platform: 'win32', isBun: false, execPath: 'node.exe', env: { PATH: 'C:\\x;relative' }, isFile: () => false,
|
||||
})).toBe('bun');
|
||||
});
|
||||
|
||||
test('spawnTerminalAgent spawns through the resolver, not a bare bun', () => {
|
||||
const source = fs.readFileSync(path.resolve(import.meta.dir, '../src/terminal-agent-control.ts'), 'utf8');
|
||||
const spawnSite = source.slice(source.indexOf('export function spawnTerminalAgent'));
|
||||
expect(spawnSite).toContain('spawn([resolveBunExecutable(), \'run\'');
|
||||
expect(spawnSite).not.toMatch(/spawn\(\[\s*'bun'/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findWindowsBunExe is the one bun.exe lookup', () => {
|
||||
test('returns null, not a bare bun, when no bun.exe is installed', () => {
|
||||
expect(findWindowsBunExe({ isBun: false, execPath: 'node.exe', env: { PATH: 'C:\\x;relative' }, isFile: () => false })).toBeNull();
|
||||
});
|
||||
|
||||
test('the native cookie-import worker finds an npm-global bun.exe through the same lookup', () => {
|
||||
const npm = 'C:\\Users\\u\\AppData\\Roaming\\npm';
|
||||
expect(findWindowsBunExe({
|
||||
isBun: false, execPath: 'C:\\Program Files\\nodejs\\node.exe', env: { PATH: npm },
|
||||
isFile: files(`${npm}\\bun.cmd`, `${npm}\\node_modules\\bun\\bin\\bun.exe`),
|
||||
})).toBe(`${npm}\\node_modules\\bun\\bin\\bun.exe`);
|
||||
});
|
||||
|
||||
test('cookie-import-native has no bun.exe candidate list of its own', () => {
|
||||
const source = fs.readFileSync(path.resolve(import.meta.dir, '../src/cookie-import-native.ts'), 'utf8');
|
||||
expect(source).toContain('const bunExecutable = findWindowsBunExe();');
|
||||
expect(source).not.toContain("'bun.exe'");
|
||||
});
|
||||
});
|
||||
+3
-1
@@ -152,7 +152,9 @@ gstack itself uses this: `/ship` Step 14.5 dispatches the `/document-release`
|
||||
subagent with this prefix before final commit, verification and publication.
|
||||
Its ship-owned scope overrides generic spawned auto-choice: risky or uncertain
|
||||
documentation changes return as blockers for the parent, without interactive
|
||||
questions or automatic approval. Deliberately narrow: only `spawned` is honored — `headless`
|
||||
questions or automatic approval. Deliberately narrow: only `spawned` and `unattended` are honored
|
||||
(`unattended` is a separate, explicitly selected contract for a parent that
|
||||
reads a run's artifacts; see [unattended.md](unattended.md)) — `headless`
|
||||
already has `GSTACK_HEADLESS`, and letting an env var force `interactive`
|
||||
over CI markers would be a misclassification footgun. Empty or other values
|
||||
are reserved and ignored (fall through to ambient detection). Note that hook
|
||||
|
||||
@@ -141,7 +141,7 @@ session holds no lease) and a reclaim keeps the lease. `bin/gstack-lease list` s
|
||||
packageManager, `.tool-versions`, `.bun-version` and CI `bun-version:` lanes
|
||||
for this platform). CI exact pins for this platform are alternatives: a
|
||||
repository may test several Bun versions (gstack's own CI pins 1.4.2 and
|
||||
runs one job on 1.4.3), so a Bun equal to any one of them satisfies CI;
|
||||
runs one job on 1.4.4), so a Bun equal to any one of them satisfies CI;
|
||||
CI ranges and every non-CI pin stay required. One resolver picks the Bun (`gstack_bun_resolve` in
|
||||
`bin/gstack-bun-version.sh`; `resolveBun` in `lib/pregate/runtime.ts` is
|
||||
its TypeScript twin), and the installer, `--check`, the doctor, the bun
|
||||
@@ -160,7 +160,7 @@ session holds no lease) and a reclaim keeps the lease. `bin/gstack-lease list` s
|
||||
exact pin when gstack carries a verified archive for it
|
||||
(`GSTACK_BUN_VERIFIED`: 1.4.2, 1.4.3, 1.4.4); with only CI exact pins, the install
|
||||
default when it is one of them, else the highest verified one; else the
|
||||
install default `GSTACK_BUN_DEFAULT` (1.4.3, above the 1.4.2 minimum; one
|
||||
install default `GSTACK_BUN_DEFAULT` (1.4.4, above the 1.4.2 minimum; one
|
||||
CI job runs the quick free tier on it).
|
||||
`GSTACK_BUN_INSTALL_VERSION=<v>` overrides the default; a version without a
|
||||
verified archive or below the minimum stops the install
|
||||
|
||||
@@ -137,6 +137,10 @@ Two stages with separate clocks, both printed:
|
||||
included, are source code, so a `dependencies` `[]` declaration clears them and `empty_lanes`
|
||||
does not), transitively through
|
||||
non-test files; deleted and renamed files select the tests that named them.
|
||||
An extensionless JS/TS import (relative, a tsconfig/jsconfig `paths` alias or
|
||||
`baseUrl`) tries the exact path, then `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`,
|
||||
`.cjs`, `.mts`, `.cts`, then `index.ts`, `index.tsx`, `index.js`, `index.jsx`
|
||||
and `index.mjs` inside the directory; the first file that exists wins.
|
||||
A touched source file with no lane is `FAIL lanes — <file> has no test lane`,
|
||||
never a pass. Markdown that no test depends on is a docs/no-tests empty lane
|
||||
instead (`docs/no-tests: N documentation file(s)`), including when the base
|
||||
|
||||
+13
-6
@@ -337,7 +337,9 @@ gstack-ship-queue train build --prs 41@<sha>,42@<sha>,43@<sha>
|
||||
|
||||
It prints `ORDER:` (the slot order to announce: screened PRs first in READY
|
||||
order, then unscreened; when `max_size` or more unscreened PRs wait they form
|
||||
their own train and `HELD:` lists them), one `PREFIX: T<i> <commit>
|
||||
their own train and `HELD:` lists them; `--order <pr>,<pr>,...` naming every
|
||||
pinned PR once replaces that rule, for a PR that must merge before another,
|
||||
and the train's later `NEXT:` rebuilds keep it), one `PREFIX: T<i> <commit>
|
||||
tree=<tree>` per prefix and one `SLOT:` per PR. `--dry-run` adds the
|
||||
`ESTIMATE:` line and writes, pushes and dispatches nothing. Otherwise it
|
||||
writes `train.json`, pushes `train/<nonce>`, dispatches `free_workflow` there
|
||||
@@ -355,7 +357,11 @@ merging #<a>,#<b>; nothing dispatched, no branch pushed; fix: …` line names
|
||||
the build without that PR) and `QUEUE_TRAIN_FREE_RED` (free tests red,
|
||||
missing, cancelled or on another SHA). In a merge, version files take the
|
||||
prefix's side and the CHANGELOG merges by section, since the stamp at merge
|
||||
rewrites both.
|
||||
rewrites both; a PR whose CHANGELOG drops or renames a released heading (or
|
||||
carries a released entry's text in a section of its own) is a CHANGELOG
|
||||
conflict, never merged with the damage. READY receipts and train records are
|
||||
keyed on origin's GitHub owner/repo, so lanes whose origin uses another URL
|
||||
form (git.capy.ai, ssh, a token URL) share the coordinator's queue.
|
||||
|
||||
**3. Coordinator: status and verdict.**
|
||||
|
||||
@@ -406,13 +412,13 @@ EVAL_POLICY records it. Bisect results never turn a train green.
|
||||
```bash
|
||||
git fetch origin main
|
||||
git merge --no-ff --no-commit origin/main # expect a CHANGELOG.md conflict only
|
||||
bun --no-env-file --config=/dev/null -e "import { mergeChangelogs } from './lib/changelog-check'; import { spawnSync } from 'node:child_process'; import { writeFileSync } from 'node:fs'; const s = (n) => spawnSync('git', ['show', ':' + n + ':CHANGELOG.md'], { encoding: 'utf8', maxBuffer: 1 << 26, timeout: 30000 }).stdout; const m = mergeChangelogs(s(2), s(3)); if (!m) { console.error('CHANGELOG needs a hand merge'); process.exit(1); } writeFileSync('CHANGELOG.md', m);"
|
||||
bun --no-env-file --config=/dev/null -e "import { mergeChangelogs } from './lib/changelog-check'; import { spawnSync } from 'node:child_process'; import { writeFileSync } from 'node:fs'; const s = (n) => spawnSync('git', ['show', ':' + n + ':CHANGELOG.md'], { encoding: 'utf8', maxBuffer: 1 << 26, timeout: 30000 }).stdout; const m = mergeChangelogs(s(2), s(3), s(1)); if (!m) { console.error('CHANGELOG needs a hand merge'); process.exit(1); } writeFileSync('CHANGELOG.md', m);"
|
||||
git add CHANGELOG.md && git commit --no-edit
|
||||
gstack-restamp --next --base main --pr 42 --expect-base <main sha> --expect-order <order> --allow-repo-commands
|
||||
git add CHANGELOG.md VERSION package.json <release outputs> && git commit -m "v<X> stamp" && git push
|
||||
```
|
||||
|
||||
A conflict in any file other than CHANGELOG.md means the merge isn't the train's prefix. Stop and report it. `verify` accepts the resulting head because its tree equals T<i> modulo stamps; the `head:` line says "T<i> with main merged in".
|
||||
A conflict in any file other than CHANGELOG.md means the merge isn't the train's prefix. Stop and report it. When the helper says the CHANGELOG needs a hand merge, take main's CHANGELOG unchanged and put your own entry above it. `verify` accepts the resulting head because it contains main and its tree equals T<i> modulo stamps, with its CHANGELOG judged as main's plus lines your pin added, however the conflict was resolved; the `head:` line says "T<i> with main merged in".
|
||||
|
||||
**5. Coordinator: verify, merge, verify again.** For each slot in order:
|
||||
|
||||
@@ -423,8 +429,9 @@ gstack-ship-queue verify --pr 41 --train <nonce> # VERIFY_TRAIN: #41 merged; m
|
||||
```
|
||||
|
||||
Before a merge, main's tree must equal the prefix before the PR modulo stamps
|
||||
and the PR must be at its pin (stamps aside) or at T<i> modulo stamps (its pin
|
||||
with main merged in); after it, main's tree
|
||||
and the PR must be at its pin (stamps aside) or contain main and be T<i>
|
||||
modulo stamps (its pin with main merged in; its CHANGELOG must be main's with
|
||||
only lines from the pin's own CHANGELOG diff above it); after it, main's tree
|
||||
must equal the prefix after it. Anything else is `QUEUE_TRAIN_TREE_MISMATCH`
|
||||
(or `QUEUE_TRAIN_MOVED`). When nothing of the train has merged, that is a plain
|
||||
refusal: build a new train on current main. When earlier slots already
|
||||
|
||||
@@ -1590,7 +1590,7 @@ next `--check` passes.
|
||||
### `gstack-capy-install: GSTACK_BUN_INSTALL_VERSION=<v> is not a verified Bun at or above gstack's supported minimum ...; fix: ... (BUN_INSTALL_VERSION_INVALID)`
|
||||
|
||||
**Meaning.** `GSTACK_BUN_INSTALL_VERSION` overrides the Bun the installer
|
||||
downloads when no exact project pin decides (`GSTACK_BUN_DEFAULT`, 1.4.3). It
|
||||
downloads when no exact project pin decides (`GSTACK_BUN_DEFAULT`, 1.4.4). It
|
||||
must be listed in `GSTACK_BUN_VERIFIED` (a SHA-256-verified archive) and sit at
|
||||
or above the supported minimum (`GSTACK_BUN_TESTED`). Nothing was installed.
|
||||
|
||||
@@ -2440,9 +2440,9 @@ reply with the printed `gate_rev`.
|
||||
**Meaning.** Part of the reply matched no gate item or option. Nothing was
|
||||
applied; the unparsed tokens are listed.
|
||||
|
||||
**Fix.** Reply with `all`, with `<id><option>` tokens (`d3b uc1a`), or with
|
||||
`all except <tokens>`. A bare `yes` or `no` is ambiguous across multi-option
|
||||
items and is never guessed.
|
||||
**Fix.** Reply with `rec` (alias `all`), with `rec except <tokens>`, with
|
||||
`<id><option>` tokens (`d3b uc1a`), or with `as-is` (the plan as written). A
|
||||
bare `yes` or `no` is ambiguous across multi-option items and is never guessed.
|
||||
|
||||
<a id="answer-by-required"></a>
|
||||
### `ANSWER_BY_REQUIRED`
|
||||
|
||||
+27
-2
@@ -57,18 +57,41 @@ An unknown `GSTACK_SESSION_KIND` value coerces to `interactive` and says so:
|
||||
|
||||
## The terminal line
|
||||
|
||||
Every workflow skill ends an unattended run with one line the parent greps:
|
||||
A run that writes a record ends with one line the parent greps:
|
||||
|
||||
```
|
||||
GSTACK_RESULT: skill=<name> status=<status> run=<dir>
|
||||
```
|
||||
|
||||
`status` is one of `complete`, `gate_pending`, `incomplete`, `interrupted` or
|
||||
`refused`; `run` is the directory that holds the artifacts below. `/autoplan`
|
||||
`refused`; `run` is where the run's record is. `/autoplan`
|
||||
always ends `gate_pending` when it reaches the final gate, because the gate is
|
||||
written and never approved. Pure queries (`gstack-artifact schema`,
|
||||
`gstack-gate render`) print no result line.
|
||||
|
||||
Not every workflow writes this line or a run directory today:
|
||||
|
||||
- **`/autoplan`** writes the full run directory below through `gstack-autoplan`,
|
||||
and `gstack-artifact validate` checks it. It is the only skill with steps
|
||||
and helpers that write `run.json`, `decisions.jsonl` and `gate.json`.
|
||||
- **Workflow commands** `gstack-pregate`, `gstack-regen`, `gstack-restamp` and
|
||||
`gstack-ship-queue` print their own line, with `run=` naming their own record
|
||||
(the pregate receipt, the regen worktree, the restamp journal, the queue
|
||||
record), not a run directory.
|
||||
- **A refused launch.** Any skill whose install check refuses it at startup
|
||||
prints `GSTACK_RESULT: skill=<name> status=refused run=-` and stops.
|
||||
- **Every other workflow skill** (`/ship`, `/review`, `/qa`, `/design-review`,
|
||||
`/spec`, `/land-and-deploy` and the rest) gets the session rules above (no
|
||||
prompts, approvals stay pending, nothing published), but has no step that
|
||||
writes a run directory or prints the line, so a parent should not wait for
|
||||
either. `/eval-plan` asks for a `gate_pending` line at its spend gate, but no
|
||||
helper writes its `gate.json` yet (`gstack-gate` renders and parses a gate
|
||||
list; it does not create one). `/cso` skips the shared startup, so these
|
||||
session rules do not reach it at all. Read each skill's own record instead:
|
||||
the ship receipt in the PR body (`gstack-ship-receipt read --pr <n> --json`)
|
||||
for `/ship`, the review-log rows (`gstack-review-read`) for `/review`, and the
|
||||
`qa-report-*.md` file for `/qa`.
|
||||
|
||||
Every workflow command shares one exit table: `0` ok, `1` fail, `2` usage,
|
||||
`3` refused or needs a flag. Error lines end with a result code in parentheses,
|
||||
for example `findings.jsonl:12: ... (ARTIFACT_SCHEMA)`; each code has an anchor
|
||||
@@ -77,6 +100,8 @@ a `fix:` clause. Grep the code, not the prose.
|
||||
|
||||
## The run directory
|
||||
|
||||
`/autoplan` writes these files; the next section shows how a parent reads them.
|
||||
|
||||
| File | Schema | Contents |
|
||||
|------|--------|----------|
|
||||
| `run.json` | `run` v1 | The manifest: `run` id, `status`, `required_phases`, each phase's `native` and `outside` outcome (`status`, `model`, `provider`), input `snapshot` hashes, `counts`, every artifact's `path` (relative to the run directory) and `sha256`, `session_kind`, `gate_rev`, `deviations`, `consumed_by`. |
|
||||
|
||||
+75
-2
@@ -115,6 +115,76 @@ export function normalizeChangelog(text: string): string {
|
||||
return [parsed.header.join('\n').replace(/\n+$/, ''), ...sections].join('\n\u0000');
|
||||
}
|
||||
|
||||
/**
|
||||
* A merged-in head's CHANGELOG, however its conflict was resolved: why it is
|
||||
* not main's CHANGELOG with the PR's own entry above it, or null. Main's
|
||||
* header and every main section must follow unchanged and in order, and the
|
||||
* sections above them may hold only lines the PR itself added (`authored`,
|
||||
* the + lines of its pin's CHANGELOG diff), so a hand resolve can drop
|
||||
* damage but never add text the train gate did not carry.
|
||||
*/
|
||||
export function ownEntryAboveMain(head: string, main: string, authored: ReadonlySet<string>): string | null {
|
||||
const h = parseChangelog(head);
|
||||
const m = parseChangelog(main);
|
||||
const trim = (lines: string[]) => { const out = [...lines]; while (out.length && !out[out.length - 1]!.trim()) out.pop(); return out.join('\n'); };
|
||||
if (trim(h.header) !== trim(m.header)) return 'the header differs from main';
|
||||
const own = h.sections.length - m.sections.length;
|
||||
if (own < 0) return `it has ${h.sections.length} sections, main has ${m.sections.length}`;
|
||||
const moved = m.sections.findIndex((s, i) => trim(s.lines) !== trim(h.sections[own + i]!.lines));
|
||||
if (moved >= 0) return `main's ${m.sections[moved]!.heading.trim()} is not carried unchanged below the PR's entry`;
|
||||
const mainKeys = new Set(m.sections.map(s => s.version ?? 'Unreleased'));
|
||||
const clash = h.sections.slice(0, own).find(s => mainKeys.has(s.version ?? 'Unreleased'));
|
||||
if (clash) return `${clash.heading.trim()} is headed twice`;
|
||||
const added = h.sections.slice(0, own).flatMap(s => s.lines.slice(1)).find(l => l.trim() && !authored.has(l));
|
||||
return added === undefined ? null : `the PR's entry holds a line its pin never added: ${JSON.stringify(added.slice(0, 80))}`;
|
||||
}
|
||||
|
||||
const MIN_BLOCK_CHARS = 12;
|
||||
|
||||
/** Paragraph blocks of a section's entry text (heading lines dropped), long enough to identify one entry. */
|
||||
function entryBlocks(s: ChangelogSection): string[] {
|
||||
const blocks: string[] = [];
|
||||
let run: string[] = [];
|
||||
const flush = () => { const text = run.join('\n').trim(); if (text.length >= MIN_BLOCK_CHARS) blocks.push(text); run = []; };
|
||||
for (const line of s.lines.slice(1)) {
|
||||
if (!line.trim() || line.startsWith('#')) flush();
|
||||
else run.push(line);
|
||||
}
|
||||
flush();
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why one side of a CHANGELOG merge damaged released history, or null. A
|
||||
* side damages it when it lacks a released heading the merge base carries
|
||||
* (dropped, or renamed), or when a section only that side carries holds a
|
||||
* paragraph of a released entry the other side carries (a released heading
|
||||
* replaced, so its body reads as part of the section above it; #3345).
|
||||
*/
|
||||
export function changelogDamage(ours: string, theirs: string, base?: string): string | null {
|
||||
const sides = [{ name: 'ours', parsed: parseChangelog(ours) }, { name: 'theirs', parsed: parseChangelog(theirs) }];
|
||||
const key = (s: ChangelogSection) => (s.unreleased ? 'Unreleased' : s.version!);
|
||||
if (base !== undefined) {
|
||||
const released = parseChangelog(base).sections.filter(s => !s.unreleased).map(key);
|
||||
for (const side of sides) {
|
||||
const has = new Set(side.parsed.sections.map(key));
|
||||
const missing = released.find(v => !has.has(v));
|
||||
if (missing) return `${side.name} drops released heading ${missing} that the merge base carries`;
|
||||
}
|
||||
}
|
||||
for (const [i, side] of sides.entries()) {
|
||||
const other = sides[1 - i]!.parsed.sections;
|
||||
const otherKeys = new Set(other.map(key));
|
||||
const releasedText = new Map<string, string>();
|
||||
for (const s of other.filter(s => !s.unreleased)) for (const b of entryBlocks(s)) releasedText.set(b, s.version!);
|
||||
for (const s of side.parsed.sections.filter(s => !otherKeys.has(key(s)))) {
|
||||
const hit = entryBlocks(s).find(b => releasedText.has(b));
|
||||
if (hit) return `${side.name}'s ${key(s)} section holds released ${releasedText.get(hit)} text (${JSON.stringify(hit.slice(0, 60))}); a released heading was dropped or renamed`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate-ahead merge of two CHANGELOGs that conflict textually but not
|
||||
* semantically: the predecessor's header and sections, with the branch's own
|
||||
@@ -122,9 +192,12 @@ export function normalizeChangelog(text: string): string {
|
||||
* branch's top section carries the same version heading as a predecessor
|
||||
* section with a different body (both took the same tentative stamp), the
|
||||
* branch's entry is kept under `[Unreleased]` so the restamp re-heads it.
|
||||
* Both sides carrying `[Unreleased]` is a real conflict and returns null.
|
||||
* Both sides carrying `[Unreleased]` is a real conflict and returns null, and
|
||||
* so is damage to released history (`changelogDamage`): null means a hand
|
||||
* resolve, never a merge that carries the damage through.
|
||||
*/
|
||||
export function mergeChangelogs(ours: string, theirs: string): string | null {
|
||||
export function mergeChangelogs(ours: string, theirs: string, base?: string): string | null {
|
||||
if (changelogDamage(ours, theirs, base)) return null;
|
||||
const a = parseChangelog(ours);
|
||||
const b = parseChangelog(theirs);
|
||||
const key = (s: ChangelogSection) => (s.unreleased ? 'Unreleased' : s.version!);
|
||||
|
||||
@@ -178,7 +178,6 @@ function openaiLifecycle(text: string): ParsedSource {
|
||||
if (!upcoming || !modelTables.length) throw new Error('missing-deprecation-tables');
|
||||
const lifecycle = modelTables.flatMap(table => table.slice(1).flatMap(row => {
|
||||
if (/^(?:Videos API|Assistants API|OpenAI-Beta: |New fine-tuning training on )/.test(row[1]) || row[1].startsWith('`/')) return [];
|
||||
if (!row[1].startsWith('`')) throw new Error('unrecognized-deprecated-model-cell');
|
||||
const floor = row[0].startsWith('at earliest ');
|
||||
const date = lifecycleDate(row[0].replace(/^at earliest /, ''));
|
||||
if (!date) throw new Error('missing-shutdown-date');
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ export function renderBrief(inputs: BriefInputs, o: { page?: number; summary?: s
|
||||
'',
|
||||
renderDecisions(inputs, page).trimEnd(),
|
||||
'',
|
||||
`Full review: ${record}${recordNote}. Answer with \`gstack-autoplan answer --out <dir> --gate-rev ${inputs.gate.gate_rev} --reply "<all | d3b uc1a | all except d3b>" --by human\`.`,
|
||||
`Full review: ${record}${recordNote}. Answer with \`gstack-autoplan answer --out <dir> --gate-rev ${inputs.gate.gate_rev} --reply "<rec | rec except d3b | d3b uc1a | as-is>" --by human\`.`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ const scanner = new Bun.Transpiler({ loader: 'tsx' });
|
||||
// that matches more text only gains an edge when it resolves to a repo file.
|
||||
const QUOTED = /['"`]([A-Za-z0-9_@][A-Za-z0-9_./@()[\]+-]*)['"`]/g;
|
||||
const JOIN = /\bjoin\(([^()]*)\)/g;
|
||||
const RESOLVE_EXT = ['', '.ts', '.tsx', '.js', '.mjs', '.cjs', '/index.ts', '/index.js'];
|
||||
const RESOLVE_EXT = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts', '/index.ts', '/index.tsx', '/index.js', '/index.jsx', '/index.mjs'];
|
||||
// A statement starts a line or follows `;` or a compound statement's `:`. The module spec is
|
||||
// non-empty and starts with a dot or a word character, so no two repetitions can share a dot or a space.
|
||||
const PY_IMPORT = /(?:^|[;:])[ \t\f]*(?:from[ \t]+(\.+(?:\w[\w.]*)?|\w[\w.]*)[ \t]+import[ \t]+(\([^)]*\)|[^\n;]+)|import[ \t]+([^\n;]+))/gm;
|
||||
|
||||
+4
-2
@@ -68,7 +68,7 @@ export interface RestampResult {
|
||||
|
||||
function sha256(buf: Buffer | string): string { return createHash('sha256').update(buf).digest('hex'); }
|
||||
function git(cwd: string, args: string[], env?: NodeJS.ProcessEnv): { ok: boolean; out: string; err: string; status: number | null } {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 60_000, env: env ?? process.env });
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 60_000, env: env ?? process.env, maxBuffer: 64 * 1024 * 1024 });
|
||||
return { ok: r.status === 0, out: (r.stdout ?? '').replace(/\n$/, ''), err: (r.stderr ?? '').trim(), status: r.status };
|
||||
}
|
||||
class Refusal extends Error { constructor(public code: ResultCodeName, message: string, public extra: Partial<RestampResult> = {}) { super(message); } }
|
||||
@@ -578,7 +578,9 @@ function gateAhead(o: RestampOptions, plan: Plan, stages: StageRecord[], common:
|
||||
if (resolvable) {
|
||||
for (const c of conflicts) {
|
||||
if (c === plan.policy.changelog) {
|
||||
const merged = mergeChangelogs(git(dir, ['show', `:2:${c}`]).out + '\n', git(dir, ['show', `:3:${c}`]).out + '\n');
|
||||
const stage = (n: number) => git(dir, ['show', `:${n}:${c}`]);
|
||||
const [base, ours, theirs] = [stage(1), stage(2), stage(3)];
|
||||
const merged = ours.ok && theirs.ok ? mergeChangelogs(ours.out + '\n', theirs.out + '\n', base.ok ? base.out + '\n' : undefined) : null;
|
||||
if (merged === null) { unresolved = [c]; break; }
|
||||
fs.writeFileSync(path.join(dir, c), merged);
|
||||
} else git(dir, ['checkout', '--theirs', '--', c]);
|
||||
|
||||
@@ -99,7 +99,8 @@ export function recomputeMergeTree(repoRoot: string, branchHead: string, predece
|
||||
if (!ours || !theirs) return { error: `${file} conflicts beyond a content merge (added or deleted on one side)` };
|
||||
if (file === policy.changelog) {
|
||||
const text = (sha: string) => git(repoRoot, ['cat-file', 'blob', sha]).out + '\n';
|
||||
const merged = mergeChangelogs(text(ours), text(theirs));
|
||||
const base = entry.get(1);
|
||||
const merged = mergeChangelogs(text(ours), text(theirs), base ? text(base) : undefined);
|
||||
if (merged === null) return { error: `${file} conflicts beyond a heading move` };
|
||||
const blob = git(repoRoot, ['hash-object', '-w', '--stdin'], undefined, merged);
|
||||
if (!blob.ok) return { error: `cannot write the merged ${file}: ${blob.err}` };
|
||||
|
||||
@@ -15,7 +15,9 @@ import { trainBisect, trainRebuild, trainVerdict } from './ship-queue-train-verd
|
||||
import { verifyTrain } from './ship-queue-train-verify';
|
||||
import { redactCause } from './ship-queue-train-record';
|
||||
|
||||
export const TRAIN_HELP = ` train build --prs <n>@<sha>[,...] [--urgent | --repair] [--dry-run] [--free-timeout <s>]
|
||||
export const TRAIN_HELP = ` train build --prs <n>@<sha>[,...] [--order <n>,...] [--urgent | --repair] [--dry-run] [--free-timeout <s>]
|
||||
--order: explicit slot order, every PR in --prs once (default: screened
|
||||
first, then READY order)
|
||||
train build --train <nonce> resume a train (push, free tests, dispatch) from its record
|
||||
merge pinned unstamped heads in slot order onto main, free tests on
|
||||
train/<nonce>, then one full gate through eval-bg's nonce
|
||||
@@ -77,8 +79,9 @@ export async function trainMain(ctx: QueueContext, args: string[]): Promise<numb
|
||||
const freeTimeoutSec = positive(flag(rest, '--free-timeout'), 2700);
|
||||
if (Number.isNaN(freeTimeoutSec)) return usage('--free-timeout needs seconds');
|
||||
if (rest.includes('--urgent') && rest.includes('--repair')) return usage('--urgent and --repair are separate one-PR trains; pass one');
|
||||
if (rest.includes('--order') && (nonce || !flag(rest, '--order'))) return usage('--order <n>,... orders a new train from --prs; a resumed train keeps its recorded order');
|
||||
return guarded('train build', () => buildTrain(ctx, {
|
||||
pins: flag(rest, '--prs'), resume: nonce, dryRun, urgent: rest.includes('--urgent'), repair: rest.includes('--repair'), freeTimeoutSec,
|
||||
pins: flag(rest, '--prs'), order: flag(rest, '--order'), resume: nonce, dryRun, urgent: rest.includes('--urgent'), repair: rest.includes('--repair'), freeTimeoutSec,
|
||||
pollMs: Number(process.env.GSTACK_SHIP_QUEUE_POLL_MS) || 30_000,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { randomBytes } from 'node:crypto';
|
||||
import { DISPATCH_INPUTS, awaitNonceRun, dedupeKey, dispatchWorkflow, findDuplicateRun, listDispatchRuns, makeNonce, type WorkflowDispatch } from '../scripts/eval-bg';
|
||||
import { EXIT } from './headless-artifacts';
|
||||
import { ready, type QueueContext } from './ship-queue';
|
||||
import { classifyTrainHead } from './version-gate-train';
|
||||
import { git } from './ship-queue-gate';
|
||||
import { execFor, repoSlug } from './ship-queue-train-github';
|
||||
import { loadReady, nowIso, persist, readyPath, type ReadyReceipt, type ScreenChoice } from './ship-queue-train-record';
|
||||
@@ -100,6 +101,8 @@ export async function trainReady(ctx: QueueContext, o: ReadyOptions): Promise<Tr
|
||||
}
|
||||
const head = git(ctx.repoRoot, ['rev-parse', 'HEAD']).out;
|
||||
const baseSha = git(ctx.repoRoot, ['rev-parse', `${env.remote}/${ctx.base}`]).out;
|
||||
const kind = classifyTrainHead({ repoRoot: ctx.repoRoot, baseRef: ctx.base, head });
|
||||
if (kind.kind === 'invalid') return outRefused({ code: 'QUEUE_NOT_READY', message: `the Version Gate refuses ${head.slice(0, 12)}: ${kind.reason}` }, base.lines, `fix the head, push, then ${Q} ready --pr ${o.pr} --screen|--no-screen`, 'none');
|
||||
const sel = await screenSelection(env, head, baseSha);
|
||||
const lines = [...base.lines, ...sel.lines];
|
||||
const rec = sel.recommend ?? 'no-screen';
|
||||
@@ -119,7 +122,7 @@ export async function trainReady(ctx: QueueContext, o: ReadyOptions): Promise<Tr
|
||||
}
|
||||
lines.splice(1, 0, `screen: ${choice.choice}${choice.policy ? ` policy=${choice.policy} nonce=${choice.nonce} run=${choice.run ?? '-'}` : ''}`);
|
||||
const now = nowIso();
|
||||
const readyAt = loadReady(env.repo.key, o.pr, head)?.ready_at ?? now;
|
||||
const readyAt = loadReady(env.repo.keys, o.pr, head)?.ready_at ?? now;
|
||||
const receipt: ReadyReceipt = { schema: 1, kind: 'ready', repo_key: env.repo.key, pr: o.pr, head, base_sha: baseSha, ready_at: readyAt, screen: choice, updated_at: now };
|
||||
if (o.dryRun) return outOk([...lines, 'DRY-RUN: no READY receipt written, nothing dispatched'], `${Q} ready --pr ${o.pr} --${o.screen}${o.broad ? ' --broad' : ''}`, 'none');
|
||||
const p = persist(ctx.repoRoot, [receipt], { remote: env.remote });
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface FreeRun { run: number | null; url: string | null; head_sha: str
|
||||
export interface TrainRecord {
|
||||
schema: 1; kind: 'train'; nonce: string; key: string; repo_key: string; repo: string; base: { ref: string; sha: string }; policy: string;
|
||||
slots: TrainSlot[]; held: string[]; prefixes: TrainPrefix[]; head: string; tree: string; branch: string;
|
||||
urgent: boolean; repair: boolean; state: TrainState; free: FreeRun | null; attempts: string[];
|
||||
urgent: boolean; repair: boolean; explicit_order?: boolean; state: TrainState; free: FreeRun | null; attempts: string[];
|
||||
merged: Array<{ pr: string; sha: string; tree: string; at: string }>; superseded_by: string | null;
|
||||
effects: Array<{ at: string; what: string }>; error: string | null; created_at: string; updated_at: string;
|
||||
}
|
||||
@@ -87,11 +87,34 @@ const bisectPath = (nonce: string) => path.join(trainDir(nonce), 'bisect.json');
|
||||
const freezePath = (repoKey: string) => path.join(queueRoot(), 'freeze', `${repoKey}.json`);
|
||||
export const readyPath = (repoKey: string, pr: string, head: string) => path.join(queueRoot(), 'ready', repoKey, `${pr}-${head}.json`);
|
||||
|
||||
/** The repository's identity: origin's URL, keyed so records of different repositories never mix. */
|
||||
export function repoIdentity(repoRoot: string): { key: string; url: string } {
|
||||
const url = git(repoRoot, ['config', '--get', 'remote.origin.url']).out.trim() || repoRoot;
|
||||
const norm = url.replace(/\.git$/, '').replace(/^git@github\.com:/, 'https://github.com/').replace(/\/+$/, '');
|
||||
return { key: sha256(norm).slice(0, 16), url: norm };
|
||||
/** Hosts that serve GitHub repositories at /<owner>/<repo> (git.capy.ai is Capy's GitHub proxy, set through url.insteadOf). */
|
||||
const GITHUB_HOSTS = ['github.com', 'www.github.com', 'ssh.github.com', 'git.capy.ai'];
|
||||
|
||||
/** Before 1.91.187.0 the key hashed origin's URL with only .git, git@github.com: and trailing slashes normalized. */
|
||||
const legacyNorm = (url: string) => url.replace(/\.git$/, '').replace(/^git@github\.com:/, 'https://github.com/').replace(/\/+$/, '');
|
||||
|
||||
/** A remote URL as https://github.com/<owner>/<repo> when it names a GitHub repository in any form (scp, ssh, http(s), credentials, proxy host); otherwise the legacy form. */
|
||||
export function normalizeRemoteUrl(url: string): string {
|
||||
const m = /^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?([^/:]+)(?::\d+)?[:/]+([^/]+)\/([^/]+?)(?:\.git)?\/*$/i.exec(url.trim());
|
||||
if (m && GITHUB_HOSTS.includes(m[1]!.toLowerCase())) return `https://github.com/${m[2]!.toLowerCase()}/${m[3]!.toLowerCase()}`;
|
||||
return legacyNorm(url.trim().replace(/^([a-z+]+:\/\/)[^@/]+@/i, '$1'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The repository's identity, keyed so records of different repositories
|
||||
* never mix: a hash of origin's URL normalized to owner/repo, so lanes whose
|
||||
* origin names the same GitHub repository in another form share one key.
|
||||
* `keys` adds the keys earlier versions wrote for this URL and for the
|
||||
* repository's common URL forms; readers accept all of them, writers use `key`.
|
||||
*/
|
||||
export function repoIdentity(repoRoot: string): { key: string; url: string; keys: string[] } {
|
||||
const raw = git(repoRoot, ['config', '--get', 'remote.origin.url']).out.trim() || repoRoot;
|
||||
const url = normalizeRemoteUrl(raw);
|
||||
const key = sha256(url).slice(0, 16);
|
||||
const gh = /^https:\/\/github\.com\/(.+)$/.exec(url)?.[1];
|
||||
const forms = gh ? GITHUB_HOSTS.flatMap(h => ['https', 'http', 'ssh://git@'].map(s => `${s.includes('://') ? s : `${s}://`}${h}/${gh}`)) : [];
|
||||
const keys = [...new Set([key, ...[raw, ...forms].map(u => sha256(legacyNorm(u)).slice(0, 16))])];
|
||||
return { key, url, keys };
|
||||
}
|
||||
|
||||
function readJson<T>(file: string, kind: string): T | null {
|
||||
@@ -101,19 +124,25 @@ function readJson<T>(file: string, kind: string): T | null {
|
||||
export const loadTrain = (nonce: string) => (/^[A-Za-z0-9._-]{1,128}$/.test(nonce) ? readJson<TrainRecord>(trainFile(nonce), 'train') : null);
|
||||
export const loadAttempt = (nonce: string, id: string) => (/^[A-Za-z0-9._-]{1,64}$/.test(id) ? readJson<AttemptRecord>(attemptPath(nonce, id), 'train-attempt') : null);
|
||||
export const loadBisect = (nonce: string) => readJson<BisectRecord>(bisectPath(nonce), 'train-bisect');
|
||||
export const loadFreeze = (repoKey: string) => readJson<FreezeRecord>(freezePath(repoKey), 'freeze');
|
||||
export const loadReady = (repoKey: string, pr: string, head: string) => readJson<ReadyReceipt>(readyPath(repoKey, pr, head), 'ready');
|
||||
export const activeFreeze = (repoKey: string): FreezeRecord | null => { const f = loadFreeze(repoKey); return f && !f.cleared_at ? f : null; };
|
||||
/** A reader's repo keys: the current key first, then the legacy keys (repoIdentity().keys). */
|
||||
type RepoKeys = string | readonly string[];
|
||||
const keyList = (k: RepoKeys): readonly string[] => (typeof k === 'string' ? [k] : k);
|
||||
const firstOf = <T>(k: RepoKeys, read: (key: string) => T | null): T | null => { for (const key of keyList(k)) { const r = read(key); if (r) return r; } return null; };
|
||||
|
||||
export const loadFreeze = (repoKeys: RepoKeys) => firstOf(repoKeys, key => readJson<FreezeRecord>(freezePath(key), 'freeze'));
|
||||
export const loadReady = (repoKeys: RepoKeys, pr: string, head: string) => firstOf(repoKeys, key => readJson<ReadyReceipt>(readyPath(key, pr, head), 'ready'));
|
||||
export const activeFreeze = (repoKeys: RepoKeys): FreezeRecord | null => firstOf(repoKeys, key => { const f = readJson<FreezeRecord>(freezePath(key), 'freeze'); return f && !f.cleared_at ? f : null; });
|
||||
|
||||
export function attemptsOf(train: TrainRecord): AttemptRecord[] {
|
||||
return train.attempts.map(id => loadAttempt(train.nonce, id)).filter((a): a is AttemptRecord => !!a);
|
||||
}
|
||||
|
||||
/** Every train of this repository, newest first. */
|
||||
export function listTrains(repoKey: string): TrainRecord[] {
|
||||
export function listTrains(repoKeys: RepoKeys): TrainRecord[] {
|
||||
const dir = path.join(queueRoot(), 'trains');
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs.readdirSync(dir).map(n => loadTrain(n)).filter((t): t is TrainRecord => !!t && t.repo_key === repoKey)
|
||||
const keys = keyList(repoKeys);
|
||||
return fs.readdirSync(dir).map(n => loadTrain(n)).filter((t): t is TrainRecord => !!t && keys.includes(t.repo_key))
|
||||
.sort((a, b) => b.created_at.localeCompare(a.created_at) || b.nonce.localeCompare(a.nonce));
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
type AttemptRecord, type BisectRecord, type CasePanel, type GateOutcome, type PrefixResult, type TrainRecord,
|
||||
} from './ship-queue-train-record';
|
||||
import {
|
||||
dispatchAttempt, effect, findTrain, gateDispatch, nextFor, outFailed, outOk, outPending, outRefused, pinList, save, trainEnv,
|
||||
dispatchAttempt, effect, findTrain, gateDispatch, nextFor, outFailed, outOk, outPending, outRefused, prsArgs, save, trainEnv,
|
||||
type TrainEnv, type TrainOut,
|
||||
} from './ship-queue-train';
|
||||
|
||||
@@ -309,7 +309,7 @@ export async function trainRebuild(ctx: QueueContext, o: { train: string; infra:
|
||||
const run = trainFile(t.nonce);
|
||||
const gates = gateAttempts(t);
|
||||
const last = gates.at(-1);
|
||||
const repair = `route ${last?.red_cases.join(',') || 'the red cases'} to a repair lane (GStack Minder); the train is rebuilt only once the repair merges: ${Q} train build --prs ${pinList(t.slots)}`;
|
||||
const repair = `route ${last?.red_cases.join(',') || 'the red cases'} to a repair lane (GStack Minder); the train is rebuilt only once the repair merges: ${Q} train build ${prsArgs(t.slots, t.explicit_order)}`;
|
||||
const refuse = (reason: string) => outRefused({ code: 'QUEUE_TRAIN_REBUILD_REFUSED', message: `rebuild refused: ${reason}` }, gates.map(attemptLine), repair, run);
|
||||
if (o.infra) {
|
||||
if (!last || last.outcome !== 'INCOMPLETE' || !last.infra_eligible) return refuse(`no INFRA/INCOMPLETE-only record (the last gate is ${last?.outcome ?? 'missing'})`);
|
||||
@@ -321,7 +321,7 @@ export async function trainRebuild(ctx: QueueContext, o: { train: string; infra:
|
||||
if (b.outcome.kind !== 'unattributed' || !b.outcome.all_green) return refuse(`the bisect was not all green (${Object.entries(b.outcome.patterns).map(([c, s]) => `${c}=${s}`).join(' ')})`);
|
||||
}
|
||||
const tip = remoteBranchSha(args => git(ctx.repoRoot, args), env.remote, t.branch);
|
||||
if (tip !== t.head) return outRefused({ code: 'QUEUE_TRAIN_MOVED', message: `${t.branch} is at ${tip?.slice(0, 12) ?? 'nothing'}, not the recorded head ${t.head.slice(0, 12)}` }, [], `${Q} train build --prs ${pinList(t.slots)}`, run);
|
||||
if (tip !== t.head) return outRefused({ code: 'QUEUE_TRAIN_MOVED', message: `${t.branch} is at ${tip?.slice(0, 12) ?? 'nothing'}, not the recorded head ${t.head.slice(0, 12)}` }, [], `${Q} train build ${prsArgs(t.slots, t.explicit_order)}`, run);
|
||||
const slug = repoSlug(ctx);
|
||||
if (!slug) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: 'gh repo view could not name the repository' }, [], `${Q} train rebuild --train ${t.nonce}${o.infra ? ' --infra' : ''}`, run);
|
||||
const a = await dispatchAttempt(env, slug, t, o.infra ? 'infra-redispatch' : 'rebuild', gateDispatch(env, t, `${t.nonce}.${gates.length + 1}`));
|
||||
|
||||
@@ -20,8 +20,9 @@ import { git } from './ship-queue-gate';
|
||||
import { checkRun, execFor, repoSlug } from './ship-queue-train-github';
|
||||
import { activeFreeze, attemptsOf, nowIso, persist, trainFile, type FreezeRecord, type TrainRecord } from './ship-queue-train-record';
|
||||
import {
|
||||
effect, fetchBase, fetchTrainBranch, findTrain, frozenLines, nextFor, outFailed, outOk, outRefused, pinList, save, trainEnv, type TrainEnv, type TrainOut,
|
||||
effect, fetchBase, fetchTrainBranch, findTrain, frozenLines, nextFor, outFailed, outOk, outRefused, pinList, prsArgs, save, trainEnv, type TrainEnv, type TrainOut,
|
||||
} from './ship-queue-train';
|
||||
import { ownEntryAboveMain } from './changelog-check';
|
||||
import { buildReceipt } from './tree-receipt';
|
||||
|
||||
const Q = 'gstack-ship-queue';
|
||||
@@ -32,6 +33,37 @@ function sameModuloStamps(env: TrainEnv, gated: string, current: string): { same
|
||||
return { same: r.tree === 'same-modulo-stamps', detail: r.tree === 'same-modulo-stamps' ? `same modulo stamps (${r.files.length} stamp file(s))` : `differs: ${r.residual.slice(0, 8).join(', ')}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* A slot head with main merged in (from slot 2 on the lane merges main into
|
||||
* its pin so GitHub can merge it): it must contain main, and its tree must be
|
||||
* the slot's prefix T<i> modulo stamps. The CHANGELOG is judged against main
|
||||
* instead of T<i>'s mechanical merge, so a hand resolve is accepted when it
|
||||
* is main's CHANGELOG plus the PR's own entry (ownEntryAboveMain); any other
|
||||
* difference is refused.
|
||||
*/
|
||||
function mergedIn(env: TrainEnv, t: TrainRecord, i: number, head: string, main: string): { same: boolean; detail: string } {
|
||||
const after = t.prefixes[i + 1]!;
|
||||
const root = env.ctx.repoRoot;
|
||||
if (!git(root, ['merge-base', '--is-ancestor', main, head]).ok) return { same: false, detail: `does not contain main ${main.slice(0, 12)}` };
|
||||
const r = buildReceipt({ repoRoot: root, gated: after.commit, current: head, policy: env.policy.policy, policyLabel: env.policy.source.label });
|
||||
if ('error' in r) return { same: false, detail: r.error };
|
||||
const stamps = `${r.files.filter(f => f.verdict === 'stamp').length} stamp file(s)`;
|
||||
if (r.tree === 'same-modulo-stamps') return { same: true, detail: `T${after.index} with main merged in, same modulo stamps (${stamps})` };
|
||||
const cl = env.policy.policy.changelog;
|
||||
const beyond = r.residual.filter(f => f !== cl);
|
||||
if (beyond.length || !cl) return { same: false, detail: `differs from T${after.index}: ${r.residual.slice(0, 8).join(', ')}` };
|
||||
const pin = t.slots[i]!.head;
|
||||
const forkPoint = git(root, ['merge-base', pin, t.prefixes[0]!.commit]).out;
|
||||
const diff = git(root, ['diff', '--unified=0', '--no-color', '--no-ext-diff', forkPoint, pin, '--', cl]);
|
||||
const show = (rev: string) => git(root, ['show', `${rev}:${cl}`]);
|
||||
const [headCl, mainCl] = [show(head), show(main)];
|
||||
if (!forkPoint || !diff.ok || !headCl.ok || !mainCl.ok) return { same: false, detail: `cannot read ${cl} at the pin, the head or main` };
|
||||
const authored = new Set(diff.out.split('\n').filter(l => l.startsWith('+') && !l.startsWith('+++')).map(l => l.slice(1)));
|
||||
const why = ownEntryAboveMain(headCl.out, mainCl.out, authored);
|
||||
if (why) return { same: false, detail: `differs from T${after.index} in ${cl}, which is not main's plus #${t.slots[i]!.pr}'s own entry: ${why}` };
|
||||
return { same: true, detail: `T${after.index} with main merged in, same modulo stamps (${stamps}) except ${cl}, resolved as main's ${cl} plus #${t.slots[i]!.pr}'s own entry` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Freeze the queue: the record goes to local state and the notes ref before
|
||||
* anything remote, then the backstop gate is dispatched on main's sha.
|
||||
@@ -61,8 +93,8 @@ async function freezeQueue(env: TrainEnv, t: TrainRecord, reason: string, mainSh
|
||||
/** Interrupted publication: freeze when earlier slots merged; otherwise a plain refusal (nothing was published). */
|
||||
async function stopped(env: TrainEnv, t: TrainRecord, code: 'QUEUE_TRAIN_TREE_MISMATCH' | 'QUEUE_TRAIN_MOVED', message: string, mainSha: string, lines: string[]): Promise<TrainOut> {
|
||||
const run = trainFile(t.nonce);
|
||||
if (!t.merged.length) return outFailed({ code, message: `${message}; nothing of train ${t.nonce} merged, so the queue is not frozen` }, lines, `${Q} train build --prs ${pinList(t.slots)} (a new train on current main)`, run);
|
||||
const f = activeFreeze(env.repo.key) ?? await freezeQueue(env, t, message, mainSha);
|
||||
if (!t.merged.length) return outFailed({ code, message: `${message}; nothing of train ${t.nonce} merged, so the queue is not frozen` }, lines, `${Q} train build ${prsArgs(t.slots, t.explicit_order)} (a new train on current main)`, run);
|
||||
const f = activeFreeze(env.repo.keys) ?? await freezeQueue(env, t, message, mainSha);
|
||||
return outFailed({ code, message: `${message}; the queue is frozen` }, [...lines, ...frozenLines(f)], `${Q} train build --repair --prs <repair-pr>@<sha>`, run);
|
||||
}
|
||||
|
||||
@@ -84,7 +116,7 @@ export async function verifyTrain(ctx: QueueContext, o: { pr: string; train: str
|
||||
if (check?.conclusion !== 'success') return outRefused({ code: 'QUEUE_GATE_MISSING', message: `no successful ${env.train.full_gate_check} check run on the train head ${t.head.slice(0, 12)}` }, [], `${Q} train verdict --train ${t.nonce}`, run);
|
||||
if (!fetchTrainBranch(env, t)) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `the train's prefix commits are not here and ${t.branch} could not be fetched` }, [], `${Q} train status --train ${t.nonce}`, run);
|
||||
const main = git(ctx.repoRoot, ['rev-parse', `${env.remote}/${ctx.base}`]).out;
|
||||
const freeze = activeFreeze(env.repo.key);
|
||||
const freeze = activeFreeze(env.repo.keys);
|
||||
if (freeze && freeze.repair?.train !== t.nonce) return outRefused({ code: 'QUEUE_TRAIN_FROZEN', message: `the queue is frozen (${freeze.reason}); only the admitted repair train merges` }, frozenLines(freeze), `${Q} train build --repair --prs <repair-pr>@<sha>`, run);
|
||||
const after = t.prefixes[i + 1]!;
|
||||
const before = t.prefixes[i]!;
|
||||
@@ -98,9 +130,8 @@ export async function verifyTrain(ctx: QueueContext, o: { pr: string; train: str
|
||||
const headRef = o.noFetch ? '' : (git(ctx.repoRoot, ['fetch', '--no-tags', '-q', env.remote, `+refs/pull/${o.pr}/head:refs/gstack/queue/pr-${o.pr}`]).ok ? `refs/gstack/queue/pr-${o.pr}` : '');
|
||||
const head = headRef ? git(ctx.repoRoot, ['rev-parse', headRef]).out : t.slots[i]!.head;
|
||||
const pinned = head === t.slots[i]!.head ? { same: true, detail: 'the pinned head' } : sameModuloStamps(env, t.slots[i]!.head, head);
|
||||
const asPrefix = pinned.same ? null : sameModuloStamps(env, after.commit, head);
|
||||
const stampOnly = asPrefix?.same ? { same: true, detail: `T${after.index} with main merged in, ${asPrefix.detail}` } : pinned;
|
||||
if (!stampOnly.same) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr} moved past its pinned head ${t.slots[i]!.head.slice(0, 12)} and is not T${after.index} modulo stamps (${head.slice(0, 12)}: ${pinned.detail})`, main, lines);
|
||||
const stampOnly = pinned.same ? pinned : mergedIn(env, t, i, head, main);
|
||||
if (!stampOnly.same) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr} moved past its pinned head ${t.slots[i]!.head.slice(0, 12)} and is not T${after.index} modulo stamps (${head.slice(0, 12)}: ${stampOnly.detail}; against the pin it ${pinned.detail})`, main, lines);
|
||||
return outOk([...lines, `main: ${main} = T${before.index} (${pre.detail})`, `head: ${head} (${stampOnly.detail})`,
|
||||
`VERIFY_TRAIN: #${o.pr} may merge as slot ${i + 1} of train ${t.nonce} (green run ${gate.run?.id ?? '-'}, ${env.train.full_gate_check} success)`, `MERGE_CONDITION: sha=${head}`],
|
||||
`merge #${o.pr} with expected head ${head.slice(0, 12)} (gstack-restamp stamps at merge), then ${Q} verify --pr ${o.pr} --train ${t.nonce}`, run);
|
||||
@@ -113,7 +144,7 @@ function confirmMerged(env: TrainEnv, t: TrainRecord, pr: string, main: string,
|
||||
effect(t, `verified #${pr} merged as T${index}`);
|
||||
save(env, t);
|
||||
lines.push(`VERIFY_TRAIN: #${pr} merged; main's tree is T${index} of train ${t.nonce} (${t.merged.length}/${t.slots.length} merged)`);
|
||||
const freeze = activeFreeze(env.repo.key);
|
||||
const freeze = activeFreeze(env.repo.keys);
|
||||
if (freeze && freeze.repair?.train === t.nonce && t.state === 'merged') {
|
||||
freeze.cleared_at = nowIso();
|
||||
freeze.cleared_by = `repair #${pr} via train ${t.nonce}; main ${main.slice(0, 12)} carries a green record`;
|
||||
|
||||
+53
-25
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { DISPATCH_INPUTS, awaitNonceRun, dispatchWorkflow, listDispatchRuns, makeNonce, runForNonce, type WorkflowDispatch } from '../scripts/eval-bg';
|
||||
import { mergeChangelogs, parseChangelog, type ChangelogSection } from './changelog-check';
|
||||
import { changelogDamage, mergeChangelogs, parseChangelog, type ChangelogSection } from './changelog-check';
|
||||
import type { ResultStatus } from './headless-artifacts';
|
||||
import { versionAtRef, versionFiles } from './restamp';
|
||||
import type { LoadedPolicy, ShipPolicy, TrainPolicy } from './ship-policy';
|
||||
@@ -34,7 +34,7 @@ export const outRefused = (failure: QueueFailure, lines: string[], next: string,
|
||||
export const outFailed = (failure: QueueFailure, lines: string[], next: string, run: string): TrainOut => ({ exit: 1, status: 'refused', lines, next, failure, run });
|
||||
export const outPending = (failure: QueueFailure, lines: string[], next: string, run: string): TrainOut => ({ exit: 3, status: 'incomplete', lines, next, failure, run });
|
||||
|
||||
export interface TrainEnv { ctx: QueueContext; policy: LoadedPolicy; train: TrainPolicy; repo: { key: string; url: string }; remote: string }
|
||||
export interface TrainEnv { ctx: QueueContext; policy: LoadedPolicy; train: TrainPolicy; repo: { key: string; url: string; keys: string[] }; remote: string }
|
||||
|
||||
const HEX = /^[0-9a-f]{40,64}$/;
|
||||
const Q = 'gstack-ship-queue';
|
||||
@@ -92,9 +92,20 @@ export function parsePins(spec: string | undefined): Array<{ pr: string; sha: st
|
||||
}
|
||||
|
||||
export const pinList = (slots: Array<{ pr: string; head: string }>) => slots.map(s => `${s.pr}@${s.head}`).join(',');
|
||||
/** `--prs <pins>` for a rebuild of these slots, plus `--order` when the train was built with an explicit order. */
|
||||
export const prsArgs = (slots: Array<{ pr: string; head: string }>, explicit?: boolean) => `--prs ${pinList(slots)}${explicit ? ` --order ${slots.map(s => s.pr).join(',')}` : ''}`;
|
||||
|
||||
/** Slot order: screened PRs first, then unscreened, each in READY order; enough unscreened PRs form their own train. */
|
||||
export function orderSlots(slots: TrainSlot[], max: number): { train: TrainSlot[]; held: TrainSlot[]; split: boolean } {
|
||||
/**
|
||||
* Slot order: screened PRs first, then unscreened, each in READY order;
|
||||
* enough unscreened PRs form their own train. An explicit order (`--order`,
|
||||
* every admitted PR once) replaces that rule: slots follow it, the first
|
||||
* `max` ride and the rest are held.
|
||||
*/
|
||||
export function orderSlots(slots: TrainSlot[], max: number, explicit?: readonly string[]): { train: TrainSlot[]; held: TrainSlot[]; split: boolean } {
|
||||
if (explicit) {
|
||||
const all = explicit.map(pr => slots.find(s => s.pr === pr)!);
|
||||
return { train: all.slice(0, max), held: all.slice(max), split: false };
|
||||
}
|
||||
const byReady = (a: TrainSlot, b: TrainSlot) => a.ready_at.localeCompare(b.ready_at) || Number(a.pr) - Number(b.pr);
|
||||
const screened = slots.filter(s => s.screen.choice === 'screen').sort(byReady);
|
||||
const unscreened = slots.filter(s => s.screen.choice !== 'screen').sort(byReady);
|
||||
@@ -130,7 +141,9 @@ function unionChangelog(ours: string, theirs: string): string {
|
||||
* One train merge, in object storage: the PR head onto the previous prefix.
|
||||
* Version files take the prefix's side; the CHANGELOG merges by section (the
|
||||
* gate-ahead rule, else a union of sections, since each heading is restamped
|
||||
* at merge). Any other conflict is reported with every conflicting path.
|
||||
* at merge), unless a side damaged released history (changelogDamage), which
|
||||
* is a conflict the lane resolves. Any other conflict is reported with every
|
||||
* conflicting path.
|
||||
*/
|
||||
export function mergeStep(repoRoot: string, pred: string, head: string, policy: ShipPolicy): { tree: string } | { conflicts: string[] } {
|
||||
const r = git(repoRoot, ['merge-tree', '--write-tree', head, pred]);
|
||||
@@ -157,6 +170,9 @@ export function mergeStep(repoRoot: string, pred: string, head: string, policy:
|
||||
if (!ours || !theirs) conflicts.push(file);
|
||||
else if (file === policy.changelog) {
|
||||
const text = (sha: string) => git(repoRoot, ['cat-file', 'blob', sha]).out + '\n';
|
||||
const base = entry.get(1);
|
||||
const damage = changelogDamage(text(ours), text(theirs), base ? text(base) : undefined);
|
||||
if (damage) { conflicts.push(`${file} (${damage})`); continue; }
|
||||
const merged = mergeChangelogs(text(ours), text(theirs)) ?? unionChangelog(text(ours), text(theirs));
|
||||
const blob = git(repoRoot, ['hash-object', '-w', '--stdin'], undefined, merged);
|
||||
if (blob.ok) resolved.push({ path: file, blob: blob.out }); else conflicts.push(file);
|
||||
@@ -207,12 +223,12 @@ export function nextFor(t: TrainRecord): string {
|
||||
case 'attributed': {
|
||||
const drop = loadBisect(n)?.outcome?.prs ?? [];
|
||||
const keep = t.slots.filter(s => !drop.includes(s.pr));
|
||||
return keep.length ? `${Q} train build --prs ${pinList(keep)}` : 'none (every PR was dropped)';
|
||||
return keep.length ? `${Q} train build ${prsArgs(keep, t.explicit_order)}` : 'none (every PR was dropped)';
|
||||
}
|
||||
case 'red-on-main': return `fix forward on main first, then ${Q} train build --prs ${pinList(t.slots)}`;
|
||||
case 'red-on-main': return `fix forward on main first, then ${Q} train build ${prsArgs(t.slots, t.explicit_order)}`;
|
||||
case 'unattributed': return loadBisect(n)?.outcome?.all_green && !attemptsOf(t).some(a => a.purpose === 'rebuild')
|
||||
? `${Q} train rebuild --train ${n}`
|
||||
: `route the red cases to a repair lane (GStack Minder); once the repair merges: ${Q} train build --prs ${pinList(t.slots)}`;
|
||||
: `route the red cases to a repair lane (GStack Minder); once the repair merges: ${Q} train build ${prsArgs(t.slots, t.explicit_order)}`;
|
||||
default: return `${Q} train prune --train ${n}`;
|
||||
}
|
||||
}
|
||||
@@ -249,7 +265,17 @@ export function gateDispatch(env: TrainEnv, train: TrainRecord, nonce: string):
|
||||
return { workflow: env.train.gate_workflow, inputs: { [DISPATCH_INPUTS.evalsAll]: 'true', [DISPATCH_INPUTS.expectedSha]: train.head, [DISPATCH_INPUTS.nonce]: nonce } };
|
||||
}
|
||||
|
||||
export interface BuildOptions { pins?: string; resume?: string; dryRun: boolean; urgent: boolean; repair: boolean; freeTimeoutSec: number; pollMs: number }
|
||||
export interface BuildOptions { pins?: string; order?: string; resume?: string; dryRun: boolean; urgent: boolean; repair: boolean; freeTimeoutSec: number; pollMs: number }
|
||||
|
||||
/** `--order <pr>,<pr>,...`: every pinned PR exactly once (a leading # allowed). */
|
||||
export function parseOrder(spec: string | undefined, pins: Array<{ pr: string }>): string[] | undefined | { error: string } {
|
||||
if (spec === undefined) return undefined;
|
||||
const order = spec.split(',').map(s => s.trim().replace(/^#/, '')).filter(Boolean);
|
||||
const want = pins.map(p => p.pr).sort((a, b) => Number(a) - Number(b));
|
||||
const got = [...order].sort((a, b) => Number(a) - Number(b));
|
||||
if (order.some(pr => !/^\d+$/.test(pr)) || got.join(',') !== want.join(',')) return { error: `--order must list every PR in --prs exactly once (--prs has ${want.map(p => '#' + p).join(',')}; got ${spec})` };
|
||||
return order;
|
||||
}
|
||||
|
||||
/** The slots a new train would carry: trusted, unmoved, READY PRs, in slot order. */
|
||||
function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>): { slots: TrainSlot[]; lines: string[] } | TrainOut {
|
||||
@@ -267,7 +293,7 @@ function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>): {
|
||||
if (!info.headRefOid.startsWith(p.sha) || fetched !== info.headRefOid) {
|
||||
return outRefused({ code: 'QUEUE_TRAIN_MOVED', message: `PR #${p.pr} moved: pinned ${p.sha.slice(0, 12)}, head is now ${info.headRefOid.slice(0, 12)}` }, [], `ask #${p.pr}'s lane for READY on ${info.headRefOid.slice(0, 12)}, then ${Q} train build --prs <pins>`, 'none');
|
||||
}
|
||||
const ready = loadReady(env.repo.key, p.pr, info.headRefOid);
|
||||
const ready = loadReady(env.repo.keys, p.pr, info.headRefOid);
|
||||
if (!ready) return outRefused({ code: 'QUEUE_NOT_READY', message: `no READY receipt for #${p.pr} at ${info.headRefOid.slice(0, 12)} (local state or ${'refs/notes/gstack-trains'})` }, [], `the lane runs ${Q} ready --pr ${p.pr} --screen|--no-screen`, 'none');
|
||||
slots.push({ pr: p.pr, head: info.headRefOid, ready_at: ready.ready_at, screen: ready.screen });
|
||||
}
|
||||
@@ -280,28 +306,30 @@ async function newTrain(env: TrainEnv, o: BuildOptions): Promise<{ t: TrainRecor
|
||||
if (typeof pins === 'string') return outRefused({ code: 'QUEUE_PROOF_MISSING', message: pins }, [], 'none', 'none');
|
||||
if ((o.urgent || o.repair) && pins.length !== 1) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `${o.repair ? '--repair' : '--urgent'} admits exactly one PR` }, [], 'none', 'none');
|
||||
rebuildFromNotes(env.ctx.repoRoot, env.remote);
|
||||
const freeze = activeFreeze(env.repo.key);
|
||||
const freeze = activeFreeze(env.repo.keys);
|
||||
if (freeze && !o.repair) return outRefused({ code: 'QUEUE_TRAIN_FROZEN', message: `the queue is frozen since ${freeze.created_at}: train ${freeze.train} stopped after merging ${freeze.merged.map(p => '#' + p).join(',')} (${freeze.reason})` },
|
||||
frozenLines(freeze), `${Q} train build --repair --prs <repair-pr>@<sha>`, 'none');
|
||||
const explicit = parseOrder(o.order, pins);
|
||||
if (explicit && 'error' in explicit) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: explicit.error }, [], 'none', 'none');
|
||||
const admitted = admitSlots(env, pins);
|
||||
if ('exit' in admitted) return admitted;
|
||||
const order = orderSlots(admitted.slots, o.repair ? 1 : env.train.max_size);
|
||||
const order = orderSlots(admitted.slots, o.repair ? 1 : env.train.max_size, explicit);
|
||||
const lines = [
|
||||
`ORDER: ${order.train.map((s, i) => `${i + 1}:#${s.pr}(${s.screen.choice === 'screen' ? 'screened' : 'unscreened'})`).join(' ')}`,
|
||||
`ORDER: ${order.train.map((s, i) => `${i + 1}:#${s.pr}(${s.screen.choice === 'screen' ? 'screened' : 'unscreened'})`).join(' ')}${explicit ? ' (explicit --order)' : ''}`,
|
||||
...(order.held.length ? [`HELD: ${order.held.map(s => '#' + s.pr).join(',')} (${order.split ? 'unscreened PRs form their own train' : `over the train size ${env.train.max_size}`})`] : []),
|
||||
];
|
||||
const due = fillDue(order.train, env.train, nowIso(), o.urgent || o.repair);
|
||||
if (due && !o.dryRun) return outPending({ code: 'QUEUE_TRAIN_WAITING', message: `${order.train.length} of ${env.train.max_size} PRs; the oldest READY dispatches at ${due}` }, lines, `${Q} train build --prs ${pinList(order.train)} (after ${due}, or with more PRs, or --urgent for one)`, 'none');
|
||||
if (due && !o.dryRun) return outPending({ code: 'QUEUE_TRAIN_WAITING', message: `${order.train.length} of ${env.train.max_size} PRs; the oldest READY dispatches at ${due}` }, lines, `${Q} train build ${prsArgs(order.train, !!explicit)} (after ${due}, or with more PRs, or --urgent for one)`, 'none');
|
||||
const baseSha = git(env.ctx.repoRoot, ['rev-parse', `${env.remote}/${env.ctx.base}`]).out;
|
||||
const key = sha256(JSON.stringify({ base: baseSha, slots: order.train.map(s => `${s.pr}@${s.head}`) }));
|
||||
const live = listTrains(env.repo.key).find(t => t.key === key && !TERMINAL_STATES.includes(t.state) && t.state !== 'red' && t.state !== 'unattributed');
|
||||
const live = listTrains(env.repo.keys).find(t => t.key === key && !TERMINAL_STATES.includes(t.state) && t.state !== 'red' && t.state !== 'unattributed');
|
||||
if (live) return { t: Object.assign(live, { held: order.held.map(s => s.pr) }), lines };
|
||||
const chain = buildChain(env.ctx.repoRoot, baseSha, order.train, env.policy.policy);
|
||||
if ('stoppedAt' in chain) {
|
||||
const without = order.train.filter(s => s.pr !== chain.stoppedAt);
|
||||
return outFailed({ code: 'QUEUE_TRAIN_CONFLICT', message: `#${chain.stoppedAt} conflicts with the prefix before it` }, [...lines,
|
||||
`QUEUE_TRAIN_CONFLICT: train ${key.slice(0, 12)} stopped at #${chain.stoppedAt} (conflicts: ${chain.conflicts.join(', ')}) after merging ${chain.merged.map(p => '#' + p).join(',') || '(none)'}; nothing dispatched, no branch pushed; fix: ${Q} train build --prs ${pinList(without)}`],
|
||||
without.length ? `${Q} train build --prs ${pinList(without)}` : 'none', 'none');
|
||||
`QUEUE_TRAIN_CONFLICT: train ${key.slice(0, 12)} stopped at #${chain.stoppedAt} (conflicts: ${chain.conflicts.join(', ')}) after merging ${chain.merged.map(p => '#' + p).join(',') || '(none)'}; nothing dispatched, no branch pushed; fix: ${Q} train build ${prsArgs(without, !!explicit)}`],
|
||||
without.length ? `${Q} train build ${prsArgs(without, !!explicit)}` : 'none', 'none');
|
||||
}
|
||||
const head = chain.prefixes.at(-1)!;
|
||||
const nonce = makeNonce('gate', head.commit, baseSha, randomBytes(3).toString('hex'), { purpose: 'train' });
|
||||
@@ -309,7 +337,7 @@ async function newTrain(env: TrainEnv, o: BuildOptions): Promise<{ t: TrainRecor
|
||||
return { lines, t: {
|
||||
schema: 1, kind: 'train', nonce, key, repo_key: env.repo.key, repo: env.repo.url, base: { ref: env.ctx.base, sha: baseSha }, policy: env.policy.source.label,
|
||||
slots: order.train, held: order.held.map(s => s.pr), prefixes: chain.prefixes, head: head.commit, tree: head.tree, branch: `train/${nonce}`,
|
||||
urgent: o.urgent, repair: o.repair, state: 'built', free: null, attempts: [], merged: [], superseded_by: null, effects: [], error: null,
|
||||
urgent: o.urgent, repair: o.repair, ...(explicit ? { explicit_order: true } : {}), state: 'built', free: null, attempts: [], merged: [], superseded_by: null, effects: [], error: null,
|
||||
created_at: now, updated_at: now,
|
||||
} };
|
||||
}
|
||||
@@ -342,7 +370,7 @@ async function freePhase(env: TrainEnv, slug: string, t: TrainRecord, o: BuildOp
|
||||
t.state = 'free-red';
|
||||
effect(t, `${env.train.free_workflow} dispatch refused by gh`);
|
||||
save(env, t);
|
||||
return outFailed({ code: 'QUEUE_TRAIN_FREE_RED', message: `gh could not dispatch ${env.train.free_workflow} on ${t.branch}` }, describe(t), `${Q} train build --prs ${pinList(t.slots)}`, run);
|
||||
return outFailed({ code: 'QUEUE_TRAIN_FREE_RED', message: `gh could not dispatch ${env.train.free_workflow} on ${t.branch}` }, describe(t), `${Q} train build ${prsArgs(t.slots, t.explicit_order)}`, run);
|
||||
}
|
||||
t.state = 'free-pending';
|
||||
effect(t, `${env.train.free_workflow} dispatched on ${t.branch}`);
|
||||
@@ -422,12 +450,12 @@ export async function buildTrain(ctx: QueueContext, o: BuildOptions): Promise<Tr
|
||||
if (!('kind' in t)) return t;
|
||||
if (o.dryRun) {
|
||||
return outOk([...lines, ...describe(t), `ESTIMATE: one ${env.train.gate_workflow} full gate (evals_all) on T${t.prefixes.length - 1} plus ${env.train.free_workflow}; dollar figure pending the T0 cost replay (lib/gate-cost.ts)`,
|
||||
'DRY-RUN: nothing written, pushed or dispatched'], `${Q} train build --prs ${pinList(t.slots)}`, 'none');
|
||||
'DRY-RUN: nothing written, pushed or dispatched'], `${Q} train build ${prsArgs(t.slots, t.explicit_order)}`, 'none');
|
||||
}
|
||||
if (t.state === 'built' && !t.attempts.length && !loadTrain(t.nonce)) {
|
||||
effect(t, 'train.json written');
|
||||
save(env, t);
|
||||
for (const old of listTrains(env.repo.key)) {
|
||||
for (const old of listTrains(env.repo.keys)) {
|
||||
if (old.nonce === t.nonce || TERMINAL_STATES.includes(old.state) || old.state === 'green' || old.state === 'merging') continue;
|
||||
if (!old.slots.some(s => t.slots.some((n: TrainSlot) => n.pr === s.pr))) continue;
|
||||
old.state = 'superseded';
|
||||
@@ -435,7 +463,7 @@ export async function buildTrain(ctx: QueueContext, o: BuildOptions): Promise<Tr
|
||||
save(env, old);
|
||||
}
|
||||
if (o.repair) {
|
||||
const f = activeFreeze(env.repo.key)!;
|
||||
const f = activeFreeze(env.repo.keys)!;
|
||||
f.repair = { pr: t.slots[0]!.pr, head: t.slots[0]!.head, train: t.nonce };
|
||||
f.updated_at = nowIso();
|
||||
persist(env.ctx.repoRoot, [f], { remote: env.remote });
|
||||
@@ -454,7 +482,7 @@ export function trainStatus(ctx: QueueContext, o: { train?: string }): TrainOut
|
||||
if ('code' in env) return outRefused(env, [], 'none', 'none');
|
||||
const restored = rebuildFromNotes(ctx.repoRoot, env.remote);
|
||||
const lines = [`NOTES: read ${restored.read} record(s), restored ${restored.restored} (${restored.detail})`];
|
||||
const trains = o.train ? [loadTrain(o.train)].filter((t): t is TrainRecord => !!t) : listTrains(env.repo.key).filter(t => !TERMINAL_STATES.includes(t.state));
|
||||
const trains = o.train ? [loadTrain(o.train)].filter((t): t is TrainRecord => !!t) : listTrains(env.repo.keys).filter(t => !TERMINAL_STATES.includes(t.state));
|
||||
if (o.train && !trains.length) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `no train record ${o.train} here or in refs/notes/gstack-trains` }, lines, `${Q} train status`, 'none');
|
||||
const slug = trains.length ? repoSlug(ctx) : null;
|
||||
const exec = execFor(ctx);
|
||||
@@ -482,7 +510,7 @@ export function trainStatus(ctx: QueueContext, o: { train?: string }): TrainOut
|
||||
lines.push(`NEXT(${t.nonce}): ${nextFor(t)}`);
|
||||
}
|
||||
if (!trains.length) lines.push('TRAIN: none live');
|
||||
const freeze = activeFreeze(env.repo.key);
|
||||
const freeze = activeFreeze(env.repo.keys);
|
||||
if (freeze) lines.push(...frozenLines(freeze));
|
||||
const single = trains.length === 1 ? trains[0]! : null;
|
||||
const next = freeze && !freeze.repair ? `${Q} train build --repair --prs <repair-pr>@<sha>` : single ? nextFor(single) : trains.length ? 'see each NEXT(<nonce>) line' : 'none';
|
||||
@@ -496,7 +524,7 @@ export function pruneTrains(ctx: QueueContext, o: { dryRun: boolean; train?: str
|
||||
rebuildFromNotes(ctx.repoRoot, env.remote);
|
||||
const lines: string[] = [];
|
||||
const rgit = (args: string[]) => git(ctx.repoRoot, args);
|
||||
for (const t of listTrains(env.repo.key).filter(x => !o.train || x.nonce === o.train)) {
|
||||
for (const t of listTrains(env.repo.keys).filter(x => !o.train || x.nonce === o.train)) {
|
||||
if (!TERMINAL_STATES.includes(t.state)) { lines.push(`KEEP: ${t.branch} (${t.state})`); continue; }
|
||||
if (!remoteBranchSha(rgit, env.remote, t.branch)) { lines.push(`GONE: ${t.branch} (${t.state})`); continue; }
|
||||
if (o.dryRun) { lines.push(`WOULD-DELETE: ${t.branch} (${t.state})`); continue; }
|
||||
|
||||
@@ -18,7 +18,7 @@ export type TrainHead =
|
||||
| { kind: 'invalid'; reason: string };
|
||||
|
||||
function git(repoRoot: string, args: string[]): string | null {
|
||||
const r = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 });
|
||||
const r = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 64 * 1024 * 1024 });
|
||||
return r.status === 0 ? r.stdout : null;
|
||||
}
|
||||
|
||||
@@ -50,11 +50,18 @@ export function classifyTrainHead(o: { repoRoot: string; baseRef: string; head:
|
||||
}
|
||||
|
||||
const file = typeof policy.changelog === 'string' ? policy.changelog : 'CHANGELOG.md';
|
||||
const before = parseChangelog(show(mergeBase, file) ?? '');
|
||||
const after = parseChangelog(show(o.head, file) ?? '');
|
||||
const exists = (rev: string) => git(o.repoRoot, ['cat-file', '-e', `${rev}:${file}`]) !== null;
|
||||
const read = (rev: string) => (exists(rev) ? show(rev, file) : '');
|
||||
const [beforeText, afterText] = [read(mergeBase), read(o.head)];
|
||||
if (beforeText === null || afterText === null) return { kind: 'invalid', reason: `cannot read ${file} at ${beforeText === null ? `the merge base ${mergeBase.slice(0, 12)}` : o.head}` };
|
||||
const before = parseChangelog(beforeText);
|
||||
const after = parseChangelog(afterText);
|
||||
const unreleased = after.sections.filter(s => s.unreleased);
|
||||
if (unreleased.length > 1) return { kind: 'invalid', reason: `${file} has ${unreleased.length} [Unreleased] sections` };
|
||||
if (unreleased.length === 1 && after.sections[0] !== unreleased[0]) return { kind: 'invalid', reason: `${file}: [Unreleased] must be the top section` };
|
||||
const kept = new Set(after.sections.filter(s => !s.unreleased).map(s => s.version));
|
||||
const dropped = before.sections.filter(s => !s.unreleased && !kept.has(s.version)).map(s => s.version);
|
||||
if (dropped.length) return { kind: 'invalid', reason: `${file} drops released heading ${dropped.slice(0, 3).join(', ')}${dropped.length > 3 ? ` (+${dropped.length - 3})` : ''}; an unstamped head may only add its [Unreleased] section above the latest release` };
|
||||
const released = (c: typeof after) => [c.header.join('\n'), ...c.sections.filter(s => !s.unreleased).map(s => s.lines.join('\n'))];
|
||||
const was = released(before);
|
||||
const now = released(after);
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.91.186",
|
||||
"version": "1.91.194",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+10
-9
@@ -793,7 +793,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number
|
||||
forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level.
|
||||
|
||||
3. **Queue-aware pick** (workspace-aware ship):
|
||||
3. **Queue-aware pick:**
|
||||
```bash
|
||||
QUEUE_JSON=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version --base <base> --bump "$BUMP_LEVEL" --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
|
||||
CANDIDATE_VERSION=$(echo "$QUEUE_JSON" | jq -r '.version // empty')
|
||||
@@ -803,6 +803,10 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
Offline output without that fallback, failure, malformed output or an empty version
|
||||
is unusable, even when it contains a version-looking string.
|
||||
|
||||
**Incomplete** (`complete:false`): PRs in `unknown_claims` went unread; the pick may
|
||||
collide. Rerun item 3 once; still incomplete, AskUserQuestion: use it (recommended;
|
||||
Version Gate CI rereads the queue) or stop.
|
||||
|
||||
- **Usable candidate:** print warnings and claimed queue. FRESH sets `NEW_VERSION=CANDIDATE_VERSION`.
|
||||
ALREADY_BUMPED compares it with `currentVersion`: if different, ask to rebump
|
||||
(refresh CHANGELOG/PR title) or keep current (CI rejects a collision).
|
||||
@@ -814,16 +818,13 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
|
||||
4. **Write the bump** (FRESH, or an approved rebump):
|
||||
```bash
|
||||
bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest
|
||||
bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest --changelog CHANGELOG.md
|
||||
```
|
||||
The CLI validates `MAJOR.MINOR.PATCH.MICRO` (or pinned 3-digit semver), writes
|
||||
VERSION, the manifest (`--package-json-path` → `.gstack/package-json-path` →
|
||||
`./package.json`) and existing npm lockfiles, never creating one, with the 3-digit
|
||||
translation (`1.67.0.0` → `1.67.0`); VERSION is authoritative. Exit 3 is a half-write:
|
||||
reclassify and `repair` DRIFT_STALE_PKG.
|
||||
It writes VERSION, manifest and lockfiles, and moves `## [Unreleased]` under NEW_VERSION
|
||||
for Step 13. Exit 2 wrote nothing: show stderr, stop.
|
||||
Exit 3 is a half-write: reclassify and `repair` DRIFT_STALE_PKG.
|
||||
|
||||
`--regen-digest` runs repo code (`scripts/gen-agents-digest.ts`) with Step 5's privileges
|
||||
only when it and committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is
|
||||
`--regen-digest` runs `scripts/gen-agents-digest.ts` only when it and a committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is
|
||||
false, run `bun --no-env-file --config=/dev/null scripts/gen-agents-digest.ts` and stage the digest with the bump. Before
|
||||
push, verify the committed digest matches generation for the selected VERSION.
|
||||
|
||||
|
||||
+10
-9
@@ -265,7 +265,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number
|
||||
forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level.
|
||||
|
||||
3. **Queue-aware pick** (workspace-aware ship):
|
||||
3. **Queue-aware pick:**
|
||||
```bash
|
||||
QUEUE_JSON=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version --base <base> --bump "$BUMP_LEVEL" --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
|
||||
CANDIDATE_VERSION=$(echo "$QUEUE_JSON" | jq -r '.version // empty')
|
||||
@@ -275,6 +275,10 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
Offline output without that fallback, failure, malformed output or an empty version
|
||||
is unusable, even when it contains a version-looking string.
|
||||
|
||||
**Incomplete** (`complete:false`): PRs in `unknown_claims` went unread; the pick may
|
||||
collide. Rerun item 3 once; still incomplete, AskUserQuestion: use it (recommended;
|
||||
Version Gate CI rereads the queue) or stop.
|
||||
|
||||
- **Usable candidate:** print warnings and claimed queue. FRESH sets `NEW_VERSION=CANDIDATE_VERSION`.
|
||||
ALREADY_BUMPED compares it with `currentVersion`: if different, ask to rebump
|
||||
(refresh CHANGELOG/PR title) or keep current (CI rejects a collision).
|
||||
@@ -286,16 +290,13 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
|
||||
4. **Write the bump** (FRESH, or an approved rebump):
|
||||
```bash
|
||||
bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest
|
||||
bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest --changelog CHANGELOG.md
|
||||
```
|
||||
The CLI validates `MAJOR.MINOR.PATCH.MICRO` (or pinned 3-digit semver), writes
|
||||
VERSION, the manifest (`--package-json-path` → `.gstack/package-json-path` →
|
||||
`./package.json`) and existing npm lockfiles, never creating one, with the 3-digit
|
||||
translation (`1.67.0.0` → `1.67.0`); VERSION is authoritative. Exit 3 is a half-write:
|
||||
reclassify and `repair` DRIFT_STALE_PKG.
|
||||
It writes VERSION, manifest and lockfiles, and moves `## [Unreleased]` under NEW_VERSION
|
||||
for Step 13. Exit 2 wrote nothing: show stderr, stop.
|
||||
Exit 3 is a half-write: reclassify and `repair` DRIFT_STALE_PKG.
|
||||
|
||||
`--regen-digest` runs repo code (`scripts/gen-agents-digest.ts`) with Step 5's privileges
|
||||
only when it and committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is
|
||||
`--regen-digest` runs `scripts/gen-agents-digest.ts` only when it and a committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is
|
||||
false, run `bun --no-env-file --config=/dev/null scripts/gen-agents-digest.ts` and stage the digest with the bump. Before
|
||||
push, verify the committed digest matches generation for the selected VERSION.
|
||||
|
||||
|
||||
+15
-15
@@ -3,7 +3,7 @@
|
||||
* bin/gstack-bun-version.sh and `resolveBun` / `launchRuntime` in
|
||||
* lib/pregate/runtime.ts must pick the same binary (by realpath) for the
|
||||
* GBRA-60 layout: the image's old Bun first on PATH, Bun 1.4.2 in
|
||||
* $BUN_INSTALL/bin, Bun 1.4.3 elsewhere, a project pinning 1.4.3 only through
|
||||
* $BUN_INSTALL/bin, Bun 1.4.4 elsewhere, a project pinning 1.4.4 only through
|
||||
* a CI lane, an unpinned project and a directory outside any project. A file
|
||||
* carrying the shim marker is never returned, and a candidate is chosen only
|
||||
* when it satisfies every applicable pin.
|
||||
@@ -41,7 +41,7 @@ function layout(opts: { storeVersions?: string[]; homeVersion?: string } = {}) {
|
||||
const pinned = path.join(base, 'gbrain');
|
||||
fs.mkdirSync(path.join(pinned, '.github/workflows'), { recursive: true });
|
||||
fs.writeFileSync(path.join(pinned, 'package.json'), '{ "name": "gbrain", "engines": { "bun": ">=1.4.0" } }\n');
|
||||
fs.writeFileSync(path.join(pinned, '.github/workflows/ci.yml'), 'jobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: oven-sh/setup-bun@v2\n with:\n bun-version: 1.4.3\n');
|
||||
fs.writeFileSync(path.join(pinned, '.github/workflows/ci.yml'), 'jobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: oven-sh/setup-bun@v2\n with:\n bun-version: 1.4.4\n');
|
||||
const unpinned = path.join(base, 'plain');
|
||||
fs.mkdirSync(unpinned);
|
||||
fs.writeFileSync(path.join(unpinned, 'package.json'), '{ "name": "plain" }\n');
|
||||
@@ -74,24 +74,24 @@ function tsResolve(l: ReturnType<typeof layout>, project: string | null): string
|
||||
}
|
||||
|
||||
describe('one Bun resolution contract, two twins', () => {
|
||||
test('GBRA-60: 1.4.3 in the store, 1.4.2 in $BUN_INSTALL, a CI-lane pin of 1.4.3 — both twins pick the store 1.4.3 for the project and for an unpinned or outside directory', () => {
|
||||
const l = layout({ storeVersions: ['1.4.3'] });
|
||||
const store143 = path.join(bunStore(l.state), '1.4.3/bun');
|
||||
for (const [project, want] of [[l.pinned, `${store143} 1.4.3 pin`], [l.unpinned, `${store143} 1.4.3 default`], [null, `${store143} 1.4.3 default`]] as const) {
|
||||
test('GBRA-60: 1.4.4 in the store, 1.4.2 in $BUN_INSTALL, a CI-lane pin of 1.4.4 — both twins pick the store 1.4.4 for the project and for an unpinned or outside directory', () => {
|
||||
const l = layout({ storeVersions: ['1.4.4'] });
|
||||
const store144 = path.join(bunStore(l.state), '1.4.4/bun');
|
||||
for (const [project, want] of [[l.pinned, `${store144} 1.4.4 pin`], [l.unpinned, `${store144} 1.4.4 default`], [null, `${store144} 1.4.4 default`]] as const) {
|
||||
expect(bashResolve(l, project)).toBe(want);
|
||||
expect(tsResolve(l, project)).toBe(want);
|
||||
}
|
||||
const lanes = launchRuntime(l.pinned, l.env);
|
||||
expect(path.join(lanes.dir!, 'bun')).toBe(store143);
|
||||
expect(path.join(lanes.dir!, 'bun')).toBe(store144);
|
||||
expect(lanes.verdict).toBe('skipped');
|
||||
expect(lanes.label).toContain('install verdict: not checked');
|
||||
});
|
||||
|
||||
test('1.4.2 in $BUN_INSTALL and 1.4.3 in ~/.bun/bin, nothing in the store: the pinned project skips the ineligible 1.4.2; unpinned directories take $BUN_INSTALL first', () => {
|
||||
const l = layout({ homeVersion: '1.4.3' });
|
||||
const home143 = path.join(l.home, '.bun/bin/bun');
|
||||
test('1.4.2 in $BUN_INSTALL and 1.4.4 in ~/.bun/bin, nothing in the store: the pinned project skips the ineligible 1.4.2; unpinned directories take $BUN_INSTALL first', () => {
|
||||
const l = layout({ homeVersion: '1.4.4' });
|
||||
const home144 = path.join(l.home, '.bun/bin/bun');
|
||||
const bi142 = path.join(l.bunInstall, 'bin/bun');
|
||||
for (const [project, want] of [[l.pinned, `${home143} 1.4.3 home`], [l.unpinned, `${bi142} 1.4.2 BUN_INSTALL`], [null, `${bi142} 1.4.2 BUN_INSTALL`]] as const) {
|
||||
for (const [project, want] of [[l.pinned, `${home144} 1.4.4 home`], [l.unpinned, `${bi142} 1.4.2 BUN_INSTALL`], [null, `${bi142} 1.4.2 BUN_INSTALL`]] as const) {
|
||||
expect(bashResolve(l, project)).toBe(want);
|
||||
expect(tsResolve(l, project)).toBe(want);
|
||||
}
|
||||
@@ -106,20 +106,20 @@ describe('one Bun resolution contract, two twins', () => {
|
||||
});
|
||||
|
||||
test('a symlinked candidate resolves to its target realpath in both twins (portable readlink loop, no realpath(1))', () => {
|
||||
const l = layout({ storeVersions: ['1.4.3'] });
|
||||
const l = layout({ storeVersions: ['1.4.4'] });
|
||||
const linkDir = path.join(l.base, 'links');
|
||||
fs.mkdirSync(linkDir);
|
||||
fs.symlinkSync('../home/.gstack/runtimes/bun/1.4.3/bun', path.join(linkDir, 'bun'));
|
||||
fs.symlinkSync('../home/.gstack/runtimes/bun/1.4.4/bun', path.join(linkDir, 'bun'));
|
||||
fs.rmSync(path.join(l.bunInstall, 'bin/bun'));
|
||||
const env = { ...l.env, GSTACK_STATE_ROOT: path.join(l.base, 'elsewhere'), PATH: `${linkDir}:${l.env.PATH}` };
|
||||
const want = `${path.join(bunStore(l.state), '1.4.3/bun')} 1.4.3 PATH`;
|
||||
const want = `${path.join(bunStore(l.state), '1.4.4/bun')} 1.4.4 PATH`;
|
||||
expect(bashResolve({ ...l, env, state: env.GSTACK_STATE_ROOT }, null)).toBe(want);
|
||||
expect(tsResolve({ ...l, env, state: env.GSTACK_STATE_ROOT }, null)).toBe(want);
|
||||
});
|
||||
|
||||
// Integration item 8: the gstack checkout itself binds the 'none' verdict.
|
||||
test('launchRuntime on the gstack checkout reads the none verdict and launches its recorded Bun', () => {
|
||||
const l = layout({ storeVersions: ['1.4.3'] });
|
||||
const l = layout({ storeVersions: ['1.4.4'] });
|
||||
const { installVerdictPath } = require('../lib/install-verdict') as typeof import('../lib/install-verdict');
|
||||
const checkout = fs.realpathSync(ROOT);
|
||||
const file = installVerdictPath(checkout, l.state, null);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* CHANGELOG merges that would carry damage to released history (GSTA-67,
|
||||
* from #3345 on 2026-10-22). #3345's pin 1eb7092 replaced main's
|
||||
* `## [1.91.179.0]` heading with `## [Unreleased]` instead of inserting
|
||||
* above it, and mergeChangelogs returned non-null output with 179's body
|
||||
* duplicated into [Unreleased]; the train's T1 6755ab8 carried it. The
|
||||
* fixtures under test/fixtures/merge-train-3345/ are the real CHANGELOGs at
|
||||
* those commits, cut below 1.91.177.0. A null merge means a hand resolve.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { changelogDamage, mergeChangelogs, ownEntryAboveMain, parseChangelog } from '../lib/changelog-check';
|
||||
|
||||
const fixture = (name: string) => fs.readFileSync(path.join(import.meta.dir, 'fixtures', 'merge-train-3345', name), 'utf8');
|
||||
const base = fixture('base-a59b5e4.md');
|
||||
const pin = fixture('pin-1eb7092.md');
|
||||
const main = fixture('main-4a98e40.md');
|
||||
const keys = (text: string) => parseChangelog(text).sections.map(s => s.version ?? 'Unreleased');
|
||||
|
||||
describe('mergeChangelogs refuses damage to released history', () => {
|
||||
test('GSTA-64 repro: a released heading replaced by [Unreleased] is null, not a merge with the 1.0.1 body duplicated', () => {
|
||||
const ours = '# Changelog\n\n## [Unreleased]\n\nNew entry.\n\nOld 1.0.1 body.\n\n## [1.0.0] - d\n\nOld 1.0.0 body.\n';
|
||||
const theirs = '# Changelog\n\n## [1.0.2] - d\n\nMain body.\n\n## [1.0.1] - d\n\nOld 1.0.1 body.\n\n## [1.0.0] - d\n\nOld 1.0.0 body.\n';
|
||||
expect(changelogDamage(ours, theirs)).toContain("ours's Unreleased section holds released 1.0.1 text");
|
||||
expect(mergeChangelogs(ours, theirs)).toBeNull();
|
||||
const baseText = '# Changelog\n\n## [1.0.1] - d\n\nOld 1.0.1 body.\n\n## [1.0.0] - d\n\nOld 1.0.0 body.\n';
|
||||
expect(changelogDamage(ours, theirs, baseText)).toBe('ours drops released heading 1.0.1 that the merge base carries');
|
||||
});
|
||||
|
||||
test('#3345: pin 1eb7092 against main 4a98e40 is null with or without the merge base; the train T1 it produced duplicated 179', () => {
|
||||
expect(changelogDamage(pin, main, base)).toBe('ours drops released heading 1.91.179.0 that the merge base carries');
|
||||
expect(changelogDamage(pin, main)).toMatch(/^ours's Unreleased section holds released 1\.91\.179\.0 text/);
|
||||
expect(mergeChangelogs(pin, main, base)).toBeNull();
|
||||
expect(mergeChangelogs(pin, main)).toBeNull();
|
||||
const t1 = fixture('train-T1-6755ab8.md');
|
||||
expect(keys(t1)).toEqual(['Unreleased', '1.91.181.0', '1.91.180.0', '1.91.179.0', '1.91.178.0', '1.91.177.0']);
|
||||
expect(changelogDamage(t1, main)).toMatch(/^ours's Unreleased section holds released 1\.91\.179\.0 text/);
|
||||
});
|
||||
|
||||
test('either side, and a renamed heading, count as damage', () => {
|
||||
expect(changelogDamage(main, pin, base)).toBe('theirs drops released heading 1.91.179.0 that the merge base carries');
|
||||
const renamed = main.replace('## [1.91.178.0]', '## [1.91.178.9]');
|
||||
expect(changelogDamage(renamed, main, base)).toBe('ours drops released heading 1.91.178.0 that the merge base carries');
|
||||
expect(changelogDamage(renamed, main)).toMatch(/^ours's 1\.91\.178\.9 section holds released 1\.91\.178\.0 text/);
|
||||
});
|
||||
|
||||
test('negative controls: the real hand resolve, an entry above a newer main, a shared tentative stamp and two [Unreleased] blocks are not damage', () => {
|
||||
const fixed = pin.replace(/\n\n\*\*docsync-fault-interface/, '\n\n## [1.91.179.0] - 2026-10-22\n\n**docsync-fault-interface');
|
||||
expect(keys(fixed)).toEqual(['Unreleased', '1.91.179.0', '1.91.178.0', '1.91.177.0']);
|
||||
expect(changelogDamage(fixed, main, base)).toBeNull();
|
||||
const merged = mergeChangelogs(fixed, main, base)!;
|
||||
expect(keys(merged)).toEqual(['Unreleased', '1.91.181.0', '1.91.180.0', '1.91.179.0', '1.91.178.0', '1.91.177.0']);
|
||||
expect(merged.split('**docsync-fault-interface fits its budgets').length).toBe(2);
|
||||
const b = '## [1.2.3.0] - d\n\n- base release entry\n';
|
||||
expect(mergeChangelogs(`# C\n\n## [1.2.4.0] - d\n\n- mine, a new entry\n\n${b}`, `# C\n\n## [1.2.4.0] - e\n\n- theirs, a new entry\n\n${b}`, `# C\n\n${b}`)).not.toBeNull();
|
||||
expect(changelogDamage(`# C\n\n## [Unreleased]\n\n- #2 change, its entry\n\n${b}`, `# C\n\n## [Unreleased]\n\n- #1 change, its entry\n\n${b}`, `# C\n\n${b}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ownEntryAboveMain: a merged-in head's CHANGELOG however it was resolved", () => {
|
||||
const dir = path.join(import.meta.dir, 'fixtures', 'merge-train-3345');
|
||||
const diff = spawnSync('git', ['diff', '--no-index', '--unified=0', '--no-color', path.join(dir, 'base-a59b5e4.md'), path.join(dir, 'pin-1eb7092.md')], { encoding: 'utf8', timeout: 30_000 }).stdout;
|
||||
const authored = new Set(diff.split('\n').filter(l => l.startsWith('+') && !l.startsWith('+++')).map(l => l.slice(1)));
|
||||
const stamped = fixture('stamped-de23f5d.md');
|
||||
|
||||
test("#3345's stamped head de23f5d is main 4a98e40's CHANGELOG plus its own entry; the damaged train T1 is not", () => {
|
||||
expect(authored.has('### Fixed')).toBe(true);
|
||||
expect(authored.has('## [1.91.179.0] - 2026-10-22')).toBe(false);
|
||||
expect(ownEntryAboveMain(stamped, main, authored)).toBeNull();
|
||||
expect(ownEntryAboveMain(fixture('train-T1-6755ab8.md'), main, authored)).toMatch(/^the PR's entry holds a line its pin never added: "\*\*docsync-fault-interface/);
|
||||
});
|
||||
|
||||
test('negative controls: an invented line, a dropped or edited main section, a changed header and a duplicate heading are refused', () => {
|
||||
expect(ownEntryAboveMain(stamped.replace('### Fixed\n', '### Fixed\n- added after the gate\n'), main, authored)).toBe('the PR\'s entry holds a line its pin never added: "- added after the gate"');
|
||||
expect(ownEntryAboveMain(stamped.replace(/## \[1\.91\.180\.0\][^\n]*\n/, ''), main, authored)).toBe("main's ## [1.91.181.0] - 2026-10-23 is not carried unchanged below the PR's entry");
|
||||
expect(ownEntryAboveMain(stamped.replace('## [1.91.178.0]', '## [1.91.178.0]\n\n- smuggled'), main, authored)).toBe("main's ## [1.91.178.0] - 2026-10-22 is not carried unchanged below the PR's entry");
|
||||
expect(ownEntryAboveMain(stamped.replace('# Changelog', '# Change log'), main, authored)).toBe('the header differs from main');
|
||||
expect(ownEntryAboveMain(stamped.replace('## [1.91.182.0]', '## [1.91.181.0]'), main, authored)).toMatch(/is headed twice$/);
|
||||
});
|
||||
});
|
||||
@@ -185,6 +185,52 @@ describe('version gate: unstamped heads under a train policy', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('a CHANGELOG over 1 MiB (GSTA-67: #3345 head 1eb7092 passed because the 1.67 MB CHANGELOG overflowed the read)', () => {
|
||||
const excerpt = (name: string) => fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'merge-train-3345', name), 'utf8');
|
||||
const baseTop = excerpt('base-a59b5e4.md');
|
||||
const padding = Array.from({ length: 2400 }, (_, i) => `## [1.0.${2400 - i}.0] - 2026-01-01\n\nOlder release ${i}: ${'x'.repeat(440)}\n`).join('\n');
|
||||
const big = `${baseTop}\n${padding}`;
|
||||
const bigFixture = () => {
|
||||
const r = fixture();
|
||||
write(r, 'CHANGELOG.md', big);
|
||||
commit(r, 'main with a full-size CHANGELOG');
|
||||
r.git('push', '-q', 'origin', 'main');
|
||||
r.git('fetch', '-q', 'origin');
|
||||
branch(r);
|
||||
expect(Buffer.byteLength(big)).toBeGreaterThan(1.05 * 1024 * 1024);
|
||||
return r;
|
||||
};
|
||||
|
||||
test("#3345's change (the 1.91.179.0 heading replaced by [Unreleased]) is refused, naming the dropped heading", () => {
|
||||
const r = bigFixture();
|
||||
write(r, 'CHANGELOG.md', big.replace(baseTop, excerpt('pin-1eb7092.md')));
|
||||
const res = run(r, commit(r, 'the 1eb7092 CHANGELOG edit'));
|
||||
expect(res.code).toBe(1);
|
||||
expect(res.out).toContain('::error::PR #3320: CHANGELOG.md drops released heading 1.91.179.0; an unstamped head may only add its [Unreleased] section above the latest release.');
|
||||
});
|
||||
|
||||
test('control: an additive [Unreleased] entry above the latest release still passes at full size', () => {
|
||||
const r = bigFixture();
|
||||
write(r, 'CHANGELOG.md', big.replace('## [1.91.179.0]', '## [Unreleased]\n\n- the PR entry\n\n## [1.91.179.0]'));
|
||||
const res = run(r, commit(r, 'additive entry'));
|
||||
expect(res.code).toBe(0);
|
||||
expect(res.out).toContain('is unstamped under the base');
|
||||
});
|
||||
|
||||
test('a CHANGELOG that cannot be read is refused, never passed', () => {
|
||||
const r = bigFixture();
|
||||
write(r, 'CHANGELOG.md', big.replace('## [1.91.179.0]', '## [Unreleased]\n\n- the PR entry\n\n## [1.91.179.0]'));
|
||||
const head = commit(r, 'additive entry, then a damaged object');
|
||||
const blob = r.git('rev-parse', `${head}:CHANGELOG.md`);
|
||||
const loose = path.join(r.work, '.git', 'objects', blob.slice(0, 2), blob.slice(2));
|
||||
fs.chmodSync(loose, 0o644);
|
||||
fs.writeFileSync(loose, fs.readFileSync(loose).subarray(0, 2000));
|
||||
const res = run(r, head);
|
||||
expect(res.code).toBe(1);
|
||||
expect(res.out).toContain(`::error::PR #3320: cannot read CHANGELOG.md at ${head}.`);
|
||||
});
|
||||
});
|
||||
|
||||
test('the workflow runs the gate from the merge commit with the base ref and head sha', () => {
|
||||
const workflow = Bun.YAML.parse(fs.readFileSync(path.join(ROOT, '.github', 'workflows', 'version-gate.yml'), 'utf8')) as any;
|
||||
const steps = workflow.jobs.check.steps;
|
||||
|
||||
+10
-9
@@ -788,7 +788,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number
|
||||
forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level.
|
||||
|
||||
3. **Queue-aware pick** (workspace-aware ship):
|
||||
3. **Queue-aware pick:**
|
||||
```bash
|
||||
QUEUE_JSON=$(bun --no-env-file --config=/dev/null run ~/.gemini/antigravity-cli/skills/gstack/bin/gstack-next-version --base <base> --bump "$BUMP_LEVEL" --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
|
||||
CANDIDATE_VERSION=$(echo "$QUEUE_JSON" | jq -r '.version // empty')
|
||||
@@ -798,6 +798,10 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
Offline output without that fallback, failure, malformed output or an empty version
|
||||
is unusable, even when it contains a version-looking string.
|
||||
|
||||
**Incomplete** (`complete:false`): PRs in `unknown_claims` went unread; the pick may
|
||||
collide. Rerun item 3 once; still incomplete, AskUserQuestion: use it (recommended;
|
||||
Version Gate CI rereads the queue) or stop.
|
||||
|
||||
- **Usable candidate:** print warnings and claimed queue. FRESH sets `NEW_VERSION=CANDIDATE_VERSION`.
|
||||
ALREADY_BUMPED compares it with `currentVersion`: if different, ask to rebump
|
||||
(refresh CHANGELOG/PR title) or keep current (CI rejects a collision).
|
||||
@@ -809,16 +813,13 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
|
||||
4. **Write the bump** (FRESH, or an approved rebump):
|
||||
```bash
|
||||
bun --no-env-file --config=/dev/null run ~/.gemini/antigravity-cli/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest
|
||||
bun --no-env-file --config=/dev/null run ~/.gemini/antigravity-cli/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest --changelog CHANGELOG.md
|
||||
```
|
||||
The CLI validates `MAJOR.MINOR.PATCH.MICRO` (or pinned 3-digit semver), writes
|
||||
VERSION, the manifest (`--package-json-path` → `.gstack/package-json-path` →
|
||||
`./package.json`) and existing npm lockfiles, never creating one, with the 3-digit
|
||||
translation (`1.67.0.0` → `1.67.0`); VERSION is authoritative. Exit 3 is a half-write:
|
||||
reclassify and `repair` DRIFT_STALE_PKG.
|
||||
It writes VERSION, manifest and lockfiles, and moves `## [Unreleased]` under NEW_VERSION
|
||||
for Step 13. Exit 2 wrote nothing: show stderr, stop.
|
||||
Exit 3 is a half-write: reclassify and `repair` DRIFT_STALE_PKG.
|
||||
|
||||
`--regen-digest` runs repo code (`scripts/gen-agents-digest.ts`) with Step 5's privileges
|
||||
only when it and committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is
|
||||
`--regen-digest` runs `scripts/gen-agents-digest.ts` only when it and a committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is
|
||||
false, run `bun --no-env-file --config=/dev/null scripts/gen-agents-digest.ts` and stage the digest with the bump. Before
|
||||
push, verify the committed digest matches generation for the selected VERSION.
|
||||
|
||||
|
||||
+10
-9
@@ -793,7 +793,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number
|
||||
forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level.
|
||||
|
||||
3. **Queue-aware pick** (workspace-aware ship):
|
||||
3. **Queue-aware pick:**
|
||||
```bash
|
||||
QUEUE_JSON=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version --base <base> --bump "$BUMP_LEVEL" --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
|
||||
CANDIDATE_VERSION=$(echo "$QUEUE_JSON" | jq -r '.version // empty')
|
||||
@@ -803,6 +803,10 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
Offline output without that fallback, failure, malformed output or an empty version
|
||||
is unusable, even when it contains a version-looking string.
|
||||
|
||||
**Incomplete** (`complete:false`): PRs in `unknown_claims` went unread; the pick may
|
||||
collide. Rerun item 3 once; still incomplete, AskUserQuestion: use it (recommended;
|
||||
Version Gate CI rereads the queue) or stop.
|
||||
|
||||
- **Usable candidate:** print warnings and claimed queue. FRESH sets `NEW_VERSION=CANDIDATE_VERSION`.
|
||||
ALREADY_BUMPED compares it with `currentVersion`: if different, ask to rebump
|
||||
(refresh CHANGELOG/PR title) or keep current (CI rejects a collision).
|
||||
@@ -814,16 +818,13 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
|
||||
4. **Write the bump** (FRESH, or an approved rebump):
|
||||
```bash
|
||||
bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest
|
||||
bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest --changelog CHANGELOG.md
|
||||
```
|
||||
The CLI validates `MAJOR.MINOR.PATCH.MICRO` (or pinned 3-digit semver), writes
|
||||
VERSION, the manifest (`--package-json-path` → `.gstack/package-json-path` →
|
||||
`./package.json`) and existing npm lockfiles, never creating one, with the 3-digit
|
||||
translation (`1.67.0.0` → `1.67.0`); VERSION is authoritative. Exit 3 is a half-write:
|
||||
reclassify and `repair` DRIFT_STALE_PKG.
|
||||
It writes VERSION, manifest and lockfiles, and moves `## [Unreleased]` under NEW_VERSION
|
||||
for Step 13. Exit 2 wrote nothing: show stderr, stop.
|
||||
Exit 3 is a half-write: reclassify and `repair` DRIFT_STALE_PKG.
|
||||
|
||||
`--regen-digest` runs repo code (`scripts/gen-agents-digest.ts`) with Step 5's privileges
|
||||
only when it and committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is
|
||||
`--regen-digest` runs `scripts/gen-agents-digest.ts` only when it and a committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is
|
||||
false, run `bun --no-env-file --config=/dev/null scripts/gen-agents-digest.ts` and stage the digest with the bump. Before
|
||||
push, verify the committed digest matches generation for the selected VERSION.
|
||||
|
||||
|
||||
+10
-9
@@ -810,7 +810,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number
|
||||
forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level.
|
||||
|
||||
3. **Queue-aware pick** (workspace-aware ship):
|
||||
3. **Queue-aware pick:**
|
||||
```bash
|
||||
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack;[ -d "$_r/bin" ]||_r=${CODEX_HOME:-~/.codex}/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host codex from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
|
||||
QUEUE_JSON=$(bun --no-env-file --config=/dev/null run $GSTACK_ROOT/bin/gstack-next-version --base <base> --bump "$BUMP_LEVEL" --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
|
||||
@@ -821,6 +821,10 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
Offline output without that fallback, failure, malformed output or an empty version
|
||||
is unusable, even when it contains a version-looking string.
|
||||
|
||||
**Incomplete** (`complete:false`): PRs in `unknown_claims` went unread; the pick may
|
||||
collide. Rerun item 3 once; still incomplete, AskUserQuestion: use it (recommended;
|
||||
Version Gate CI rereads the queue) or stop.
|
||||
|
||||
- **Usable candidate:** print warnings and claimed queue. FRESH sets `NEW_VERSION=CANDIDATE_VERSION`.
|
||||
ALREADY_BUMPED compares it with `currentVersion`: if different, ask to rebump
|
||||
(refresh CHANGELOG/PR title) or keep current (CI rejects a collision).
|
||||
@@ -833,16 +837,13 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
4. **Write the bump** (FRESH, or an approved rebump):
|
||||
```bash
|
||||
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack;[ -d "$_r/bin" ]||_r=${CODEX_HOME:-~/.codex}/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host codex from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
|
||||
bun --no-env-file --config=/dev/null run $GSTACK_ROOT/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest
|
||||
bun --no-env-file --config=/dev/null run $GSTACK_ROOT/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest --changelog CHANGELOG.md
|
||||
```
|
||||
The CLI validates `MAJOR.MINOR.PATCH.MICRO` (or pinned 3-digit semver), writes
|
||||
VERSION, the manifest (`--package-json-path` → `.gstack/package-json-path` →
|
||||
`./package.json`) and existing npm lockfiles, never creating one, with the 3-digit
|
||||
translation (`1.67.0.0` → `1.67.0`); VERSION is authoritative. Exit 3 is a half-write:
|
||||
reclassify and `repair` DRIFT_STALE_PKG.
|
||||
It writes VERSION, manifest and lockfiles, and moves `## [Unreleased]` under NEW_VERSION
|
||||
for Step 13. Exit 2 wrote nothing: show stderr, stop.
|
||||
Exit 3 is a half-write: reclassify and `repair` DRIFT_STALE_PKG.
|
||||
|
||||
`--regen-digest` runs repo code (`scripts/gen-agents-digest.ts`) with Step 5's privileges
|
||||
only when it and committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is
|
||||
`--regen-digest` runs `scripts/gen-agents-digest.ts` only when it and a committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is
|
||||
false, run `bun --no-env-file --config=/dev/null scripts/gen-agents-digest.ts` and stage the digest with the bump. Before
|
||||
push, verify the committed digest matches generation for the selected VERSION.
|
||||
|
||||
|
||||
+10
-9
@@ -791,7 +791,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number
|
||||
forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level.
|
||||
|
||||
3. **Queue-aware pick** (workspace-aware ship):
|
||||
3. **Queue-aware pick:**
|
||||
```bash
|
||||
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.factory/skills/gstack;[ -d "$_r/bin" ]||_r=~/.factory/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host factory from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
|
||||
QUEUE_JSON=$(bun --no-env-file --config=/dev/null run $GSTACK_ROOT/bin/gstack-next-version --base <base> --bump "$BUMP_LEVEL" --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
|
||||
@@ -802,6 +802,10 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
Offline output without that fallback, failure, malformed output or an empty version
|
||||
is unusable, even when it contains a version-looking string.
|
||||
|
||||
**Incomplete** (`complete:false`): PRs in `unknown_claims` went unread; the pick may
|
||||
collide. Rerun item 3 once; still incomplete, AskUserQuestion: use it (recommended;
|
||||
Version Gate CI rereads the queue) or stop.
|
||||
|
||||
- **Usable candidate:** print warnings and claimed queue. FRESH sets `NEW_VERSION=CANDIDATE_VERSION`.
|
||||
ALREADY_BUMPED compares it with `currentVersion`: if different, ask to rebump
|
||||
(refresh CHANGELOG/PR title) or keep current (CI rejects a collision).
|
||||
@@ -814,16 +818,13 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
4. **Write the bump** (FRESH, or an approved rebump):
|
||||
```bash
|
||||
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.factory/skills/gstack;[ -d "$_r/bin" ]||_r=~/.factory/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host factory from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
|
||||
bun --no-env-file --config=/dev/null run $GSTACK_ROOT/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest
|
||||
bun --no-env-file --config=/dev/null run $GSTACK_ROOT/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest --changelog CHANGELOG.md
|
||||
```
|
||||
The CLI validates `MAJOR.MINOR.PATCH.MICRO` (or pinned 3-digit semver), writes
|
||||
VERSION, the manifest (`--package-json-path` → `.gstack/package-json-path` →
|
||||
`./package.json`) and existing npm lockfiles, never creating one, with the 3-digit
|
||||
translation (`1.67.0.0` → `1.67.0`); VERSION is authoritative. Exit 3 is a half-write:
|
||||
reclassify and `repair` DRIFT_STALE_PKG.
|
||||
It writes VERSION, manifest and lockfiles, and moves `## [Unreleased]` under NEW_VERSION
|
||||
for Step 13. Exit 2 wrote nothing: show stderr, stop.
|
||||
Exit 3 is a half-write: reclassify and `repair` DRIFT_STALE_PKG.
|
||||
|
||||
`--regen-digest` runs repo code (`scripts/gen-agents-digest.ts`) with Step 5's privileges
|
||||
only when it and committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is
|
||||
`--regen-digest` runs `scripts/gen-agents-digest.ts` only when it and a committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is
|
||||
false, run `bun --no-env-file --config=/dev/null scripts/gen-agents-digest.ts` and stage the digest with the bump. Before
|
||||
push, verify the committed digest matches generation for the selected VERSION.
|
||||
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
## [1.91.179.0] - 2026-10-22
|
||||
|
||||
**docsync-fault-interface fits its budgets under load again.** On 2026-10-18 it timed out under load on a Ubicloud full run. Its nested 36-case suite spent most of its time starting processes: each fault actor spawn loaded about 36 ms of imports (an empty bun takes 5.5 ms), inspect repeated Git reads, and the whole nested run sat under one 120 s outer test. With real Git on 4 cores, inner cases now take a 0.70 s median (max 1.25 s) unloaded, against 1.07 s before. Under 8 busy loops the max is 3.44 s, inside bun's default 5 s; before, cases reached 4.45 s. The worst outer test went from about 100 s of its 120 s cap to 33 s. No timeout, budget, retry, assertion or case changed.
|
||||
|
||||
### Fixed
|
||||
- **`test/helpers/docsync-fault-actor.ts`**: imports only light helper modules, hashes with `Bun.CryptoHasher` instead of loading `node:crypto`, and loads the redaction engine only on the snapshot-failure path. inspect makes 8 Git calls instead of 11, and the snapshot base check reuses the `--base` it just passed. Each actor spawn now costs 15–19 ms.
|
||||
- **`test/helpers/docsync-repo.ts`, `test/helpers/docsync-interface.ts`** (new): the fixture's Git and file helpers and `docsNativeInterface` moved out of the fixture and observer modules, so the actor stops loading the eval store and the QA observer. `docsync-fixture.ts` and `docsync-observer.ts` re-export them, so no importer changed. The fixture writes its Git identity with one append to `.git/config` instead of three `git config` calls.
|
||||
- **`bin/gstack-docs-candidate`**: snapshot lists `ls-files` once instead of twice and hashes with `Bun.CryptoHasher`. A snapshot spawn went from 68 ms to 46 ms, and the record it writes is byte-identical.
|
||||
- **`test/docsync-fault-interface.test.ts`**: the nested callback run is split into three outer tests by test-name pattern (10, 12 and 14 cases). Each part asserts its exact pass count, its exact filtered-out count and `0 fail`, so all 36 cases still run and the patterns are disjoint.
|
||||
|
||||
## [1.91.178.0] - 2026-10-22
|
||||
|
||||
**A /qa run no longer gets a hint that another report root could change its verdict.** Train gate 39646086155 failed qa-functional-webhook-fix once, and gate 39166935531 failed qa-functional-cli-fix the same way on 2026-10-18. Both times the agent ran `gstack-qa-evidence materialize`, read a fail verdict that ended "this verdict is final for this report root", and its very next call wrote a stray placeholder file in the fixture's parent directory (`qa-reports-placeholder-unused`, `qa-Q3vLqZ-placeholder`) before writing `report.md`. Each agent then reported the stray file itself. The out-of-fixture write detector was right both times and is unchanged.
|
||||
|
||||
### Fixed
|
||||
- **`lib/qa-evidence.ts`**: materialize's `next` instruction now ends "this verdict is final. Next, write the Markdown report beside evidence.json." It names the next write and where it goes, instead of qualifying the verdict by report root.
|
||||
- **`test/helpers/qa-functional-eval.ts`**: the functional fixture's declared interface says that every Write/Edit targets an owned path inside the working directory, and that a write anywhere else, including the parent directory, fails acceptance. That was already the acceptance rule; the prompt now states it.
|
||||
|
||||
### Gate integrity
|
||||
- No detector, threshold, trial count or case kind changed. `test/qa-evidence.test.ts` pins the new `next` wording and checks that it no longer mentions a report root.
|
||||
|
||||
## [1.91.177.0] - 2026-10-22
|
||||
|
||||
**The /cso runtime promotion workflow can finish.** Promote run 39691485727 failed after its `cso-runtime-release` approval. Its "Generate a fail-closed catalog candidate" step calls `gh api …/compare` to prove the staged commit is an ancestor of the qualification head, but the step had no `GH_TOKEN`. On a hosted runner `gh` then exits 4 ("set the GH_TOKEN environment variable"). The run stopped before it signed, pushed or opened anything.
|
||||
|
||||
### Fixed
|
||||
- **`.github/workflows/cso-runtime-promote.yml`**: the candidate step gets `GH_TOKEN: ${{ github.token }}`, like every other step there that calls `gh`.
|
||||
- **`test/workflow-gh-token.test.ts`** (new): fails when any step in any workflow invokes `gh` with no `GH_TOKEN` or `GITHUB_TOKEN` in its step, job or workflow env. actionlint does not check this. This step was the only one in the repository missing a token.
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
# Changelog
|
||||
|
||||
## [1.91.181.0] - 2026-10-23
|
||||
|
||||
**/cso can run its contained runtime checks.** Before this release the runtime catalog qualified nothing, so /cso always fell back to static assessment. It now qualifies ten pinned images: Node 24.4.0, Bun 1.3.10, Python 3.13.4 with uv 0.8.0, Rails on Ruby 3.4.4, and PostgreSQL 17.2, each for linux/amd64 and linux/arm64. With Docker installed, comprehensive /cso boots the target app inside the matching image and tests findings and repairs against it. The images passed the private evaluator's release qualification (run-5 statements, re-delivered under Garry's approval). The ingress then checked them in qualify run 39674065531 at 514fbfd.
|
||||
|
||||
### Added
|
||||
- **`lib/cso/runtime-catalog.json`** is now catalog `cso-v3-39674065531-133cb360842d`, which replaces `cso-v3-build-reviewed-2026-09-10`. Promote run 39841502986 generated it from qualification run 39674065531's authenticated statements and attested it. GitHub Actions committed the attested bytes (`sha256:078e0bf6…`). Each runtime's image is pinned to a public `ghcr.io/garrytan/gstack/cso-staging/…@sha256` digest, and each entry records its source commit (133cb36), SBOM and provenance digests, and requalification triggers (`preparationSha256` `c721b585…`).
|
||||
|
||||
### For contributors
|
||||
- **The /cso free tests no longer assume the committed runtime catalog is empty.** Six tests pinned `runtimes: []` and broke as soon as a promotion landed. Tests that need an unqualified catalog now build one with `unqualifiedRuntimeCatalog()`. The replay tests expect whichever fail-closed rejection the committed catalog produces, so the suite passes with the catalog both before and after a promotion.
|
||||
|
||||
## [1.91.180.0] - 2026-10-22
|
||||
|
||||
**The /cso docs say what the qualified catalogs now allow.** AGENTS.md, the README and `docs/cso-release-qualification.md` still said that no qualified runtime or scanner image existed, so /cso ran static assessment only. The scanner catalog has been qualified since #3042. The runtime catalog becomes qualified with the runtime promotion PR that ships alongside this one.
|
||||
|
||||
### Changed
|
||||
- **AGENTS.md**, **README.md** and **`docs/cso-release-qualification.md`**: the /cso entries name the ten qualified runtime images and the twelve scanner images, and say when /cso still falls back to static assessment: without Docker, or where no profile matches.
|
||||
|
||||
## [1.91.179.0] - 2026-10-22
|
||||
|
||||
**docsync-fault-interface fits its budgets under load again.** On 2026-10-18 it timed out under load on a Ubicloud full run. Its nested 36-case suite spent most of its time starting processes: each fault actor spawn loaded about 36 ms of imports (an empty bun takes 5.5 ms), inspect repeated Git reads, and the whole nested run sat under one 120 s outer test. With real Git on 4 cores, inner cases now take a 0.70 s median (max 1.25 s) unloaded, against 1.07 s before. Under 8 busy loops the max is 3.44 s, inside bun's default 5 s; before, cases reached 4.45 s. The worst outer test went from about 100 s of its 120 s cap to 33 s. No timeout, budget, retry, assertion or case changed.
|
||||
|
||||
### Fixed
|
||||
- **`test/helpers/docsync-fault-actor.ts`**: imports only light helper modules, hashes with `Bun.CryptoHasher` instead of loading `node:crypto`, and loads the redaction engine only on the snapshot-failure path. inspect makes 8 Git calls instead of 11, and the snapshot base check reuses the `--base` it just passed. Each actor spawn now costs 15–19 ms.
|
||||
- **`test/helpers/docsync-repo.ts`, `test/helpers/docsync-interface.ts`** (new): the fixture's Git and file helpers and `docsNativeInterface` moved out of the fixture and observer modules, so the actor stops loading the eval store and the QA observer. `docsync-fixture.ts` and `docsync-observer.ts` re-export them, so no importer changed. The fixture writes its Git identity with one append to `.git/config` instead of three `git config` calls.
|
||||
- **`bin/gstack-docs-candidate`**: snapshot lists `ls-files` once instead of twice and hashes with `Bun.CryptoHasher`. A snapshot spawn went from 68 ms to 46 ms, and the record it writes is byte-identical.
|
||||
- **`test/docsync-fault-interface.test.ts`**: the nested callback run is split into three outer tests by test-name pattern (10, 12 and 14 cases). Each part asserts its exact pass count, its exact filtered-out count and `0 fail`, so all 36 cases still run and the patterns are disjoint.
|
||||
|
||||
## [1.91.178.0] - 2026-10-22
|
||||
|
||||
**A /qa run no longer gets a hint that another report root could change its verdict.** Train gate 39646086155 failed qa-functional-webhook-fix once, and gate 39166935531 failed qa-functional-cli-fix the same way on 2026-10-18. Both times the agent ran `gstack-qa-evidence materialize`, read a fail verdict that ended "this verdict is final for this report root", and its very next call wrote a stray placeholder file in the fixture's parent directory (`qa-reports-placeholder-unused`, `qa-Q3vLqZ-placeholder`) before writing `report.md`. Each agent then reported the stray file itself. The out-of-fixture write detector was right both times and is unchanged.
|
||||
|
||||
### Fixed
|
||||
- **`lib/qa-evidence.ts`**: materialize's `next` instruction now ends "this verdict is final. Next, write the Markdown report beside evidence.json." It names the next write and where it goes, instead of qualifying the verdict by report root.
|
||||
- **`test/helpers/qa-functional-eval.ts`**: the functional fixture's declared interface says that every Write/Edit targets an owned path inside the working directory, and that a write anywhere else, including the parent directory, fails acceptance. That was already the acceptance rule; the prompt now states it.
|
||||
|
||||
### Gate integrity
|
||||
- No detector, threshold, trial count or case kind changed. `test/qa-evidence.test.ts` pins the new `next` wording and checks that it no longer mentions a report root.
|
||||
|
||||
## [1.91.177.0] - 2026-10-22
|
||||
|
||||
**The /cso runtime promotion workflow can finish.** Promote run 39691485727 failed after its `cso-runtime-release` approval. Its "Generate a fail-closed catalog candidate" step calls `gh api …/compare` to prove the staged commit is an ancestor of the qualification head, but the step had no `GH_TOKEN`. On a hosted runner `gh` then exits 4 ("set the GH_TOKEN environment variable"). The run stopped before it signed, pushed or opened anything.
|
||||
|
||||
### Fixed
|
||||
- **`.github/workflows/cso-runtime-promote.yml`**: the candidate step gets `GH_TOKEN: ${{ github.token }}`, like every other step there that calls `gh`.
|
||||
- **`test/workflow-gh-token.test.ts`** (new): fails when any step in any workflow invokes `gh` with no `GH_TOKEN` or `GITHUB_TOKEN` in its step, job or workflow env. actionlint does not check this. This step was the only one in the repository missing a token.
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
**gstack installs on a project that pins Bun 1.4.4 again.** gbrain master moved its CI to Bun 1.4.4 (gbrain #7374, the Brotli CVE-2026-111497 fix), and gstack's Capy installer then stopped every gbrain session with `RUNTIME_PIN_CONFLICT` ("verified archives: 1.4.2 1.4.3 … pins exactly 1.4.4"), because it installs only Bun archives whose SHA-256 it carries. It now carries 1.4.4, so the same run installs the pinned Bun and ends `gstack: ok`.
|
||||
|
||||
### Fixed
|
||||
- **`bin/gstack-bun-version.sh`**: `GSTACK_BUN_VERIFIED` is now 1.4.2 1.4.3 1.4.4, with 1.4.4's checksums for linux-x64, linux-x64-baseline and linux-aarch64, the targets 1.4.3 covers. They come from the release's `SHASUMS256.txt.asc`, whose clearsigned signature verifies against Bun's release key F3DCC08A8572C0749B3E18888EAB4D40A7B22B59 (Robobun, the key oven-sh/bun's own Dockerfiles assert), and each archive was downloaded and hashed to the same value. The 1.4.2 and 1.4.3 entries are unchanged and verify the same way. gstack's own minimum (1.4.2), install default (1.4.3) and CI pins do not move.
|
||||
- **`test/gstack-capy-install.test.ts`**: a gbrain-like repo whose CI pins only 1.4.4 refuses `~/.bun` 1.4.3 and installs the 1.4.4 archive. **`test/bun-version-drift.test.ts`**: every verified version must carry a checksum for each installer target. The existing "verified archives" text assertions list 1.4.4.
|
||||
|
||||
|
||||
**docsync-fault-interface fits its budgets under load again.** On 2026-10-18 it timed out under load on a Ubicloud full run. Its nested 36-case suite spent most of its time starting processes: each fault actor spawn loaded about 36 ms of imports (an empty bun takes 5.5 ms), inspect repeated Git reads, and the whole nested run sat under one 120 s outer test. With real Git on 4 cores, inner cases now take a 0.70 s median (max 1.25 s) unloaded, against 1.07 s before. Under 8 busy loops the max is 3.44 s, inside bun's default 5 s; before, cases reached 4.45 s. The worst outer test went from about 100 s of its 120 s cap to 33 s. No timeout, budget, retry, assertion or case changed.
|
||||
|
||||
### Fixed
|
||||
- **`test/helpers/docsync-fault-actor.ts`**: imports only light helper modules, hashes with `Bun.CryptoHasher` instead of loading `node:crypto`, and loads the redaction engine only on the snapshot-failure path. inspect makes 8 Git calls instead of 11, and the snapshot base check reuses the `--base` it just passed. Each actor spawn now costs 15–19 ms.
|
||||
- **`test/helpers/docsync-repo.ts`, `test/helpers/docsync-interface.ts`** (new): the fixture's Git and file helpers and `docsNativeInterface` moved out of the fixture and observer modules, so the actor stops loading the eval store and the QA observer. `docsync-fixture.ts` and `docsync-observer.ts` re-export them, so no importer changed. The fixture writes its Git identity with one append to `.git/config` instead of three `git config` calls.
|
||||
- **`bin/gstack-docs-candidate`**: snapshot lists `ls-files` once instead of twice and hashes with `Bun.CryptoHasher`. A snapshot spawn went from 68 ms to 46 ms, and the record it writes is byte-identical.
|
||||
- **`test/docsync-fault-interface.test.ts`**: the nested callback run is split into three outer tests by test-name pattern (10, 12 and 14 cases). Each part asserts its exact pass count, its exact filtered-out count and `0 fail`, so all 36 cases still run and the patterns are disjoint.
|
||||
|
||||
## [1.91.178.0] - 2026-10-22
|
||||
|
||||
**A /qa run no longer gets a hint that another report root could change its verdict.** Train gate 39646086155 failed qa-functional-webhook-fix once, and gate 39166935531 failed qa-functional-cli-fix the same way on 2026-10-18. Both times the agent ran `gstack-qa-evidence materialize`, read a fail verdict that ended "this verdict is final for this report root", and its very next call wrote a stray placeholder file in the fixture's parent directory (`qa-reports-placeholder-unused`, `qa-Q3vLqZ-placeholder`) before writing `report.md`. Each agent then reported the stray file itself. The out-of-fixture write detector was right both times and is unchanged.
|
||||
|
||||
### Fixed
|
||||
- **`lib/qa-evidence.ts`**: materialize's `next` instruction now ends "this verdict is final. Next, write the Markdown report beside evidence.json." It names the next write and where it goes, instead of qualifying the verdict by report root.
|
||||
- **`test/helpers/qa-functional-eval.ts`**: the functional fixture's declared interface says that every Write/Edit targets an owned path inside the working directory, and that a write anywhere else, including the parent directory, fails acceptance. That was already the acceptance rule; the prompt now states it.
|
||||
|
||||
### Gate integrity
|
||||
- No detector, threshold, trial count or case kind changed. `test/qa-evidence.test.ts` pins the new `next` wording and checks that it no longer mentions a report root.
|
||||
|
||||
## [1.91.177.0] - 2026-10-22
|
||||
|
||||
**The /cso runtime promotion workflow can finish.** Promote run 39691485727 failed after its `cso-runtime-release` approval. Its "Generate a fail-closed catalog candidate" step calls `gh api …/compare` to prove the staged commit is an ancestor of the qualification head, but the step had no `GH_TOKEN`. On a hosted runner `gh` then exits 4 ("set the GH_TOKEN environment variable"). The run stopped before it signed, pushed or opened anything.
|
||||
|
||||
### Fixed
|
||||
- **`.github/workflows/cso-runtime-promote.yml`**: the candidate step gets `GH_TOKEN: ${{ github.token }}`, like every other step there that calls `gh`.
|
||||
- **`test/workflow-gh-token.test.ts`** (new): fails when any step in any workflow invokes `gh` with no `GH_TOKEN` or `GITHUB_TOKEN` in its step, job or workflow env. actionlint does not check this. This step was the only one in the repository missing a token.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Changelog
|
||||
|
||||
## [1.91.182.0] - 2026-10-23
|
||||
|
||||
**gstack installs on a project that pins Bun 1.4.4 again.** gbrain master moved its CI to Bun 1.4.4 (gbrain #7374, the Brotli CVE-2026-111497 fix), and gstack's Capy installer then stopped every gbrain session with `RUNTIME_PIN_CONFLICT` ("verified archives: 1.4.2 1.4.3 … pins exactly 1.4.4"), because it installs only Bun archives whose SHA-256 it carries. It now carries 1.4.4, so the same run installs the pinned Bun and ends `gstack: ok`.
|
||||
|
||||
### Fixed
|
||||
- **`bin/gstack-bun-version.sh`**: `GSTACK_BUN_VERIFIED` is now 1.4.2 1.4.3 1.4.4, with 1.4.4's checksums for linux-x64, linux-x64-baseline and linux-aarch64, the targets 1.4.3 covers. They come from the release's `SHASUMS256.txt.asc`, whose clearsigned signature verifies against Bun's release key F3DCC08A8572C0749B3E18888EAB4D40A7B22B59 (Robobun, the key oven-sh/bun's own Dockerfiles assert), and each archive was downloaded and hashed to the same value. The 1.4.2 and 1.4.3 entries are unchanged and verify the same way. gstack's own minimum (1.4.2), install default (1.4.3) and CI pins do not move.
|
||||
- **`test/gstack-capy-install.test.ts`**: a gbrain-like repo whose CI pins only 1.4.4 refuses `~/.bun` 1.4.3 and installs the 1.4.4 archive. **`test/bun-version-drift.test.ts`**: every verified version must carry a checksum for each installer target. The existing "verified archives" text assertions list 1.4.4.
|
||||
|
||||
## [1.91.181.0] - 2026-10-23
|
||||
|
||||
**/cso can run its contained runtime checks.** Before this release the runtime catalog qualified nothing, so /cso always fell back to static assessment. It now qualifies ten pinned images: Node 24.4.0, Bun 1.3.10, Python 3.13.4 with uv 0.8.0, Rails on Ruby 3.4.4, and PostgreSQL 17.2, each for linux/amd64 and linux/arm64. With Docker installed, comprehensive /cso boots the target app inside the matching image and tests findings and repairs against it. The images passed the private evaluator's release qualification (run-5 statements, re-delivered under Garry's approval). The ingress then checked them in qualify run 39674065531 at 514fbfd.
|
||||
|
||||
### Added
|
||||
- **`lib/cso/runtime-catalog.json`** is now catalog `cso-v3-39674065531-133cb360842d`, which replaces `cso-v3-build-reviewed-2026-09-10`. Promote run 39841502986 generated it from qualification run 39674065531's authenticated statements and attested it. GitHub Actions committed the attested bytes (`sha256:078e0bf6…`). Each runtime's image is pinned to a public `ghcr.io/garrytan/gstack/cso-staging/…@sha256` digest, and each entry records its source commit (133cb36), SBOM and provenance digests, and requalification triggers (`preparationSha256` `c721b585…`).
|
||||
|
||||
### For contributors
|
||||
- **The /cso free tests no longer assume the committed runtime catalog is empty.** Six tests pinned `runtimes: []` and broke as soon as a promotion landed. Tests that need an unqualified catalog now build one with `unqualifiedRuntimeCatalog()`. The replay tests expect whichever fail-closed rejection the committed catalog produces, so the suite passes with the catalog both before and after a promotion.
|
||||
|
||||
## [1.91.180.0] - 2026-10-22
|
||||
|
||||
**The /cso docs say what the qualified catalogs now allow.** AGENTS.md, the README and `docs/cso-release-qualification.md` still said that no qualified runtime or scanner image existed, so /cso ran static assessment only. The scanner catalog has been qualified since #3042. The runtime catalog becomes qualified with the runtime promotion PR that ships alongside this one.
|
||||
|
||||
### Changed
|
||||
- **AGENTS.md**, **README.md** and **`docs/cso-release-qualification.md`**: the /cso entries name the ten qualified runtime images and the twelve scanner images, and say when /cso still falls back to static assessment: without Docker, or where no profile matches.
|
||||
|
||||
## [1.91.179.0] - 2026-10-22
|
||||
|
||||
**docsync-fault-interface fits its budgets under load again.** On 2026-10-18 it timed out under load on a Ubicloud full run. Its nested 36-case suite spent most of its time starting processes: each fault actor spawn loaded about 36 ms of imports (an empty bun takes 5.5 ms), inspect repeated Git reads, and the whole nested run sat under one 120 s outer test. With real Git on 4 cores, inner cases now take a 0.70 s median (max 1.25 s) unloaded, against 1.07 s before. Under 8 busy loops the max is 3.44 s, inside bun's default 5 s; before, cases reached 4.45 s. The worst outer test went from about 100 s of its 120 s cap to 33 s. No timeout, budget, retry, assertion or case changed.
|
||||
|
||||
### Fixed
|
||||
- **`test/helpers/docsync-fault-actor.ts`**: imports only light helper modules, hashes with `Bun.CryptoHasher` instead of loading `node:crypto`, and loads the redaction engine only on the snapshot-failure path. inspect makes 8 Git calls instead of 11, and the snapshot base check reuses the `--base` it just passed. Each actor spawn now costs 15–19 ms.
|
||||
- **`test/helpers/docsync-repo.ts`, `test/helpers/docsync-interface.ts`** (new): the fixture's Git and file helpers and `docsNativeInterface` moved out of the fixture and observer modules, so the actor stops loading the eval store and the QA observer. `docsync-fixture.ts` and `docsync-observer.ts` re-export them, so no importer changed. The fixture writes its Git identity with one append to `.git/config` instead of three `git config` calls.
|
||||
- **`bin/gstack-docs-candidate`**: snapshot lists `ls-files` once instead of twice and hashes with `Bun.CryptoHasher`. A snapshot spawn went from 68 ms to 46 ms, and the record it writes is byte-identical.
|
||||
- **`test/docsync-fault-interface.test.ts`**: the nested callback run is split into three outer tests by test-name pattern (10, 12 and 14 cases). Each part asserts its exact pass count, its exact filtered-out count and `0 fail`, so all 36 cases still run and the patterns are disjoint.
|
||||
|
||||
## [1.91.178.0] - 2026-10-22
|
||||
|
||||
**A /qa run no longer gets a hint that another report root could change its verdict.** Train gate 39646086155 failed qa-functional-webhook-fix once, and gate 39166935531 failed qa-functional-cli-fix the same way on 2026-10-18. Both times the agent ran `gstack-qa-evidence materialize`, read a fail verdict that ended "this verdict is final for this report root", and its very next call wrote a stray placeholder file in the fixture's parent directory (`qa-reports-placeholder-unused`, `qa-Q3vLqZ-placeholder`) before writing `report.md`. Each agent then reported the stray file itself. The out-of-fixture write detector was right both times and is unchanged.
|
||||
|
||||
### Fixed
|
||||
- **`lib/qa-evidence.ts`**: materialize's `next` instruction now ends "this verdict is final. Next, write the Markdown report beside evidence.json." It names the next write and where it goes, instead of qualifying the verdict by report root.
|
||||
- **`test/helpers/qa-functional-eval.ts`**: the functional fixture's declared interface says that every Write/Edit targets an owned path inside the working directory, and that a write anywhere else, including the parent directory, fails acceptance. That was already the acceptance rule; the prompt now states it.
|
||||
|
||||
### Gate integrity
|
||||
- No detector, threshold, trial count or case kind changed. `test/qa-evidence.test.ts` pins the new `next` wording and checks that it no longer mentions a report root.
|
||||
|
||||
## [1.91.177.0] - 2026-10-22
|
||||
|
||||
**The /cso runtime promotion workflow can finish.** Promote run 39691485727 failed after its `cso-runtime-release` approval. Its "Generate a fail-closed catalog candidate" step calls `gh api …/compare` to prove the staged commit is an ancestor of the qualification head, but the step had no `GH_TOKEN`. On a hosted runner `gh` then exits 4 ("set the GH_TOKEN environment variable"). The run stopped before it signed, pushed or opened anything.
|
||||
|
||||
### Fixed
|
||||
- **`.github/workflows/cso-runtime-promote.yml`**: the candidate step gets `GH_TOKEN: ${{ github.token }}`, like every other step there that calls `gh`.
|
||||
- **`test/workflow-gh-token.test.ts`** (new): fails when any step in any workflow invokes `gh` with no `GH_TOKEN` or `GITHUB_TOKEN` in its step, job or workflow env. actionlint does not check this. This step was the only one in the repository missing a token.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
**gstack installs on a project that pins Bun 1.4.4 again.** gbrain master moved its CI to Bun 1.4.4 (gbrain #7374, the Brotli CVE-2026-111497 fix), and gstack's Capy installer then stopped every gbrain session with `RUNTIME_PIN_CONFLICT` ("verified archives: 1.4.2 1.4.3 … pins exactly 1.4.4"), because it installs only Bun archives whose SHA-256 it carries. It now carries 1.4.4, so the same run installs the pinned Bun and ends `gstack: ok`.
|
||||
|
||||
### Fixed
|
||||
- **`bin/gstack-bun-version.sh`**: `GSTACK_BUN_VERIFIED` is now 1.4.2 1.4.3 1.4.4, with 1.4.4's checksums for linux-x64, linux-x64-baseline and linux-aarch64, the targets 1.4.3 covers. They come from the release's `SHASUMS256.txt.asc`, whose clearsigned signature verifies against Bun's release key F3DCC08A8572C0749B3E18888EAB4D40A7B22B59 (Robobun, the key oven-sh/bun's own Dockerfiles assert), and each archive was downloaded and hashed to the same value. The 1.4.2 and 1.4.3 entries are unchanged and verify the same way. gstack's own minimum (1.4.2), install default (1.4.3) and CI pins do not move.
|
||||
- **`test/gstack-capy-install.test.ts`**: a gbrain-like repo whose CI pins only 1.4.4 refuses `~/.bun` 1.4.3 and installs the 1.4.4 archive. **`test/bun-version-drift.test.ts`**: every verified version must carry a checksum for each installer target. The existing "verified archives" text assertions list 1.4.4.
|
||||
|
||||
|
||||
**docsync-fault-interface fits its budgets under load again.** On 2026-10-18 it timed out under load on a Ubicloud full run. Its nested 36-case suite spent most of its time starting processes: each fault actor spawn loaded about 36 ms of imports (an empty bun takes 5.5 ms), inspect repeated Git reads, and the whole nested run sat under one 120 s outer test. With real Git on 4 cores, inner cases now take a 0.70 s median (max 1.25 s) unloaded, against 1.07 s before. Under 8 busy loops the max is 3.44 s, inside bun's default 5 s; before, cases reached 4.45 s. The worst outer test went from about 100 s of its 120 s cap to 33 s. No timeout, budget, retry, assertion or case changed.
|
||||
|
||||
### Fixed
|
||||
- **`test/helpers/docsync-fault-actor.ts`**: imports only light helper modules, hashes with `Bun.CryptoHasher` instead of loading `node:crypto`, and loads the redaction engine only on the snapshot-failure path. inspect makes 8 Git calls instead of 11, and the snapshot base check reuses the `--base` it just passed. Each actor spawn now costs 15–19 ms.
|
||||
- **`test/helpers/docsync-repo.ts`, `test/helpers/docsync-interface.ts`** (new): the fixture's Git and file helpers and `docsNativeInterface` moved out of the fixture and observer modules, so the actor stops loading the eval store and the QA observer. `docsync-fixture.ts` and `docsync-observer.ts` re-export them, so no importer changed. The fixture writes its Git identity with one append to `.git/config` instead of three `git config` calls.
|
||||
- **`bin/gstack-docs-candidate`**: snapshot lists `ls-files` once instead of twice and hashes with `Bun.CryptoHasher`. A snapshot spawn went from 68 ms to 46 ms, and the record it writes is byte-identical.
|
||||
- **`test/docsync-fault-interface.test.ts`**: the nested callback run is split into three outer tests by test-name pattern (10, 12 and 14 cases). Each part asserts its exact pass count, its exact filtered-out count and `0 fail`, so all 36 cases still run and the patterns are disjoint.
|
||||
|
||||
## [1.91.181.0] - 2026-10-23
|
||||
|
||||
**/cso can run its contained runtime checks.** Before this release the runtime catalog qualified nothing, so /cso always fell back to static assessment. It now qualifies ten pinned images: Node 24.4.0, Bun 1.3.10, Python 3.13.4 with uv 0.8.0, Rails on Ruby 3.4.4, and PostgreSQL 17.2, each for linux/amd64 and linux/arm64. With Docker installed, comprehensive /cso boots the target app inside the matching image and tests findings and repairs against it. The images passed the private evaluator's release qualification (run-5 statements, re-delivered under Garry's approval). The ingress then checked them in qualify run 39674065531 at 514fbfd.
|
||||
|
||||
### Added
|
||||
- **`lib/cso/runtime-catalog.json`** is now catalog `cso-v3-39674065531-133cb360842d`, which replaces `cso-v3-build-reviewed-2026-09-10`. Promote run 39841502986 generated it from qualification run 39674065531's authenticated statements and attested it. GitHub Actions committed the attested bytes (`sha256:078e0bf6…`). Each runtime's image is pinned to a public `ghcr.io/garrytan/gstack/cso-staging/…@sha256` digest, and each entry records its source commit (133cb36), SBOM and provenance digests, and requalification triggers (`preparationSha256` `c721b585…`).
|
||||
|
||||
### For contributors
|
||||
- **The /cso free tests no longer assume the committed runtime catalog is empty.** Six tests pinned `runtimes: []` and broke as soon as a promotion landed. Tests that need an unqualified catalog now build one with `unqualifiedRuntimeCatalog()`. The replay tests expect whichever fail-closed rejection the committed catalog produces, so the suite passes with the catalog both before and after a promotion.
|
||||
|
||||
## [1.91.180.0] - 2026-10-22
|
||||
|
||||
**The /cso docs say what the qualified catalogs now allow.** AGENTS.md, the README and `docs/cso-release-qualification.md` still said that no qualified runtime or scanner image existed, so /cso ran static assessment only. The scanner catalog has been qualified since #3042. The runtime catalog becomes qualified with the runtime promotion PR that ships alongside this one.
|
||||
|
||||
### Changed
|
||||
- **AGENTS.md**, **README.md** and **`docs/cso-release-qualification.md`**: the /cso entries name the ten qualified runtime images and the twelve scanner images, and say when /cso still falls back to static assessment: without Docker, or where no profile matches.
|
||||
|
||||
## [1.91.179.0] - 2026-10-22
|
||||
|
||||
**docsync-fault-interface fits its budgets under load again.** On 2026-10-18 it timed out under load on a Ubicloud full run. Its nested 36-case suite spent most of its time starting processes: each fault actor spawn loaded about 36 ms of imports (an empty bun takes 5.5 ms), inspect repeated Git reads, and the whole nested run sat under one 120 s outer test. With real Git on 4 cores, inner cases now take a 0.70 s median (max 1.25 s) unloaded, against 1.07 s before. Under 8 busy loops the max is 3.44 s, inside bun's default 5 s; before, cases reached 4.45 s. The worst outer test went from about 100 s of its 120 s cap to 33 s. No timeout, budget, retry, assertion or case changed.
|
||||
|
||||
### Fixed
|
||||
- **`test/helpers/docsync-fault-actor.ts`**: imports only light helper modules, hashes with `Bun.CryptoHasher` instead of loading `node:crypto`, and loads the redaction engine only on the snapshot-failure path. inspect makes 8 Git calls instead of 11, and the snapshot base check reuses the `--base` it just passed. Each actor spawn now costs 15–19 ms.
|
||||
- **`test/helpers/docsync-repo.ts`, `test/helpers/docsync-interface.ts`** (new): the fixture's Git and file helpers and `docsNativeInterface` moved out of the fixture and observer modules, so the actor stops loading the eval store and the QA observer. `docsync-fixture.ts` and `docsync-observer.ts` re-export them, so no importer changed. The fixture writes its Git identity with one append to `.git/config` instead of three `git config` calls.
|
||||
- **`bin/gstack-docs-candidate`**: snapshot lists `ls-files` once instead of twice and hashes with `Bun.CryptoHasher`. A snapshot spawn went from 68 ms to 46 ms, and the record it writes is byte-identical.
|
||||
- **`test/docsync-fault-interface.test.ts`**: the nested callback run is split into three outer tests by test-name pattern (10, 12 and 14 cases). Each part asserts its exact pass count, its exact filtered-out count and `0 fail`, so all 36 cases still run and the patterns are disjoint.
|
||||
|
||||
## [1.91.178.0] - 2026-10-22
|
||||
|
||||
**A /qa run no longer gets a hint that another report root could change its verdict.** Train gate 39646086155 failed qa-functional-webhook-fix once, and gate 39166935531 failed qa-functional-cli-fix the same way on 2026-10-18. Both times the agent ran `gstack-qa-evidence materialize`, read a fail verdict that ended "this verdict is final for this report root", and its very next call wrote a stray placeholder file in the fixture's parent directory (`qa-reports-placeholder-unused`, `qa-Q3vLqZ-placeholder`) before writing `report.md`. Each agent then reported the stray file itself. The out-of-fixture write detector was right both times and is unchanged.
|
||||
|
||||
### Fixed
|
||||
- **`lib/qa-evidence.ts`**: materialize's `next` instruction now ends "this verdict is final. Next, write the Markdown report beside evidence.json." It names the next write and where it goes, instead of qualifying the verdict by report root.
|
||||
- **`test/helpers/qa-functional-eval.ts`**: the functional fixture's declared interface says that every Write/Edit targets an owned path inside the working directory, and that a write anywhere else, including the parent directory, fails acceptance. That was already the acceptance rule; the prompt now states it.
|
||||
|
||||
### Gate integrity
|
||||
- No detector, threshold, trial count or case kind changed. `test/qa-evidence.test.ts` pins the new `next` wording and checks that it no longer mentions a report root.
|
||||
|
||||
## [1.91.177.0] - 2026-10-22
|
||||
|
||||
**The /cso runtime promotion workflow can finish.** Promote run 39691485727 failed after its `cso-runtime-release` approval. Its "Generate a fail-closed catalog candidate" step calls `gh api …/compare` to prove the staged commit is an ancestor of the qualification head, but the step had no `GH_TOKEN`. On a hosted runner `gh` then exits 4 ("set the GH_TOKEN environment variable"). The run stopped before it signed, pushed or opened anything.
|
||||
|
||||
### Fixed
|
||||
- **`.github/workflows/cso-runtime-promote.yml`**: the candidate step gets `GH_TOKEN: ${{ github.token }}`, like every other step there that calls `gh`.
|
||||
- **`test/workflow-gh-token.test.ts`** (new): fails when any step in any workflow invokes `gh` with no `GH_TOKEN` or `GITHUB_TOKEN` in its step, job or workflow env. actionlint does not check this. This step was the only one in the repository missing a token.
|
||||
@@ -9,6 +9,7 @@ This page lists all API deprecations, along with recommended replacements.
|
||||
| Shutdown date | Model / system | Recommended replacement |
|
||||
| --- | --- | --- |
|
||||
| Apr 1, 2027 | `gpt-5.3-codex` | `gpt-6-sol` |
|
||||
| Feb 16, 2027 | GPT-4o (`gpt-4o-2024-08-06`, `gpt-4o-2024-11-20`) | `gpt-6-sol` |
|
||||
|
||||
## Past deprecations
|
||||
|
||||
|
||||
@@ -131,6 +131,41 @@ describe('free-tests workflow wiring', () => {
|
||||
} finally { fs.rmSync(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('every Linux Playwright install has a version-keyed browser cache, 5-minute attempts and one retry of the install alone (run 39641712129)', () => {
|
||||
const jobs = (Bun.YAML.parse(source) as any).jobs;
|
||||
const installing = Object.keys(jobs).filter(name => jobs[name].steps.some((step: any) => step.run?.includes('playwright install --with-deps')));
|
||||
expect(installing.sort()).toEqual(['free-suite', 'quick-bun-default']);
|
||||
for (const name of installing) {
|
||||
const steps = jobs[name].steps;
|
||||
const version = steps.findIndex((step: any) => step.id === 'playwright');
|
||||
const cache = steps.findIndex((step: any) => step.with?.path === '~/.cache/ms-playwright');
|
||||
const install = steps.findIndex((step: any) => step.id === 'playwright_install');
|
||||
const retry = steps.findIndex((step: any) => step.if === "steps.playwright_install.outcome == 'failure'");
|
||||
expect(steps[version].run, name).toContain('require("playwright-core/package.json").version');
|
||||
expect(steps[cache].with.key, name).toBe('linux-playwright-chromium-${{ steps.playwright.outputs.version }}');
|
||||
expect(steps[cache].with['restore-keys'], name).toBeUndefined();
|
||||
expect(version, name).toBeLessThan(cache);
|
||||
expect(cache, name).toBeLessThan(install);
|
||||
expect(retry, name).toBe(install + 1);
|
||||
for (const step of [steps[install], steps[retry]]) {
|
||||
expect(step['timeout-minutes'], name).toBe(5);
|
||||
expect(step.run, name).toBe('npx playwright install --with-deps chromium');
|
||||
}
|
||||
expect(steps[install]['continue-on-error'], name).toBe(true);
|
||||
expect(steps.filter((step: any) => step['continue-on-error']), name).toEqual([steps[install]]);
|
||||
}
|
||||
});
|
||||
|
||||
test('test jobs give PR and dispatch runs the local main that push runs get from checkout (#3237)', () => {
|
||||
const jobs = (Bun.YAML.parse(source) as any).jobs;
|
||||
for (const name of ['free-suite', 'quick-bun-default']) {
|
||||
const [checkout, guard] = jobs[name].steps;
|
||||
expect(checkout.uses, name).toStartWith('actions/checkout@');
|
||||
expect(guard.if, name).toBe("github.ref != 'refs/heads/main'");
|
||||
expect(guard.run, name).toBe('git checkout -q -B main');
|
||||
}
|
||||
});
|
||||
|
||||
test('least-privilege token: contents read-only, credentials not persisted', () => {
|
||||
// The job executes PR-controlled code (install lifecycle scripts + the
|
||||
// suite itself). A default-grant GITHUB_TOKEN persisted into .git/config
|
||||
|
||||
@@ -15,6 +15,7 @@ import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
const REPO = path.resolve(import.meta.dir, '..');
|
||||
const BUN_DEFAULT = fs.readFileSync(path.join(REPO, 'bin/gstack-bun-version.sh'), 'utf8').match(/^GSTACK_BUN_DEFAULT="([^"]+)"$/m)![1];
|
||||
const bases: string[] = [];
|
||||
afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); });
|
||||
|
||||
@@ -211,17 +212,17 @@ describe('gstack-capy-install', () => {
|
||||
const f = fixture({ bun: '1.3.14' });
|
||||
const zip = path.join(f.base, 'bad.zip');
|
||||
fs.mkdirSync(path.join(f.base, 'z/bun-linux-x64'), { recursive: true });
|
||||
script(path.join(f.base, 'z/bun-linux-x64/bun'), ['echo 1.4.3']);
|
||||
script(path.join(f.base, 'z/bun-linux-x64/bun'), [`echo ${BUN_DEFAULT}`]);
|
||||
expect(sh(`cd "${f.base}/z" && zip -qr "${zip}" bun-linux-x64`).status).toBe(0);
|
||||
const r = run(f, [], { STUB_CURL_ZIP: zip });
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.stderr).toContain('SHA-256 mismatch');
|
||||
expect(r.stderr).toContain('nothing installed');
|
||||
expect(f.calls().filter(l => l.startsWith('curl '))).toHaveLength(1);
|
||||
expect(f.calls()[0]).toContain('https://github.com/oven-sh/bun/releases/download/bun-v1.4.3/bun-linux-x64');
|
||||
expect(f.calls()[0]).toContain(`https://github.com/oven-sh/bun/releases/download/bun-v${BUN_DEFAULT}/bun-linux-x64`);
|
||||
expect(fs.existsSync(path.join(f.home, '.bun/bin/bun'))).toBe(false);
|
||||
expect(fs.existsSync(store(f, '1.4.3'))).toBe(false);
|
||||
expect(fs.readdirSync(path.dirname(store(f, '1.4.3')))).toEqual([]);
|
||||
expect(fs.existsSync(store(f, BUN_DEFAULT))).toBe(false);
|
||||
expect(fs.readdirSync(path.dirname(store(f, BUN_DEFAULT)))).toEqual([]);
|
||||
expect(f.calls().some(l => l.startsWith('setup '))).toBe(false);
|
||||
});
|
||||
|
||||
@@ -230,16 +231,16 @@ describe('gstack-capy-install', () => {
|
||||
const target = sh('grep -q avx2 /proc/cpuinfo && echo linux-x64 || echo linux-x64-baseline').stdout.trim();
|
||||
const zip = path.join(f.base, 'good.zip');
|
||||
fs.mkdirSync(path.join(f.base, `z/bun-${target}`), { recursive: true });
|
||||
script(path.join(f.base, `z/bun-${target}/bun`), ['echo 1.4.3']);
|
||||
script(path.join(f.base, `z/bun-${target}/bun`), [`echo ${BUN_DEFAULT}`]);
|
||||
expect(sh(`cd "${f.base}/z" && zip -qr "${zip}" bun-${target}`).status).toBe(0);
|
||||
trustZip(f.root, '1.4.3', target, zip);
|
||||
trustZip(f.root, BUN_DEFAULT, target, zip);
|
||||
const r = run(f, ['--for', 'autoplan'], { STUB_CURL_ZIP: zip });
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(r.stdout).toContain('installing bun 1.4.3 from its release archive');
|
||||
expect(r.stdout).toContain(`bun 1.4.3 installed at ${store(f, '1.4.3')}/bun`);
|
||||
expect(r.stdout).toContain(`bun 1.4.3 at ${store(f, '1.4.3')}/bun (gstack's install default, from gstack's runtime store;`);
|
||||
expect(r.stdout).toContain(`installing bun ${BUN_DEFAULT} from its release archive`);
|
||||
expect(r.stdout).toContain(`bun ${BUN_DEFAULT} installed at ${store(f, BUN_DEFAULT)}/bun`);
|
||||
expect(r.stdout).toContain(`bun ${BUN_DEFAULT} at ${store(f, BUN_DEFAULT)}/bun (gstack's install default, from gstack's runtime store;`);
|
||||
expect(fs.existsSync(path.join(f.home, '.bun/bin/bun'))).toBe(false);
|
||||
expect(f.calls().filter(l => l.startsWith('env '))).toEqual(Array(2).fill('env GSTACK_SKIP_PLAYWRIGHT= GSTACK_SKIP_FONTS= bun=1.4.3'));
|
||||
expect(f.calls().filter(l => l.startsWith('env '))).toEqual(Array(2).fill(`env GSTACK_SKIP_PLAYWRIGHT= GSTACK_SKIP_FONTS= bun=${BUN_DEFAULT}`));
|
||||
expect(lastLine(r.stdout)).toBe('gstack: ok 1.2.3 for=autoplan');
|
||||
});
|
||||
|
||||
@@ -333,15 +334,15 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd
|
||||
|
||||
test.skipIf(process.platform !== 'linux')('after installing Bun the launch line carries its PATH; a second run reuses it (one download) and skips setup on the same HEAD', () => {
|
||||
const f = fixture({ bun: '1.3.14' });
|
||||
const zip = zipBun(f, '1.4.3');
|
||||
const bunDir = store(f, '1.4.3');
|
||||
const zip = zipBun(f, BUN_DEFAULT);
|
||||
const bunDir = store(f, BUN_DEFAULT);
|
||||
const first = run(f, ['--for', 'autoplan'], { STUB_CURL_ZIP: zip });
|
||||
expect(first.status, first.stdout + first.stderr).toBe(0);
|
||||
expect(first.stdout).toContain(`GSTACK_LAUNCH: PATH=${bunDir}:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=${path.join(f.home, '.gstack-state')} GSTACK_EPHEMERAL=1 GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=`);
|
||||
expect(first.stdout).toContain(`GSTACK_LAUNCH_PATH: ${bunDir}`);
|
||||
const second = run(f, ['--for', 'autoplan'], { STUB_CURL_ZIP: zip });
|
||||
expect(second.status, second.stdout + second.stderr).toBe(0);
|
||||
expect(second.stdout).toContain(`bun 1.4.3 at ${bunDir}/bun`);
|
||||
expect(second.stdout).toContain(`bun ${BUN_DEFAULT} at ${bunDir}/bun`);
|
||||
expect(second.stdout).toContain(`GSTACK_LAUNCH: PATH=${bunDir}:$PATH `);
|
||||
expect(f.calls().filter(l => l.startsWith('curl '))).toHaveLength(1);
|
||||
expect(f.calls().filter(l => l.startsWith('setup '))).toHaveLength(2);
|
||||
@@ -477,19 +478,19 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: one resoluti
|
||||
expectLaunch(outside, `${bi}/bin/bun`);
|
||||
});
|
||||
|
||||
// Integration item 1: gstack's own CI pins 1.4.2 on every lane and 1.4.3 on
|
||||
// the install-default job. They are alternatives, so the real checkout as
|
||||
// --project certifies with either Bun; a repo whose CI pins only 1.4.3 still
|
||||
// refuses 1.4.2.
|
||||
test('the real gstack checkout as --project with a separate global install certifies with ~/.bun 1.4.2 and with 1.4.3; nothing is downloaded', () => {
|
||||
for (const version of ['1.4.2', '1.4.3']) {
|
||||
// Integration item 1: gstack's own CI pins 1.4.2 on every lane and the
|
||||
// install default on the install-default job. They are alternatives, so the
|
||||
// real checkout as --project certifies with either Bun; a repo whose CI pins
|
||||
// only 1.4.3 still refuses 1.4.2.
|
||||
test('the real gstack checkout as --project with a separate global install certifies with ~/.bun 1.4.2 and with the install default; nothing is downloaded', () => {
|
||||
for (const version of ['1.4.2', BUN_DEFAULT]) {
|
||||
const f = fixture({ bun: '1.3.14' });
|
||||
withPins(f);
|
||||
realBun(path.join(f.home, '.bun/bin/bun'), version);
|
||||
const r = run(f, ['--for', 'review', '--project', REPO], {}, f.home);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(r.stderr).not.toContain('RUNTIME_PIN_CONFLICT');
|
||||
expect(r.stdout).toContain(`bun ${version} at ${f.home}/.bun/bin/bun ($BUN_INSTALL/bin; satisfies gstack's supported minimum 1.4.2 and the project's CI pins (alternatives: 1.4.2 1.4.3))`);
|
||||
expect(r.stdout).toContain(`bun ${version} at ${f.home}/.bun/bin/bun ($BUN_INSTALL/bin; satisfies gstack's supported minimum 1.4.2 and the project's CI pins (alternatives: 1.4.2 ${BUN_DEFAULT}))`);
|
||||
expect(f.calls().some(l => l.startsWith('curl '))).toBe(false);
|
||||
expect(verdictFor(f, r)).toMatchObject({ project: REPO, bun_version: version, bun_rule: 'BUN_INSTALL' });
|
||||
expect(run(f, ['--check', '--for', 'review', '--project', REPO], {}, f.home).status).toBe(0);
|
||||
@@ -499,9 +500,9 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: one resoluti
|
||||
test('CI alternatives with nothing installed: the install default is published when it is one of them; a gbrain-like repo (CI 1.4.3 only) refuses ~/.bun 1.4.2 and installs 1.4.3', () => {
|
||||
const f = fixture({ bun: '1.3.14' });
|
||||
withPins(f);
|
||||
const r = run(f, ['--for', 'review', '--project', REPO], { STUB_CURL_ZIP: zipFor(f, '1.4.3') }, f.home);
|
||||
const r = run(f, ['--for', 'review', '--project', REPO], { STUB_CURL_ZIP: zipFor(f, BUN_DEFAULT) }, f.home);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(r.stdout).toContain(`bun 1.4.3 at ${store(f, '1.4.3')}/bun (`);
|
||||
expect(r.stdout).toContain(`bun ${BUN_DEFAULT} at ${store(f, BUN_DEFAULT)}/bun (`);
|
||||
const g = fixture({ bun: '1.3.14' });
|
||||
withPins(g);
|
||||
realBun(path.join(g.home, '.bun/bin/bun'), '1.4.2');
|
||||
@@ -546,13 +547,13 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: one resoluti
|
||||
test('a broken store entry is repaired in place: the version directory is never moved aside, bun is renamed over its predecessor and bunx is published', () => {
|
||||
const f = fixture({ bun: '1.3.14' });
|
||||
withPins(f);
|
||||
const dir = store(f, '1.4.3');
|
||||
const dir = store(f, BUN_DEFAULT);
|
||||
realBun(path.join(dir, 'bun'), '1.4.0');
|
||||
const inode = fs.statSync(dir).ino;
|
||||
const r = run(f, ['--for', 'autoplan'], { BUN_INSTALL: '', STUB_CURL_ZIP: zipFor(f, '1.4.3') }, f.base);
|
||||
const r = run(f, ['--for', 'autoplan'], { BUN_INSTALL: '', STUB_CURL_ZIP: zipFor(f, BUN_DEFAULT) }, f.base);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(fs.statSync(dir).ino).toBe(inode);
|
||||
expect(sh(`"${dir}/bun" --version`).stdout.trim()).toBe('1.4.3');
|
||||
expect(sh(`"${dir}/bun" --version`).stdout.trim()).toBe(BUN_DEFAULT);
|
||||
expect(fs.readlinkSync(path.join(dir, 'bunx'))).toBe('bun');
|
||||
expect(fs.readdirSync(path.dirname(dir)).filter(n => n.startsWith('.tmp'))).toEqual([]);
|
||||
});
|
||||
@@ -588,10 +589,10 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: one resoluti
|
||||
const unset = run(f, ['--check', '--for', 'autoplan'], { BUN_INSTALL: '' });
|
||||
expect(unset.status, unset.stdout + unset.stderr).toBe(0);
|
||||
expectLaunch(unset, `${bi}/bin/bun`);
|
||||
realBun(path.join(store(f, '1.4.3'), 'bun'), '1.4.3');
|
||||
realBun(path.join(store(f, BUN_DEFAULT), 'bun'), BUN_DEFAULT);
|
||||
const newer = run(f, ['--check', '--for', 'autoplan'], { BUN_INSTALL: bi });
|
||||
expect(newer.status, newer.stdout + newer.stderr).toBe(0);
|
||||
expect(newer.stdout).toContain(`note: the recorded bun 1.4.2 at ${bi}/bin/bun stays certified; bun 1.4.3 at ${store(f, '1.4.3')}/bun (default) would now resolve first; run ${f.root}/bin/gstack-capy-install --for autoplan to record the current resolution`);
|
||||
expect(newer.stdout).toContain(`note: the recorded bun 1.4.2 at ${bi}/bin/bun stays certified; bun ${BUN_DEFAULT} at ${store(f, BUN_DEFAULT)}/bun (default) would now resolve first; run ${f.root}/bin/gstack-capy-install --for autoplan to record the current resolution`);
|
||||
expectLaunch(newer, `${bi}/bin/bun`);
|
||||
expect(newer.stdout).not.toContain('BUN_RESOLUTION_DRIFT');
|
||||
expect(run(f, ['--for', 'autoplan'], { BUN_INSTALL: bi }).status).toBe(0);
|
||||
@@ -622,7 +623,7 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: one resoluti
|
||||
expect(f.calls().some(l => l.startsWith('curl ') || l.startsWith('setup '))).toBe(false);
|
||||
const r = run(f, ['--for', 'autoplan'], { GSTACK_BUN_INSTALL_VERSION: '1.4.2', STUB_CURL_ZIP: zipFor(f, '1.4.2') });
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(r.stdout).toContain('install version 1.4.2 from GSTACK_BUN_INSTALL_VERSION (default 1.4.3)');
|
||||
expect(r.stdout).toContain(`install version 1.4.2 from GSTACK_BUN_INSTALL_VERSION (default ${BUN_DEFAULT})`);
|
||||
expect(r.stdout).toContain(`bun 1.4.2 at ${store(f, '1.4.2')}/bun (gstack's install default, from gstack's runtime store;`);
|
||||
});
|
||||
|
||||
@@ -662,8 +663,8 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire
|
||||
const state = path.join(f.base, 'custom-state');
|
||||
const storeDir = path.join(state, 'runtimes/bun');
|
||||
realBun(path.join(storeDir, '1.4.2/bun'), '1.4.2');
|
||||
realBun(path.join(storeDir, '1.4.3/bun'), '1.4.3');
|
||||
const a = repo(f, 'a', '1.4.2'), b = repo(f, 'b', '1.4.3'), c = repo(f, 'c');
|
||||
realBun(path.join(storeDir, `${BUN_DEFAULT}/bun`), BUN_DEFAULT);
|
||||
const a = repo(f, 'a', '1.4.2'), b = repo(f, 'b', BUN_DEFAULT), c = repo(f, 'c');
|
||||
for (const dir of [a, b, c]) {
|
||||
const r = run(f, ['--for', 'review'], { GSTACK_STATE_ROOT: state }, dir);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
@@ -673,14 +674,14 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire
|
||||
expect(shim).toContain(`store='${storeDir}'`);
|
||||
const outside = path.join(f.base, 'outside');
|
||||
fs.mkdirSync(outside);
|
||||
for (const [dir, want] of [[a, '1.4.2'], [b, '1.4.3'], [a, '1.4.2'], [c, '1.4.3'], [outside, '1.4.3'], [b, '1.4.3']] as const) {
|
||||
for (const [dir, want] of [[a, '1.4.2'], [b, BUN_DEFAULT], [a, '1.4.2'], [c, BUN_DEFAULT], [outside, BUN_DEFAULT], [b, BUN_DEFAULT]] as const) {
|
||||
const r = fresh(f, dir, 'bun --version');
|
||||
expect(r.stdout.trim(), `${dir}\n${r.stderr}`).toBe(want);
|
||||
expect(r.stderr).toBe('');
|
||||
}
|
||||
const explained = fresh(f, path.join(a), 'mkdir -p sub && cd sub && bun --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' });
|
||||
expect(explained.stderr).toMatch(new RegExp(`^gstack bun shim: project=${a} record=${state}/install-verdicts/[0-9a-f]{64}/[0-9a-f]{64}\\.json chose=${storeDir}/1\\.4\\.2/bun rule=record\\n$`));
|
||||
expect(fresh(f, outside, 'bun --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }).stderr).toBe(`gstack bun shim: project=none record=none chose=${storeDir}/1.4.3/bun rule=default\n`);
|
||||
expect(fresh(f, outside, 'bun --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }).stderr).toBe(`gstack bun shim: project=none record=none chose=${storeDir}/${BUN_DEFAULT}/bun rule=default\n`);
|
||||
const bunx = fresh(f, a, 'bunx --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' });
|
||||
expect(bunx.stderr).toContain(`chose=${storeDir}/1.4.2/bun rule=record`);
|
||||
const unrecordedPinned = repo(f, 'd', '1.4.2');
|
||||
@@ -690,7 +691,7 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire
|
||||
// Integration item 6: an unrecorded, unpinned repo falls through quietly;
|
||||
// only GSTACK_BUN_SHIM_EXPLAIN=1 names the missing record.
|
||||
const e = fresh(f, unrecorded, 'bun --version');
|
||||
expect([e.stdout.trim(), e.stderr]).toEqual(['1.4.3', '']);
|
||||
expect([e.stdout.trim(), e.stderr]).toEqual([BUN_DEFAULT, '']);
|
||||
expect(fresh(f, unrecorded, 'bun --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }).stderr).toContain(`gstack bun shim: ${unrecorded} has no recorded Bun and no exact pin; run ${f.root}/bin/gstack-capy-install --check here to record one\n`);
|
||||
});
|
||||
|
||||
@@ -700,7 +701,7 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire
|
||||
fs.mkdirSync(path.join(f.home, '.capy/system'), { recursive: true });
|
||||
const state = path.join(f.base, 'state');
|
||||
const storeDir = path.join(state, 'runtimes/bun');
|
||||
realBun(path.join(storeDir, '1.4.3/bun'), '1.4.3');
|
||||
realBun(path.join(storeDir, `${BUN_DEFAULT}/bun`), BUN_DEFAULT);
|
||||
expect(run(f, ['--for', 'autoplan'], { GSTACK_STATE_ROOT: state }, f.base).status).toBe(0);
|
||||
const pwned = path.join(f.base, 'pwned');
|
||||
const payload = (dir: string) => script(path.join(dir, 'bun'), [`touch "${pwned}"`, 'echo payload']);
|
||||
@@ -709,15 +710,15 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire
|
||||
write(path.join(evilRepo, '.bun-version'), '../../../repo/payload\n');
|
||||
expect(fs.existsSync(path.join(storeDir, '../../../repo/payload/bun'))).toBe(true);
|
||||
const t = fresh(f, evilRepo, 'bun --version');
|
||||
expect(t.stdout.trim()).toBe('1.4.3');
|
||||
expect(t.stdout.trim()).toBe(BUN_DEFAULT);
|
||||
expect(t.stderr).toContain(`${evilRepo} pins bun ../../../repo/payload, which is not in gstack's runtime store`);
|
||||
payload(path.join(f.base, 'evil'));
|
||||
fs.symlinkSync(path.join(f.base, 'evil'), path.join(storeDir, '1.4.9'));
|
||||
const escape = fresh(f, repo(f, 'escape', '1.4.9'), 'bun --version');
|
||||
expect(escape.stdout.trim()).toBe('1.4.3');
|
||||
expect(escape.stdout.trim()).toBe(BUN_DEFAULT);
|
||||
write(path.join(storeDir, '1.4.8/bun'), '#!/bin/sh\n# gstack-bun-shim: gstack-owned; delete this file to remove it\necho loop\n', 0o755);
|
||||
const marked = fresh(f, repo(f, 'marked', '1.4.8'), 'bun --version');
|
||||
expect(marked.stdout.trim()).toBe('1.4.3');
|
||||
expect(marked.stdout.trim()).toBe(BUN_DEFAULT);
|
||||
expect(fs.existsSync(pwned)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -732,7 +733,7 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire
|
||||
const r = run(f, ['--for', 'autoplan'], { BUN_INSTALL: '' }, outside);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(r.stdout).toContain(`bun 1.4.2 at ${f.home}/.bun/bin/bun (~/.bun/bin;`);
|
||||
expect(fs.existsSync(path.join(f.home, '.gstack-state/runtimes/bun/1.4.3'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(f.home, `.gstack-state/runtimes/bun/${BUN_DEFAULT}`))).toBe(false);
|
||||
for (const dir of [outside, f.root]) {
|
||||
const e = fresh(f, dir, 'bun --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' });
|
||||
expect(e.stdout.trim(), e.stderr).toBe('1.4.2');
|
||||
@@ -752,13 +753,13 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire
|
||||
const f = fixture({ bun: '1.3.14' });
|
||||
fs.mkdirSync(path.join(f.home, '.capy/system'), { recursive: true });
|
||||
const state = path.join(f.base, "it's state");
|
||||
realBun(path.join(state, 'runtimes/bun/1.4.3/bun'), '1.4.3');
|
||||
realBun(path.join(state, `runtimes/bun/${BUN_DEFAULT}/bun`), BUN_DEFAULT);
|
||||
expect(run(f, ['--for', 'autoplan'], { GSTACK_STATE_ROOT: state }, f.base).status).toBe(0);
|
||||
expect(sh(`sh -n "${f.home}/.local/bin/bun"`).status).toBe(0);
|
||||
const r = fresh(f, f.base, 'bun --version && bunx --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' });
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(r.stdout.trim().split('\n')[0]).toBe('1.4.3');
|
||||
expect(r.stderr.split(`chose=${state}/runtimes/bun/1.4.3/bun rule=record`), r.stderr).toHaveLength(3);
|
||||
expect(r.stdout.trim().split('\n')[0]).toBe(BUN_DEFAULT);
|
||||
expect(r.stderr.split(`chose=${state}/runtimes/bun/${BUN_DEFAULT}/bun rule=record`), r.stderr).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('a foreign ~/.local/bin/bun or a dangling link is never touched; GSTACK_BUN_SHIM=0 writes nothing; a rerun rewrites only gstack\'s own file', () => {
|
||||
@@ -789,8 +790,8 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire
|
||||
test('the shim adds little over the Bun it runs (fixture timing bound)', () => {
|
||||
const f = fixture();
|
||||
fs.mkdirSync(path.join(f.home, '.capy/system'), { recursive: true });
|
||||
const storeBun = path.join(f.home, '.gstack-state/runtimes/bun/1.4.3/bun');
|
||||
realBun(storeBun, '1.4.3');
|
||||
const storeBun = path.join(f.home, `.gstack-state/runtimes/bun/${BUN_DEFAULT}/bun`);
|
||||
realBun(storeBun, BUN_DEFAULT);
|
||||
expect(run(f).status).toBe(0);
|
||||
const time = (cmd: string) => {
|
||||
const samples = Array.from({ length: 7 }, () => { const t = performance.now(); fresh(f, f.base, cmd); return performance.now() - t; }).sort((x, y) => x - y);
|
||||
|
||||
@@ -9,8 +9,11 @@ import { describe, test, expect, afterAll } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { execFileSync, spawnSync } from 'child_process';
|
||||
import { classifyState, VERSION_RE } from '../bin/gstack-version-bump';
|
||||
import { checkChangelog, normalizeChangelog, parseChangelog } from '../lib/changelog-check';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { cleanup, makeFixtureRepo, makePr, readFile, restamp, runBin } from './helpers/restamp-fixture';
|
||||
|
||||
const BIN = path.join(import.meta.dir, '..', 'bin', 'gstack-version-bump');
|
||||
|
||||
@@ -962,3 +965,115 @@ describe('G1: version source outcomes (absent / valid / ambiguous / broken)', ()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('write --changelog re-heads [Unreleased] (opt-in, restamp-compatible)', () => {
|
||||
const dirs: string[] = [];
|
||||
afterAll(() => { for (const d of dirs) fs.rmSync(d, { recursive: true, force: true }); });
|
||||
const RELEASED = '## [1.0.0.0] - 2026-10-01\n\n- first release\n';
|
||||
const ENTRY = '## [Unreleased]\n\n### Fixed\n- the branch change\n';
|
||||
const repo = (changelog: string | null) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-changelog-'));
|
||||
dirs.push(dir);
|
||||
fs.writeFileSync(path.join(dir, 'VERSION'), '1.0.0.0\n');
|
||||
if (changelog !== null) fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), changelog);
|
||||
return dir;
|
||||
};
|
||||
const run = (dir: string, args: string[]) => {
|
||||
const r = spawnSync('bun', [BIN, 'write', ...args], { cwd: dir, encoding: 'utf8', timeout: 30_000 });
|
||||
return { code: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
|
||||
};
|
||||
const today = () => new Date().toISOString().slice(0, 10);
|
||||
const readCl = (dir: string) => fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
|
||||
|
||||
test('without the flag write leaves [Unreleased] and the output shape unchanged', () => {
|
||||
const before = `# Changelog\n\n${ENTRY}\n${RELEASED}`;
|
||||
const dir = repo(before);
|
||||
const r = run(dir, ['--version', '1.0.1.0']);
|
||||
expect(r.code).toBe(0);
|
||||
expect(JSON.parse(r.stdout)).not.toHaveProperty('changelog');
|
||||
expect(readCl(dir)).toBe(before);
|
||||
});
|
||||
|
||||
test('re-heads [Unreleased] to the written version, dated today (UTC), keeping every other byte', () => {
|
||||
const before = `# Changelog\n\n${ENTRY}\n${RELEASED}`;
|
||||
const dir = repo(before);
|
||||
const d0 = today();
|
||||
const r = run(dir, ['--version', '1.0.1.0', '--changelog', 'CHANGELOG.md']);
|
||||
const d1 = today();
|
||||
expect(r.code).toBe(0);
|
||||
expect(JSON.parse(r.stdout).changelog).toEqual({ path: 'CHANGELOG.md', moved: true, detail: 'Unreleased -> 1.0.1.0' });
|
||||
const after = readCl(dir);
|
||||
expect([d0, d1].map(d => `# Changelog\n\n## [1.0.1.0] - ${d}\n\n### Fixed\n- the branch change\n\n${RELEASED}`)).toContain(after);
|
||||
expect(normalizeChangelog(after)).toBe(normalizeChangelog(before));
|
||||
expect(checkChangelog(after, '1.0.1.0')).toEqual([]);
|
||||
});
|
||||
|
||||
test('an [Unreleased] entry below the predecessor (after a main merge) moves to the top', () => {
|
||||
const dir = repo(`# Changelog\n\n${RELEASED}\n${ENTRY}`);
|
||||
expect(run(dir, ['--version', '1.0.1.0', '--changelog', 'CHANGELOG.md']).code).toBe(0);
|
||||
const sections = parseChangelog(readCl(dir)).sections;
|
||||
expect(sections.map(s => s.version)).toEqual(['1.0.1.0', '1.0.0.0']);
|
||||
expect(sections[1]!.lines.join('\n').trimEnd()).toBe(RELEASED.trimEnd());
|
||||
});
|
||||
|
||||
test('a rerun is a no-op, and a CHANGELOG with no [Unreleased] never has a released heading renamed', () => {
|
||||
const dir = repo(`# Changelog\n\n${ENTRY}\n${RELEASED}`);
|
||||
expect(run(dir, ['--version', '1.0.1.0', '--changelog', 'CHANGELOG.md']).code).toBe(0);
|
||||
const once = readCl(dir);
|
||||
const again = run(dir, ['--version', '1.0.1.0', '--changelog', 'CHANGELOG.md']);
|
||||
expect(JSON.parse(again.stdout).changelog).toEqual({ path: 'CHANGELOG.md', moved: false, detail: 'no [Unreleased] section' });
|
||||
expect(readCl(dir)).toBe(once);
|
||||
const released = repo(`# Changelog\n\n${RELEASED}`);
|
||||
expect(run(released, ['--version', '1.0.1.0', '--changelog', 'CHANGELOG.md']).code).toBe(0);
|
||||
expect(readCl(released)).toBe(`# Changelog\n\n${RELEASED}`);
|
||||
});
|
||||
|
||||
test('an absent CHANGELOG is a no-op that creates nothing', () => {
|
||||
const dir = repo(null);
|
||||
const r = run(dir, ['--version', '1.0.1.0', '--changelog', 'CHANGELOG.md']);
|
||||
expect(r.code).toBe(0);
|
||||
expect(JSON.parse(r.stdout).changelog).toEqual({ path: 'CHANGELOG.md', moved: false, detail: 'CHANGELOG.md absent' });
|
||||
expect(fs.existsSync(path.join(dir, 'CHANGELOG.md'))).toBe(false);
|
||||
});
|
||||
|
||||
test('a heading the move would duplicate refuses with exit 2 before VERSION is written', () => {
|
||||
const before = `# Changelog\n\n${ENTRY}\n## [1.0.1.0] - 2026-10-02\n\n- taken\n\n${RELEASED}`;
|
||||
const dir = repo(before);
|
||||
const r = run(dir, ['--version', '1.0.1.0', '--changelog', 'CHANGELOG.md']);
|
||||
expect(r.code).toBe(2);
|
||||
expect(r.stderr).toContain('1.0.1.0 already headed');
|
||||
expect(fs.readFileSync(path.join(dir, 'VERSION'), 'utf8')).toBe('1.0.0.0\n');
|
||||
expect(readCl(dir)).toBe(before);
|
||||
});
|
||||
|
||||
test('a missing value or an escaping path refuses with exit 2 and writes nothing', () => {
|
||||
const dir = repo(`# Changelog\n\n${ENTRY}`);
|
||||
for (const args of [['--changelog'], ['--changelog', '--regen-digest'], ['--changelog', '../CHANGELOG.md']]) {
|
||||
const r = run(dir, ['--version', '1.0.1.0', ...args]);
|
||||
expect(r.code).toBe(2);
|
||||
expect(fs.readFileSync(path.join(dir, 'VERSION'), 'utf8')).toBe('1.0.0.0\n');
|
||||
}
|
||||
});
|
||||
|
||||
test('gstack-restamp composes: an already re-headed entry is kept, and a later stamp moves it as the branch stamp', () => {
|
||||
const fx = makeFixtureRepo({ prefix: 'vbump-restamp-' });
|
||||
try {
|
||||
makePr(fx, 'pr-a');
|
||||
const w = runBin(fx, 'gstack-version-bump', ['write', '--version', '1.2.4.0', '--changelog', 'CHANGELOG.md']);
|
||||
expect(w.status).toBe(0);
|
||||
fx.git('commit', '-qam', 'bump 1.2.4.0');
|
||||
const headed = readFile(fx, 'CHANGELOG.md');
|
||||
const same = restamp(fx, ['--version', '1.2.4.0']);
|
||||
expect(same.status).toBe(EXIT.ok);
|
||||
expect(same.out).toContain('already headed 1.2.4.0');
|
||||
expect(readFile(fx, 'CHANGELOG.md')).toBe(headed);
|
||||
const next = restamp(fx, ['--version', '1.2.5.0']);
|
||||
expect(next.status).toBe(EXIT.ok);
|
||||
expect(next.out).toContain('1.2.4.0 -> 1.2.5.0');
|
||||
expect(parseChangelog(readFile(fx, 'CHANGELOG.md')).sections.map(s => s.version)).toEqual(['1.2.5.0', '1.2.3.0', '1.2.2.0']);
|
||||
} finally {
|
||||
cleanup(fx);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -138,6 +138,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
|
||||
'test/fixtures/investigate-owned-question-39195396438.json': ['test/workflow-boundaries-fixture.test.ts'],
|
||||
'test/fixtures/judge-calibration/**': ['scripts/judge-calibration.ts', 'test/judge-calibration.test.ts'],
|
||||
'test/fixtures/judge-panel-replay-2026-10-05.json': ['test/judge-panel-median.test.ts'],
|
||||
'test/fixtures/merge-train-3345/**': ['test/changelog-merge-damage.test.ts', 'test/compare-pr-version.test.ts'],
|
||||
'test/fixtures/module-size-ratchet.json': ['test/helpers/module-size.ts', 'test/module-size-ratchet.test.ts'],
|
||||
'test/fixtures/module-size/**': ['test/helpers/module-size.ts', 'test/module-size-ratchet.test.ts'],
|
||||
'test/fixtures/multi-agent-wave/**': ['test/headless-artifacts.test.ts', 'test/helpers/autoplan-run-fixture.ts', 'test/plan-reality.test.ts'],
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* artifact downloads and check-run reads. Every call is logged, so tests can
|
||||
* prove what was (and was not) dispatched.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
@@ -58,7 +59,7 @@ export function repoKey(fx: TrainFixture): string {
|
||||
}
|
||||
|
||||
/** A same-repo PR: a branch from origin/main touching `file`, published as refs/pull/<n>/head (and /merge), known to the gh shim. */
|
||||
export function openPr(fx: TrainFixture, pr: string, opts: { file?: string; text?: string; changelog?: boolean; cross?: boolean; from?: string } = {}): string {
|
||||
export function openPr(fx: TrainFixture, pr: string, opts: { file?: string; text?: string; changelog?: boolean | ((text: string) => string); cross?: boolean; from?: string } = {}): string {
|
||||
const { repo } = fx;
|
||||
repo.git('checkout', '-q', opts.from ?? 'origin/main', '-B', `pr-${pr}`);
|
||||
const file = opts.file ?? `pr-${pr}.txt`;
|
||||
@@ -66,7 +67,7 @@ export function openPr(fx: TrainFixture, pr: string, opts: { file?: string; text
|
||||
fs.writeFileSync(path.join(repo.work, file), opts.text ?? `change from #${pr}\n`);
|
||||
if (opts.changelog !== false) {
|
||||
const cl = fs.readFileSync(path.join(repo.work, 'CHANGELOG.md'), 'utf8');
|
||||
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), cl.replace('# Changelog\n', `# Changelog\n\n## [Unreleased]\n\n- #${pr} change\n`));
|
||||
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), typeof opts.changelog === 'function' ? opts.changelog(cl) : cl.replace('# Changelog\n', `# Changelog\n\n## [Unreleased]\n\n- #${pr} change\n`));
|
||||
}
|
||||
repo.git('add', '-A');
|
||||
repo.git('commit', '-q', '-m', `feat #${pr}`);
|
||||
@@ -76,9 +77,19 @@ export function openPr(fx: TrainFixture, pr: string, opts: { file?: string; text
|
||||
return head;
|
||||
}
|
||||
|
||||
/** The repo key of a GitHub-form origin URL: sha256 of the URL with .git and trailing slashes dropped. */
|
||||
export const urlKey = (url: string) => createHash('sha256').update(url.replace(/\.git$/, '').replace(/\/+$/, '')).digest('hex').slice(0, 16);
|
||||
|
||||
/** Point the fixture clone's origin at a GitHub-form URL that git rewrites to the local bare origin (url.insteadOf). */
|
||||
export function githubOrigin(fx: TrainFixture, url: string, cwd = fx.repo.work): void {
|
||||
const git = (...args: string[]) => spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...fx.repo.env } });
|
||||
git('config', `url.${fx.repo.origin}.insteadOf`, url);
|
||||
git('remote', 'set-url', 'origin', url);
|
||||
}
|
||||
|
||||
/** A READY receipt as `ready --screen|--no-screen` writes it (the lane side is tested separately). */
|
||||
export function readyReceipt(fx: TrainFixture, pr: string, head: string, opts: { screen?: boolean; at?: string } = {}): void {
|
||||
const key = repoKey(fx);
|
||||
export function readyReceipt(fx: TrainFixture, pr: string, head: string, opts: { screen?: boolean; at?: string; key?: string } = {}): void {
|
||||
const key = opts.key ?? repoKey(fx);
|
||||
const file = path.join(fx.state, 'ship-queue', 'ready', key, `${pr}-${head}.json`);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const at = opts.at ?? '2026-10-18T08:00:00.000Z';
|
||||
|
||||
@@ -11,7 +11,7 @@ describe('official recommendation and lifecycle structures', () => {
|
||||
expect(anthropic.recommendations).toEqual({ frontier: 'claude-fable-5-1', smart: 'claude-opus-5-5' });
|
||||
expect(anthropic.lineup).not.toContain('claude-mythos-5-1');
|
||||
const openai = parseOfficialSource('openai-lifecycle', freshnessFixture('openai-lifecycle'));
|
||||
expect(openai.lifecycle?.map(row => row.modelId)).toEqual(['gpt-5.3-codex', 'gpt-old', 'gpt-unicode-date', 'gpt-iso-date', 'gpt-date-floor']);
|
||||
expect(openai.lifecycle?.map(row => row.modelId)).toEqual(['gpt-5.3-codex', 'gpt-4o-2024-08-06', 'gpt-4o-2024-11-20', 'gpt-old', 'gpt-unicode-date', 'gpt-iso-date', 'gpt-date-floor']);
|
||||
expect(openai.lifecycle?.find(row => row.modelId === 'gpt-date-floor')).toMatchObject({ deadline: null, supportFloor: '2024-06-13' });
|
||||
});
|
||||
|
||||
@@ -57,6 +57,16 @@ describe('official recommendation and lifecycle structures', () => {
|
||||
expect(report.state.lifecycle[0]).toMatchObject({ modelId: 'gpt-6-astra', deadline: '2027-04-01' });
|
||||
});
|
||||
|
||||
test('OpenAI deprecated cells with a display name before the backticked IDs yield every ID', () => {
|
||||
const openai = parseOfficialSource('openai-lifecycle', freshnessFixture('openai-lifecycle'));
|
||||
expect(openai.lifecycle?.filter(row => row.modelId.startsWith('gpt-4o-'))).toEqual([
|
||||
{ modelId: 'gpt-4o-2024-08-06', state: 'deprecated', deadline: '2027-02-16', supportFloor: null },
|
||||
{ modelId: 'gpt-4o-2024-11-20', state: 'deprecated', deadline: '2027-02-16', supportFloor: null },
|
||||
]);
|
||||
const unticked = freshnessFixture('openai-lifecycle').replace('GPT-4o (`gpt-4o-2024-08-06`, `gpt-4o-2024-11-20`)', 'GPT-4o (gpt-4o-2024-08-06, gpt-4o-2024-11-20)');
|
||||
expect(() => parseOfficialSource('openai-lifecycle', unticked)).toThrow('unrecognized-deprecated-model-cell');
|
||||
});
|
||||
|
||||
test('missing required structures and semantic drift fail closed', () => {
|
||||
expect(() => parseOfficialSource('anthropic-models', freshnessFixture('anthropic-models').replace('Claude API ID', 'Google Cloud ID'))).toThrow('missing-api-id-row');
|
||||
expect(() => parseOfficialSource('anthropic-models', freshnessFixture('anthropic-models').replace('for demanding reasoning', 'for unexplained goals'))).toThrow('unrecognized-recommendation');
|
||||
|
||||
@@ -82,6 +82,55 @@ describe('import graph (static imports ∪ repo-path literals ∪ bin convention
|
||||
expect(testsFor(g, 'notes/scratch.md')).toMatchObject({ tests: [], declaredNone: false });
|
||||
});
|
||||
|
||||
test('#3340: an extensionless import of a .jsx, .mts or .cts file maps to that file', () => {
|
||||
const repo = fixture();
|
||||
commit(repo, {
|
||||
'src/components/Foo.jsx': 'export default function Foo() { return null; }\n',
|
||||
'src/lib/m.mts': 'export const m = 1;\n',
|
||||
'src/lib/c.cts': 'export const c = 1;\n',
|
||||
'test/foo.test.jsx': "import Foo from '../src/components/Foo';\nimport { m } from '../src/lib/m';\nimport { c } from '../src/lib/c';\n",
|
||||
});
|
||||
const g = buildGraph(repo.work);
|
||||
for (const f of ['src/components/Foo.jsx', 'src/lib/m.mts', 'src/lib/c.cts']) {
|
||||
expect(testsFor(g, f).tests).toEqual(['test/foo.test.jsx']);
|
||||
expect(testsFor(g, f).provenance[0]).toContain('(import)');
|
||||
}
|
||||
});
|
||||
|
||||
test('#3340: a directory import resolves to its index.jsx, index.tsx or index.mjs, not only the dir/** fallback', () => {
|
||||
const repo = fixture();
|
||||
commit(repo, {
|
||||
'src/Bar/index.jsx': 'export default 1;\n',
|
||||
'src/Bar/other.js': 'export default 2;\n',
|
||||
'src/Baz/index.tsx': 'export default 1;\n',
|
||||
'src/Baz/other.js': 'export default 2;\n',
|
||||
'src/Qux/index.mjs': 'export default 1;\n',
|
||||
'src/Qux/other.js': 'export default 2;\n',
|
||||
'test/dirs.test.js': "import Bar from '../src/Bar';\nimport Baz from '../src/Baz';\nimport Qux from '../src/Qux';\n",
|
||||
});
|
||||
const g = buildGraph(repo.work);
|
||||
for (const f of ['src/Bar/index.jsx', 'src/Baz/index.tsx', 'src/Qux/index.mjs']) {
|
||||
expect(testsFor(g, f).tests).toEqual(['test/dirs.test.js']);
|
||||
expect(testsFor(g, f).provenance[0]).not.toContain('/**');
|
||||
}
|
||||
for (const f of ['src/Bar/other.js', 'src/Baz/other.js', 'src/Qux/other.js']) expect(testsFor(g, f).tests).toEqual([]);
|
||||
});
|
||||
|
||||
test('#3340: an import through a jsconfig paths alias reaches a .jsx file; an existing .js sibling still wins', () => {
|
||||
const repo = fixture();
|
||||
commit(repo, {
|
||||
'jsconfig.json': '{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }\n',
|
||||
'src/ui/Button.jsx': 'export default function Button() { return null; }\n',
|
||||
'src/ui/Card.js': 'export default 1;\n',
|
||||
'src/ui/Card.jsx': 'export default 2;\n',
|
||||
'test/ui.test.jsx': "import Button from '@/ui/Button';\nimport Card from '@/ui/Card';\n",
|
||||
});
|
||||
const g = buildGraph(repo.work);
|
||||
expect(testsFor(g, 'src/ui/Button.jsx').tests).toEqual(['test/ui.test.jsx']);
|
||||
expect(testsFor(g, 'src/ui/Card.js').tests).toEqual(['test/ui.test.jsx']);
|
||||
expect(testsFor(g, 'src/ui/Card.jsx').tests).toEqual([]);
|
||||
});
|
||||
|
||||
test('python: absolute, from-import submodule, relative and parenthesised imports and mock.patch targets map; stdlib, comments and docstrings do not', () => {
|
||||
const repo = fixture();
|
||||
commit(repo, {
|
||||
|
||||
@@ -13,6 +13,7 @@ import * as path from 'path';
|
||||
import { classify, collectPins, evaluatePins, renderPinLines, satisfies } from '../lib/runtime-pins';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const BUN_DEFAULT = fs.readFileSync(path.join(ROOT, 'bin/gstack-bun-version.sh'), 'utf8').match(/^GSTACK_BUN_DEFAULT="([^"]+)"$/m)![1];
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => { for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true }); });
|
||||
|
||||
@@ -142,15 +143,15 @@ describe('CI exact pins are alternatives', () => {
|
||||
const lane = (name: string, version: string, os = 'ubuntu-latest') => [` ${name}:`, ` runs-on: ${os}`, ' steps:', ' - uses: oven-sh/setup-bun@v2', ' with:', ` bun-version: ${version}`];
|
||||
const workflow = (...jobs: string[][]) => ['jobs:', ...jobs.flat(), ''].join('\n');
|
||||
|
||||
test('the real gstack checkout (CI 1.4.2 lanes plus the 1.4.3 install-default job) passes on 1.4.2 and on 1.4.3, and names the alternatives on 1.4.1', () => {
|
||||
test('the real gstack checkout (CI 1.4.2 lanes plus the install-default job) passes on 1.4.2 and on the install default, and names the alternatives on 1.4.1', () => {
|
||||
const pins = collectPins(ROOT);
|
||||
const ci = [...new Set(pins.filter(p => p.tool === 'bun' && p.field === 'CI' && p.kind === 'exact' && p.lane !== 'windows' && p.lane !== 'macos').map(p => p.constraint))].sort();
|
||||
expect(ci).toEqual(['1.4.2', '1.4.3']);
|
||||
for (const bun of ['1.4.2', '1.4.3']) expect(evaluatePins(pins, { bun }, 'linux').failures.filter(f => f.startsWith('bun'))).toEqual([]);
|
||||
expect(ci).toEqual(['1.4.2', BUN_DEFAULT]);
|
||||
for (const bun of ['1.4.2', BUN_DEFAULT]) expect(evaluatePins(pins, { bun }, 'linux').failures.filter(f => f.startsWith('bun'))).toEqual([]);
|
||||
const low = evaluatePins(pins, { bun: '1.4.1' }, 'linux').failures.find(f => f.startsWith('bun '))!;
|
||||
expect(low).toStartWith('bun 1.4.1 outside engines.bun >=1.4.2 (package.json:');
|
||||
expect(low).toContain('CI exact pins are alternatives, any one of 1.4.2, 1.4.3 satisfies CI');
|
||||
expect(renderPinLines(evaluatePins(pins, { bun: '1.4.2' }, 'linux'), 'linux')).toContain('PIN_CI_ALTERNATIVES: bun 1.4.2 1.4.3');
|
||||
expect(low).toContain(`CI exact pins are alternatives, any one of 1.4.2, ${BUN_DEFAULT} satisfies CI`);
|
||||
expect(renderPinLines(evaluatePins(pins, { bun: '1.4.2' }, 'linux'), 'linux')).toContain(`PIN_CI_ALTERNATIVES: bun 1.4.2 ${BUN_DEFAULT}`);
|
||||
});
|
||||
|
||||
test('gbrain-like (every CI lane 1.4.3) still fails a local 1.4.2 exactly as before', () => {
|
||||
|
||||
@@ -8,13 +8,15 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { DISPATCH_INPUTS, dedupeKey, dispatchPlan, findDuplicateRun, makeNonce, nonceScope, type RunSummary } from '../scripts/eval-bg';
|
||||
import { DEFAULT_POLICY, TRAIN_KEYS_VERSION, validatePolicy } from '../lib/ship-policy';
|
||||
import { fillDue, orderSlots, parsePins } from '../lib/ship-queue-train';
|
||||
import { fillDue, orderSlots, parseOrder, parsePins, prsArgs } from '../lib/ship-queue-train';
|
||||
import { gstackPaidSelection, skillOwnCases, touchedSkills } from '../lib/ship-queue-train-ready';
|
||||
import type { TrainSlot } from '../lib/ship-queue-train-record';
|
||||
import { normalizeRemoteUrl, repoIdentity, type TrainSlot } from '../lib/ship-queue-train-record';
|
||||
import { INFRA_REDISPATCH, REBUILD_ONCE, classifyBisect, classifyGate } from '../lib/ship-queue-train-verdict';
|
||||
import { EVAL_POLICY } from './helpers/periodic-exclude-data';
|
||||
import { COMPLETE, FAIL, PASS, TRAIN_BLOCK } from './helpers/ship-queue-train-fixture';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
@@ -54,6 +56,54 @@ describe('ordering and fill', () => {
|
||||
expect(parsePins('12')).toMatch(/entries are <pr>@<sha>/);
|
||||
expect(parsePins(`12@${SHA},12@${SHA}`)).toBe('a PR appears twice in --prs');
|
||||
});
|
||||
|
||||
test('--order (GSTA-67, #3337 docs slotted ahead of the #3343 catalog it describes): an explicit order replaces screened-first and READY order; absent, the default is unchanged', () => {
|
||||
const docs = slot('3337', false, '2026-10-22T18:00:00Z');
|
||||
const catalog = slot('3343', false, '2026-10-22T19:00:00Z');
|
||||
expect(orderSlots([docs, catalog], 5).train.map(s => s.pr)).toEqual(['3337', '3343']);
|
||||
expect(orderSlots([docs, catalog], 5, undefined).train.map(s => s.pr)).toEqual(['3337', '3343']);
|
||||
const pins = [{ pr: '3337' }, { pr: '3343' }];
|
||||
const order = parseOrder('3343,#3337', pins);
|
||||
expect(order).toEqual(['3343', '3337']);
|
||||
expect(orderSlots([docs, catalog], 5, order as string[]).train.map(s => s.pr)).toEqual(['3343', '3337']);
|
||||
const screened = slot('9', true, '2026-10-22T20:00:00Z');
|
||||
const over = orderSlots([docs, catalog, screened], 2, ['3343', '3337', '9']);
|
||||
expect([over.train.map(s => s.pr), over.held.map(s => s.pr), over.split]).toEqual([['3343', '3337'], ['9'], false]);
|
||||
expect(parseOrder(undefined, pins)).toBeUndefined();
|
||||
for (const bad of ['3343', '3343,3337,3337', '3343,3337,1', '3343,x']) expect(parseOrder(bad, pins)).toEqual({ error: `--order must list every PR in --prs exactly once (--prs has #3337,#3343; got ${bad})` });
|
||||
expect(prsArgs([{ pr: '3343', head: SHA }, { pr: '3337', head: SHA }], true)).toBe(`--prs 3343@${SHA},3337@${SHA} --order 3343,3337`);
|
||||
expect(prsArgs([{ pr: '3343', head: SHA }], false)).toBe(`--prs 3343@${SHA}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('repo identity (GSTA-67, #3331 on 2026-10-21)', () => {
|
||||
test("every URL form of one GitHub repository gives one key, the github.com key gstack's records already use", () => {
|
||||
const canonical = 'https://github.com/garrytan/gstack';
|
||||
for (const url of ['https://github.com/garrytan/gstack', 'https://github.com/garrytan/gstack.git', 'https://git.capy.ai/garrytan/gstack', 'https://git.capy.ai/garrytan/gstack.git/',
|
||||
'git@github.com:garrytan/gstack.git', 'ssh://git@github.com/garrytan/gstack.git', 'ssh://git@ssh.github.com:443/garrytan/gstack.git', 'https://x-access-token:PASSWORD@github.com/garrytan/gstack.git',
|
||||
'http://www.github.com/GarryTan/GStack']) expect(normalizeRemoteUrl(url)).toBe(canonical);
|
||||
expect(normalizeRemoteUrl('https://gitlab.com/acme/widget.git')).toBe('https://gitlab.com/acme/widget');
|
||||
expect(normalizeRemoteUrl('https://user:PASSWORD@gitlab.com/acme/widget.git')).toBe('https://gitlab.com/acme/widget');
|
||||
expect(normalizeRemoteUrl('/tmp/fixture/origin.git')).toBe('/tmp/fixture/origin');
|
||||
});
|
||||
|
||||
test("#3331's lane (origin https://git.capy.ai/garrytan/gstack) now writes b321d228efd2bc14, and readers still accept the 1fb5dd69740a8cf8 receipt it wrote", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'repo-identity-'));
|
||||
try {
|
||||
const git = (...args: string[]) => spawnSync('git', args, { cwd: dir, encoding: 'utf8', timeout: 30_000 });
|
||||
git('init', '-q');
|
||||
git('remote', 'add', 'origin', 'https://git.capy.ai/garrytan/gstack');
|
||||
const lane = repoIdentity(dir);
|
||||
expect(lane.key).toBe('b321d228efd2bc14');
|
||||
expect(lane.url).toBe('https://github.com/garrytan/gstack');
|
||||
expect(lane.keys).toContain('1fb5dd69740a8cf8');
|
||||
git('remote', 'set-url', 'origin', 'https://github.com/garrytan/gstack.git');
|
||||
const coordinator = repoIdentity(dir);
|
||||
expect(coordinator.key).toBe('b321d228efd2bc14');
|
||||
expect(coordinator.keys).toEqual(expect.arrayContaining(['b321d228efd2bc14', '1fb5dd69740a8cf8']));
|
||||
expect(coordinator.keys[0]).toBe('b321d228efd2bc14');
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
|
||||
describe('verdict class mapping', () => {
|
||||
|
||||
@@ -19,7 +19,7 @@ import { TRAIN_HELP } from '../lib/ship-queue-train-cli';
|
||||
import { BASE_POLICY, cleanup, runBin } from './helpers/restamp-fixture';
|
||||
import { coordinatorClone } from './helpers/ship-queue-fixture';
|
||||
import {
|
||||
COMPLETE, FAIL, GREEN_GATE, PASS, RED_GATE, TRAIN_BLOCK, TRAIN_POLICY, dispatches, line, nonceOf, openPr, queue, readyReceipt, repoKey, trainFixture, trainJson,
|
||||
COMPLETE, FAIL, GREEN_GATE, PASS, RED_GATE, TRAIN_BLOCK, TRAIN_POLICY, dispatches, githubOrigin, line, nonceOf, openPr, queue, readyReceipt, repoKey, trainFixture, trainJson, urlKey,
|
||||
type Rule, type TrainFixture,
|
||||
} from './helpers/ship-queue-train-fixture';
|
||||
|
||||
@@ -127,6 +127,30 @@ describe('ready under a train policy: the screen choice', () => {
|
||||
expect(dispatches(fx)).toEqual([]);
|
||||
}, 120_000);
|
||||
|
||||
test("GSTA-67: a lane whose origin is another URL form of the repository (#3331's git.capy.ai) writes its receipt under the owner/repo key", () => {
|
||||
const { fx, head, pregate } = readyFixture();
|
||||
githubOrigin(fx, 'https://git.capy.ai/fixture/repo.git');
|
||||
const ok = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen']);
|
||||
expect(ok.status).toBe(EXIT.ok);
|
||||
const canonical = urlKey('https://github.com/fixture/repo');
|
||||
expect(canonical).not.toBe(urlKey('https://git.capy.ai/fixture/repo'));
|
||||
expect(line(ok.out, 'READY_RECEIPT:')).toContain(path.join(fx.state, 'ship-queue', 'ready', canonical, `1-${head}.json`));
|
||||
}, 120_000);
|
||||
|
||||
test("GSTA-67: ready refuses a head the Version Gate refuses (#3345's CHANGELOG replaced a released heading)", () => {
|
||||
const fx = make();
|
||||
const head = openPr(fx, '1', { changelog: cl => cl.replace('## [1.2.3.0] - 2026-10-01', '## [Unreleased]\n\n- #1 change') });
|
||||
fx.write(d => { d.ci[head] = [{ status: 'completed', conclusion: 'success' }]; });
|
||||
const tree = runBin(fx.repo, 'gstack-wtree', []).out.trim();
|
||||
const pregate = path.join(fx.repo.root, 'pregate.json');
|
||||
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
|
||||
const r = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen']);
|
||||
expect(r.status).toBe(EXIT.refused);
|
||||
expect(r.err).toContain(`the Version Gate refuses ${head.slice(0, 12)}: CHANGELOG.md drops released heading 1.2.3.0`);
|
||||
expect(r.err).toContain('(QUEUE_NOT_READY)');
|
||||
expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'ready'))).toBe(false);
|
||||
}, 120_000);
|
||||
|
||||
test('--screen dispatches one screen with the PR, its merge SHA and the policy; a repeat adopts it; a broad screen never adopts the narrow one', () => {
|
||||
const { fx, head, pregate } = readyFixture();
|
||||
const dry = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen', '--dry-run']);
|
||||
@@ -207,6 +231,57 @@ describe('train build', () => {
|
||||
expect(dispatches(fx)).toEqual([]);
|
||||
}, 120_000);
|
||||
|
||||
test("GSTA-67: a PR whose CHANGELOG drops a released heading (#3345's pin 1eb7092) is a CHANGELOG conflict, never merged with the damage", () => {
|
||||
const fx = make();
|
||||
const h1 = openPr(fx, '1');
|
||||
const h2 = openPr(fx, '2', { changelog: cl => cl.replace('## [1.2.3.0] - 2026-10-01', '## [Unreleased]\n\n- #2 change') });
|
||||
readyReceipt(fx, '1', h1);
|
||||
readyReceipt(fx, '2', h2);
|
||||
const r = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`, '--dry-run']);
|
||||
expect(r.status).toBe(EXIT.fail);
|
||||
expect(line(r.out, 'QUEUE_TRAIN_CONFLICT:')).toContain('stopped at #2 (conflicts: CHANGELOG.md (ours drops released heading 1.2.3.0 that the merge base carries)) after merging #1');
|
||||
expect(dispatches(fx)).toEqual([]);
|
||||
}, 120_000);
|
||||
|
||||
test("GSTA-67: READY receipts under any URL form of the repository admit (#3331's receipt was keyed on git.capy.ai, the coordinator's origin on github.com)", () => {
|
||||
const fx = make();
|
||||
githubOrigin(fx, 'https://github.com/fixture/repo.git');
|
||||
const h1 = openPr(fx, '1');
|
||||
const h2 = openPr(fx, '2');
|
||||
readyReceipt(fx, '1', h1, { key: urlKey('https://git.capy.ai/fixture/repo') });
|
||||
readyReceipt(fx, '2', h2, { key: urlKey('https://github.com/fixture/repo') });
|
||||
const r = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`, '--dry-run']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(line(r.out, 'ORDER:')).toBe('ORDER: 1:#1(unscreened) 2:#2(unscreened)');
|
||||
const h3 = openPr(fx, '3');
|
||||
readyReceipt(fx, '3', h3, { key: urlKey('https://gitlab.com/fixture/repo') });
|
||||
expect(queue(fx, ['train', 'build', '--prs', `3@${h3}`, '--dry-run']).err).toMatch(/no READY receipt for #3 .*\(QUEUE_NOT_READY\)/);
|
||||
}, 120_000);
|
||||
|
||||
test('GSTA-67 --order: an explicit slot order (#3343 catalog before the #3337 docs that describe it) replaces READY order and carries into NEXT; the default is unchanged', () => {
|
||||
const fx = make();
|
||||
const docs = openPr(fx, '3337');
|
||||
const catalog = openPr(fx, '3343');
|
||||
readyReceipt(fx, '3337', docs, { at: '2026-10-18T07:00:00.000Z' });
|
||||
readyReceipt(fx, '3343', catalog, { at: '2026-10-18T08:00:00.000Z' });
|
||||
const pins = `3337@${docs},3343@${catalog}`;
|
||||
expect(line(queue(fx, ['train', 'build', '--prs', pins, '--dry-run']).out, 'ORDER:')).toBe('ORDER: 1:#3337(unscreened) 2:#3343(unscreened)');
|
||||
const dry = queue(fx, ['train', 'build', '--prs', pins, '--order', '3343,3337', '--dry-run']);
|
||||
expect(dry.status).toBe(EXIT.ok);
|
||||
expect(line(dry.out, 'ORDER:')).toBe('ORDER: 1:#3343(unscreened) 2:#3337(unscreened) (explicit --order)');
|
||||
expect(dry.out).toMatch(new RegExp(`^SLOT: 1 #3343 head=${catalog} `, 'm'));
|
||||
expect(next(dry.out)).toBe(`NEXT: gstack-ship-queue train build --prs 3343@${catalog},3337@${docs} --order 3343,3337`);
|
||||
const partial = queue(fx, ['train', 'build', '--prs', pins, '--order', '3343']);
|
||||
expect(partial.status).toBe(EXIT.refused);
|
||||
expect(partial.err).toContain('--order must list every PR in --prs exactly once (--prs has #3337,#3343; got 3343)');
|
||||
expect(queue(fx, ['train', 'build', '--train', 'x', '--order', '3343']).status).toBe(EXIT.usage);
|
||||
fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; });
|
||||
const real = queue(fx, ['train', 'build', '--prs', pins, '--order', '3343,3337']);
|
||||
expect(real.status).toBe(EXIT.ok);
|
||||
const t = trainJson(fx, nonceOf(real.out));
|
||||
expect([t.slots.map((s: any) => s.pr), t.explicit_order, t.prefixes[1].pr]).toEqual([['3343', '3337'], true, '3343']);
|
||||
}, 180_000);
|
||||
|
||||
test('refusals: a fork PR, a moved head, a PR without READY, and a train still filling (until --urgent)', () => {
|
||||
const fx = make();
|
||||
const h1 = openPr(fx, '1');
|
||||
@@ -557,4 +632,50 @@ describe('verify, freeze, repair and clear', () => {
|
||||
expect(moved.status).toBe(EXIT.fail);
|
||||
expect(moved.err).toMatch(new RegExp(`#2 moved past its pinned head ${h2.slice(0, 12)} and is not T2 modulo stamps \\(${stray.slice(0, 12)}: .*\\(QUEUE_TRAIN_MOVED\\)`));
|
||||
}, 300_000);
|
||||
|
||||
test("GSTA-67 (#3345 at 593e61c): a merged-in head whose CHANGELOG was resolved by hand verifies even where T<i>'s mechanical CHANGELOG differs; an invented line or a head without main is still refused", () => {
|
||||
const fx = make();
|
||||
const h1 = openPr(fx, '1', { changelog: false });
|
||||
const h2 = openPr(fx, '2', { changelog: cl => cl.replace('# Changelog\n', '# Changelog\n\n## [Unreleased]\n\n- #2 change\n').replace('## [1.2.2.0] - 2026-09-01\n\n', '') });
|
||||
readyReceipt(fx, '1', h1);
|
||||
readyReceipt(fx, '2', h2);
|
||||
fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; });
|
||||
const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`]).out);
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
const t = trainJson(fx, nonce);
|
||||
expect(fx.repo.git('show', `${t.prefixes[2].commit}:CHANGELOG.md`)).not.toContain('## [1.2.2.0]');
|
||||
const coord = coordinatorClone(fx.repo);
|
||||
const v = () => queue(fx, ['verify', '--pr', '2', '--train', nonce], {}, coord);
|
||||
const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0');
|
||||
const { repo } = fx;
|
||||
const laneHead = (entry: string, parents: string[]) => {
|
||||
repo.git('reset', '-q', '--hard');
|
||||
repo.git('checkout', '-q', '-B', 'lane2', h2);
|
||||
repo.git('rm', '-rq', '.');
|
||||
repo.git('checkout', t.prefixes[2].commit, '--', '.');
|
||||
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.5.0\n');
|
||||
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), repo.git('show', `${main1}:CHANGELOG.md`).replace('# Changelog\n', `# Changelog\n\n## [1.2.5.0] - 2026-10-18\n\n${entry}\n`) + '\n');
|
||||
repo.git('add', '-A');
|
||||
const head = repo.git('commit-tree', repo.git('write-tree'), ...parents.flatMap(p => ['-p', p]), '-m', 'merge main into #2 (CHANGELOG by hand), stamp 1.2.5.0');
|
||||
repo.git('push', '-q', '-f', 'origin', `${head}:refs/pull/2/head`);
|
||||
return head;
|
||||
};
|
||||
|
||||
const byHand = laneHead('- #2 change', [h2, main1]);
|
||||
const ok = v();
|
||||
expect(ok.status).toBe(EXIT.ok);
|
||||
expect(ok.out).toContain(`head: ${byHand} (T2 with main merged in, same modulo stamps (1 stamp file(s)) except CHANGELOG.md, resolved as main's CHANGELOG.md plus #2's own entry)`);
|
||||
expect(line(ok.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${byHand}`);
|
||||
|
||||
const invented = laneHead('- #2 change\n- added after the gate', [h2, main1]);
|
||||
const refused = v();
|
||||
expect(refused.status).toBe(EXIT.fail);
|
||||
expect(refused.err).toContain(`#2 moved past its pinned head ${h2.slice(0, 12)} and is not T2 modulo stamps (${invented.slice(0, 12)}: differs from T2 in CHANGELOG.md, which is not main's plus #2's own entry: the PR's entry holds a line its pin never added: "- added after the gate"; against the pin it differs: `);
|
||||
expect(refused.err).toContain('(QUEUE_TRAIN_MOVED)');
|
||||
|
||||
const orphan = laneHead('- #2 change', [h2]);
|
||||
const noMain = v();
|
||||
expect(noMain.status).toBe(EXIT.fail);
|
||||
expect(noMain.err).toContain(`(${orphan.slice(0, 12)}: does not contain main ${main1.slice(0, 12)}; against the pin it differs: `);
|
||||
}, 300_000);
|
||||
});
|
||||
@@ -308,3 +308,44 @@ test("rendered /ship handles every version-source outcome the classifier emits",
|
||||
expect(step18).toMatch(/NO_VERSION[^.]*replaces items 1-3/);
|
||||
expect(step18).toContain("no version prefix");
|
||||
});
|
||||
|
||||
// GSTA-70: next-version marks a pick provisional with `complete:false` when
|
||||
// open PRs' VERSION claims could not be read. Step 12 used to print the
|
||||
// warning and write the pick as if it were a verified slot.
|
||||
test("rendered Step 12 reruns, then asks, on an incomplete queue instead of treating it as verified", () => {
|
||||
const ship = readFileSync(join(import.meta.dir, "../ship/SKILL.md"), "utf8");
|
||||
const nextVersion = readFileSync(join(import.meta.dir, "../bin/gstack-next-version"), "utf8");
|
||||
expect(nextVersion).toMatch(/unknown_claims: unknownClaims,\s+complete,/);
|
||||
const qualifyAt = ship.indexOf("**Qualify first:**");
|
||||
const incompleteAt = ship.indexOf("**Incomplete** (`complete:false`)");
|
||||
const usableAt = ship.indexOf("**Usable candidate:**");
|
||||
expect(incompleteAt).toBeGreaterThan(qualifyAt);
|
||||
expect(usableAt).toBeGreaterThan(incompleteAt);
|
||||
const incomplete = ship.slice(incompleteAt, usableAt).replace(/\s+/g, " ");
|
||||
expect(incomplete).toContain("`unknown_claims`");
|
||||
expect(incomplete).toContain("may collide");
|
||||
expect(incomplete).toContain("Rerun item 3 once");
|
||||
expect(incomplete).toMatch(/still incomplete, AskUserQuestion: use it \(recommended[^)]*\) or stop/);
|
||||
});
|
||||
|
||||
// GSTA-70: the rendered Step 12 write command, run verbatim against the real
|
||||
// CLI, re-heads the branch's [Unreleased] entry under NEW_VERSION.
|
||||
test("rendered Step 12 write re-heads an existing [Unreleased] entry and leaves released entries alone", () => {
|
||||
const ship = readFileSync(join(import.meta.dir, "../ship/SKILL.md"), "utf8");
|
||||
const step12 = ship.slice(ship.indexOf("## Step 12:"), ship.indexOf("## Step 14:"));
|
||||
const cmd = step12.match(/bun --no-env-file --config=\/dev\/null run \S+\/bin\/gstack-version-bump write [^\n]+/)?.[0];
|
||||
expect(cmd).toContain("--changelog CHANGELOG.md");
|
||||
const bin = join(import.meta.dir, "../bin");
|
||||
const runnable = cmd!.replace(/\S+\/bin\/gstack-version-bump/, join(bin, "gstack-version-bump"));
|
||||
const released = "## [1.0.0.0] - 2026-10-01\n\n- first\n";
|
||||
writeFiles({ VERSION: "1.0.0.0\n", "CHANGELOG.md": `# Changelog\n\n## [Unreleased]\n\n- branch change\n\n${released}` });
|
||||
execSync(runnable, { cwd: dir, shell: "/bin/bash", env: { ...process.env, NEW_VERSION: "1.0.1.0" }, stdio: "pipe", timeout: 30_000 });
|
||||
const changelog = readFileSync(join(dir, "CHANGELOG.md"), "utf8");
|
||||
expect(changelog).toMatch(/^# Changelog\n\n## \[1\.0\.1\.0\] - \d{4}-\d{2}-\d{2}\n\n- branch change\n\n/);
|
||||
expect(changelog.endsWith(released)).toBe(true);
|
||||
expect(changelog).not.toContain("[Unreleased]");
|
||||
writeFiles({ VERSION: "1.0.1.0\n", "CHANGELOG.md": `# Changelog\n\n${released}` });
|
||||
execSync(runnable, { cwd: dir, shell: "/bin/bash", env: { ...process.env, NEW_VERSION: "1.0.2.0" }, stdio: "pipe", timeout: 30_000 });
|
||||
expect(readFileSync(join(dir, "CHANGELOG.md"), "utf8")).toBe(`# Changelog\n\n${released}`);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user