Merge remote-tracking branch 'origin/main' into capy/merge-train-hardening-repo

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
garrytan committed 2026-10-25 03:20:40 +00:00
commit 96a51d1e1e
32 files changed
+616 -146

No files matched your search

+63 -6
View File
@@ -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
+66
View File
@@ -25,6 +25,72 @@
- `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.
+12 -3
View File
@@ -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 -1
View File
@@ -1 +1 @@
1.91.186.0
1.91.191.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.186.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.191.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,
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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",
);
}
+3 -1
View File
@@ -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
+2 -2
View File
@@ -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
+4
View File
@@ -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
+4 -4
View File
@@ -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
View File
@@ -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`. |
-1
View File
@@ -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
View File
@@ -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');
}
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.186",
"version": "1.91.191",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+10 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+10 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
@@ -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
+35
View File
@@ -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
+44 -43
View File
@@ -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);
+116 -1
View File
@@ -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);
}
});
});
+11 -1
View File
@@ -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');
+49
View File
@@ -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, {
+6 -5
View File
@@ -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', () => {
+41
View File
@@ -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}`);
});