mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-07 17:28:23 +01:00
ship-policy: refuse a floor above stable; doctor floor row uses runningGstackVersion (GST-1637) (#4080)
* ship-policy: refuse a min_gstack_version floor above the newest stable release; doctor floor row compares runningGstackVersion and offers no no-op fast-forward (GST-1637) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(policy-floor-stable): build the expected doctor row from the project path, not a forward-slash join (Windows) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: keep fixtures off the live stable release (GSTACK_STABLE_REPO in test-setup); pregate-invalidation counts the policy-floor check Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes A vendored gstack copy (no .git of its own, inside a project) no longer resolves the newest stable release from the enclosing project's release tags: newestStableRelease and the doctor's _dc_newest_stable resolve only when the root is the top of its own git checkout, else the floor is not compared (STABLE_RELEASE_UNKNOWN) and the doctor keeps the real upgrade fix. Review-Finding: p1/red-team#1 reader=red-team Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes The newest stable release is the highest release tag at origin's stable commit (a tag a half-way cut left behind no longer counts), and locally the VERSION at the stable ref wins over local tags. The doctor skips the remote lookup where timeout is missing (stock macOS) instead of running it unbounded. Review-Finding: p1/outside-voice#1 reader=outside-voice Review-Finding: p1/outside-voice#2 reader=outside-voice Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: the newest stable release is the tag at origin's stable commit Review-Finding: p3/fix-check#2 reader=fix-check 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
9ceec3f05e
commit
64862e4187
20 files changed
+502
-22
No files matched your search
+11
-4
@@ -626,7 +626,10 @@ EOF_SHIM
|
||||
# behind=<n> (latest: <ver>); upgrade skipped: <reason> (<CODE>); fix: <command>
|
||||
# (below the minimum the bootstrapper fails revision before anything runs).
|
||||
# A project floor the doctor could not meet (`WARN floor`, PROJECT_FLOOR_UNMET)
|
||||
# adds `floor=<ver> (<file>)` and a `fix:` note the same way. A passing run
|
||||
# adds `floor=<ver> (<file>)` and a `fix:` note the same way; when no release
|
||||
# meets the floor (the row says `no release meets this floor yet`), the note is
|
||||
# `floor=<ver> (<file>; no release meets this floor yet)` and no fix rides it,
|
||||
# because no install or upgrade can clear it. A passing run
|
||||
# whose launch file cannot be written becomes
|
||||
# gstack: fail launch-file — could not write the launch file <path>; fix: <command> (LAUNCH_FILE_UNWRITABLE)
|
||||
INSTALLED=$(head -1 "$ROOT/VERSION")
|
||||
@@ -668,9 +671,13 @@ FLOOR_ROW=$(printf '%s\n' "$OUT" | grep '^WARN floor — ' | head -1)
|
||||
if [ -n "$FLOOR_ROW" ]; then
|
||||
FLOOR_VER=$(printf '%s' "$FLOOR_ROW" | sed -n 's/.* is below the floor \([0-9][0-9.]*\) that \(.*\) declares (.*/\1/p')
|
||||
FLOOR_FILE=$(printf '%s' "$FLOOR_ROW" | sed -n 's/.* is below the floor [0-9][0-9.]* that \(.*\) declares (.*/\1/p')
|
||||
note "floor=${FLOOR_VER:-unknown} (${FLOOR_FILE:-$GSTACK_PROJECT_FLOOR_FILE})"
|
||||
[ -n "$FIX_NOTE" ] || FIX_NOTE="${GSTACK_CAPY_BEHIND_FIX:-${GSTACK_CAPY_WRAPPER_PATH:+bash $GSTACK_CAPY_WRAPPER_PATH${FOR:+ --for $FOR} (fast-forwards)}}"
|
||||
[ -n "$FIX_NOTE" ] || FIX_NOTE="$ROOT/bin/gstack-upgrade"
|
||||
case "$FLOOR_ROW" in
|
||||
*"no release meets this floor yet"*) note "floor=${FLOOR_VER:-unknown} (${FLOOR_FILE:-$GSTACK_PROJECT_FLOOR_FILE}; no release meets this floor yet)" ;;
|
||||
*)
|
||||
note "floor=${FLOOR_VER:-unknown} (${FLOOR_FILE:-$GSTACK_PROJECT_FLOOR_FILE})"
|
||||
[ -n "$FIX_NOTE" ] || FIX_NOTE="${GSTACK_CAPY_BEHIND_FIX:-${GSTACK_CAPY_WRAPPER_PATH:+bash $GSTACK_CAPY_WRAPPER_PATH${FOR:+ --for $FOR} (fast-forwards)}}"
|
||||
[ -n "$FIX_NOTE" ] || FIX_NOTE="$ROOT/bin/gstack-upgrade" ;;
|
||||
esac
|
||||
fi
|
||||
[ -n "$FIX_NOTE" ] && note "fix: $FIX_NOTE"
|
||||
case "$TRAILER" in "gstack: ok "*)
|
||||
|
||||
@@ -482,7 +482,7 @@ _dc_check_revision() {
|
||||
# project rule, never a security minimum, so the row is PASS, SKIP or WARN
|
||||
# and never fails the run (the supported minimum is _dc_check_min_revision).
|
||||
_dc_check_floor() {
|
||||
local project="$CHECK_PROJECT" file flat floor installed fix
|
||||
local project="$CHECK_PROJECT" file flat floor installed fix running known as stable
|
||||
if [ -z "$project" ]; then
|
||||
project=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
[ -n "$project" ] && [ "$(cd "$project" && pwd -P)" = "$ROOT_REAL" ] && project=""
|
||||
@@ -510,16 +510,67 @@ _dc_check_floor() {
|
||||
return
|
||||
fi
|
||||
installed=$(clamp '0-9A-Za-z.+-' 40 2>/dev/null <"$ROOT/VERSION")
|
||||
if _dc_version_below "${installed:-0}" "$floor"; then
|
||||
# The version this checkout reads policies as, the one validatePolicy compares
|
||||
# (runningGstackVersion): VERSION, or the newest policy key it knows when that
|
||||
# is later, so an unstamped main checkout that already reads a key is not told
|
||||
# to upgrade to read it. lib/ship-policy-keys.ts writes every newer key in the
|
||||
# `_KEY_VERSION = '<version>'` form its stamp pattern moves.
|
||||
running="${installed:-0}"
|
||||
for known in $(sed -n "s/.*_KEYS\{0,1\}_VERSION = '\([0-9][0-9.]*\)'.*/\1/p" "$ROOT/lib/ship-policy-keys.ts" 2>/dev/null); do
|
||||
_dc_version_below "$running" "$known" && running="$known"
|
||||
done
|
||||
as=""
|
||||
[ "$running" != "${installed:-0}" ] && as=", reads policies as $running"
|
||||
if _dc_version_below "$running" "$floor"; then
|
||||
stable=$(_dc_newest_stable)
|
||||
if [ -n "$stable" ] && _dc_version_below "$stable" "$floor"; then
|
||||
_dc_row WARN floor "installed ${installed:-unknown}$as is below the floor $floor that $file declares ($GSTACK_PROJECT_FLOOR_FIELD), and no release meets this floor yet: the newest stable gstack is $stable, so upgrading or rerunning the install cannot clear this row" "fix: none here; the project lowers $GSTACK_PROJECT_FLOOR_FIELD to $stable or below (gstack-ship-policy validate refuses a floor above stable)" PROJECT_FLOOR_UNMET
|
||||
return
|
||||
fi
|
||||
if [ -n "${GSTACK_CAPY_WRAPPER_PATH:-}" ]; then
|
||||
fix="bash $GSTACK_CAPY_WRAPPER_PATH${CHECK_FOR:+ --for $CHECK_FOR} (fast-forwards a clean checkout to origin and reinstalls), or $SRC/bin/gstack-upgrade"
|
||||
else
|
||||
fix="$SRC/bin/gstack-upgrade (fast-forwards to the remote default branch, then reinstalls; interactive: /gstack-upgrade)"
|
||||
fi
|
||||
_dc_row WARN floor "installed ${installed:-unknown} is below the floor $floor that $file declares ($GSTACK_PROJECT_FLOOR_FIELD)" "fix: $fix" PROJECT_FLOOR_UNMET
|
||||
_dc_row WARN floor "installed ${installed:-unknown}$as is below the floor $floor that $file declares ($GSTACK_PROJECT_FLOOR_FIELD)" "fix: $fix" PROJECT_FLOOR_UNMET
|
||||
return
|
||||
fi
|
||||
_dc_row PASS floor "$floor declared by $file (installed ${installed:-unknown})"
|
||||
_dc_row PASS floor "$floor declared by $file (installed ${installed:-unknown}$as)"
|
||||
}
|
||||
|
||||
# _dc_newest_stable — the newest stable gstack release, as lib/stable-release.ts
|
||||
# resolves it: the highest v<version> release tag at origin's stable commit (one
|
||||
# ls-remote bounded by timeout, skipped where timeout is missing, as on stock
|
||||
# macOS; any tag when origin has no stable branch), else VERSION at the stable
|
||||
# ref (origin/stable, then stable), else the highest local release tag. Prints
|
||||
# nothing when none resolves, or when the root is not the top of its own git
|
||||
# checkout (a vendored copy).
|
||||
_dc_newest_stable() {
|
||||
local repo="${GSTACK_STABLE_REPO:-$ROOT_REAL}" best="" v ref listed="" top stable_sha
|
||||
top=$(git -C "$repo" rev-parse --show-toplevel 2>/dev/null) || return 0
|
||||
[ "$(cd "$top" 2>/dev/null && pwd -P)" = "$(cd "$repo" 2>/dev/null && pwd -P)" ] || return 0
|
||||
_dc_higher() { [ -z "$best" ] || _dc_version_below "$best" "$1" && best="$1"; }
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
listed=$(GIT_TERMINAL_PROMPT=0 timeout 15 git -C "$repo" ls-remote origin refs/heads/stable 'refs/tags/v[0-9]*' 2>/dev/null)
|
||||
fi
|
||||
stable_sha=$(printf '%s\n' "$listed" | awk -F'\t' '$2 == "refs/heads/stable" { print $1; exit }')
|
||||
for v in $(printf '%s\n' "$listed" | awk -F'\t' -v s="$stable_sha" '
|
||||
index($2, "refs/tags/v") == 1 { n = substr($2, 11); p = 0
|
||||
if (substr(n, length(n) - 2) == "^{}") { n = substr(n, 1, length(n) - 3); p = 1 }
|
||||
if (p || !(n in at)) at[n] = $1 }
|
||||
END { for (n in at) if (s == "" || at[n] == s) print n }' | sed -n 's/^v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\(\.[0-9][0-9]*\)\{0,1\}\)$/\1/p'); do _dc_higher "$v"; done
|
||||
if [ -z "$best" ]; then
|
||||
for ref in refs/remotes/origin/stable refs/heads/stable; do
|
||||
git -C "$repo" rev-parse --verify --quiet "$ref^{commit}" >/dev/null 2>&1 || continue
|
||||
v=$(git -C "$repo" show "$ref:VERSION" 2>/dev/null | head -1 | clamp '0-9.' 32)
|
||||
case "$v" in [0-9]*.[0-9]*.[0-9]*) _dc_higher "$v" ;; esac
|
||||
break
|
||||
done
|
||||
fi
|
||||
if [ -z "$best" ]; then
|
||||
for v in $(git -C "$repo" tag --list 'v[0-9]*' 2>/dev/null | sed -n 's/^v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\(\.[0-9][0-9]*\)\{0,1\}\)$/\1/p'); do _dc_higher "$v"; done
|
||||
fi
|
||||
printf '%s' "$best"
|
||||
}
|
||||
|
||||
# _dc_wrapper_rev FILE — the GSTACK_CAPY_WRAPPER revision a Capy skill copy
|
||||
|
||||
@@ -258,6 +258,8 @@ gstack_result_anchor() {
|
||||
DX_CAPTURE_REDACTION_FAILED) printf '%s' 'dx-capture-redaction-failed' ;;
|
||||
DX_CAPTURE_INTERNAL) printf '%s' 'dx-capture-internal' ;;
|
||||
POLICY_GSTACK_TOO_OLD) printf '%s' 'policy-gstack-too-old' ;;
|
||||
POLICY_FLOOR_ABOVE_STABLE) printf '%s' 'policy-floor-above-stable' ;;
|
||||
STABLE_RELEASE_UNKNOWN) printf '%s' 'stable-release-unknown' ;;
|
||||
PREGATE_GSTACK_TOO_OLD) printf '%s' 'pregate-gstack-too-old' ;;
|
||||
QUEUE_NOT_READY) printf '%s' 'queue-not-ready' ;;
|
||||
QUEUE_PROOF_MISSING) printf '%s' 'queue-proof-missing' ;;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
minimumReader, policyUnavailableLine, renderEffective, resolveBaseBranch, type LoadedPolicy,
|
||||
} from '../lib/ship-policy';
|
||||
import { logShipCall } from '../lib/ship-call-log';
|
||||
import { floorAgainstStable, newestStableRelease } from '../lib/stable-release';
|
||||
|
||||
logShipCall('gstack-ship-policy');
|
||||
|
||||
@@ -89,14 +90,17 @@ function main(argv: string[]): number {
|
||||
if (cmd === 'validate' || cmd === 'show-effective') {
|
||||
const loaded = load(root, rest, cmd === 'validate' ? 'head' : 'base');
|
||||
if (typeof loaded === 'number') return loaded;
|
||||
const floor: ReturnType<typeof floorAgainstStable> = cmd === 'validate' && loaded.present ? floorAgainstStable(loaded.policy.min_gstack_version, newestStableRelease()) : {};
|
||||
if (floor.error) loaded.errors.push(floor.error);
|
||||
const set = POLICY_KEYS.filter(k => loaded.values[k] && loaded.values[k] !== 'default');
|
||||
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');
|
||||
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, ...(cmd === 'validate' ? { stable: floor.stable ?? null, warnings: floor.warning ? [floor.warning] : [] } : {}), errors: loaded.errors }) + '\n');
|
||||
return loaded.errors.length ? EXIT.fail : EXIT.ok;
|
||||
}
|
||||
printErrors(loaded);
|
||||
if (floor.warning) process.stderr.write(`gstack-ship-policy: WARN ${floor.warning}\n`);
|
||||
if (cmd === 'validate') {
|
||||
process.stdout.write(loaded.errors.length ? `POLICY_INVALID: ${loaded.errors.length} error(s) in ${loaded.source.label}\n` : `POLICY_VALID: ${loaded.present ? loaded.source.label : `no ${SHIP_POLICY_PATH} (defaults apply)`}\n`);
|
||||
for (const k of set) process.stdout.write(`KEY: ${k} introduced=${introduced[k]}\n`);
|
||||
|
||||
@@ -10,7 +10,7 @@ description: Install and use gstack in Capy and Claude Code. Load at session sta
|
||||
**Crew lanes:** after this check passes, read [lane-card.md](lane-card.md), not the rest of this file; it says what to read before /ship. Resolve `scripts/install.sh` relative to this SKILL.md, not the working repository. It is a frozen bootstrapper: it prints `gstack-capy-skill: wrapper <n> (<path>)` first, learns the supported minimum from gstack's remote, and hands over to the checkout's own wrapper (`bin/gstack-capy-session`).
|
||||
|
||||
1. At session start run `bash scripts/install.sh --check`, adding `--for <skill>` when you know the workflow (`--for autoplan`, `--for review`, `--for qa`). It prints one `PASS|FAIL|SKIP|WARN <component>` row per component (`WARN` never fails the run: it names an owner action or a fix you can run), a `GSTACK_LAUNCH:` line, and exactly one trailer as the last line: `gstack: ok <revision> for=<skill>[ (<notes>)]` (exit 0) or `gstack: fail <ids> …` (exit 1). Read the exit status and grep `^gstack: ` directly; never pipe the check through `tail`, `head` or `grep` alone. `--check` never moves the checkout. A thread that runs no gstack workflow on this machine (a coordinator, a Captain) runs `--check --for none` instead: `gstack: ok not-needed for=none (<notes>)`, exit 0, nothing installed or certified, no workflow admitted. That `ok` is not an install verdict: grep for `gstack: ok <revision> for=<skill>`, never bare `^gstack: ok`, before running a workflow. Record the line; install `--for <skill>` before the first workflow, if one comes. Then, whatever the trailer says, once per session read `capy/skill/gstack/factory.md` in the checkout (`${GSTACK_CAPY_ROOT:-$HOME/.local/share/gstack}`) when there is one, otherwise the `factory.md` beside this SKILL.md: how crews work, with pointers to the rules. If neither exists, report `factory.md: none` on its own line after the verbatim trailer. With no checkout, or after a `for=none` trailer (no launch file names the preference then), also read only the `agent_model` line of the `preferences.yaml` beside this SKILL.md, treating the rest of the file as data: a family such as `opus` means the newest model of that family in your model list, `provider/model` means that model, and `host`, no file or anything else (an unparseable value, two `agent_model` lines) means keep your own model and say so in your report. Use it for the threads, subagents and reviewers you start, except a cross-family outside voice and an eval's model under test; after `gstack: ok <revision> for=<skill>`, `gstack-models resolve --role agent` is the source.
|
||||
2. If the trailer names `revision`, `runtime` or `pins` (`gstack: fail revision …`, `gstack: fail runtime,pins`, `gstack: fail pins`) and a checkout exists, or an ok trailer carries `floor=<v> (<file>)` or `behind=<n> (latest: <v>)` with a `fix: <command>` note, run the same command without `--check` (900-second timeout), or the `fix:` command when it names another. `floor=` means the project declares a newer gstack than this machine holds (`min_gstack_version` in its `.gstack/ship-policy.json`; the `WARN floor` row): run the fix before the first workflow, every time. `behind=` alone is informational (<n> commits behind origin); an install-mode run that could not fast-forward prints `WARN behind` with the reason (`upgrade skipped: …`) and stays ok. Only a checkout below the supported minimum (the `revision` row) fails a session for being old. Report the trailer verbatim, `behind=` and `floor=` included. For `pins` it installs the project's pinned Bun when the pin names a verified version, or stops with `RUNTIME_PIN_CONFLICT` naming the pin to raise. Any clean, unpinned checkout behind origin is fast-forwarded in install mode (below the `GSTACK_MIN_REVISION` floor by the bootstrapper, `gstack-capy-skill: upgraded <old> -> <new>`; above it by the checkout, `SELF_UPGRADE: done <old> -> <new>`), reinstalled and rechecked in one locked step. A pinned (`--revision`, detached), dirty or diverged checkout, or one another live session holds a lease on (`UPGRADE_BUSY`), is never moved: the trailer names the cause and the exact commands (above the floor it stays ok with `WARN behind` after `SELF_UPGRADE: skipped (<reason>)`, carrying `UPGRADE_BUSY` or `UPGRADE_REFUSED`). A lease whose pid was reused after a wake is reclaimed on its own. `GSTACK_CAPY_AUTO_UPGRADE=0` disables every fast-forward, `GSTACK_CAPY_SELF_UPGRADE=off` only the above-floor one.
|
||||
2. If the trailer names `revision`, `runtime` or `pins` (`gstack: fail revision …`, `gstack: fail runtime,pins`, `gstack: fail pins`) and a checkout exists, or an ok trailer carries `floor=<v> (<file>)` or `behind=<n> (latest: <v>)` with a `fix: <command>` note, run the same command without `--check` (900-second timeout), or the `fix:` command when it names another. `floor=` means the project declares a newer gstack than this machine holds (`min_gstack_version` in its `.gstack/ship-policy.json`; the `WARN floor` row): run the fix once before the first workflow; a note saying `no release meets this floor yet` has no fix: report it. `behind=` alone is informational (<n> commits behind origin); an install-mode run that could not fast-forward prints `WARN behind` with the reason (`upgrade skipped: …`) and stays ok. Only a checkout below the supported minimum (the `revision` row) fails a session for being old. Report the trailer verbatim, `behind=` and `floor=` included. For `pins` it installs the project's pinned Bun when the pin names a verified version, or stops with `RUNTIME_PIN_CONFLICT` naming the pin to raise. Any clean, unpinned checkout behind origin is fast-forwarded in install mode (below the `GSTACK_MIN_REVISION` floor by the bootstrapper, `gstack-capy-skill: upgraded <old> -> <new>`; above it by the checkout, `SELF_UPGRADE: done <old> -> <new>`), reinstalled and rechecked in one locked step. A pinned (`--revision`, detached), dirty or diverged checkout, or one another live session holds a lease on (`UPGRADE_BUSY`), is never moved: the trailer names the cause and the exact commands (above the floor it stays ok with `WARN behind` after `SELF_UPGRADE: skipped (<reason>)`, carrying `UPGRADE_BUSY` or `UPGRADE_REFUSED`). A lease whose pid was reused after a wake is reclaimed on its own. `GSTACK_CAPY_AUTO_UPGRADE=0` disables every fast-forward, `GSTACK_CAPY_SELF_UPGRADE=off` only the above-floor one.
|
||||
3. If the trailer is `gstack: fail not-installed …` and this session runs no gstack workflow, record that line and continue; nothing is installed. Install right before the first workflow with `bash scripts/install.sh --for <skill>` (about 30 s cold: clone, a SHA-256-verified Bun when none installed fits, both host profiles without Chromium, and a `~/.local/bin/bun` shim so fresh shells get the recorded Bun).
|
||||
4. Any other `FAIL` row carries a `fix:` command and a reason code with a troubleshooting anchor; run the fix, then `--check` again. `SKIP codex-cli (optional; …)`, `SKIP browser (lazy; …)`, `SKIP pins (planning-only; …)`, `SKIP pins (helper runtime; …)` and `SKIP floor (…)` are not failures. `WARN pins … (node pin only; bun pins pass)` is not one either: the Node on PATH misses the project's pin while Bun is right; run its fix only when this machine will run the project's tests.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ For a crew lane (a thread working for a Captain). Read this once `gstack: ok <re
|
||||
|
||||
## After the check
|
||||
|
||||
- Report the trailer verbatim in your first report, `floor=` and `behind=` notes included. A `floor=` note or a `fix:` command runs before the first workflow (§ "Session start: check, then install only when needed", step 2).
|
||||
- Report the trailer verbatim in your first report, `floor=` and `behind=` notes included. A `floor=` note or a `fix:` command runs once before the first workflow (§ "Session start: check, then install only when needed", step 2); a `floor=` note that says `no release meets this floor yet` runs nothing and goes to your Captain.
|
||||
- Read `capy/skill/gstack/factory.md` in the checkout once per session.
|
||||
- Start every shell that runs a gstack command with `. <GSTACK_LAUNCH_FILE>`. Never re-type the assignments as `export $(...)`, and never run `env`, `printenv`, a bare `set` or a bare `export`: they print API keys into the transcript. Hand a child agent the file path, never the values.
|
||||
- Every subagent runs its own `--check` and puts its trailer in its first report; a read-only reviewer uses `--for review-voice`. One machine's install never covers another.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
- **A ship-policy floor above the newest stable gstack release is refused by name, and the doctor stops sending sessions into a fix that cannot work.** `gstack-ship-policy validate`, and the new pregate `policy-floor` check on a branch that changes `.gstack/ship-policy.json`, refuse a `min_gstack_version` above the newest stable gstack release (`POLICY_FLOOR_ABOVE_STABLE`, naming the floor and the release), because every stable-channel lane would refuse that policy and no upgrade could clear it. The release is the highest `v<version>` tag at origin's `stable` commit (a tag a half-way cut left above stable does not count), else the VERSION at the local stable ref; a vendored copy with no `.git` of its own never reads the enclosing project's tags; when none resolves, both warn (`STABLE_RELEASE_UNKNOWN`) and say the floor was not compared. The doctor's `floor` row now compares the version the checkout reads policies as (`runningGstackVersion`, as `validatePolicy` does), so an unstamped main checkout that already reads the floor's keys passes instead of warning forever, and when no stable release meets the floor it says `no release meets this floor yet` and offers no fast-forward; the trailer note becomes `floor=<f> (<file>; no release meets this floor yet)` with no `fix:`. The gstack skill now runs a floor fix once and reports a floor no release meets instead of rerunning the install before every workflow. (GST-1637)
|
||||
+12
-4
@@ -148,9 +148,15 @@ there is refused with the usual `--for <skill>` fix.
|
||||
floor <f> that <file> declares (min_gstack_version); fix: bash
|
||||
<skill>/scripts/install.sh --for <skill> (fast-forwards ...), or
|
||||
bin/gstack-upgrade (PROJECT_FLOOR_UNMET)`. The trailer stays ok and carries
|
||||
`floor=<f> (<file>)` and `fix: <command>`; the lane runs the fix before its
|
||||
first workflow. Raising the floor is one commit in the project instead of a
|
||||
`floor=<f> (<file>)` and `fix: <command>`; the lane runs the fix once before
|
||||
its first workflow. Raising the floor is one commit in the project instead of a
|
||||
message to every thread ([PROJECT_FLOOR_UNMET](troubleshooting.md#project-floor-unmet)).
|
||||
The row compares the version the checkout reads policies as (VERSION, or the
|
||||
newest policy key it knows when later; `validatePolicy`'s
|
||||
`runningGstackVersion`). When the floor is above the newest stable gstack
|
||||
release too, the row says `no release meets this floor yet` and offers no
|
||||
fast-forward, and the note is `floor=<f> (<file>; no release meets this floor
|
||||
yet)` with no `fix:`; the lane runs nothing and reports it.
|
||||
6. The project's Node pin. When only the Node pin is unmet (the image's Node
|
||||
behind the repo's pin while every Bun pin is met), `pins` is `WARN pins —
|
||||
... (node pin only; bun pins pass); fix: ... (PROJECT_NODE_PIN_UNMET)`, not a
|
||||
@@ -419,8 +425,10 @@ with every bootstrapper revision), optionally followed by `upgrade skipped:
|
||||
<reason> (<CODE>)` when an install-mode fast-forward was refused, or `latest:
|
||||
<ver> available` (origin's default branch has a newer VERSION; the bootstrapper
|
||||
exports it as `GSTACK_CAPY_REMOTE_VERSION` only after a fetch that succeeded
|
||||
in this run); then `floor=<f> (<file>)` when the project's floor is unmet; and
|
||||
last exactly one `fix: <command>` whenever `behind=` or `floor=` is present.
|
||||
in this run); then `floor=<f> (<file>)` when the project's floor is unmet
|
||||
(`floor=<f> (<file>; no release meets this floor yet)` when no stable release
|
||||
meets it either, with no fix of its own); and last exactly one `fix: <command>`
|
||||
whenever `behind=` or a fixable `floor=` is present.
|
||||
A fail trailer never carries notes. `gstack: ok not-needed for=none (...)` is
|
||||
the `--for none` outcome: no revision, nothing certified, no workflow admitted. Match `^gstack: ok ` or
|
||||
`^gstack: fail `; never parse past the first space after the version.
|
||||
|
||||
@@ -264,6 +264,13 @@ Two stages with separate clocks, both printed:
|
||||
this branch` when the merge-base file lacks the marker. The policy has no
|
||||
waiver key, so neither a base nor a pushed `pregate.json` can widen it.
|
||||
|
||||
- `policy-floor`: on a branch that changes `.gstack/ship-policy.json`, a
|
||||
`min_gstack_version` above the newest stable gstack release fails
|
||||
(`POLICY_FLOOR_ABOVE_STABLE`, naming the floor and the release), since every
|
||||
stable-channel lane would refuse that policy; when the release cannot be
|
||||
resolved (no release tag on gstack's origin, no local stable ref or tag) it
|
||||
warns (`STABLE_RELEASE_UNKNOWN`). The same rule as `gstack-ship-policy
|
||||
validate` ([ship-policy.md](ship-policy.md)).
|
||||
- `timing`: added or changed test lines that compare an elapsed wall-clock or
|
||||
CPU time against a bound, or sleep and then assert, print
|
||||
`TIMING_ASSERTION: <file>:<line> <pattern>` and a
|
||||
|
||||
@@ -36,6 +36,15 @@ than 1.91.98.0 set to a non-default value requires `min_gstack_version` at
|
||||
least at its introducing version, and a gstack older than `min_gstack_version`
|
||||
refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with
|
||||
`MIN_READER: gstack <version>`, the floor every lane reading the policy needs.
|
||||
`validate` also refuses a `min_gstack_version` above the newest stable gstack
|
||||
release (`POLICY_FLOOR_ABOVE_STABLE`, naming both versions): every
|
||||
stable-channel lane would refuse that policy until a later release is cut. The
|
||||
release is the highest `v<version>` tag at the commit gstack's origin `stable`
|
||||
branch points to (a tag a half-way cut left above stable does not count), else
|
||||
the VERSION at the local stable ref, else the highest local release tag
|
||||
(`GSTACK_STABLE_REPO` names another gstack checkout); when none resolves it warns (`STABLE_RELEASE_UNKNOWN`) and says the
|
||||
floor was not compared. The pregate `policy-floor` check does the same on a
|
||||
branch that changes the policy.
|
||||
|
||||
| Key | Values | Default | Meaning |
|
||||
|---|---|---|---|
|
||||
|
||||
+43
-3
@@ -1706,11 +1706,23 @@ the row is a WARN and the trailer stays `gstack: ok` with a `floor=<f>
|
||||
(<file>)` note and a `fix:` note; only the supported minimum
|
||||
(`REVISION_BELOW_MINIMUM`) fails a session for being old.
|
||||
|
||||
**Fix.** Run the fix before the first workflow: on Capy, `bash
|
||||
The row compares the version this checkout reads policies as, the same one
|
||||
`validatePolicy` uses: VERSION, or the newest policy key the checkout knows
|
||||
when that is later, so an unstamped main checkout that already reads the
|
||||
floor's keys passes (`installed <v>, reads policies as <r>`).
|
||||
|
||||
When the floor is also above the newest stable gstack release, the row says
|
||||
`and no release meets this floor yet: the newest stable gstack is <s>` and
|
||||
offers no fast-forward, and the trailer note is `floor=<f> (<file>; no release
|
||||
meets this floor yet)` with no `fix:`: upgrading cannot clear it.
|
||||
|
||||
**Fix.** Run the fix once before the first workflow: on Capy, `bash
|
||||
<skill>/scripts/install.sh --for <skill>` fast-forwards a clean checkout to
|
||||
origin and reinstalls in one step (`SELF_UPGRADE: done`); elsewhere
|
||||
`bin/gstack-upgrade`. If the floor is above gstack's latest release, lower it
|
||||
in the project.
|
||||
`bin/gstack-upgrade`. If the row says no release meets the floor, run nothing:
|
||||
the project lowers `min_gstack_version` to the stable release named
|
||||
(`gstack-ship-policy validate` refuses a floor above stable,
|
||||
[POLICY_FLOOR_ABOVE_STABLE](#policy-floor-above-stable)).
|
||||
|
||||
<a id="project-node-pin-unmet"></a>
|
||||
### `WARN pins — node <v> outside <pin> (<file>); node read from <path> (node pin only; bun pins pass); fix: ... (PROJECT_NODE_PIN_UNMET)`
|
||||
@@ -5160,6 +5172,34 @@ gstack would read differently or not at all, so every ship-policy reader
|
||||
raising `min_gstack_version` raises the crew's install floor at the same
|
||||
time; `gstack-ship-policy validate` prints `MIN_READER:` for exactly that.
|
||||
|
||||
<a id="policy-floor-above-stable"></a>
|
||||
### `POLICY_FLOOR_ABOVE_STABLE`
|
||||
|
||||
**Meaning.** `.gstack/ship-policy.json` sets `min_gstack_version` above the
|
||||
newest stable gstack release (the highest `v<version>` release tag at
|
||||
origin's `stable` commit, or the VERSION at the stable ref). Every lane on the stable update channel runs that
|
||||
release, so each one refuses the policy (`POLICY_GSTACK_TOO_OLD`), and
|
||||
upgrading cannot help until a release at or above the floor is cut; the
|
||||
doctor's floor row says `no release meets this floor yet`.
|
||||
`gstack-ship-policy validate` and the pregate `policy-floor` check (on a
|
||||
branch that changes the policy) refuse it, naming the floor and the release.
|
||||
|
||||
**Fix.** Set `min_gstack_version` to the stable release the message names, or
|
||||
lower, and leave out the keys that need a newer gstack until a stable release
|
||||
carries them. Then rerun `gstack-ship-policy validate`.
|
||||
|
||||
<a id="stable-release-unknown"></a>
|
||||
### `STABLE_RELEASE_UNKNOWN`
|
||||
|
||||
**Meaning.** A warning, never a refusal: `gstack-ship-policy validate` or the
|
||||
pregate `policy-floor` check could not resolve the newest stable gstack
|
||||
release, so the policy's `min_gstack_version` was not compared with it. The
|
||||
release comes from the gstack checkout's origin (its release tags), or, when
|
||||
origin cannot be reached, from its local stable ref and release tags.
|
||||
|
||||
**Fix.** Run `git fetch origin --tags` in the gstack checkout, or point
|
||||
`GSTACK_STABLE_REPO` at a gstack checkout that has the tags, then rerun.
|
||||
|
||||
<a id="queue-not-ready"></a>
|
||||
### `QUEUE_NOT_READY`
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* pregate check `policy-floor` (tier 1, preflight; GST-1637): on a branch that changes .gstack/ship-policy.json, the
|
||||
* policy's min_gstack_version may not be above the newest stable gstack release, which would break every
|
||||
* stable-channel lane (lib/stable-release.ts). When that release cannot be resolved the check warns and says so.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { SHIP_POLICY_PATH } from '../../ship-policy';
|
||||
import { floorAgainstStable, newestStableRelease } from '../../stable-release';
|
||||
import { baseInputs } from '../context';
|
||||
import type { CheckDef, CheckResult, PregateContext } from '../types';
|
||||
|
||||
function run(ctx: PregateContext): CheckResult {
|
||||
const inputs = baseInputs(ctx);
|
||||
const base = { id: 'policy-floor', stage: 'preflight' as const, inputs };
|
||||
if (!ctx.touched.some(t => t.path === SHIP_POLICY_PATH && t.status !== 'D')) return { ...base, status: 'pass', detail: `${SHIP_POLICY_PATH} unchanged` };
|
||||
let floor: unknown;
|
||||
try { floor = (JSON.parse(fs.readFileSync(path.join(ctx.repoRoot, SHIP_POLICY_PATH), 'utf8')) as Record<string, unknown> | null)?.min_gstack_version; }
|
||||
catch { return { ...base, status: 'pass', detail: `${SHIP_POLICY_PATH} does not parse; gstack-ship-policy validate names why` }; }
|
||||
const verdict = floorAgainstStable(floor, newestStableRelease(ctx.env));
|
||||
if (verdict.error) return { ...base, status: 'fail', code: 'POLICY_FLOOR_ABOVE_STABLE', detail: verdict.error.message, fix: `set min_gstack_version in ${SHIP_POLICY_PATH} to ${verdict.stable} or lower, then gstack-ship-policy validate` };
|
||||
if (verdict.warning) return { ...base, status: 'warn', code: 'STABLE_RELEASE_UNKNOWN', detail: verdict.warning, fix: 'git fetch origin --tags in the gstack checkout, or set GSTACK_STABLE_REPO to one that has the release tags, then rerun' };
|
||||
return { ...base, status: 'pass', detail: verdict.stable ? `min_gstack_version ${floor as string} is at or below the newest stable gstack release ${verdict.stable}` : `${SHIP_POLICY_PATH} sets no min_gstack_version` };
|
||||
}
|
||||
|
||||
export const policyFloorCheck: CheckDef = { id: 'policy-floor', stage: 'preflight', tier: 1, downgradable: true, needsRepoCommands: false, run };
|
||||
@@ -21,6 +21,7 @@ import { guardsCheck } from './checks/guards';
|
||||
import { findCarry, importedTestLines } from '../ci-carry';
|
||||
import { clearHint, fromRunProfiles, lanesCheck, parseRemoteReport, REMOTE_GATE_LANE, remoteReportProblem, treeMismatch } from './checks/lanes';
|
||||
import { literalsCheck } from './checks/literals';
|
||||
import { policyFloorCheck } from './checks/policy-floor';
|
||||
import { regenCheck } from './checks/regen';
|
||||
import { secretsCheck } from './checks/secrets';
|
||||
import { straysCheck } from './checks/strays';
|
||||
@@ -35,7 +36,7 @@ import { parseRunReceipt, parseRunRef, runChanged, runReceiptFields, uncoveredFi
|
||||
import type { CheckDef, CheckResult, PregateContext, Stage } from './types';
|
||||
import { displayNamePattern, workflowCallers } from './workflows';
|
||||
|
||||
export const CHECKS: readonly CheckDef[] = [regenCheck, secretsCheck, straysCheck, literalsCheck, timingCheck, guardsCheck, lanesCheck];
|
||||
export const CHECKS: readonly CheckDef[] = [regenCheck, secretsCheck, straysCheck, literalsCheck, policyFloorCheck, timingCheck, guardsCheck, lanesCheck];
|
||||
export const CHECK_IDS = CHECKS.map(c => c.id);
|
||||
export const PREGATE_OUT_REL = '.gstack/tmp/pregate.json';
|
||||
/** Where `--stage guards` (the pre-push housekeeping guards) writes by default, so it never replaces the full run's file. */
|
||||
|
||||
@@ -16,6 +16,16 @@ export const RELEASE_RESULT_CODES = {
|
||||
summary: 'the ship policy sets min_gstack_version above this gstack, so this gstack refuses to read it',
|
||||
fix: 'upgrade gstack (bin/gstack-upgrade, or the session install with --for <skill>) to the version named, then rerun',
|
||||
},
|
||||
POLICY_FLOOR_ABOVE_STABLE: {
|
||||
anchor: 'policy-floor-above-stable',
|
||||
summary: 'the ship policy sets min_gstack_version above the newest stable gstack release, so every stable-channel lane refuses it and no upgrade can clear that until a later release is cut',
|
||||
fix: 'set min_gstack_version to the stable release named (or lower) and drop the keys that need more, then rerun gstack-ship-policy validate',
|
||||
},
|
||||
STABLE_RELEASE_UNKNOWN: {
|
||||
anchor: 'stable-release-unknown',
|
||||
summary: 'the newest stable gstack release could not be resolved (no release tag on origin, no stable ref, no local release tag), so min_gstack_version was not compared with it; a warning, not a refusal',
|
||||
fix: 'git fetch origin --tags in the gstack checkout (or set GSTACK_STABLE_REPO to one that has them), then rerun',
|
||||
},
|
||||
PREGATE_GSTACK_TOO_OLD: {
|
||||
anchor: 'pregate-gstack-too-old',
|
||||
summary: 'the pregate policy uses a key this gstack does not know, and the project floor (min_gstack_version in .gstack/ship-policy.json) is above this gstack, so the key belongs to a newer gstack; the policy stays refused',
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* stable-release — the newest stable gstack release, and the check that a ship policy's min_gstack_version floor is
|
||||
* not above it (GST-1637). A floor above every stable release breaks every stable-channel lane: each one refuses the
|
||||
* policy (POLICY_GSTACK_TOO_OLD) and no upgrade can clear it until a later release is cut.
|
||||
*
|
||||
* A release is an annotated v<VERSION> tag (docs/releases.md), and stable only moves to a tagged release, but a tag
|
||||
* can exist before stable reaches it (a cut that stopped half-way), so the newest one is the highest release tag at
|
||||
* origin's stable commit (one bounded ls-remote; the highest tag origin lists when it has no stable branch), or, when
|
||||
* origin cannot be reached, the VERSION at the stable ref (origin/stable, then stable), else the highest local release
|
||||
* tag. The repo is
|
||||
* this gstack's own checkout; GSTACK_STABLE_REPO names another one (a fixture, or a checkout with fresher refs). A root
|
||||
* that is not the top of its own git checkout (a vendored copy with no .git inside a project) resolves nothing, so the
|
||||
* project's own release tags never stand in for gstack's.
|
||||
* bin/gstack-doctor-check.sh resolves it the same way in Bash for its floor row.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { cmpVersion, parseVersion } from './version-source';
|
||||
import type { PolicyError } from './ship-policy';
|
||||
|
||||
export type StableRelease = { version: string; source: string } | { version: null; why: string };
|
||||
|
||||
const higher = (a: string | null, b: string | null): string | null => (!a ? b : !b ? a : cmpVersion(parseVersion(a)!, parseVersion(b)!) >= 0 ? a : b);
|
||||
const highestTag = (names: string[]): string | null => names.map(n => n.trim().replace(/^.*refs\/tags\//, '').replace(/^v/, '')).filter(v => parseVersion(v)).reduce<string | null>(higher, null);
|
||||
|
||||
export function newestStableRelease(env: NodeJS.ProcessEnv = process.env): StableRelease {
|
||||
const repo = env.GSTACK_STABLE_REPO || path.resolve(import.meta.dir, '..');
|
||||
const git = (args: string[], timeout = 15_000) => {
|
||||
const r = spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8', timeout, env: { ...env, GIT_TERMINAL_PROMPT: '0' } });
|
||||
return r.status === 0 && !r.error ? (r.stdout ?? '') : null;
|
||||
};
|
||||
const real = (p: string) => { try { return fs.realpathSync(p); } catch { return null; } };
|
||||
const top = git(['rev-parse', '--show-toplevel'])?.trim();
|
||||
if (!top || real(top) !== real(repo)) return { version: null, why: `${repo} is not a gstack checkout of its own (${top ? `git resolves it to ${top}` : 'no git repository'})` };
|
||||
const listed = git(['ls-remote', 'origin', 'refs/heads/stable', 'refs/tags/v[0-9]*']);
|
||||
const rows = (listed ?? '').split('\n').filter(Boolean).map(l => l.split('\t'));
|
||||
const stableSha = rows.find(([, r]) => r === 'refs/heads/stable')?.[0];
|
||||
const tagAt = new Map<string, string>();
|
||||
for (const [sha, r] of rows) {
|
||||
const m = /^refs\/tags\/(v.+?)(\^\{\})?$/.exec(r ?? '');
|
||||
if (m && (m[2] || !tagAt.has(m[1]))) tagAt.set(m[1], sha);
|
||||
}
|
||||
const remote = highestTag([...tagAt].filter(([, sha]) => !stableSha || sha === stableSha).map(([name]) => name));
|
||||
if (remote) return { version: remote, source: stableSha ? `release tag v${remote} at origin's stable` : `release tag v${remote} on origin` };
|
||||
const origin = listed === null ? 'unreachable' : stableSha ? 'lists no release tag at its stable' : 'lists no release tag';
|
||||
const ref = ['refs/remotes/origin/stable', 'refs/heads/stable'].find(r => git(['rev-parse', '--verify', '--quiet', `${r}^{commit}`]) !== null);
|
||||
const atRef = ref ? (git(['show', `${ref}:VERSION`]) ?? '').trim() : '';
|
||||
if (parseVersion(atRef)) return { version: atRef, source: `${ref!.replace(/^refs\/(remotes\/|heads\/)/, '')} VERSION (local; origin ${origin})` };
|
||||
const tag = highestTag((git(['tag', '--list', 'v[0-9]*']) ?? '').split('\n').filter(Boolean));
|
||||
if (!tag) return { version: null, why: `${repo} has no release tag on origin (${listed === null ? 'unreachable' : 'none listed'}), no stable ref and no local v<version> tag` };
|
||||
return { version: tag, source: `release tag v${tag} (local; origin ${origin})` };
|
||||
}
|
||||
|
||||
/**
|
||||
* The floor against the newest stable release: an error naming both when the floor is above it, a warning when the
|
||||
* release could not be resolved (the floor is then not compared), or nothing.
|
||||
*/
|
||||
export function floorAgainstStable(floor: unknown, release: StableRelease): { error?: PolicyError; warning?: string; stable?: string } {
|
||||
if (typeof floor !== 'string' || !parseVersion(floor)) return {};
|
||||
if (release.version === null) return { warning: `could not resolve the newest stable gstack release (${release.why}), so min_gstack_version ${floor} was not compared with it; a floor above stable breaks every stable-channel lane (STABLE_RELEASE_UNKNOWN)` };
|
||||
if (cmpVersion(parseVersion(floor)!, parseVersion(release.version)!) <= 0) return { stable: release.version };
|
||||
return {
|
||||
stable: release.version,
|
||||
error: { code: 'POLICY_FLOOR_ABOVE_STABLE', path: 'min_gstack_version', message: `min_gstack_version ${floor} is above the newest stable gstack release ${release.version} (${release.source}), so every stable-channel lane refuses this policy and no upgrade clears it until a release at or above ${floor} is cut; set min_gstack_version to ${release.version} or lower, without the keys that need more` },
|
||||
};
|
||||
}
|
||||
@@ -54,6 +54,12 @@ process.env.GSTACK_TEST_LEGACY_ROOT = path.join(os.tmpdir(), `gstack-test-legacy
|
||||
// runner's lease would make `bin/gstack-upgrade` here refuse UPGRADE_BUSY.
|
||||
// Every spawned bin inherits a per-process scratch lease directory instead.
|
||||
process.env.GSTACK_LEASE_DIR ??= path.join(os.tmpdir(), `gstack-test-leases-${process.pid}`);
|
||||
// The newest stable gstack release (lib/stable-release.ts) is read from this
|
||||
// checkout's origin, so a fixture policy's floor would be judged against
|
||||
// whatever gstack last released (GST-1637). Point every spawned bin at a path
|
||||
// that is never created: the release is unknown and the policy-floor check
|
||||
// warns. Tests of that check name their own fixture repo.
|
||||
process.env.GSTACK_STABLE_REPO ??= path.join(os.tmpdir(), `gstack-test-no-stable-repo-${process.pid}-unused`);
|
||||
|
||||
// Narrowly restore PATH after every test. Defends against the recurring
|
||||
// pollution class where one test sets `process.env.PATH = '/test/bin:/usr/bin'`
|
||||
|
||||
@@ -338,6 +338,17 @@ describe('gstack-capy-install', () => {
|
||||
expect(lastLine(run(f, [], { STUB_DOCTOR_EXTRA: row, STUB_DOCTOR_FAIL: '1' }).stdout)).toBe('gstack: fail codex');
|
||||
});
|
||||
|
||||
// Value: protects=sessions from rerunning an install that cannot clear the floor (GST-1637); fails_when=the trailer offers the fast-forward when no release meets the floor; seam=bin/gstack-capy-install; no_claim=-
|
||||
test('a WARN floor row saying no release meets the floor adds the floor note without a fix; behind keeps its own fix', () => {
|
||||
const f = fixture();
|
||||
const row = 'WARN floor — installed 1.2.3 is below the floor 1.2.9 that /p/.gstack/ship-policy.json declares (min_gstack_version), and no release meets this floor yet: the newest stable gstack is 1.2.5, so upgrading or rerunning the install cannot clear this row; fix: none here; the project lowers min_gstack_version to 1.2.5 or below (gstack-ship-policy validate refuses a floor above stable) (PROJECT_FLOOR_UNMET; d)';
|
||||
const alone = run(f, ['--for', 'autoplan'], { STUB_DOCTOR_EXTRA: row, GSTACK_CAPY_WRAPPER_PATH: '/s/install.sh' });
|
||||
expect(alone.status, alone.stdout + alone.stderr).toBe(0);
|
||||
expect(lastLine(alone.stdout)).toBe('gstack: ok 1.2.3 for=autoplan (floor=1.2.9 (/p/.gstack/ship-policy.json; no release meets this floor yet))');
|
||||
const behind = run(f, [], { STUB_DOCTOR_EXTRA: row, GSTACK_CAPY_BEHIND: '5', GSTACK_CAPY_BEHIND_TO: '1.2.9', GSTACK_CAPY_BEHIND_FIX: 'bash /s/install.sh --for autoplan' });
|
||||
expect(lastLine(behind.stdout)).toBe('gstack: ok 1.2.3 for=autoplan (behind=5 (latest: 1.2.9); floor=1.2.9 (/p/.gstack/ship-policy.json; no release meets this floor yet); fix: bash /s/install.sh --for autoplan)');
|
||||
});
|
||||
|
||||
test('--for none certifies nothing and installs nothing: ok not-needed, exit 0, no setup, no verdict, with or without a checkout', () => {
|
||||
const f = fixture();
|
||||
const r = run(f, ['--for', 'none']);
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* GST-1637: a ship-policy floor above the newest stable gstack release breaks every stable-channel lane.
|
||||
* gstack-ship-policy validate and the pregate policy-floor check refuse it by name; the doctor's floor row
|
||||
* compares runningGstackVersion (as validatePolicy does) and offers no fast-forward when no release meets it.
|
||||
* The stable release comes from fixture repos named by GSTACK_STABLE_REPO, never from the network.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { runningGstackVersion, validatePolicy } from '../lib/ship-policy';
|
||||
import { useDoctorCleanup, REPO, makeFixture, doctor, write as writeFile } from './helpers/doctor-fixture';
|
||||
import { cleanup as cleanupPregate, commit, makeFixtureRepo as makePregateRepo, runBin as runPregateBin, type FixtureRepo as PregateRepo } from './helpers/pregate-fixture';
|
||||
import { cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
|
||||
useDoctorCleanup();
|
||||
const dirs: string[] = [];
|
||||
const repos: FixtureRepo[] = [];
|
||||
const pregateRepos: PregateRepo[] = [];
|
||||
afterEach(() => {
|
||||
while (repos.length) cleanup(repos.pop()!);
|
||||
while (pregateRepos.length) cleanupPregate(pregateRepos.pop()!);
|
||||
while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const GIT_ENV = { ...process.env, GIT_AUTHOR_NAME: 'fx', GIT_AUTHOR_EMAIL: 'fx@example.com', GIT_COMMITTER_NAME: 'fx', GIT_COMMITTER_EMAIL: 'fx@example.com', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' };
|
||||
const git = (cwd: string, ...args: string[]) => {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, env: GIT_ENV });
|
||||
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
|
||||
return r.stdout.trim();
|
||||
};
|
||||
const tmp = () => { const d = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-stable-fx-'))); dirs.push(d); return d; };
|
||||
|
||||
/** A gstack stand-in: release tags pushed to its origin (remote), or a stable branch with no origin (local). */
|
||||
function stableRepo(version: string, how: 'remote' | 'local'): string {
|
||||
const base = tmp(), work = path.join(base, 'gstack');
|
||||
fs.mkdirSync(work);
|
||||
git(work, 'init', '-q', '-b', 'main');
|
||||
fs.writeFileSync(path.join(work, 'VERSION'), `${version}\n`);
|
||||
git(work, 'add', 'VERSION');
|
||||
git(work, 'commit', '-q', '-m', 'release');
|
||||
if (how === 'local') { git(work, 'branch', 'stable'); return work; }
|
||||
git(base, 'init', '-q', '--bare', 'origin.git');
|
||||
git(work, 'tag', '-a', `v${version}`, '-m', `v${version}`);
|
||||
git(work, 'tag', '-a', 'v0.0.1', '-m', 'v0.0.1');
|
||||
git(work, 'remote', 'add', 'origin', path.join(base, 'origin.git'));
|
||||
git(work, 'push', '-q', 'origin', 'main', '--tags');
|
||||
git(work, 'tag', '-d', `v${version}`);
|
||||
return work;
|
||||
}
|
||||
|
||||
/** A cut that stopped half-way: stable at v<stable>, and a v<tagged> release tag on a later commit stable never reached. */
|
||||
function halfCutRepo(stable: string, tagged: string, how: 'remote' | 'local'): string {
|
||||
const base = tmp(), work = path.join(base, 'gstack');
|
||||
fs.mkdirSync(work);
|
||||
git(work, 'init', '-q', '-b', 'main');
|
||||
for (const v of [stable, tagged]) {
|
||||
fs.writeFileSync(path.join(work, 'VERSION'), `${v}\n`);
|
||||
git(work, 'add', 'VERSION');
|
||||
git(work, 'commit', '-q', '-m', `v${v}`);
|
||||
git(work, 'tag', '-a', `v${v}`, '-m', `v${v}`);
|
||||
if (v === stable) git(work, 'branch', 'stable');
|
||||
}
|
||||
if (how === 'local') return work;
|
||||
git(base, 'init', '-q', '--bare', 'origin.git');
|
||||
git(work, 'remote', 'add', 'origin', path.join(base, 'origin.git'));
|
||||
git(work, 'push', '-q', 'origin', 'main', 'stable', '--tags');
|
||||
return work;
|
||||
}
|
||||
|
||||
/** A vendored gstack copy: a directory with no .git inside a project repo whose own release tag is v1.2.0.0. */
|
||||
function vendoredRoot(): string {
|
||||
const app = tmp();
|
||||
git(app, 'init', '-q', '-b', 'main');
|
||||
git(app, 'commit', '-q', '--allow-empty', '-m', 'app');
|
||||
git(app, 'tag', '-a', 'v1.2.0.0', '-m', 'v1.2.0.0');
|
||||
const vendored = path.join(app, '.claude', 'skills', 'gstack');
|
||||
fs.mkdirSync(vendored, { recursive: true });
|
||||
return vendored;
|
||||
}
|
||||
|
||||
const FLOOR = runningGstackVersion();
|
||||
const BELOW = '1.0.0.0';
|
||||
const policyRepo = (floor: string) => {
|
||||
const repo = makeFixtureRepo({ policy: null });
|
||||
repos.push(repo);
|
||||
fs.mkdirSync(path.join(repo.work, '.gstack'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo.work, '.gstack', 'ship-policy.json'), JSON.stringify({ min_gstack_version: floor }) + '\n');
|
||||
return repo;
|
||||
};
|
||||
|
||||
describe('gstack-ship-policy validate: min_gstack_version against the newest stable release', () => {
|
||||
// Value: protects=stable-channel lanes from a policy floor no stable release meets; fails_when=validate passes a floor above stable and every stable lane refuses the policy (POLICY_GSTACK_TOO_OLD) with no upgrade that clears it;
|
||||
// why_new=GST-1637 (FOUN-32, founderstar#626); seam=bin/gstack-ship-policy; no_claim=the network path (fixtures use a local origin)
|
||||
test('a floor above the newest stable release tag is refused, naming the floor and the stable version', () => {
|
||||
expect(validatePolicy({ min_gstack_version: FLOOR })).toEqual([]);
|
||||
const repo = policyRepo(FLOOR);
|
||||
const r = runBin(repo, 'gstack-ship-policy', ['validate'], { GSTACK_STABLE_REPO: stableRepo(BELOW, 'remote') });
|
||||
expect(r.status, r.out + r.err).toBe(EXIT.fail);
|
||||
expect(r.err).toContain(`min_gstack_version ${FLOOR} is above the newest stable gstack release ${BELOW} (release tag v${BELOW} on origin)`);
|
||||
expect(r.err).toContain('(POLICY_FLOOR_ABOVE_STABLE)');
|
||||
expect(r.out).toMatch(/^POLICY_INVALID: 1 error\(s\)/);
|
||||
const j = JSON.parse(runBin(repo, 'gstack-ship-policy', ['validate', '--json'], { GSTACK_STABLE_REPO: stableRepo(BELOW, 'local') }).out);
|
||||
expect([j.valid, j.stable, j.errors.map((e: { code: string; path: string }) => `${e.code}:${e.path}`)]).toEqual([false, BELOW, ['POLICY_FLOOR_ABOVE_STABLE:min_gstack_version']]);
|
||||
expect(j.errors[0].message).toContain('(stable VERSION (local; origin unreachable))');
|
||||
});
|
||||
|
||||
test('a floor at the newest stable release passes', () => {
|
||||
const repo = policyRepo(FLOOR);
|
||||
const r = runBin(repo, 'gstack-ship-policy', ['validate'], { GSTACK_STABLE_REPO: stableRepo(FLOOR, 'remote') });
|
||||
expect(r.status, r.out + r.err).toBe(EXIT.ok);
|
||||
expect(r.out).toMatch(/^POLICY_VALID: /);
|
||||
expect(r.err).toBe('');
|
||||
expect(JSON.parse(runBin(repo, 'gstack-ship-policy', ['validate', '--json'], { GSTACK_STABLE_REPO: stableRepo(FLOOR, 'local') }).out)).toMatchObject({ valid: true, stable: FLOOR, warnings: [] });
|
||||
});
|
||||
|
||||
test('no reachable tags or stable ref: a warning that says the floor was not compared, never a refusal', () => {
|
||||
const repo = policyRepo(FLOOR);
|
||||
const r = runBin(repo, 'gstack-ship-policy', ['validate'], { GSTACK_STABLE_REPO: tmp() });
|
||||
expect(r.status, r.out + r.err).toBe(EXIT.ok);
|
||||
expect(r.out).toMatch(/^POLICY_VALID: /);
|
||||
expect(r.err).toContain(`gstack-ship-policy: WARN could not resolve the newest stable gstack release`);
|
||||
expect(r.err).toContain(`so min_gstack_version ${FLOOR} was not compared with it`);
|
||||
expect(r.err).toContain('(STABLE_RELEASE_UNKNOWN)');
|
||||
});
|
||||
|
||||
// Value: protects=stable-channel lanes during a cut that tagged a release but never moved stable; fails_when=the highest tag wins over the tag at stable and a floor no stable lane can read passes;
|
||||
// why_new=GST-1637 /ship outside voice; seam=lib/stable-release.ts newestStableRelease; no_claim=-
|
||||
test('a release tag stable never reached does not count: the floor is judged against the tag at stable', () => {
|
||||
const repo = policyRepo(FLOOR);
|
||||
const remote = runBin(repo, 'gstack-ship-policy', ['validate'], { GSTACK_STABLE_REPO: halfCutRepo(BELOW, FLOOR, 'remote') });
|
||||
expect(remote.status, remote.out + remote.err).toBe(EXIT.fail);
|
||||
expect(remote.err).toContain(`min_gstack_version ${FLOOR} is above the newest stable gstack release ${BELOW} (release tag v${BELOW} at origin's stable)`);
|
||||
const local = runBin(repo, 'gstack-ship-policy', ['validate'], { GSTACK_STABLE_REPO: halfCutRepo(BELOW, FLOOR, 'local') });
|
||||
expect(local.status, local.out + local.err).toBe(EXIT.fail);
|
||||
expect(local.err).toContain(`the newest stable gstack release ${BELOW} (stable VERSION (local; origin unreachable))`);
|
||||
});
|
||||
|
||||
// Value: protects=vendored installs (a .git-less copy inside a project) from comparing the floor with the project's own release tags; fails_when=git -C walks up to the project repo and its v<version> tags stand in for gstack's;
|
||||
// why_new=GST-1637 /ship red-team; seam=lib/stable-release.ts newestStableRelease; no_claim=-
|
||||
test('a gstack root with no .git of its own never reads the enclosing project repo: the floor is not compared', () => {
|
||||
const repo = policyRepo(FLOOR);
|
||||
const r = runBin(repo, 'gstack-ship-policy', ['validate'], { GSTACK_STABLE_REPO: vendoredRoot() });
|
||||
expect(r.status, r.out + r.err).toBe(EXIT.ok);
|
||||
expect(r.err).toContain('is not a gstack checkout of its own');
|
||||
expect(r.err).toContain('(STABLE_RELEASE_UNKNOWN)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pregate policy-floor: on a branch that changes the policy', () => {
|
||||
const pregate = (repo: PregateRepo, stable: string) => runPregateBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'preflight', '--only', 'policy-floor'], { GSTACK_STABLE_REPO: stable });
|
||||
const fx = () => { const r = makePregateRepo(); pregateRepos.push(r); return r; };
|
||||
|
||||
test('refuses a floor above stable by name, passes one at stable, warns when stable is unresolvable, and ignores a branch that leaves the policy alone', () => {
|
||||
const repo = fx();
|
||||
expect(pregate(repo, stableRepo(BELOW, 'remote')).out).toMatch(/^preflight {2}policy-floor +pass .*\.gstack\/ship-policy\.json unchanged/m);
|
||||
commit(repo, { '.gstack/ship-policy.json': JSON.stringify({ min_gstack_version: FLOOR }) + '\n' });
|
||||
const above = pregate(repo, stableRepo(BELOW, 'remote'));
|
||||
expect(above.status, above.out + above.err).toBe(EXIT.fail);
|
||||
expect(above.out + above.err).toContain(`min_gstack_version ${FLOOR} is above the newest stable gstack release ${BELOW}`);
|
||||
expect(above.err).toContain('(POLICY_FLOOR_ABOVE_STABLE)');
|
||||
const at = pregate(repo, stableRepo(FLOOR, 'remote'));
|
||||
expect(at.status, at.out + at.err).toBe(EXIT.ok);
|
||||
expect(at.out).toContain(`min_gstack_version ${FLOOR} is at or below the newest stable gstack release ${FLOOR}`);
|
||||
const unknown = pregate(repo, tmp());
|
||||
expect(unknown.out).toMatch(/^preflight {2}policy-floor +warn .*could not resolve the newest stable gstack release/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-doctor --check floor row', () => {
|
||||
const VERSION = fs.readFileSync(path.join(REPO, 'VERSION'), 'utf8').trim();
|
||||
const floorRow = (f: ReturnType<typeof makeFixture>, project: string, extra: Record<string, string> = {}) => {
|
||||
const out = doctor(f, ['--check', '--project', project, '--for', 'health'], extra).out;
|
||||
const line = out.split('\n').find(l => /^(PASS|WARN|SKIP|FAIL) floor\b/.test(l)) ?? '';
|
||||
return { status: line.split(' ')[0], line };
|
||||
};
|
||||
const project = (f: ReturnType<typeof makeFixture>, floor: string) => {
|
||||
const dir = path.join(f.base, 'project');
|
||||
writeFile(path.join(dir, '.gstack/ship-policy.json'), `{\n "min_gstack_version": "${floor}"\n}\n`);
|
||||
return dir;
|
||||
};
|
||||
|
||||
// Value: protects=sessions on an unstamped main checkout from a floor WARN that no fast-forward clears; fails_when=the doctor compares VERSION while validatePolicy compares runningGstackVersion;
|
||||
// why_new=GST-1637; seam=bin/gstack-doctor-check.sh _dc_check_floor; no_claim=-
|
||||
test('an unstamped main checkout gets the verdict validatePolicy gives: the newest policy key it knows counts', () => {
|
||||
const f = makeFixture();
|
||||
fs.copyFileSync(path.join(REPO, 'lib/ship-policy-keys.ts'), path.join(f.root, 'lib/ship-policy-keys.ts'));
|
||||
const known = runningGstackVersion();
|
||||
const dir = project(f, known);
|
||||
const at = floorRow(f, dir);
|
||||
expect(validatePolicy({ min_gstack_version: known })).toEqual([]);
|
||||
expect(at.status, at.line).toBe('PASS');
|
||||
expect(at.line).toBe(known === VERSION ? `PASS floor ${known} declared by ${dir}/.gstack/ship-policy.json (installed ${VERSION})` : `PASS floor ${known} declared by ${dir}/.gstack/ship-policy.json (installed ${VERSION}, reads policies as ${known})`);
|
||||
// A checkout whose VERSION lags a key it carries reads the key's floor (validatePolicy agrees), so the row passes.
|
||||
writeFile(path.join(f.root, 'VERSION'), '1.0.0.0\n');
|
||||
const lagging = floorRow(f, project(f, known));
|
||||
expect(lagging.line).toBe(`PASS floor ${known} declared by ${dir}/.gstack/ship-policy.json (installed 1.0.0.0, reads policies as ${known})`);
|
||||
expect(validatePolicy({ min_gstack_version: known }, { gstackVersion: runningGstackVersion() })).toEqual([]);
|
||||
const next = known.replace(/^(\d+)\.(\d+)\..*$/, (_, a, b) => `${a}.${Number(b) + 1}.0.0`);
|
||||
expect(validatePolicy({ min_gstack_version: next }).map(e => e.code)).toEqual(['POLICY_GSTACK_TOO_OLD']);
|
||||
expect(floorRow(f, project(f, next)).status).toBe('WARN');
|
||||
});
|
||||
|
||||
test('no fast-forward is offered when the newest stable release does not meet the floor; one is when it does', () => {
|
||||
const f = makeFixture();
|
||||
const wrapper = '/drive/skills/gstack/scripts/install.sh';
|
||||
const dir = project(f, '999.0.0.0');
|
||||
for (const how of ['remote', 'local'] as const) {
|
||||
const none = floorRow(f, dir, { GSTACK_CAPY_WRAPPER_PATH: wrapper, GSTACK_STABLE_REPO: stableRepo('1.2.0.0', how) });
|
||||
expect(none.status, none.line).toBe('WARN');
|
||||
expect(none.line).toContain(`installed ${VERSION} is below the floor 999.0.0.0 that ${dir}/.gstack/ship-policy.json declares (min_gstack_version), and no release meets this floor yet: the newest stable gstack is 1.2.0.0`);
|
||||
expect(none.line).not.toContain('fast-forwards');
|
||||
expect(none.line).not.toContain(wrapper);
|
||||
expect(none.line).toContain('(PROJECT_FLOOR_UNMET; ');
|
||||
}
|
||||
const met = floorRow(f, dir, { GSTACK_CAPY_WRAPPER_PATH: wrapper, GSTACK_STABLE_REPO: stableRepo('999.0.0.0', 'remote') });
|
||||
expect(met.status).toBe('WARN');
|
||||
expect(met.line).toContain(`fix: bash ${wrapper} --for health (fast-forwards a clean checkout to origin and reinstalls)`);
|
||||
expect(met.line).not.toContain('no release meets this floor yet');
|
||||
for (const how of ['remote', 'local'] as const) {
|
||||
const halfCut = floorRow(f, dir, { GSTACK_CAPY_WRAPPER_PATH: wrapper, GSTACK_STABLE_REPO: halfCutRepo('1.2.0.0', '999.0.0.0', how) });
|
||||
expect(halfCut.line).toContain('no release meets this floor yet: the newest stable gstack is 1.2.0.0');
|
||||
}
|
||||
const vendored = floorRow(f, dir, { GSTACK_CAPY_WRAPPER_PATH: wrapper, GSTACK_STABLE_REPO: vendoredRoot() });
|
||||
expect(vendored.line).toContain(`fix: bash ${wrapper} --for health (fast-forwards a clean checkout to origin and reinstalls)`);
|
||||
expect(vendored.line).not.toContain('no release meets this floor yet');
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,7 @@ describe('pregate invalidation', () => {
|
||||
const ok = receipt(repo);
|
||||
expect(ok.status).toBe(EXIT.ok);
|
||||
const parsed = JSON.parse(ok.out);
|
||||
expect(parsed.pregate).toMatch(/^pass @[0-9a-f]{12} \(7 checks, current for [0-9a-f]{12}\)$/);
|
||||
expect(parsed.pregate).toMatch(/^pass @[0-9a-f]{12} \(8 checks, current for [0-9a-f]{12}\)$/);
|
||||
expect(parsed.requires_remote).toBeUndefined();
|
||||
expect(Object.keys(parsed).indexOf('pregate')).toBeGreaterThan(Object.keys(parsed).indexOf('history') < 0 ? Object.keys(parsed).indexOf('session_kind') : Object.keys(parsed).indexOf('history'));
|
||||
// The stamp (or any edit) after the run moves the tree: the record is no longer evidence.
|
||||
|
||||
@@ -452,7 +452,7 @@ describe('gstack-pregate (bin)', () => {
|
||||
const repo = fixture();
|
||||
const help = runBin(repo, 'gstack-pregate', ['--help']);
|
||||
expect(help.out).toContain('Exit codes: 0 ok · 1 fail · 2 usage · 3 refused or needs a flag');
|
||||
expect(help.out).toContain('stage 1 mechanical preflight (regen, secrets, strays, literals, timing, guards)');
|
||||
expect(help.out).toContain('stage 1 mechanical preflight (regen, secrets, strays, literals, policy-floor, timing, guards)');
|
||||
expect(help.out).toContain('stage 2 test execution (lanes)');
|
||||
expect(runBin(repo, 'gstack-pregate', ['bogus']).status).toBe(EXIT.usage);
|
||||
expect(pregate(repo, ['--only', 'hermetic']).status).toBe(EXIT.usage);
|
||||
@@ -476,7 +476,7 @@ describe('gstack-pregate (bin)', () => {
|
||||
const out = readOut(repo);
|
||||
expect(out.schema_version).toBe(1);
|
||||
expect(out.tree).toMatch(/^[0-9a-f]{40}$/);
|
||||
expect(out.checks.map((c: any) => [c.id, c.status, c.stage])).toEqual([['regen', 'pass', 'preflight'], ['secrets', 'pass', 'preflight'], ['strays', 'pass', 'preflight'], ['literals', 'pass', 'preflight'], ['timing', 'pass', 'preflight'], ['guards', 'pass', 'preflight'], ['lanes', 'pass', 'tests']]);
|
||||
expect(out.checks.map((c: any) => [c.id, c.status, c.stage])).toEqual([['regen', 'pass', 'preflight'], ['secrets', 'pass', 'preflight'], ['strays', 'pass', 'preflight'], ['literals', 'pass', 'preflight'], ['policy-floor', 'pass', 'preflight'], ['timing', 'pass', 'preflight'], ['guards', 'pass', 'preflight'], ['lanes', 'pass', 'tests']]);
|
||||
for (const c of out.checks) { expect(c.inputs.tree).toBe(out.tree); expect(typeof c.ms).toBe('number'); }
|
||||
expect(check(repo, 'lanes').inputs.command).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(out.verdict).toBe('pass');
|
||||
|
||||
Reference in new issue
Block a user