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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-25 02:04:24 +00:00
1 parent 7b8c3d316d
commit e66c43c572
13 files changed
+282 -59

No files matched your search

+12
View File
@@ -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 <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.
+1 -1
View File
@@ -1 +1 @@
1.91.190.0
1.91.191.0
+1 -1
View File
@@ -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,
+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",
);
}
+1 -1
View File
@@ -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",
+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.
+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.
+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);
}
});
});
+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}`);
});