From e66c43c57209a17e2d9a79847a2e985c27ca80bd Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 24 Oct 2026 19:04:24 -0700 Subject: [PATCH] v1.91.191.0 fix(version-bump): write --changelog re-heads [Unreleased]; /ship Step 12 acts on an incomplete queue (GSTA-70) (#3372) * fix(version-bump): write --changelog re-heads [Unreleased]; /ship Step 12 acts on an incomplete queue (GSTA-70) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.191.0 stamp Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 12 +++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-version-bump | 51 ++++++++- package.json | 2 +- ship/SKILL.md | 19 ++-- ship/SKILL.md.tmpl | 19 ++-- test/fixtures/golden/agy-ship-SKILL.md | 19 ++-- test/fixtures/golden/claude-ship-SKILL.md | 19 ++-- test/fixtures/golden/codex-ship-SKILL.md | 19 ++-- test/fixtures/golden/factory-ship-SKILL.md | 19 ++-- test/gstack-version-bump.test.ts | 117 ++++++++++++++++++++- test/ship-version-sync.test.ts | 41 ++++++++ 13 files changed, 282 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f3fb826d..b0fb7b918 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [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 `. It re-heads an existing `## [Unreleased]` section as `## [] - ` 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. diff --git a/VERSION b/VERSION index ef60487ee..acf19638c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.190.0 +1.91.191.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index e0dc41159..3c65ea06f 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.190.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, diff --git a/bin/gstack-version-bump b/bin/gstack-version-bump index 0d470c351..ba6a51254 100755 --- a/bin/gstack-version-bump +++ b/bin/gstack-version-bump @@ -25,8 +25,16 @@ // the caller must handle), exit 2 on bad args / unresolvable base / // broken version source. // -// write --version [--version-path

] [--regen-digest] +// write --version [--version-path

] [--regen-digest] [--changelog

] // Validates the 4-digit pattern, writes VERSION + package.json.version. +// --changelog

(opt-in): re-head the branch's `## [Unreleased]` +// entry in

as `## [] - ` 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 { + 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 ", 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", ); } diff --git a/package.json b/package.json index b523042c2..9ab941103 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.190", + "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", diff --git a/ship/SKILL.md b/ship/SKILL.md index 924983ab4..24d4cfafb 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -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 --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. diff --git a/ship/SKILL.md.tmpl b/ship/SKILL.md.tmpl index 6adf2d0ae..fcff6bc33 100644 --- a/ship/SKILL.md.tmpl +++ b/ship/SKILL.md.tmpl @@ -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 --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. diff --git a/test/fixtures/golden/agy-ship-SKILL.md b/test/fixtures/golden/agy-ship-SKILL.md index 3be910710..6cc30d0b4 100644 --- a/test/fixtures/golden/agy-ship-SKILL.md +++ b/test/fixtures/golden/agy-ship-SKILL.md @@ -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 --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. diff --git a/test/fixtures/golden/claude-ship-SKILL.md b/test/fixtures/golden/claude-ship-SKILL.md index 924983ab4..24d4cfafb 100644 --- a/test/fixtures/golden/claude-ship-SKILL.md +++ b/test/fixtures/golden/claude-ship-SKILL.md @@ -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 --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. diff --git a/test/fixtures/golden/codex-ship-SKILL.md b/test/fixtures/golden/codex-ship-SKILL.md index 3106f86c4..bbadb86ec 100644 --- a/test/fixtures/golden/codex-ship-SKILL.md +++ b/test/fixtures/golden/codex-ship-SKILL.md @@ -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 --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. diff --git a/test/fixtures/golden/factory-ship-SKILL.md b/test/fixtures/golden/factory-ship-SKILL.md index 04255582c..a59827a30 100644 --- a/test/fixtures/golden/factory-ship-SKILL.md +++ b/test/fixtures/golden/factory-ship-SKILL.md @@ -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 --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. diff --git a/test/gstack-version-bump.test.ts b/test/gstack-version-bump.test.ts index 74fcbdc31..b90077dac 100644 --- a/test/gstack-version-bump.test.ts +++ b/test/gstack-version-bump.test.ts @@ -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); + } + }); +}); + diff --git a/test/ship-version-sync.test.ts b/test/ship-version-sync.test.ts index 705cd61fd..329af41bb 100644 --- a/test/ship-version-sync.test.ts +++ b/test/ship-version-sync.test.ts @@ -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}`); +}); +