mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
v1.91.353.0 fix(ship): Step 12 keeps VERSION on --keep-current, adds bump never, and waits for the owner's answer before an unattended bump (GST-380) (#3666)
* test(ship): failing-first Step 12 keep-current refusal and unattended early bump write (GST-380) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): Step 12 keeps VERSION on --keep-current, adds bump never, and waits for the owner's answer before an unattended bump (GST-380) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: regenerate ship SKILL goldens (GST-380) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (answer gate covers ALREADY_BUMPED --version writes; typed session rows; never drift and --answer rejection cases) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (the answer gate names every flag that decides the write; Version Gate and validate understand bump never) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: under bump never a release PR that moves VERSION takes the Version Gate's ordinary rules Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): fragment for GST-380 Step 12 keep-current and bump never Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
2356792e43
commit
e744bfe994
27 files changed
+242
-34
No files matched your search
@@ -1,5 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## [1.91.353.0] - 2026-10-31
|
||||
|
||||
**`/ship` Step 12 now has a way through for repos whose unit PRs never change VERSION, and an unattended `/ship` no longer writes a version bump nobody approved.** On gstack-evals (no policy file, so `bump: ask`), `--keep-current`, `--version 0.0.1` and `--accept-incomplete` all stopped with `BUMP_LEVEL needed` (gstack-evals#85), and on gstack-evals#89 an unattended run took the recommended level and wrote 0.0.2 before anyone was asked (GST-380).
|
||||
|
||||
### Added
|
||||
- **`"bump": "never"` in `.gstack/ship-policy.json`** for a repo whose unit PRs never version. Step 12 prints `VERSION: <v> kept (policy: bump never; no write)`, asks nothing and refuses `--bump`; a branch that moved VERSION stops until it is put back. `gstack-restamp --next` then needs an explicit `--bump` or `--version`. The value needs `min_gstack_version` at its introducing release and cannot be combined with `queue_mode: stamp-at-merge`, `train` or `changelog_fragments`, which stamp a version at every merge.
|
||||
|
||||
### Fixed
|
||||
- **`gstack-ship-release version`**: on a branch whose VERSION still equals the base, `--keep-current` and `--version <current>` print `VERSION: <v> kept`, write nothing and read no queue. Passing `--bump` with either stops and names the conflict. The `BUMP_LEVEL needed` stop now names `--keep-current` too.
|
||||
- **No version write before the owner answers.** Under `bump: ask`, when `gstack-session-kind` reports anything but `interactive` (unattended, spawned, OpenClaw, headless or CI), a Step 12 call that would write a version stops with `BUMP_ANSWER_REQUIRED` and writes nothing until it is rerun with `--answer "<the owner's words>"` (one line, at most 200 characters). This covers a FRESH `--bump` and a `--version` rewrite of an already bumped branch. `bump: patch` is a standing answer and needs no `--answer`. `ship/DRIVER.md` lists the stop as an approval, so a brief's "take the recommended option" no longer answers it.
|
||||
|
||||
## [1.91.352.0] - 2026-10-31
|
||||
|
||||
**gstack now knows particle-1 and has measured it on its own tasks.** particle-1 (Particle AI, October 28, 2026) passed 15/15 cells on a preregistered five-task panel, the same as Sonnet 5.5 and Opus 5.5, at $0.007 per task against Sonnet 5.5's $0.037 (run `2026-10-30-0105`, Claude Code through OpenRouter). The panel is easy, so it shows no measured difference between the models, not that they are equal. Results and every cell are in [docs/evals/particle-1-2026-10.md](docs/evals/particle-1-2026-10.md).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.352.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.91.353.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
@@ -18,7 +18,7 @@ import * as path from 'node:path';
|
||||
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
|
||||
import { resultLine } from '../lib/result-codes';
|
||||
import {
|
||||
POLICY_KEYS, POLICY_KEY_INTRODUCED, SHIP_POLICY_PATH, examplePolicyJson, explainPolicy, isUnavailable, loadPolicyFromBase, loadPolicyFromHead,
|
||||
BUMP_NEVER_KEY_VERSION, POLICY_KEYS, POLICY_KEY_INTRODUCED, SHIP_POLICY_PATH, examplePolicyJson, explainPolicy, isUnavailable, loadPolicyFromBase, loadPolicyFromHead,
|
||||
minimumReader, policyUnavailableLine, renderEffective, resolveBaseBranch, type LoadedPolicy,
|
||||
} from '../lib/ship-policy';
|
||||
|
||||
@@ -87,8 +87,8 @@ function main(argv: string[]): number {
|
||||
const loaded = load(root, rest, cmd === 'validate' ? 'head' : 'base');
|
||||
if (typeof loaded === 'number') return loaded;
|
||||
const set = POLICY_KEYS.filter(k => loaded.values[k] && loaded.values[k] !== 'default');
|
||||
const introduced = Object.fromEntries(set.map(k => [k, POLICY_KEY_INTRODUCED[k]]));
|
||||
const minReader = set.length ? minimumReader(introduced) : null;
|
||||
const introduced = Object.fromEntries(set.map(k => [k, k === 'bump' && loaded.policy.bump === 'never' ? BUMP_NEVER_KEY_VERSION : POLICY_KEY_INTRODUCED[k]]));
|
||||
const minReader = set.length ? minimumReader(Object.fromEntries(set.map(k => [k, loaded.policy[k]]))) : null;
|
||||
if (json) {
|
||||
process.stdout.write(JSON.stringify({ valid: loaded.errors.length === 0, present: loaded.present, source: loaded.source, policy: loaded.policy, values: loaded.values, introduced, min_reader: minReader, errors: loaded.errors }) + '\n');
|
||||
return loaded.errors.length ? EXIT.fail : EXIT.ok;
|
||||
|
||||
+3
-1
@@ -39,7 +39,7 @@ refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with
|
||||
|
||||
| Key | Values | Default | Meaning |
|
||||
|---|---|---|---|
|
||||
| `bump` | `patch` \| `ask` | `ask` | `patch` skips the MINOR/MAJOR prompt in `/ship` Step 12 and `gstack-restamp --next` bumps PATCH; `ask` keeps the prompt. |
|
||||
| `bump` | `patch` \| `ask` \| `never` | `ask` | `patch` skips the MINOR/MAJOR prompt in `/ship` Step 12 and `gstack-restamp --next` bumps PATCH; `ask` keeps the prompt, and in a session no person answers any version Step 12 would write (`--bump` on a fresh branch, `--version <v>` on a bumped one) stops with `BUMP_ANSWER_REQUIRED` until `--answer "<the owner's words>"`; `never` is for a repo whose unit PRs never change VERSION: Step 12 keeps it with no question, refuses `--bump`, and `gstack-restamp --next` needs an explicit `--bump` or `--version` (the release process bumps). CI's Version Gate passes a head that keeps VERSION; a release PR that moves it takes the ordinary bump and collision rules. `never` needs `min_gstack_version` at its introducing release and cannot be combined with `queue_mode: stamp-at-merge`, `train` or `changelog_fragments`, which stamp a version at every merge. |
|
||||
| `queue_mode` | `claim-at-open` \| `stamp-at-merge` | `claim-at-open` | See [stamp-at-merge](#stamp-at-merge). `claim-at-open` is today's queue (`gstack-next-version` claims a slot when the PR opens). |
|
||||
| `release_tool` | command or `null` | `null` | The repo's own release command (`bun run release:restamp`). Runs in an isolated staging worktree under the [release-tool contract](#release-tool-contract). |
|
||||
| `release_outputs` | globs | `[]` | Paths the release tool may write beyond the version files, lockfiles, CHANGELOG and `stamp_paths`. Anything else is `RESTAMP_OUTPUT_OUTSIDE_ALLOWED`. |
|
||||
@@ -866,6 +866,8 @@ which carry the workflow consequences the skill prose would otherwise spell out:
|
||||
| `ship: shared merge queue: …` | in a coordinator-run queue, `gstack-ship-queue ready`, `gate-ahead` and `stamp` replace the restamp calls; see [Run a shared merge queue](#run-a-shared-merge-queue) |
|
||||
| `ship: gate-ahead: …` | with `.gstack/tmp/gate-ahead.json` present, Step 16 reuses only the lanes `gstack-tree-receipt … --json > .gstack/tmp/tree-receipt.json` marks reusable; stamp-sensitive and not-eligible lanes run live; `--force-gate <reason>` is the coordinator's call |
|
||||
| `ship: bump patch: …` | Step 12 never asks for MINOR or MAJOR; PATCH or MICRO by size |
|
||||
| `ship: bump never: …` | Step 12 prints `VERSION: <v> kept (policy: bump never; no write)`, asks nothing and refuses `--bump`; a branch that moved VERSION stops until it is put back |
|
||||
| `ship: bump ask: …` | Step 12 asks for MINOR and MAJOR; on FRESH, `--keep-current` (or `--version <current>`) keeps VERSION, and in a session no person answers any version Step 12 would write waits for `--answer` |
|
||||
| `ship: preregistration: …` | `gstack-evidence ancestor --from-policy --base <base>` must print only `ANCESTOR: ok` before push |
|
||||
| `ship: release_tool: …` | the command runs only inside `gstack-restamp` (staging worktree, outputs confined to `release_outputs`) |
|
||||
| `ship: review_scope …` | Step 9.4 item 0 runs `gstack-review-scope classify` on each pass's findings; see [review_scope](#review-scope) |
|
||||
|
||||
@@ -672,6 +672,7 @@ export function restamp(o: RestampOptions): RestampResult {
|
||||
try {
|
||||
const { loaded, label } = loadPolicy(o);
|
||||
if (loaded.errors.length) throw new Refusal(loaded.errors[0]!.code, `${SHIP_POLICY_PATH} (${label}): ${loaded.errors[0]!.message} at ${loaded.errors[0]!.path}; nothing written`);
|
||||
if (loaded.policy.bump === 'never' && !o.bump && !o.version) throw new Refusal('RESTAMP_VERSION_SOURCE', `${SHIP_POLICY_PATH} (${label}): bump never names no level; a release stamp passes --bump <level> or --version <X>; nothing written`);
|
||||
const commands = declaredCommands(loaded.policy);
|
||||
if (commands.length && !o.allowRepoCommands) {
|
||||
throw new Refusal('REPO_COMMANDS_NOT_ALLOWED', `repo commands not executed (pass --allow-repo-commands): ${commands.map(c => `${c.key}: ${c.command}`).join('; ')}; nothing written`, { commands });
|
||||
|
||||
+18
-7
@@ -21,7 +21,7 @@ import { cmpVersion, parseVersion } from './version-source';
|
||||
export const SHIP_POLICY_PATH = '.gstack/ship-policy.json';
|
||||
export const SHIP_POLICY_SCHEMA = 'https://github.com/garrytan/gstack/docs/ship-policy.md#schema-v1';
|
||||
|
||||
export type BumpPolicy = 'patch' | 'ask';
|
||||
export type BumpPolicy = 'patch' | 'ask' | 'never';
|
||||
export type QueueMode = 'claim-at-open' | 'stamp-at-merge';
|
||||
export type HistoryPolicy = 'merge-only' | 'squash-ok';
|
||||
/** `frozen`: /ship fixes the diff in pass 1, defers unchanged code to issues and reviews only the range after; `all`: every finding is fixed now. */
|
||||
@@ -29,7 +29,7 @@ export type ReviewScope = 'frozen' | 'diff-plus-blocking' | 'all';
|
||||
|
||||
export interface ShipPolicy {
|
||||
$schema?: string;
|
||||
/** `patch` skips the MINOR/MAJOR prompts in /ship Step 12; `ask` keeps them. */
|
||||
/** `patch` skips the MINOR/MAJOR prompts in /ship Step 12; `ask` keeps them; `never` keeps VERSION on every unit PR. */
|
||||
bump: BumpPolicy;
|
||||
/** `stamp-at-merge`: no claim at PR open; stamp when the PR is next. `claim-at-open` is today's queue. */
|
||||
queue_mode: QueueMode;
|
||||
@@ -128,7 +128,7 @@ export const POLICY_KEYS = Object.keys(DEFAULT_POLICY) as PolicyKey[];
|
||||
|
||||
/** What each key means, printed by `init --explain` (JSON carries no comments). */
|
||||
export const POLICY_EXPLANATIONS: Record<PolicyKey, string> = {
|
||||
bump: 'patch | ask. `patch` skips the MINOR/MAJOR prompt in /ship Step 12.',
|
||||
bump: 'patch | ask | never. `patch` skips the MINOR/MAJOR prompt in /ship Step 12; `ask` keeps it, and a session no person answers needs the owner\'s words (--answer) before a bump is written; `never` keeps VERSION on every unit PR (Step 12 writes nothing and asks nothing; the release process bumps). `never` needs min_gstack_version and rules out stamp-at-merge, train and changelog_fragments.',
|
||||
queue_mode: 'claim-at-open | stamp-at-merge. stamp-at-merge stamps when the PR is next (`gstack-restamp --next`), from the base branch version at that moment.',
|
||||
release_tool: 'The repo’s own release command, run in an isolated staging worktree with an enumerated allowed-output set. Never runs without /ship or --allow-repo-commands.',
|
||||
release_outputs: 'Globs the release tool may write beyond the version files, lockfiles, CHANGELOG and stamp_paths. Any other output refuses the restamp.',
|
||||
@@ -181,6 +181,8 @@ export const TRAIN_KEYS_VERSION = '1.91.146.0';
|
||||
export const REVIEW_SCOPE_KEY_VERSION = '1.91.256.0';
|
||||
export const DOCS_CAPS_KEY_VERSION = '1.91.270.0';
|
||||
export const CHANGELOG_FRAGMENTS_KEY_VERSION = '1.91.336.0';
|
||||
/** `bump: never` is a value of an older key, so the per-key floor check misses it; compatErrors checks it by value. */
|
||||
export const BUMP_NEVER_KEY_VERSION = '1.91.353.0';
|
||||
export const POLICY_KEY_INTRODUCED: Record<PolicyKey, string> = {
|
||||
bump: '1.91.73.0', queue_mode: '1.91.73.0', release_tool: '1.91.73.0', release_outputs: '1.91.73.0', stamp_paths: '1.91.73.0',
|
||||
changelog: '1.91.73.0', mirror: '1.91.73.0', history: '1.91.73.0', preregistration_shas: '1.91.73.0', stamp_sensitive_gates: '1.91.73.0',
|
||||
@@ -200,14 +202,14 @@ const newer = (a: string, b: string): boolean => cmpVersion(parseVersion(a)!, pa
|
||||
export function runningGstackVersion(): string {
|
||||
let own = '0.0.0.0';
|
||||
try { own = fs.readFileSync(path.resolve(import.meta.dir, '..', 'VERSION'), 'utf8').trim(); } catch {}
|
||||
const known = Object.values(POLICY_KEY_INTRODUCED).reduce((a, b) => (newer(b, a) ? b : a));
|
||||
const known = [...Object.values(POLICY_KEY_INTRODUCED), BUMP_NEVER_KEY_VERSION].reduce((a, b) => (newer(b, a) ? b : a));
|
||||
return parseVersion(own) && !newer(known, own) ? own : known;
|
||||
}
|
||||
|
||||
/** The newest introducing version among the keys a parsed policy sets: the gstack a crew needs to read it. */
|
||||
export function minimumReader(raw: Record<string, unknown>): string {
|
||||
return Object.keys(raw).filter((k): k is PolicyKey => POLICY_KEYS.includes(k as PolicyKey))
|
||||
.map(k => POLICY_KEY_INTRODUCED[k]).reduce((a, b) => (newer(b, a) ? b : a), POLICY_KEY_INTRODUCED.bump);
|
||||
.map(k => (k === 'bump' && raw.bump === 'never' ? BUMP_NEVER_KEY_VERSION : POLICY_KEY_INTRODUCED[k])).reduce((a, b) => (newer(b, a) ? b : a), POLICY_KEY_INTRODUCED.bump);
|
||||
}
|
||||
|
||||
export interface PolicyError { code: ResultCodeName; path: string; message: string }
|
||||
@@ -339,7 +341,11 @@ export function validatePolicy(raw: unknown, opts: { gstackVersion?: string } =
|
||||
const oneOf = (key: string, values: readonly string[]) => {
|
||||
if (key in obj && !values.includes(obj[key] as string)) errors.push({ code: 'POLICY_INVALID', path: key, message: `must be one of ${values.join(' | ')}, got ${JSON.stringify(obj[key])}` });
|
||||
};
|
||||
oneOf('bump', ['patch', 'ask']);
|
||||
oneOf('bump', ['patch', 'ask', 'never']);
|
||||
if (obj.bump === 'never') {
|
||||
const stamps = [obj.queue_mode === 'stamp-at-merge' && 'queue_mode stamp-at-merge', obj.train != null && 'train', obj.changelog_fragments != null && 'changelog_fragments'].filter(Boolean);
|
||||
if (stamps.length) errors.push({ code: 'POLICY_INVALID', path: 'bump', message: `never keeps VERSION on every PR, but ${stamps.join(' and ')} stamps a version at every merge; drop one` });
|
||||
}
|
||||
oneOf('queue_mode', ['claim-at-open', 'stamp-at-merge']);
|
||||
oneOf('history', ['merge-only', 'squash-ok']);
|
||||
oneOf('review_scope', ['frozen', 'diff-plus-blocking', 'all']);
|
||||
@@ -412,6 +418,9 @@ function compatErrors(obj: Record<string, unknown>, running: string, errors: Pol
|
||||
if (typeof min === 'string' && !newer(since, min)) continue;
|
||||
errors.push({ code: 'POLICY_INVALID', path: key, message: `introduced in gstack ${since}; set min_gstack_version to ${since} or later so an older gstack refuses this policy by name` });
|
||||
}
|
||||
if (obj.bump === 'never' && !(typeof min === 'string' && !newer(BUMP_NEVER_KEY_VERSION, min))) {
|
||||
errors.push({ code: 'POLICY_INVALID', path: 'bump', message: `never was introduced in gstack ${BUMP_NEVER_KEY_VERSION}; set min_gstack_version to ${BUMP_NEVER_KEY_VERSION} or later so an older gstack refuses this policy by name` });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -575,7 +584,9 @@ export function shipConsequences(policy: ShipPolicy, base: string): string[] {
|
||||
} else out.push('queue_mode claim-at-open: Step 12 runs as written (claims through gstack-next-version)');
|
||||
const frag = policy.changelog_fragments?.dir.replace(/\/+$/, '');
|
||||
if (frag) out.push(`changelog_fragments ${frag}: Step 12 writes no version and Step 13 writes the entry as ${frag}/<branch-slug>.md (the entry text only, no heading; \`bump: minor\` or \`bump: major\` front matter only when the change warrants more than the policy bump), never an [Unreleased] section; lint it with \`gstack-prose-lint ${frag}/<branch-slug>.md --gate\`; \`gstack-ship-queue land\` assigns the version at merge (docs/ship-policy.md#changelog-fragments)`);
|
||||
out.push(policy.bump === 'patch' ? 'bump patch: Step 12 never asks for MINOR or MAJOR; PATCH or MICRO by size' : 'bump ask: Step 12 asks for MINOR and MAJOR as written');
|
||||
out.push(policy.bump === 'patch' ? 'bump patch: Step 12 never asks for MINOR or MAJOR; PATCH or MICRO by size'
|
||||
: policy.bump === 'never' ? 'bump never: Step 12 keeps VERSION (no write, no question) and refuses --bump; the release process bumps outside /ship'
|
||||
: 'bump ask: Step 12 asks for MINOR and MAJOR as written; in a session no person answers, a bump waits for the owner\'s words (--answer)');
|
||||
out.push(policy.review_scope === 'all'
|
||||
? 'review_scope all: Step 9.4 fixes every finding on every pass (gstack-review-scope classify --policy all)'
|
||||
: policy.review_scope === 'diff-plus-blocking'
|
||||
|
||||
+27
-4
@@ -18,8 +18,9 @@ import { bin, evidenceExempt, firstLine, flagReader, fragmentPath, gh, git, host
|
||||
|
||||
export const RELEASE_HELP = `gstack-ship-release — /ship Steps 12 and 15-17 (ship/SKILL.md)
|
||||
|
||||
version --base <base> [--bump micro|patch|minor|major] [--version <v>] [--keep-current] [--accept-incomplete] [--why <text>]
|
||||
Step 12: classify, pick the queue-aware version, write it with --regen-digest --changelog, log the decision
|
||||
version --base <base> [--bump micro|patch|minor|major] [--version <v>] [--keep-current] [--accept-incomplete] [--why <text>] [--answer <owner's words>]
|
||||
Step 12: classify, pick the queue-aware version, write it with --regen-digest --changelog, log the decision;
|
||||
--keep-current (or --version <current>) keeps VERSION; under bump ask a session no person answers needs --answer before a bump is written
|
||||
finish --base <base> --reviewed-tree <tree> --docs-post <post.json> [--lane <label>=<command>]... [--build <command>]...
|
||||
[--trailer <text>] [--docs-rechecked] [--no-lanes-approved]
|
||||
Step 15's release commit, Step 16 stages 1-4 (your builds, the reviewed-tree route, docs freshness,
|
||||
@@ -31,6 +32,7 @@ At most 30 lines each; STOP: names what the agent must decide or repair, and not
|
||||
Exit codes: 0 ok · 2 usage · 3 STOP`;
|
||||
|
||||
const LEVELS: Bump[] = ['micro', 'patch', 'minor', 'major'];
|
||||
const KEPT_NEXT = 'Step 13: no version heading (the entry stays under ## [Unreleased], or is left to the repo\'s release process); an unprefixed Step 18 title and "version":null in Step 20';
|
||||
|
||||
function classify(cwd: string, base: string): { state?: string; json?: any; error?: string } {
|
||||
const c = bin(cwd, 'gstack-version-bump', ['classify', '--base', base], 60_000);
|
||||
@@ -64,6 +66,8 @@ export function version(argv: readonly string[], cwd: string): Report {
|
||||
if (policy?.queue_mode === 'stamp-at-merge' || fragment) return r.add(`VERSION: skipped (policy: ${fragment ? 'changelog_fragments' : 'queue_mode stamp-at-merge'} replaces Step 12 items 1-4; the merge stamps it)`).next(fragment ? `Step 13: write the entry as ${fragment} (entry text only, no heading), never an [Unreleased] section` : 'Step 13: write the CHANGELOG entry under ## [Unreleased]');
|
||||
let c = classify(cwd, base);
|
||||
if (c.error) return r.stop(`a configured version file is missing, empty, unreadable or malformed: ${c.error}; fix it or its pin, then rerun (never substitute 0.0.0.0)`);
|
||||
const never = policy?.bump === 'never';
|
||||
if (never && (c.state === 'ALREADY_BUMPED' || c.state === 'DRIFT_STALE_PKG')) return r.stop(`VERSION moved from ${c.json.baseVersion} to ${c.json.currentVersion} but the policy says bump never: put VERSION and the manifest back to the base version, or change the policy`);
|
||||
if (c.state === 'DRIFT_STALE_PKG') {
|
||||
const rep = bin(cwd, 'gstack-version-bump', ['repair'], 60_000);
|
||||
r.add(`REPAIR: ${rep.status === 0 ? 'package manifest synced to VERSION' : `failed (${firstLine(rep.stderr)})`}`);
|
||||
@@ -80,10 +84,27 @@ export function version(argv: readonly string[], cwd: string): Report {
|
||||
const fresh = c.state === 'FRESH';
|
||||
const asked = flag('--bump') as Bump | undefined;
|
||||
if (asked && !LEVELS.includes(asked)) return r.stop(`--bump ${asked} is not micro, patch, minor or major`);
|
||||
if (never) {
|
||||
if (asked || (explicit && cmpVersion(parseVersion(explicit)!, parseVersion(j.baseVersion)!) !== 0)) return r.stop(`policy bump never: unit PRs keep VERSION at ${j.baseVersion}; a release bump belongs to the release process (gstack-version-bump write), never Step 12`);
|
||||
return r.add(`VERSION: ${j.baseVersion} kept (policy: bump never; no write)`).next(KEPT_NEXT);
|
||||
}
|
||||
const atBase = !!explicit && cmpVersion(parseVersion(explicit)!, parseVersion(j.baseVersion)!) === 0;
|
||||
const keep = fresh && (has('--keep-current') || atBase);
|
||||
const mover = asked ? `--bump ${asked}` : explicit && !atBase ? `--version ${explicit}` : '';
|
||||
if (keep && mover) return r.stop(`${mover} moves VERSION and ${has('--keep-current') ? '--keep-current' : `--version ${explicit}`} keeps it: pass one`);
|
||||
if (keep) return r.add(`VERSION: ${j.baseVersion} kept (FRESH, ${has('--keep-current') ? '--keep-current' : `--version ${explicit}`}; no write)`).next(KEPT_NEXT);
|
||||
const level = fresh ? asked ?? (policy?.bump === 'patch' ? 'patch' : undefined) : asked ?? derivedLevel(j.baseVersion, j.currentVersion) ?? undefined;
|
||||
if (!level) {
|
||||
const stat = git(cwd, ['diff', `origin/${base}`, '--shortstat']).stdout.trim();
|
||||
return r.stop(`BUMP_LEVEL needed (FRESH, ${stat || 'no diff'}): MICRO under 50 lines, PATCH 50+ with no feature signal, MINOR/MAJOR ask first (Step 12.2); rerun with --bump <level>`);
|
||||
return r.stop(`BUMP_LEVEL needed (FRESH, ${stat || 'no diff'}): MICRO under 50 lines, PATCH 50+ with no feature signal, MINOR/MAJOR ask first (Step 12.2); rerun with --bump <level>, or with --keep-current to leave VERSION at ${j.baseVersion}`);
|
||||
}
|
||||
const answer = flag('--answer');
|
||||
if (explicit && cmpVersion(parseVersion(explicit)!, parseVersion(j.baseVersion)!) <= 0) return r.stop(`${explicit} is not above the base version ${j.baseVersion}`);
|
||||
const rewrites = fresh ? !!asked : !!explicit && cmpVersion(parseVersion(explicit)!, parseVersion(j.currentVersion)!) !== 0;
|
||||
if (rewrites && policy?.bump !== 'patch' && !answer) {
|
||||
const kind = bin(cwd, 'gstack-session-kind', [], 30_000).stdout.trim() || 'unknown';
|
||||
const pick = [asked && `--bump ${asked}`, explicit && `--version ${explicit}`].filter(Boolean).join(' ');
|
||||
if (kind !== 'interactive') return r.stop(`BUMP_ANSWER_REQUIRED (${kind} session, bump ask): a version Step 12 writes is the owner's approval, and a brief's "take the recommended option" is not an answer. Ask: ${pick} (recommended), keep ${fresh ? j.baseVersion : j.currentVersion} (--keep-current), or stop; then rerun with ${pick} --answer "<their words>". A repo whose unit PRs never bump sets bump never; one that always takes PATCH sets bump patch`);
|
||||
}
|
||||
let q = queue(cwd, base, level, j.baseVersion);
|
||||
if (q.json?.complete === false) q = queue(cwd, base, level, j.baseVersion);
|
||||
@@ -112,7 +133,7 @@ export function version(argv: readonly string[], cwd: string): Report {
|
||||
r.add(`VERSION: ${j.baseVersion} -> ${next} level=${level} state=${c.state} queue=${q.note} wrote=${wrote.wrote ?? next}`);
|
||||
if (wrote.agentsDigest === false) r.next('run bun --no-env-file --config=/dev/null scripts/gen-agents-digest.ts and stage the digest with the bump');
|
||||
const why = flag('--why') ?? `${level} bump from the diff`;
|
||||
bin(cwd, 'gstack-decision-log', [JSON.stringify({ decision: `Ship ${next} (${level})`, rationale: why, scope: 'repo', source: 'skill', confidence: 9 })], 30_000);
|
||||
bin(cwd, 'gstack-decision-log', [JSON.stringify({ decision: `Ship ${next} (${level})`, rationale: answer ? `${why}; owner: ${answer}` : why, scope: 'repo', source: 'skill', confidence: 9 })], 30_000);
|
||||
return r.next(`Step 13: CHANGELOG entry under ${next}`);
|
||||
}
|
||||
|
||||
@@ -234,6 +255,8 @@ export async function release(argv: readonly string[], cwd: string): Promise<{ t
|
||||
if (cmd !== 'push' && !flag('--base')) return usage(`${cmd} needs --base <base>`);
|
||||
if (cmd === 'push' && !flag('--branch')) return usage('push needs --branch <the branch Step 1 saved>');
|
||||
if (typeof lanes(rest) === 'string') return usage(lanes(rest) as string);
|
||||
const answer = flag('--answer');
|
||||
if (has('--answer') && (!answer?.trim() || answer.startsWith('--') || /[\r\n]/.test(answer) || answer.length > 200)) return usage('--answer needs the owner\'s words: one non-empty line of at most 200 characters');
|
||||
if (cmd === 'finish' && (!flag('--reviewed-tree') || !flag('--docs-post'))) return usage('finish needs --reviewed-tree <the tree saved before Step 12> and --docs-post <Step 14.5\'s post-audit snapshot>');
|
||||
const r = cmd === 'version' ? version(rest, cwd) : cmd === 'finish' ? await finish(rest, cwd) : cmd === 'push' ? push(rest, cwd) : null;
|
||||
if (!r) return usage(`unknown command ${cmd}; run gstack-ship-release --help`);
|
||||
|
||||
@@ -17,7 +17,7 @@ import { matchesAny } from './ship-policy';
|
||||
|
||||
export type TrainHead =
|
||||
| { kind: 'off' }
|
||||
| { kind: 'unstamped'; fragments?: number; entry?: 'fragment' | 'unreleased' | 'none-bot' }
|
||||
| { kind: 'unstamped'; fragments?: number; entry?: 'fragment' | 'unreleased' | 'none-bot' | 'kept' }
|
||||
| { kind: 'stamped'; expected: string; fragments?: boolean }
|
||||
| { kind: 'invalid'; reason: string };
|
||||
|
||||
@@ -96,11 +96,12 @@ export function classifyTrainHead(o: { repoRoot: string; baseRef: string; head:
|
||||
let policy: any = null;
|
||||
try { policy = JSON.parse(show(base, '.gstack/ship-policy.json') ?? 'null'); } catch { policy = null; }
|
||||
const fragDir: string | null = typeof policy?.changelog_fragments?.dir === 'string' ? policy.changelog_fragments.dir : null;
|
||||
if (!policy || ((typeof policy.train !== 'object' || policy.train === null) && !fragDir)) return { kind: 'off' };
|
||||
if (!policy || ((typeof policy.train !== 'object' || policy.train === null) && !fragDir && policy.bump !== 'never')) return { kind: 'off' };
|
||||
const mergeBase = git(o.repoRoot, ['merge-base', base, o.head])?.trim();
|
||||
if (!mergeBase) return { kind: 'invalid', reason: `no merge base between ${base} and ${o.head}` };
|
||||
|
||||
const npmVersion = (rev: string) => { try { return JSON.parse(show(rev, 'package.json') ?? '{}').version ?? null; } catch { return null; } };
|
||||
if (policy.bump === 'never') return show(mergeBase, 'VERSION') === show(o.head, 'VERSION') && npmVersion(mergeBase) === npmVersion(o.head) ? { kind: 'unstamped', entry: 'kept' } : { kind: 'off' };
|
||||
const changed = (git(o.repoRoot, ['diff', '--name-only', mergeBase, o.head]) ?? '').split('\n').filter(Boolean);
|
||||
const releaseOutputs: string[] = Array.isArray(policy.release_outputs) ? policy.release_outputs : [];
|
||||
// touched: what marks the head as stamped (the labels only feed the length check below)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.91.352",
|
||||
"version": "1.91.353",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -41,6 +41,10 @@ const prVersionEnv = (process.env.PR_VERSION ?? "").trim();
|
||||
if (process.env.BASE_REF && process.env.HEAD_SHA) {
|
||||
const train = classifyTrainHead({ repoRoot: process.cwd(), baseRef: process.env.BASE_REF, head: process.env.HEAD_SHA, author: process.env.PR_AUTHOR });
|
||||
if (train.kind === "unstamped") {
|
||||
if (train.entry === "kept") {
|
||||
console.log(`✓ ${tag} keeps its merge base's VERSION and package.json version, as the base's ship policy (bump never) requires.`);
|
||||
process.exit(0);
|
||||
}
|
||||
const entry = train.entry === "fragment" ? `its entry is ${train.fragments} CHANGELOG fragment(s)` : train.entry === "none-bot" ? "it is a bot PR with no entry (the merge step writes one from its title)" : "the CHANGELOG change is confined to [Unreleased]";
|
||||
console.log(`✓ ${tag} is unstamped under the base's ship policy: VERSION and package.json match its merge base, any changed release output is what the release tool generates at the merge base's VERSION, and ${entry}. The merge step stamps it.`);
|
||||
process.exit(0);
|
||||
|
||||
+2
-2
@@ -97,8 +97,8 @@ prints `ADVERSARIAL: skipped (review plan)`).
|
||||
Judge the voices; missing outside coverage is `unavailable`, never clean; queued fixes rerun Phases
|
||||
3-4. Then `gstack-review-dispatch verify` (`VERIFY: clean`) and `gstack-review-read`: the final Step 9 row and Step 11 native row (or `CURRENT` freshness
|
||||
after a planned skip) agree on `wtree`, `start_wtree`, `end_wtree`; save that reviewed tree. Then
|
||||
`gstack-ship-release version --base <base> [--bump micro|patch] --why '<line>'`; MINOR or MAJOR is a
|
||||
question; `VERSION: skipped (policy: ...)`: write no version.
|
||||
`gstack-ship-release version --base <base> [--bump micro|patch] --why '<line>'`; MINOR, MAJOR, `BUMP_ANSWER_REQUIRED`:
|
||||
approvals; `VERSION: skipped|kept`: no write.
|
||||
|
||||
## 6. Notes and documentation: Steps 13, 14, 14.5
|
||||
|
||||
|
||||
+2
-2
@@ -95,8 +95,8 @@ prints `ADVERSARIAL: skipped (review plan)`).
|
||||
Judge the voices; missing outside coverage is `unavailable`, never clean; queued fixes rerun Phases
|
||||
3-4. Then `gstack-review-dispatch verify` (`VERIFY: clean`) and `gstack-review-read`: the final Step 9 row and Step 11 native row (or `CURRENT` freshness
|
||||
after a planned skip) agree on `wtree`, `start_wtree`, `end_wtree`; save that reviewed tree. Then
|
||||
`gstack-ship-release version --base <base> [--bump micro|patch] --why '<line>'`; MINOR or MAJOR is a
|
||||
question; `VERSION: skipped (policy: ...)`: write no version.
|
||||
`gstack-ship-release version --base <base> [--bump micro|patch] --why '<line>'`; MINOR, MAJOR, `BUMP_ANSWER_REQUIRED`:
|
||||
approvals; `VERSION: skipped|kept`: no write.
|
||||
|
||||
## 6. Notes and documentation: Steps 13, 14, 14.5
|
||||
|
||||
|
||||
+1
-1
@@ -517,7 +517,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
|
||||
**One call:** `~/.claude/skills/gstack/bin/gstack-ship-release version --base <base> [--bump <BUMP_LEVEL>] --why '<WHY>'`
|
||||
runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun flag
|
||||
(`--bump`, `--accept-incomplete`, `--version <v>`, `--keep-current`).
|
||||
(`--bump`, `--accept-incomplete`, `--version <v>`, `--keep-current`, `--answer`).
|
||||
|
||||
`ship: queue_mode stamp-at-merge` replaces items 1–4.
|
||||
|
||||
|
||||
+1
-1
@@ -240,7 +240,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
|
||||
**One call:** `~/.claude/skills/gstack/bin/gstack-ship-release version --base <base> [--bump <BUMP_LEVEL>] --why '<WHY>'`
|
||||
runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun flag
|
||||
(`--bump`, `--accept-incomplete`, `--version <v>`, `--keep-current`).
|
||||
(`--bump`, `--accept-incomplete`, `--version <v>`, `--keep-current`, `--answer`).
|
||||
|
||||
`ship: queue_mode stamp-at-merge` replaces items 1–4.
|
||||
|
||||
|
||||
@@ -46,6 +46,10 @@
|
||||
|
||||
**NO_VERSION (Step 12):** skip this step; write no CHANGELOG entry or version header.
|
||||
|
||||
**Kept (Step 12 `VERSION: <v> kept`):** no version header. Put the entry where the repo's own rule
|
||||
puts unit-PR notes (its CHANGELOG preamble or a fragment directory it names), else under
|
||||
`## [Unreleased]`, never under a released heading.
|
||||
|
||||
**No CHANGELOG:** when the policy's `changelog` is `null`, or the file it names
|
||||
(`CHANGELOG.md` by default) is absent at HEAD, print one line,
|
||||
`CHANGELOG: skipped (none at <path>)`, write nothing and never create the file;
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
**NO_VERSION (Step 12):** skip this step; write no CHANGELOG entry or version header.
|
||||
|
||||
**Kept (Step 12 `VERSION: <v> kept`):** no version header. Put the entry where the repo's own rule
|
||||
puts unit-PR notes (its CHANGELOG preamble or a fragment directory it names), else under
|
||||
`## [Unreleased]`, never under a released heading.
|
||||
|
||||
**No CHANGELOG:** when the policy's `changelog` is `null`, or the file it names
|
||||
(`CHANGELOG.md` by default) is absent at HEAD, print one line,
|
||||
`CHANGELOG: skipped (none at <path>)`, write nothing and never create the file;
|
||||
|
||||
+1
-1
@@ -517,7 +517,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
|
||||
**One call:** `~/.gemini/antigravity-cli/skills/gstack/bin/gstack-ship-release version --base <base> [--bump <BUMP_LEVEL>] --why '<WHY>'`
|
||||
runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun flag
|
||||
(`--bump`, `--accept-incomplete`, `--version <v>`, `--keep-current`).
|
||||
(`--bump`, `--accept-incomplete`, `--version <v>`, `--keep-current`, `--answer`).
|
||||
|
||||
`ship: queue_mode stamp-at-merge` replaces items 1–4.
|
||||
|
||||
|
||||
+1
-1
@@ -517,7 +517,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
|
||||
**One call:** `~/.claude/skills/gstack/bin/gstack-ship-release version --base <base> [--bump <BUMP_LEVEL>] --why '<WHY>'`
|
||||
runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun flag
|
||||
(`--bump`, `--accept-incomplete`, `--version <v>`, `--keep-current`).
|
||||
(`--bump`, `--accept-incomplete`, `--version <v>`, `--keep-current`, `--answer`).
|
||||
|
||||
`ship: queue_mode stamp-at-merge` replaces items 1–4.
|
||||
|
||||
|
||||
+1
-1
@@ -516,7 +516,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
|
||||
**One call:** `$GSTACK_ROOT/bin/gstack-ship-release version --base <base> [--bump <BUMP_LEVEL>] --why '<WHY>'`
|
||||
runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun flag
|
||||
(`--bump`, `--accept-incomplete`, `--version <v>`, `--keep-current`).
|
||||
(`--bump`, `--accept-incomplete`, `--version <v>`, `--keep-current`, `--answer`).
|
||||
|
||||
`ship: queue_mode stamp-at-merge` replaces items 1–4.
|
||||
|
||||
|
||||
+1
-1
@@ -518,7 +518,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
|
||||
|
||||
**One call:** `$GSTACK_ROOT/bin/gstack-ship-release version --base <base> [--bump <BUMP_LEVEL>] --why '<WHY>'`
|
||||
runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun flag
|
||||
(`--bump`, `--accept-incomplete`, `--version <v>`, `--keep-current`).
|
||||
(`--bump`, `--accept-incomplete`, `--version <v>`, `--keep-current`, `--answer`).
|
||||
|
||||
`ship: queue_mode stamp-at-merge` replaces items 1–4.
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { checkChangelog, mergeChangelogs, normalizeChangelog, reheadChangelog } from '../lib/changelog-check';
|
||||
import { rewriteStamps, tentativeMentions } from '../lib/restamp';
|
||||
import { BUMP_NEVER_KEY_VERSION } from '../lib/ship-policy';
|
||||
import {
|
||||
BASE_POLICY, cleanup, ghShim, makeFixtureRepo, makePr, mergeToMain, readFile, recordAndBundle, restamp, runBin, type FixtureRepo, mergeMain } from './helpers/restamp-fixture';
|
||||
|
||||
@@ -192,6 +193,18 @@ describe('three PRs, reordered twice, zero manual edits', () => {
|
||||
expect(stamped.out).toMatch(/^next: NEW_VERSION=1\.2\.4\.0; record the release decision, then write the CHANGELOG entry text only/m);
|
||||
});
|
||||
|
||||
// Value: protects=a bump never repo's --next never invents a MICRO stamp: without --bump or --version it refuses and writes nothing, and an explicit level stamps as before; fails_when=restamp falls back to micro for a repo whose policy names no level;
|
||||
// why_new=GST-380 added bump never; seam=gstack-ship-policy, gstack-version-bump write; no_claim=the claim queue read
|
||||
test('bump never: --next without a level is RESTAMP_VERSION_SOURCE with nothing written; --bump stamps', () => {
|
||||
const repo = fixture({ policy: { ...BASE_POLICY, queue_mode: 'claim-at-open', bump: 'never', min_gstack_version: BUMP_NEVER_KEY_VERSION } });
|
||||
makePr(repo, 'pr-a');
|
||||
const before = readFile(repo, 'VERSION');
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect([r.status, /bump never names no level.*nothing written.*\(RESTAMP_VERSION_SOURCE\)/.test(r.err)]).toEqual([3, true]);
|
||||
expect(readFile(repo, 'VERSION')).toBe(before);
|
||||
expect(restamp(repo, ['--next', '--bump', 'patch', '--dry-run']).status).toBe(EXIT.ok);
|
||||
});
|
||||
|
||||
test('claim-at-open with an unknown claim says incomplete, names the PR and never says read', () => {
|
||||
const repo = fixture({ policy: { ...BASE_POLICY, queue_mode: 'claim-at-open' }, prefix: 'gstack-restamp-github.com-' });
|
||||
makePr(repo, 'pr-a');
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { CHANGELOG_FRAGMENTS_KEY_VERSION, DEFAULT_POLICY, POLICY_KEYS, POLICY_KEY_INTRODUCED, REVIEW_SCOPE_KEY_VERSION, globToRegExp, matchesAny, validatePolicy } from '../lib/ship-policy';
|
||||
import { BUMP_NEVER_KEY_VERSION, CHANGELOG_FRAGMENTS_KEY_VERSION, DEFAULT_POLICY, POLICY_KEYS, POLICY_KEY_INTRODUCED, REVIEW_SCOPE_KEY_VERSION, globToRegExp, matchesAny, minimumReader, shipConsequences, validatePolicy } from '../lib/ship-policy';
|
||||
import { fillDue } from '../lib/ship-queue-train';
|
||||
import { cmpVersion, parseVersion } from '../lib/version-source';
|
||||
import { BASE_POLICY, cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
@@ -63,6 +63,20 @@ describe('validatePolicy (schema + containment, pure)', () => {
|
||||
expect(codes({ preregistration_shas: ['not-a-sha'] })).toEqual(['POLICY_CONTAINMENT:preregistration_shas[0]']);
|
||||
});
|
||||
|
||||
// Value: protects=bump never is refused without its floor (an older gstack names the cause) and next to any policy that stamps a version at every merge, reports its reader floor, and prints its Step 12 consequence; fails_when=never validates bare, or beside stamp-at-merge, train or changelog_fragments;
|
||||
// why_new=GST-380 added the value to an older key, which the per-key floor check skips; seam=validatePolicy, minimumReader, shipConsequences; no_claim=Step 12's behavior (test/ship-release.test.ts)
|
||||
test('bump never needs its floor and rules out every policy that stamps at merge', () => {
|
||||
const codes = (p: unknown) => validatePolicy(p).map(e => `${e.code}:${e.path}`);
|
||||
const never = { bump: 'never', min_gstack_version: BUMP_NEVER_KEY_VERSION };
|
||||
expect(codes(never)).toEqual([]);
|
||||
expect(codes({ bump: 'never' })).toEqual(['POLICY_INVALID:bump']);
|
||||
expect(codes({ bump: 'never', min_gstack_version: CHANGELOG_FRAGMENTS_KEY_VERSION })).toEqual(['POLICY_INVALID:bump']);
|
||||
const train = JSON.parse(fs.readFileSync(path.join(import.meta.dir, '..', '.gstack', 'ship-policy.json'), 'utf8')).train;
|
||||
for (const extra of [{ queue_mode: 'stamp-at-merge' }, { train }, { changelog_fragments: { dir: 'changelog.d' } }]) expect(codes({ ...never, ...extra })).toContain('POLICY_INVALID:bump');
|
||||
expect(minimumReader({ bump: 'never' })).toBe(BUMP_NEVER_KEY_VERSION);
|
||||
expect(shipConsequences({ ...DEFAULT_POLICY, bump: 'never' }, 'main').some(l => l.startsWith('bump never: Step 12 keeps VERSION'))).toBe(true);
|
||||
});
|
||||
|
||||
test('globs: ** spans directories, * stays in a segment, regex caps hold, regex metacharacters are literal', () => {
|
||||
expect(matchesAny(['docs/**/*.md'], 'docs/a/b/c.md')).toBe(true);
|
||||
expect(matchesAny(['docs/**/*.md'], 'docs/c.md')).toBe(true);
|
||||
@@ -88,7 +102,7 @@ describe('policy key versions against the CHANGELOG', () => {
|
||||
const cmp = (a: number[], b: number[]) => a[0]! - b[0]! || a[1]! - b[1]! || a[2]! - b[2]! || a[3]! - b[3]!;
|
||||
const headings = [...text.matchAll(/^## \[(\d+\.\d+\.\d+\.\d+)\]/gm)].map(m => m[1]!);
|
||||
const newest = headings.map(parse).sort(cmp).at(-1)!;
|
||||
for (const [key, version] of Object.entries(POLICY_KEY_INTRODUCED)) {
|
||||
for (const [key, version] of [...Object.entries(POLICY_KEY_INTRODUCED), ['bump', BUMP_NEVER_KEY_VERSION]]) {
|
||||
if (headings.includes(version)) continue;
|
||||
expect(cmp(parse(version), newest), `${key} is introduced in ${version}, which has no CHANGELOG heading and is not newer than the newest heading ${newest.join('.')}; an unreleased key names the release that will stamp it`).toBeGreaterThan(0);
|
||||
}
|
||||
@@ -109,7 +123,7 @@ describe('policy key versions against the CHANGELOG', () => {
|
||||
}
|
||||
const parse = (v: string) => v.split('.').map(Number);
|
||||
const cmp = (a: number[], b: number[]) => a[0]! - b[0]! || a[1]! - b[1]! || a[2]! - b[2]! || a[3]! - b[3]!;
|
||||
for (const [key, version] of Object.entries(POLICY_KEY_INTRODUCED)) {
|
||||
for (const [key, version] of [...Object.entries(POLICY_KEY_INTRODUCED), ['bump', BUMP_NEVER_KEY_VERSION]]) {
|
||||
if (cmp(parse(version), parse(REVIEW_SCOPE_KEY_VERSION)) < 0) continue;
|
||||
const section = sections.get(version);
|
||||
if (section === undefined) continue;
|
||||
@@ -208,6 +222,15 @@ describe('gstack-ship-policy (bin)', () => {
|
||||
expect(runBin(repo, 'gstack-ship-policy', ['show-effective', '--policy-from', 'head']).out).toMatch(/^policy: head \(unreviewed\)/);
|
||||
});
|
||||
|
||||
// Value: protects=validate reports bump never's own floor as the policy's minimum reader; fails_when=the CLI prints the bump key's 1.91.73.0 and a crew upgrades to a gstack that refuses the policy;
|
||||
// why_new=GST-380 outside-voice review (the CLI passed introduction versions to minimumReader); seam=bin/gstack-ship-policy; no_claim=-
|
||||
test('validate --json names bump never\'s floor as the minimum reader', () => {
|
||||
const repo = fixture();
|
||||
fs.writeFileSync(path.join(repo.work, '.gstack', 'ship-policy.json'), JSON.stringify({ bump: 'never', min_gstack_version: BUMP_NEVER_KEY_VERSION }) + '\n');
|
||||
const j = JSON.parse(runBin(repo, 'gstack-ship-policy', ['validate', '--json']).out);
|
||||
expect([j.valid, j.introduced.bump, j.min_reader]).toEqual([true, BUMP_NEVER_KEY_VERSION, BUMP_NEVER_KEY_VERSION]);
|
||||
});
|
||||
|
||||
test('POLICY_SOURCE_UNAVAILABLE (exit 3) when origin/<base> does not resolve; no fallback to the working tree', () => {
|
||||
const repo = fixture();
|
||||
const r = runBin(repo, 'gstack-ship-policy', ['show-effective', '--base', 'release']);
|
||||
|
||||
@@ -56,7 +56,8 @@ export function shipFixture(base: Record<string, string> = {}, feat: Record<stri
|
||||
must(gitIn(work, ['checkout', '-qb', 'feat']), 'branch');
|
||||
writeIn(work, feat);
|
||||
must(gitIn(work, ['add', '-A']), 'add feat'); must(gitIn(work, ['commit', '-qm', 'feat']), 'feat commit');
|
||||
const env = { ...process.env, PATH: `${stub}:${process.env.PATH}`, GSTACK_STATE_ROOT: state, GSTACK_HOME: state, GIT_AUTHOR_NAME: 'Fixture', GIT_AUTHOR_EMAIL: 'fixture@example.com',
|
||||
const { GSTACK_SESSION_KIND: _k, GSTACK_HEADLESS: _h, OPENCLAW_SESSION: _o, CI: _c, GITHUB_ACTIONS: _g, ...ambient } = process.env;
|
||||
const env = { ...ambient, PATH: `${stub}:${process.env.PATH}`, GSTACK_STATE_ROOT: state, GSTACK_HOME: state, GIT_AUTHOR_NAME: 'Fixture', GIT_AUTHOR_EMAIL: 'fixture@example.com',
|
||||
GIT_COMMITTER_NAME: 'Fixture', GIT_COMMITTER_EMAIL: 'fixture@example.com', GIT_CONFIG_GLOBAL: path.join(root, 'gitconfig') };
|
||||
fs.writeFileSync(path.join(root, 'gitconfig'), '[commit]\n\tgpgsign = false\n');
|
||||
const fx: ShipFx = {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { BUMP_NEVER_KEY_VERSION } from '../lib/ship-policy';
|
||||
import { lineOf, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture';
|
||||
|
||||
setDefaultTimeout(60_000);
|
||||
@@ -60,6 +61,92 @@ describe('version (Step 12)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('version (Step 12): keeping VERSION and waiting for the bump answer (GST-380)', () => {
|
||||
const UNATTENDED = { GSTACK_SESSION_KIND: 'unattended' };
|
||||
const untouched = (f: ShipFx) => {
|
||||
expect(read(f, 'VERSION')).toBe('1.0.0.0\n');
|
||||
expect(JSON.parse(read(f, 'package.json')).version).toBe('1.0.0');
|
||||
expect(read(f, 'CHANGELOG.md')).toContain('## [Unreleased]');
|
||||
expect(f.git('status', '--porcelain').stdout).toBe('');
|
||||
};
|
||||
|
||||
// Value: protects=a repo whose unit PRs never change VERSION can pass Step 12 with --keep-current or --version <current> on a FRESH branch, and nothing is written or claimed; fails_when=FRESH demands a level before it reads those flags (gstack-evals#85: BUMP_LEVEL needed for all three flags);
|
||||
// why_new=GST-380; seam=gstack-version-bump classify; no_claim=the queue's live host read
|
||||
test('FRESH under bump ask: --keep-current and --version <current> keep VERSION with no write', () => {
|
||||
for (const args of [['--keep-current'], ['--version', '1.0.0.0']]) {
|
||||
const f = fx();
|
||||
const r = f.run(RELEASE, ['version', '--base', 'main', ...args]);
|
||||
expect([r.status, lineOf(r.stdout, 'VERSION')]).toEqual([0, expect.stringMatching(/^VERSION: 1\.0\.0\.0 kept \(FRESH, /)]);
|
||||
expect(r.stdout).toContain('NEXT: Step 13');
|
||||
untouched(f);
|
||||
}
|
||||
const g = fx();
|
||||
const incomplete = g.run(RELEASE, ['version', '--base', 'main', '--accept-incomplete']);
|
||||
expect([incomplete.status, incomplete.stdout.includes('--keep-current')]).toEqual([3, true]);
|
||||
untouched(g);
|
||||
});
|
||||
|
||||
// Value: protects=in a session no person answers (unattended, spawned, OpenClaw, headless) a bump under bump ask waits for the owner's words before anything is written; fails_when=the bin writes VERSION on a --bump the agent took as the recommended option (gstack-evals#89 wrote 0.0.2 before anyone was asked);
|
||||
// why_new=GST-380; seam=gstack-session-kind's classification, gstack-version-bump write; no_claim=that the words came from the owner
|
||||
test('an unattended --bump under bump ask stops with BUMP_ANSWER_REQUIRED and writes nothing until --answer', () => {
|
||||
const sessions: Record<string, string>[] = [UNATTENDED, { GSTACK_SESSION_KIND: 'spawned' }, { OPENCLAW_SESSION: '1' }, { GSTACK_HEADLESS: '1' }];
|
||||
for (const env of sessions) {
|
||||
const f = fx();
|
||||
const r = f.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro', '--why', 'small'], env);
|
||||
expect([r.status, /^STOP: BUMP_ANSWER_REQUIRED/m.test(r.stdout), r.stdout.includes('--keep-current')]).toEqual([3, true, true]);
|
||||
untouched(f);
|
||||
}
|
||||
const f = fx();
|
||||
expect(f.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro', '--answer', '--why', 'x'], UNATTENDED).status).toBe(2);
|
||||
for (const bad of [' ', 'a\nb', 'x'.repeat(201)]) expect(f.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro', '--answer', bad], UNATTENDED).status).toBe(2);
|
||||
untouched(f);
|
||||
const ok = f.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro', '--answer', 'micro is fine'], UNATTENDED);
|
||||
expect([ok.status, read(f, 'VERSION')]).toEqual([0, '1.0.0.1\n']);
|
||||
const rebump = f.run(RELEASE, ['version', '--base', 'main', '--version', '2.0.0.0'], UNATTENDED);
|
||||
expect([rebump.status, rebump.stdout.includes('BUMP_ANSWER_REQUIRED'), rebump.stdout.includes('Ask: --version 2.0.0.0 (recommended), keep 1.0.0.1'), read(f, 'VERSION')]).toEqual([3, true, true, '1.0.0.1\n']);
|
||||
expect(f.run(RELEASE, ['version', '--base', 'main', '--version', '1.0.0.0'], UNATTENDED).stdout).toContain('STOP: 1.0.0.0 is not above the base version 1.0.0.0');
|
||||
const rewritten = f.run(RELEASE, ['version', '--base', 'main', '--version', '2.0.0.0', '--answer', 'go to 2.0.0.0'], UNATTENDED);
|
||||
expect([rewritten.status, read(f, 'VERSION')]).toEqual([0, '2.0.0.0\n']);
|
||||
const both = fx().run(RELEASE, ['version', '--base', 'main', '--bump', 'micro', '--version', '1.0.0.5'], UNATTENDED);
|
||||
expect([both.status, both.stdout.includes('Ask: --bump micro --version 1.0.0.5 (recommended)')]).toEqual([3, true]);
|
||||
const patch = fx({ '.gstack/ship-policy.json': '{"bump":"patch"}\n' });
|
||||
expect([patch.run(RELEASE, ['version', '--base', 'main'], UNATTENDED).status, read(patch, 'VERSION')]).toEqual([0, '1.0.1.0\n']);
|
||||
});
|
||||
|
||||
// Value: protects=bump never keeps VERSION with no flag and no question, refuses a bump or a different --version, and stops on a branch that moved VERSION; fails_when=a never repo gets a write, a question, or a silently kept bump;
|
||||
// why_new=GST-380 added bump never; seam=gstack-ship-policy show-effective, gstack-version-bump classify; no_claim=the release process's own bump
|
||||
test('bump never keeps VERSION with no flag, refuses a bump, and stops on a bumped branch', () => {
|
||||
const policy = { '.gstack/ship-policy.json': JSON.stringify({ bump: 'never', min_gstack_version: BUMP_NEVER_KEY_VERSION }) + '\n' };
|
||||
const f = fx(policy);
|
||||
const kept = f.run(RELEASE, ['version', '--base', 'main'], UNATTENDED);
|
||||
expect([kept.status, lineOf(kept.stdout, 'VERSION')]).toEqual([0, 'VERSION: 1.0.0.0 kept (policy: bump never; no write)']);
|
||||
for (const args of [['--bump', 'micro', '--answer', 'yes'], ['--version', '1.0.0.1']]) {
|
||||
const r = f.run(RELEASE, ['version', '--base', 'main', ...args]);
|
||||
expect([r.status, r.stdout.includes('STOP: policy bump never: unit PRs keep VERSION at 1.0.0.0')]).toEqual([3, true]);
|
||||
}
|
||||
untouched(f);
|
||||
const bumped = fx(policy, { VERSION: '1.0.0.1\n' });
|
||||
const moved = bumped.run(RELEASE, ['version', '--base', 'main']);
|
||||
expect([moved.status, moved.stdout.includes('STOP: VERSION moved from 1.0.0.0 to 1.0.0.1 but the policy says bump never')]).toEqual([3, true]);
|
||||
const stale = fx(policy, { VERSION: '1.0.0.1\n', 'package.json': '{"name":"fx","version":"0.9.0"}\n' });
|
||||
const drift = stale.run(RELEASE, ['version', '--base', 'main']);
|
||||
expect([drift.status, drift.stdout.includes('but the policy says bump never'), drift.stdout.includes('REPAIR:')]).toEqual([3, true, false]);
|
||||
});
|
||||
|
||||
// Value: protects=a --bump beside --keep-current or --version <current> is a contradiction the bin names instead of picking one; fails_when=it silently keeps or silently bumps;
|
||||
// why_new=GST-380 review F8 (flag precedence); seam=none; no_claim=-
|
||||
test('--bump or --version <other> with --keep-current or --version <current> stops and writes nothing', () => {
|
||||
const f = fx();
|
||||
for (const args of [['--keep-current'], ['--version', '1.0.0.0']]) {
|
||||
const r = f.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro', ...args]);
|
||||
expect([r.status, r.stdout.includes('STOP: --bump micro moves VERSION and')]).toEqual([3, true]);
|
||||
}
|
||||
const keepAndMove = f.run(RELEASE, ['version', '--base', 'main', '--keep-current', '--version', '1.0.0.5']);
|
||||
expect([keepAndMove.status, keepAndMove.stdout.includes('STOP: --version 1.0.0.5 moves VERSION and --keep-current keeps it')]).toEqual([3, true]);
|
||||
untouched(f);
|
||||
});
|
||||
});
|
||||
|
||||
describe('finish (Steps 15-16)', () => {
|
||||
// Value: protects=finish commits only release metadata with the trailer, then checks the reviewed-tree route, docs freshness, each lane receipt and the pregate; fails_when=it commits feature files, skips a stage, or pushes;
|
||||
// why_new=moved from Steps 15-16; seam=gstack-wtree, gstack-docs-candidate compare, gstack-evidence, gstack-pregate; no_claim=the stage 5 report
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"bytes": 252,
|
||||
"why": "GST-380: Step 12 kept-version CHANGELOG route, --answer flag and the BUMP_ANSWER_REQUIRED approval in the driver"
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { BUMP_NEVER_KEY_VERSION } from '../lib/ship-policy';
|
||||
import { BASE_POLICY, cleanup, makeFixtureRepo, ROOT, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
|
||||
const SCRIPT = path.join(ROOT, 'scripts', 'compare-pr-version.ts');
|
||||
@@ -24,6 +25,24 @@ function run(r: FixtureRepo, head: string, extra: Record<string, string> = {}, c
|
||||
return { code: out.exitCode, out: out.stdout.toString() + out.stderr.toString() };
|
||||
}
|
||||
|
||||
describe('version gate: bump never', () => {
|
||||
// Value: protects=under bump never the Version Gate passes a head that keeps VERSION, and a release head that moves it goes through the ordinary bump and collision rules; fails_when=the gate rejects every kept-version PR as "VERSION not bumped", or rejects every release PR;
|
||||
// why_new=GST-380 added bump never (outside-voice review, fix-check); seam=classifyTrainHead, scripts/compare-pr-version.ts; no_claim=who may open a release PR
|
||||
test('a kept VERSION passes; a moved VERSION takes the ordinary rules', () => {
|
||||
const r = makeFixtureRepo({ policy: { ...BASE_POLICY, queue_mode: 'claim-at-open', release_tool: null, release_outputs: [], bump: 'never', min_gstack_version: BUMP_NEVER_KEY_VERSION }, prefix: 'version-gate-never-' });
|
||||
repos.push(r);
|
||||
r.git('checkout', '-q', '-b', 'k');
|
||||
write(r, 'k.txt', 'k\n');
|
||||
const kept = run(r, commit(r, 'k'));
|
||||
expect([kept.code, kept.out.includes('as the base\'s ship policy (bump never) requires')]).toEqual([0, true]);
|
||||
write(r, 'VERSION', '1.2.4.0\n');
|
||||
const moved = run(r, commit(r, 'bump'));
|
||||
expect([moved.code, moved.out.includes('bump never')]).toEqual([0, false]);
|
||||
const collides = run(r, r.git('rev-parse', 'HEAD').trim(), {}, [{ pr: 7, branch: 'other', version: '1.2.4.0' }]);
|
||||
expect([collides.code, collides.out.includes('VERSION collision')]).toEqual([1, true]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('version gate: changelog fragments', () => {
|
||||
test('a fragment head passes, a dependency or script edit beside it too', () => {
|
||||
const r = fixture();
|
||||
|
||||
Reference in new issue
Block a user