Merge remote-tracking branch 'origin/main' into gsta258-l5-no-stop

This commit is contained in:
garrytan committed 2026-11-04 01:39:28 +00:00
commit ca43af4284
144 files changed
+5442 -373

No files matched your search

@@ -12,6 +12,10 @@ name: Main backstop gate
# (success when a green train covers the tree or the backstop gated it green,
# neutral when the push was superseded or deferred, failure when it froze the
# queue or could not finish); the stable release reads it (docs/releases.md).
#
# Once main's policy declares a `release_gate` block, the release gate
# (release-gate.yml, every 8 hours) gates main instead and this backstop stands
# down: no gate, no freeze and no check run on a push.
on:
push:
branches: [main]
@@ -60,9 +64,13 @@ jobs:
done
fi
train=$(jq -c 'if (.train | type) == "object" then .train else null end' <<<"$policy") || { echo "::error::${GITHUB_SHA}:.gstack/ship-policy.json is not valid JSON; fix the policy file" >&2; exit 1; }
release=$(jq -r 'if (.release_gate | type) == "object" then "true" else "false" end' <<<"$policy")
if [ -z "$train" ] || [ "$train" = null ]; then
echo "train=false" >> "$GITHUB_OUTPUT"
echo "- Backstop inert: main's .gstack/ship-policy.json declares no train block; per-PR gates cover main" >> "$GITHUB_STEP_SUMMARY"
elif [ "$release" = true ]; then
echo "train=false" >> "$GITHUB_OUTPUT"
echo "- Backstop stands down: main's .gstack/ship-policy.json declares a release_gate block; the scheduled release gate gates main (release-gate.yml, docs/ship-policy.md#release-gate)" >> "$GITHUB_STEP_SUMMARY"
else
echo "train=true" >> "$GITHUB_OUTPUT"
fi
+15 -1
View File
@@ -166,7 +166,8 @@ jobs:
pull-requests: read
outputs:
mode: ${{ steps.route.outputs.mode }}
record_artifact: ${{ steps.policy.outputs.record_artifact }}
# A release bisect (gstack-release-gate) names its own prefix-record artifact; everything else uses the train block's.
record_artifact: ${{ steps.route.outputs.record_artifact || steps.policy.outputs.record_artifact }}
full_gate_check: ${{ steps.policy.outputs.full_gate_check }}
screen_head: ${{ steps.screen.outputs.head_sha }}
screen_base: ${{ steps.screen.outputs.base_sha }}
@@ -218,10 +219,23 @@ jobs:
echo "::warning::the train block at ${base} names no valid record_artifact and full_gate_check; this run routes as before"
fi
# The release gate's bisect (gstack-release-gate) routes only under a
# release_gate block at the same base revision; git and jq only.
- name: Read the release gate policy (base revision)
id: release_policy
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
policy=$(git show "${BASE_SHA:-origin/${DEFAULT_BRANCH}}:.gstack/ship-policy.json" 2>/dev/null || echo null)
release=$(jq -r 'if (.release_gate | type) == "object" then "true" else "false" end' <<<"$policy" 2>/dev/null || echo false)
echo "release=${release}" >> "$GITHUB_OUTPUT"
- name: Route this run
id: route
env:
TRAIN_POLICY: ${{ steps.policy.outputs.train }}
RELEASE_POLICY: ${{ steps.release_policy.outputs.release }}
NONCE: ${{ inputs.nonce }}
EXPECTED_SHA: ${{ inputs.expected_sha }}
EVALS_ALL: ${{ inputs.evals_all }}
+125
View File
@@ -0,0 +1,125 @@
name: Release gate
# The release gate (docs/ship-policy.md#release-gate; bin/gstack-release-gate,
# lib/release-gate.ts): the full paid gate on main's head every 8 hours, only
# when main moved since the last gate. It records the verdict as a release
# note in refs/notes/gstack-trains, publishes the main-backstop check run on
# the gated commit (what scripts/release-cut.ts reads as gated), and bisects
# a red gate's cases. It never writes the stable branch: release-cut is its
# only writer, and refuses every cut while the newest gate is red. Inert (no
# paid work, nothing written) unless main's .gstack/ship-policy.json declares
# a release_gate block; with one, the per-push main backstop
# (evals-main-backstop.yml) stands down and this replaces it.
on:
schedule:
# Every 8 hours (RELEASE_GATE_SCHEDULE_HOURS in lib/release-gate-policy.ts;
# test/release-gate-workflow.test.ts pins the two together), off the hour.
- cron: '17 */8 * * *'
workflow_dispatch:
inputs:
dry_run:
description: 'Print what the gate would test and dispatch nothing'
type: boolean
default: false
ignore_cadence:
description: 'Gate now even if the last gate is younger than cadence_hours (main must still have moved)'
type: boolean
default: true
# One gate at a time; a second run waits for the first.
concurrency:
group: release-gate
cancel-in-progress: false
permissions: {}
jobs:
release-gate:
runs-on: ubuntu-24.04
# A dispatch gates only from the default branch, with its tooling.
if: github.ref_name == github.event.repository.default_branch
# The full gate (270) + a bisect round + margin; past --budget-minutes the
# record stays resumable by the next run.
timeout-minutes: 345
outputs:
action: ${{ steps.gate.outputs.action }}
sha: ${{ steps.gate.outputs.sha }}
check: ${{ steps.gate.outputs.check }}
permissions:
# Pushes the release record to refs/notes/gstack-trains and a bisect's
# release-gate/<id> branch (deleted after its round).
contents: write
# Dispatches the gate and reads its runs and artifacts.
actions: write
checks: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
filter: blob:none
# Read with git and jq only: without a release_gate block nothing below runs.
- name: Read the release gate policy (main)
id: policy
run: |
file=.gstack/ship-policy.json
policy=$(git show "${GITHUB_SHA}:${file}" 2>/dev/null || echo null)
gate=$(jq -c 'if (.release_gate | type) == "object" then .release_gate else null end' <<<"$policy") || { echo "::error::${GITHUB_SHA}:${file} is not valid JSON; fix the policy file" >&2; exit 1; }
if [ -z "$gate" ] || [ "$gate" = null ]; then
echo "release=false" >> "$GITHUB_OUTPUT"
echo "- Release gate inert: main's ${file} declares no release_gate block; the per-push backstop gates main" >> "$GITHUB_STEP_SUMMARY"
else
echo "release=true" >> "$GITHUB_OUTPUT"
fi
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
if: steps.policy.outputs.release == 'true'
with:
bun-version: 1.4.2
- name: Gate main's head when it moved
id: gate
if: steps.policy.outputs.release == 'true'
env:
GH_TOKEN: ${{ github.token }}
GSTACK_STATE_ROOT: ${{ runner.temp }}/gstack-state
DRY_RUN: ${{ inputs.dry_run && '1' || '' }}
IGNORE_CADENCE: ${{ inputs.ignore_cadence && '1' || '' }}
# held and failed are verdicts the release-check job publishes on the gated commit. Failing this job would
# leave a failed check on main's head, which release-cut reads as red even after that head gates green, so
# only a run that recorded no action (a crash) fails it.
run: |
rc=0
./bin/gstack-release-gate run --repo "$GITHUB_REPOSITORY" --budget-minutes 330 \
--summary "$GITHUB_STEP_SUMMARY" --outputs "$GITHUB_OUTPUT" ${DRY_RUN:+--dry-run} ${IGNORE_CADENCE:+--ignore-cadence} || rc=$?
if [ "$rc" != 0 ] && ! grep -qE '^action=(held|failed)$' "$GITHUB_OUTPUT"; then exit "$rc"; fi
# The gated commit's check run (release_gate.check_name, main-backstop), the
# evidence scripts/release-cut.ts reads. Its own job with checks: write and
# no repository code, as for backstop-check: only the recorded action word.
release-check:
runs-on: ubuntu-24.04
needs: release-gate
if: ${{ !cancelled() && contains(fromJSON('["gated","held","failed"]'), needs.release-gate.outputs.action) && needs.release-gate.outputs.sha != '' }}
timeout-minutes: 5
permissions:
checks: write
steps:
- name: Publish the release gate's check run on the gated commit
env:
GH_TOKEN: ${{ github.token }}
ACTION: ${{ needs.release-gate.outputs.action }}
SHA: ${{ needs.release-gate.outputs.sha }}
CHECK: ${{ needs.release-gate.outputs.check }}
run: |
case "$ACTION" in
gated) conclusion=success ;;
held) conclusion=failure ;;
failed) conclusion=neutral ;;
*) echo "::error::unknown release gate action '$ACTION'"; exit 1 ;;
esac
[[ "$SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::'$SHA' is not a commit SHA"; exit 1; }
[[ "$CHECK" =~ ^[A-Za-z0-9_./-]{1,64}$ ]] || { echo "::error::'$CHECK' is not a check-run name"; exit 1; }
gh api "repos/${GITHUB_REPOSITORY}/check-runs" -X POST -f name="$CHECK" -f head_sha="$SHA" -f status=completed -f conclusion="$conclusion" \
-f details_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
-f "output[title]=Release gate: ${ACTION}" -f "output[summary]=${ACTION}: see the release-gate job summary of run ${GITHUB_RUN_ID}" >/dev/null
echo "- ${CHECK}: ${conclusion} (release gate ${ACTION}) on ${SHA}" >> "$GITHUB_STEP_SUMMARY"
+2 -1
View File
@@ -39,7 +39,8 @@
{ "id": "context-budget", "when": ["SKILL.md", "*/SKILL.md", "*/sections/*.md", "SKILL.md.tmpl", "*/SKILL.md.tmpl", "*/sections/*.tmpl", "*/sections/manifest.json", "*/templates/*.tmpl", "hosts/**", "model-overlays/**", "scripts/gen-skill-docs.ts", "scripts/resolvers/**", "test/context-budget-ratchet.test.ts", "test/fixtures/context-budget*.json"], "command": "bun test test/context-budget-ratchet.test.ts", "fix": "shorten the skill under its token ceiling, or lower the ceiling when the skill shrank" },
{ "id": "module-size", "when": ["lib/**/*.ts", "bin/**", "scripts/**/*.ts", "browse/src/**/*.ts", "test/helpers/**/*.ts", "test/module-size-ratchet.test.ts", "test/fixtures/module-size-ratchet.json"], "command": "bun test test/module-size-ratchet.test.ts", "fix": "split the module or function the failure names; it may not grow past its recorded size" },
{ "id": "goldens", "when": ["SKILL.md", "*/SKILL.md", "*/sections/*.md", "SKILL.md.tmpl", "*/SKILL.md.tmpl", "*/sections/*.tmpl", "*/sections/manifest.json", "*/templates/*.tmpl", "hosts/**", "model-overlays/**", "scripts/gen-skill-docs.ts", "scripts/resolvers/**", "ship/**", "scripts/gen-ship-goldens.ts", "test/host-config.test.ts", "test/fixtures/golden/**"], "command": "bun test test/host-config.test.ts", "fix": "when the render change is intended, run bun scripts/gen-ship-goldens.ts and commit the updated goldens" },
{ "id": "doc-caps", "when": ["CLAUDE.md", "test/claude-md-size.test.ts"], "command": "bun test test/claude-md-size.test.ts", "fix": "move contributor-only detail from CLAUDE.md into docs/ and leave a one-line pointer" }
{ "id": "doc-caps", "when": ["CLAUDE.md", "test/claude-md-size.test.ts"], "command": "bun test test/claude-md-size.test.ts", "fix": "move contributor-only detail from CLAUDE.md into docs/ and leave a one-line pointer" },
{ "id": "conflict-markers", "when": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.mjs", "**/*.cjs", "**/*.sh", "**/*.json", "**/*.yml", "**/*.yaml", "**/*.tmpl", "bin/**"], "command": "bun scripts/check-conflict-markers.ts", "fix": "finish the merge in each file it names, delete the marker lines and commit; a fixture that shows markers on purpose declares itself as docs/pregate.md describes" }
],
"strays": { "allow": ["modules/loadouts.json", "modules/loadouts.schema.json"] }
}
+2 -1
View File
@@ -15,5 +15,6 @@
"maintainers": ["garrytan"],
"train": { "gate_workflow": "evals.yml", "free_workflow": "free-tests.yml", "full_gate_check": "gstack/full-gate", "record_artifact": "gstack-train-record", "max_size": 5, "fill_hours": 1, "screen_selection": "gstack-paid", "trial_reservation_usd": 1.5 },
"changelog_fragments": { "dir": "changelog.d", "required_checks": ["free-tests", "windows-smoke", "Check VERSION is not stale vs queue", "quality", "check-freshness", "actionlint"] },
"min_gstack_version": "1.91.340.0"
"merge": "direct",
"min_gstack_version": "1.95.0.0"
}
+67
View File
@@ -1,5 +1,72 @@
# Changelog
## [1.95.5.0] - 2026-11-04
- **gstack's own PRs now merge at their own green head.** Its ship policy sets `merge: direct`, so the Captain lands each READY PR with `gstack-ship-queue land --pr <n> --pin <sha>` instead of a train, and VERSION waits for the release stamp. The policy's `min_gstack_version` rises to 1.95.0.0, the release that introduced the key, so an older gstack refuses it by name (`POLICY_GSTACK_TOO_OLD`) and asks for an upgrade.
## [1.95.4.0] - 2026-11-04
### Fixed
- **The lean `/autoplan` reviewer sees the committed draft on a PR-branch checkout** (GST-1241). Draft capture took `origin/HEAD` as its base, and a Capy machine started on a PR branch points `origin/HEAD` at that branch, so the reviewer got `DRAFT: none (no code changes against origin/<branch>)`. It now resolves the base through `lib/default-base.ts`, the same resolver `bin/gstack-diff-scope` uses, which skips an `origin/HEAD` naming the checked-out branch and falls back to the PR base, the remote's live HEAD, then `origin/main`.
## [1.95.3.0] - 2026-11-04
**A crew thread no longer creates threads itself. It asks its Captain, so every new thread joins a crew from the start (R68). docs/captain.md also warns that `tools.capy.read` returns a cut-down view of long files.**
### Changed
- **docs/captain.md** has a new section, "New threads join the crew". A crew thread asks its Captain for a new thread, and the Captain creates it in the crew. A thread the crew thread already made gets reported by code in that turn, so the Captain can add it with `crew.add`. A thread in another project goes Captain to Captain, and the receiving Captain creates it in its own crew as work, briefs it and returns the result. Only a project with no Captain lets the crew thread create the thread itself, with the code recorded on the work's issue. A cross-project thread the crew thread already made goes to that project's Captain to adopt, with its code on the issue. The cause: GBRA-240 created GBEV-33 directly in another project, and that thread ran outside every crew.
- **docs/captain.md** also has a new section, "Keep issues tidy" (Garry, 2026-11-03: "can we tell all the captains to audit their issues and clean them up. and have a standing rule that that is kept tiday"). Before archiving a thread, close or backlog its issues. On each wake, keep every touched issue accurate. Unassigned Todos nobody will start go to Backlog. A daily sweep closes, re-assigns or backlogs open issues untouched for 2 days whose assignee is archived, or idle with no open PR (R69). The sweep skips issues assigned to a person, and Waiting issues that name what they wait on while it is still open. The target is an In progress count close to working threads plus PRs waiting to merge.
- **The Capy factory context (`factory.md` point 12)** now has the short version and cites that section. Its byte budget in `test/capy-skill-wrapper.test.ts` goes from 3,300 to 3,450: the page's new size rounded up to the next 25 bytes, and no other point was trimmed.
- **docs/captain.md, Drive layout** says that `tools.capy.read` returns about the first 38,000 characters, with each line cut at 2,000. So a read, change and `tools.capy.write` program saves a truncated file, Drive files included. Edit long files with `tools.capy.edit`, or read and write the whole file in bash or Python and check the line count. `tools.drive.history` restores a recent version. This is how the org knowledge base's rules.md lost 37 rules. It lives in the docs page rather than the Capy skill because the skill sits at its fixed 5,315-token context budget.
## [1.95.2.0] - 2026-11-04
- Pushes from /ship now stop on leftover merge conflict markers. A new `conflict-markers` guard reads every line the branch adds, in the commit being pushed and in the working tree, and refuses one that starts with `<<<<<<<` or `>>>>>>>`, or is exactly `=======`, even inside a template literal that still parses and passes its tests (GST-1190, seen on #3927). Markdown code blocks that show git output (an unlabeled, `text` or `diff` fence) are skipped, while a conflict inside a `bash` or `ts` fence is still refused. A fixture that shows markers on purpose declares `gstack-conflict-markers: fixture` on a line of its own near the top.
### Tests
- `test/conflict-markers.test.ts`: a marker inside a template literal is refused (it failed before the guard existed); a Markdown code block that shows a marker passes while the same lines outside a fence or in a bash fence are refused; unchanged lines, declared fixtures, untracked files, quoted file names, git prefix settings and a committed conflict hidden by an unstaged repair are handled.
## [1.95.1.0] - 2026-11-04
**/ship's review depth now follows what the code does, not what a file is named (GST-1177).** An executable file stays code wherever it sits and whatever its name, and code that seals eval custody or decides what reaches an outside model now gets full review. Over the last 200 commits on main, no commit's review depth went down. Six gained a risk class, and three of those went from light to full, including the secret-egress fix in #3921 that had been planned light.
### Changed
- **An executable is code whatever its name or folder.** A file with the exec bit, a symlink, or a file whose first line is `#!` counts as code. Examples are `scripts/logs/prune`, a runnable `*.out`, or an executable `.txt` or `.json` script. It no longer counts as a run log, data, config or docs, and its changed lines are read for risk. If gstack can't read a file's mode or first bytes, it treats the file as executable. A deleted file is never counted as executable.
- **Sealed custody and outside-model egress are security risk.** Paths naming seal, sealed, custody or egress now plan full depth (harness-evals#237, #3921). So do code lines that call a redaction function, import a redaction or outside-voice module, or set a `SECRET` or `CREDENTIAL` constant to a regex.
- **The docs credential check reads every docs path**, including docs-named executables, so a script that prints an `Authorization:` header still plans full depth.
### Fixed
- **The outside voice's path-scoped slice no longer drops files with quoted or spaced names.** Git prints a quoted header when a name has a tab or non-ASCII characters. Such a file was either glued onto the file before it or left out of a diff over 2 MB without being listed. Now each one is its own file, and the brief names it when it's left out.
- **`gstack-review-plan` never opens a tracked symlink** to check for `#!` or to read an SVG drawing, so a link to a FIFO can't hang it.
- **docs/ship-policy.md describes the `review` policy key** and the `risk` and `mode` fields of the receipt's `review_depth` record.
## [1.95.0.0] - 2026-11-03
### Added
- **Merge each PR at its own green head (`merge: direct`).** `gstack-ship-queue land --pr <n> --pin <sha>` merges one READY PR with `--squash --match-head-commit`, with no train, no stamp commit and no extra CI round. It refuses unless the /ship receipt and the READY note are bound to the pin, every check there is green with none pending, GitHub reports the PR mergeable and clean, and the PR leaves VERSION, the `package.json` version and the CHANGELOG to the release. After the merge it checks that main's tree is the pin merged onto its parent and writes a land note. A new ship-policy key, `merge: train | direct`, turns it on, and `train build` refuses under `direct`. gstack switches its own policy to `direct` in a follow-up once this release exists, so `min_gstack_version` names a version that was actually published. Each refusal has its own code and fix in docs/troubleshooting.md, and docs/captain.md has the new runbook.
## [1.94.44.0] - 2026-11-03
### Fixed
- **A child's install for another workflow no longer drops /ship's install verdict on a shared machine (GST-1233).** When a /ship docs child ran `install.sh --for document-release` with a different Bun on PATH, or its install check failed, it replaced the shared install verdict, and the parent's finish pregate refused with `INSTALL_UNVERIFIED` until someone reinstalled `--for ship`. Each workflow install now also writes that workflow's own verdict beside the shared one (`<verdict>.for/<workflow>.json`), and workflow admission and the pregate fall back to it with the same checks. A new HEAD or a failed `./setup` still invalidates every record, `--check --for ship` keeps ship's own recorded Bun, and the pregate skips a launch directory whose `bun` resolves to a Bun other than the one its admitting record certified. The shared launch file is a separate follow-up (GST-1239).
## [1.94.43.0] - 2026-11-03
### Fixed
- **`gstack-evidence passed` skips the tree hash when the ledger has nothing to match.** Before pregate runs a guard, it asks `gstack-evidence passed` whether that exact command already passed at this tree (#3929). Each check hashed the working tree (`gstack-wtree`) before it looked at the ledger. Usually the ledger has no row for the command at all, so that hash was wasted. Each check took 0.19 s idle and 0.74 to 0.94 s on a busy 4-core machine, once per guard. Now the ledger is read first, and the tree is hashed only when a local row names the command. The answer is the same. Only the no-row message drops its "at tree …" suffix.
- **Two local test cases no longer run out of time at Bun's default 5 s.** `test/pregate-guards.test.ts`'s guard-timeout case ran two full pregates, each waiting out a real 1 s guard timeout. It is now two cases, one with several guards together and one with a lone timed-out guard. Its fixture also writes the manifest into the base commit instead of making a second commit. On a 4-core machine it went from 4.1 s to 2.0 s and 1.75 s idle. With every core busy (busy loops at nice 10), it went from 0 of 3 runs passing to 10 of 10. `test/spec-quality-gate-secret-sink.test.ts` rendered every host's skill docs in `beforeAll` but reads only the claude and codex output. It now renders just those two, which are byte-identical to the full render: 1.38 s down to 0.37 s. With 16 busy loops, that hook went from timing out in 3 of 3 runs to passing 3 of 3. That is the hook timeout the macOS named-regressions job hit on #3926.
## [1.94.42.0] - 2026-11-03
### Tests
- Three free tests that failed only on a busy remote test machine (remote pregate run 20261103T015240Z-11756-6ff6, PR #3892) now hold up under load:
- `test/review-army-budget.test.ts`: the red-team retry fixture spent a real 80 ms sleep inside its 200 ms deadline, so a pause of more than 120 ms failed attempt 2. Attempt 1 now waits for the cancellation and then drains, and attempt 2 doesn't sleep. With random 150 ms pauses injected, 6 of 10 runs passed before and 20 of 20 pass now.
- `test/helpers/docsync-fault-callbacks.ts`: the docsync replay assumed fixture setup took less than 5 seconds, and setup takes 5 to 12 seconds on a busy machine. The replay now checks that the session timeout equals the budget minus 15 seconds minus the time already spent, which is how the code computes it. Under 16 busy loops on 4 cores, 0 of 4 runs passed before and 4 of 4 pass now.
- `test/fixtures/plan-seed-cli.ts`: the fake CLI drew its screen and its startup question in two separate writes, so a screen read between them saw an empty input and pasted the plan. It now draws both in one write. Under load, 31 of 36 runs passed before and 36 of 36 pass now.
## [1.94.41.0] - 2026-11-03
**/ship's receipt counts its review findings by id, keeps every run, and a small change after review gets one reader instead of a second /ship (GSTA-258 L4; GST-1136, GST-1168, GST-1179).** The receipt's `REVIEW_LOOP` counts were wrong in a repeatable way: gbrain-evals#310's receipt said 3 blockers found and 2 fixed when 2 were found, 1 fixed and 1 deferred; gstack-evals#174 counted 16 blocking with 8 unrecorded against 9 found and 9 fixed; #3906 and #3911 showed `fixed=0` after real fixes. The summary paired findings by path:line only, so a native finding with no path:line, a row logged after its fix moved the line, or a late defer re-logged under another fingerprint all broke the count, and rows it couldn't place were dropped without a word.
+1 -1
View File
@@ -119,7 +119,7 @@ rounded:
## Motion
- **Approach:** Minimal-functional — only transitions that aid comprehension. The dashboard's live feed IS the motion.
- **Easing:** enter(ease-out / cubic-bezier(0.16,1,0.3,1)) exit(ease-in) move(ease-in-out)
- **Easing:** enter(ease-out / cubic-bezier(0.16,1,0.3,1)) exit(ease-out) move(ease-in-out)
- **Duration:** micro(50-100ms) short(150ms) medium(250ms) long(400ms)
- **Animated elements:** live feed dot pulse (2s infinite), skill bar fill (600ms ease-out), hover states (150ms)
+1 -1
View File
@@ -1 +1 @@
1.94.41.0
1.95.5.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.94.41.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.95.5.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+1 -1
View File
@@ -20,7 +20,7 @@ The first `next` prints `SCOPE: ui=… (terms; visual; files) dx=… (detected)
TODOS*, docs/** but not docs/*-policy.md, or other markdown outside a skill dir) runs CEO on the native voice only, then Eng. UI needs a rendered file path or a UI term next
to a visual noun in the Implementation plan. To keep flags for later runs on the same plan, add
the `SCOPE_PIN:` marker it prints (`<!-- gstack-autoplan-scope: … -->`) as the plan's first
line yourself; gstack never writes the plan. **Kinds (R65), first `next` only.** `--kind fix` reviews nothing: it prints `AUTOPLAN: not needed (bug fix; R65)`, a `PROOF:` line and `GSTACK_RESULT: skill=autoplan status=skipped run=-`, writes nothing and needs no `--out` or `--plan`; the PR still carries the bug's proof, a repro on current main or a failing test written first. `--kind feature`, the default for an unattended run on Capy (`GSTACK_CAPY=1`) that names no kind, is the lean run. Its `phase_order` is `eng` alone with one native reviewer, whose prompt merges the CEO and Eng lenses (Design or DX only when scope detects them), always asks "Any outside terms, licences or rights involved?" and shows the draft code. The draft is `git diff` from the merge base with `--base <ref>` (default `origin/HEAD`), untracked files included and the run directory left out, written to the snapshot as `draft.diff` and hashed; with no code yet it says `DRAFT: none (<why>)`. The first `next` prints `LEAN:` and each opened phase `DRAFT:`; the reviewer's line after INPUT must be `DRAFT: <sha256>` or `DRAFT: none`, or submit refuses it (`RESULT_RECEIPT_MISSING`). No outside voice, `--max-rounds 1`, no carry check and one gate revision: `revise --incorporates` applies the accepted changes without re-review and approves. `--full` brings back every phase and both voices; off Capy, and in an interactive session, the default stays full.
line yourself; gstack never writes the plan. **Kinds (R65), first `next` only.** `--kind fix` reviews nothing: it prints `AUTOPLAN: not needed (bug fix; R65)`, a `PROOF:` line and `GSTACK_RESULT: skill=autoplan status=skipped run=-`, writes nothing and needs no `--out` or `--plan`; the PR still carries the bug's proof, a repro on current main or a failing test written first. `--kind feature`, the default for an unattended run on Capy (`GSTACK_CAPY=1`) that names no kind, is the lean run. Its `phase_order` is `eng` alone with one native reviewer, whose prompt merges the CEO and Eng lenses (Design or DX only when scope detects them), always asks "Any outside terms, licences or rights involved?" and shows the draft code. The draft is `git diff` from the merge base with `--base <ref>` (default: `gstack-diff-scope`'s base, never an `origin/HEAD` naming the checked-out branch), untracked files included and the run directory left out, written to the snapshot as `draft.diff` and hashed; with no code yet it says `DRAFT: none (<why>)`. The first `next` prints `LEAN:` and each opened phase `DRAFT:`; the reviewer's line after INPUT must be `DRAFT: <sha256>` or `DRAFT: none`, or submit refuses it (`RESULT_RECEIPT_MISSING`). No outside voice, `--max-rounds 1`, no carry check and one gate revision: `revise --incorporates` applies the accepted changes without re-review and approves. `--full` brings back every phase and both voices; off Capy, and in an interactive session, the default stays full.
## The loop
+1 -1
View File
@@ -17,7 +17,7 @@ The first `next` prints `SCOPE: ui=… (terms; visual; files) dx=… (detected)
TODOS*, docs/** but not docs/*-policy.md, or other markdown outside a skill dir) runs CEO on the native voice only, then Eng. UI needs a rendered file path or a UI term next
to a visual noun in the Implementation plan. To keep flags for later runs on the same plan, add
the `SCOPE_PIN:` marker it prints (`<!-- gstack-autoplan-scope: … -->`) as the plan's first
line yourself; gstack never writes the plan. **Kinds (R65), first `next` only.** `--kind fix` reviews nothing: it prints `AUTOPLAN: not needed (bug fix; R65)`, a `PROOF:` line and `GSTACK_RESULT: skill=autoplan status=skipped run=-`, writes nothing and needs no `--out` or `--plan`; the PR still carries the bug's proof, a repro on current main or a failing test written first. `--kind feature`, the default for an unattended run on Capy (`GSTACK_CAPY=1`) that names no kind, is the lean run. Its `phase_order` is `eng` alone with one native reviewer, whose prompt merges the CEO and Eng lenses (Design or DX only when scope detects them), always asks "Any outside terms, licences or rights involved?" and shows the draft code. The draft is `git diff` from the merge base with `--base <ref>` (default `origin/HEAD`), untracked files included and the run directory left out, written to the snapshot as `draft.diff` and hashed; with no code yet it says `DRAFT: none (<why>)`. The first `next` prints `LEAN:` and each opened phase `DRAFT:`; the reviewer's line after INPUT must be `DRAFT: <sha256>` or `DRAFT: none`, or submit refuses it (`RESULT_RECEIPT_MISSING`). No outside voice, `--max-rounds 1`, no carry check and one gate revision: `revise --incorporates` applies the accepted changes without re-review and approves. `--full` brings back every phase and both voices; off Capy, and in an interactive session, the default stays full.
line yourself; gstack never writes the plan. **Kinds (R65), first `next` only.** `--kind fix` reviews nothing: it prints `AUTOPLAN: not needed (bug fix; R65)`, a `PROOF:` line and `GSTACK_RESULT: skill=autoplan status=skipped run=-`, writes nothing and needs no `--out` or `--plan`; the PR still carries the bug's proof, a repro on current main or a failing test written first. `--kind feature`, the default for an unattended run on Capy (`GSTACK_CAPY=1`) that names no kind, is the lean run. Its `phase_order` is `eng` alone with one native reviewer, whose prompt merges the CEO and Eng lenses (Design or DX only when scope detects them), always asks "Any outside terms, licences or rights involved?" and shows the draft code. The draft is `git diff` from the merge base with `--base <ref>` (default: `gstack-diff-scope`'s base, never an `origin/HEAD` naming the checked-out branch), untracked files included and the run directory left out, written to the snapshot as `draft.diff` and hashed; with no code yet it says `DRAFT: none (<why>)`. The first `next` prints `LEAN:` and each opened phase `DRAFT:`; the reviewer's line after INPUT must be `DRAFT: <sha256>` or `DRAFT: none`, or submit refuses it (`RESULT_RECEIPT_MISSING`). No outside voice, `--max-rounds 1`, no carry check and one gate revision: `revise --incorporates` applies the accepted changes without re-review and approves. `--full` brings back every phase and both voices; off Capy, and in an interactive session, the default stays full.
## The loop
+9 -3
View File
@@ -163,8 +163,10 @@ PROJECT_REAL=""; [ -n "$PROJECT" ] && PROJECT_REAL=$(cd "$PROJECT" && pwd -P) &&
VERDICT=$(gstack_install_verdict_path "$GSTACK_STATE_ROOT" "$ROOT_REAL" "$PROJECT_REAL")
REC_BUN="" REC_VER="" REC_RULE="" REC_BUN_INSTALL="" REC_FOR=""
if [ -f "$VERDICT" ]; then
REC_BUN=$(gstack_install_verdict_field "$VERDICT" bun_path) REC_VER=$(gstack_install_verdict_field "$VERDICT" bun_version)
REC_RULE=$(gstack_install_verdict_field "$VERDICT" bun_rule) REC_BUN_INSTALL=$(gstack_install_verdict_field "$VERDICT" bun_install)
_rec=$VERDICT
case "$FOR" in ''|*[!A-Za-z0-9._-]*) ;; *) [ "$PROFILE" -eq 0 ] && [ "$(gstack_install_verdict_field "${VERDICT%.json}.for/$FOR.json" ok 2>/dev/null)" = true ] && _rec="${VERDICT%.json}.for/$FOR.json" ;; esac
REC_BUN=$(gstack_install_verdict_field "$_rec" bun_path) REC_VER=$(gstack_install_verdict_field "$_rec" bun_version)
REC_RULE=$(gstack_install_verdict_field "$_rec" bun_rule) REC_BUN_INSTALL=$(gstack_install_verdict_field "$_rec" bun_install)
REC_FOR=$(gstack_install_verdict_field "$VERDICT" for)
fi
# A reviewer's machine (--for review-voice) asked to run a workflow upgrades
@@ -429,7 +431,9 @@ verdict_kept() {
# An ok run keeps what an earlier ok install certified for this checkout, project, HEAD and Bun,
# so one machine serving /ship and its docs step stays admitted for both (#3561): required is
# the union, pins count as planning-only only when both runs skipped them, and for= lists every
# workflow (comma-separated). A failing run, another HEAD or another Bun replaces the verdict.
# workflow (comma-separated). A failing run, another HEAD or another Bun replaces the verdict, so
# each run also records its own workflow's verdict at <verdict>.for/<workflow>.json, which admission
# falls back to: a later install for another workflow never drops this one (GST-1233).
record_verdict() { # TRAILER OK — --check keeps the recorded resolution (drift is reported, never re-recorded)
local v_for="${FOR:-}" v_req v_plan e_for id
if verdict_kept; then
@@ -440,6 +444,8 @@ record_verdict() { # TRAILER OK — --check keeps the recorded resolution (drift
else set -- "$1" "$2" "$RES_BIN" "$RES_VER" "$RES_RULE" "${BUN_INSTALL:-}"; fi
v_req=$(printf '%s\n' "${OUT:-}" | sed -n 's/^required: //p' | head -1)
v_plan=$(printf '%s\n' "${OUT:-}" | grep -q '^SKIP pins (planning-only' && echo 1 || echo 0)
case "$FOR" in ''|*[!A-Za-z0-9._-]*) ;; *) [ "$PROFILE" -eq 1 ] || gstack_install_verdict_write "${VERDICT%.json}.for/$FOR.json" "$ROOT_REAL" "$HEAD_SHA" "$FOR" "$v_req" "$v_plan" "$2" "$1" "${MIN_REVISION:-}" "$LAUNCH_DIR" \
"$PROJECT_REAL" "$PIN_EXACT" "$3" "$4" "$5" "$6" "$NODE_BIN" "$NODE_VER" "$NODE_RULE" || say "could not write the install verdict ${VERDICT%.json}.for/$FOR.json" ;; esac
if [ "$2" = 1 ] && [ "$PROFILE" -eq 0 ] && [ -f "$VERDICT" ] && [ "$(gstack_install_verdict_field "$VERDICT" ok)" = true ] \
&& [ "$(gstack_install_verdict_field "$VERDICT" head)" = "$HEAD_SHA" ] && [ "$(gstack_install_verdict_field "$VERDICT" checkout)" = "$ROOT_REAL" ] \
&& [ "$(gstack_install_verdict_field "$VERDICT" bun_path)" = "$3" ] && e_for=$(gstack_install_verdict_field "$VERDICT" for) \
+23 -4
View File
@@ -4,7 +4,7 @@
*
* Usage (run inside the repository):
* gstack-docs-candidate snapshot --out <candidate.json> --audit-id <id> --mode edit|read-only
* --base <rev> [--select <path>]... [--docs <path>]... [--exception <docs-audit.json>]
* --base <rev> [--select <path>]... [--docs <path>[,<path>...]]... [--exception <docs-audit.json>]
* gstack-docs-candidate compare <candidate.json>
* gstack-docs-candidate attempt <child-output> --state <attempts.json>
* gstack-docs-candidate [snapshot|compare] --help
@@ -15,7 +15,10 @@
* selected path, docs path and dirty/untracked path. Without --select, the
* selected paths are the committed, staged, unstaged and untracked paths.
* --docs takes files or directories (directories expand to their tracked and
* untracked files). It prints a short summary; the JSON file is the record.
* untracked files): repeat the flag, or give a comma-separated list (each entry
* trimmed). A value that names an existing file or directory is one path, commas
* and spaces included; any other value holding whitespace is refused, because a
* space-separated list cannot be told from a path with a space. It prints a short summary; the JSON file is the record.
* --exception names the blocked .gstack/tmp/docs-audit.json an owner accepted
* (Step 14.5 Blocked recovery): the record then carries that exception, so
* Step 16 can tell an accepted risk from a skipped audit. A --base that is HEAD
@@ -72,6 +75,22 @@ function expand(listed: string[], paths: string[]): string[] {
return [...out].sort();
}
/** --docs values as paths: a value naming an existing file or directory is one path; any other splits on commas, and an entry with whitespace or a value with no entry is refused. */
function docsPaths(listed: string[], values: string[]): string[] {
const exists = (p: string) => {
const rel = p.replace(/^\.\//, '').replace(/\/+$/, '');
return fs.existsSync(rel) || listed.some(f => f === rel || f.startsWith(rel + '/'));
};
return values.flatMap(v => {
const paths = exists(v) ? [v] : v.split(',').map(p => p.trim()).filter(Boolean);
if (!paths.length) throw new Error(`--docs ${JSON.stringify(v)} names no path: give a file or directory, repeat --docs for each path, or separate paths with commas`);
return paths;
}).map(p => {
if (/\s/.test(p) && !exists(p)) throw new Error(`--docs ${JSON.stringify(p)} names no file or directory and holds whitespace: repeat --docs for each path, or separate paths with commas`);
return p;
});
}
/** Git blob ids of working-tree content, computed in-process (any path bytes, nothing written); null when absent. */
function hashes(paths: string[]): Record<string, string | null> {
const out: Record<string, string | null> = {};
@@ -99,7 +118,7 @@ function one(args: string[], name: string): string {
return values[0]!;
}
const USAGE = `usage: gstack-docs-candidate snapshot --out <candidate.json> --audit-id <id> --mode edit|read-only --base <rev> [--select <path>]... [--docs <path>]... [--exception <docs-audit.json>]
const USAGE = `usage: gstack-docs-candidate snapshot --out <candidate.json> --audit-id <id> --mode edit|read-only --base <rev> [--select <path>]... [--docs <path>[,<path>...]]... [--exception <docs-audit.json>]
gstack-docs-candidate compare <candidate.json>
gstack-docs-candidate attempt <child-output> --state <attempts.json>`;
@@ -149,7 +168,7 @@ function main(argv: string[]): void {
const listed = nul(git(['ls-files', '-z', '--cached', '--others', '--exclude-standard']));
const selected = flag(args, '--select').length ? expand(listed, flag(args, '--select'))
: [...new Set([...now.committed, ...now.staged, ...now.unstaged, ...now.untracked])].sort();
const docs = expand(listed, flag(args, '--docs'));
const docs = expand(listed, docsPaths(listed, flag(args, '--docs')));
const tracked = [...new Set([...selected, ...docs, ...now.staged, ...now.unstaged, ...now.untracked])].sort();
const record = { schema_version: 1, audit_id: one(args, '--audit-id'), mode, base, ...now, selected, docs, hashes: hashes(tracked), ...(exception ? { exception } : {}) };
fs.writeFileSync(out, JSON.stringify(record, null, 2) + '\n', { mode: 0o600 });
+5
View File
@@ -347,6 +347,11 @@ gstack_result_anchor() {
QUEUE_LAND_CONFLICT) printf '%s' 'queue-land-conflict' ;;
QUEUE_LAND_DRIFT) printf '%s' 'queue-land-drift' ;;
QUEUE_LAND_BASE_BUSY) printf '%s' 'queue-land-base-busy' ;;
QUEUE_LAND_STAMPED) printf '%s' 'queue-land-stamped' ;;
QUEUE_LAND_PIN_MOVED) printf '%s' 'queue-land-pin-moved' ;;
QUEUE_LAND_CHECKS) printf '%s' 'queue-land-checks' ;;
QUEUE_LAND_NOT_CLEAN) printf '%s' 'queue-land-not-clean' ;;
QUEUE_LAND_UNVERIFIED) printf '%s' 'queue-land-unverified' ;;
PR_WAIT_BLOCKED) printf '%s' 'pr-wait-blocked' ;;
PR_WAIT_REFUSED) printf '%s' 'pr-wait-refused' ;;
PREGATE_HEAD_POLICY_LOOSENS) printf '%s' 'pregate-head-policy-loosens' ;;
+5 -3
View File
@@ -653,12 +653,14 @@ function cmdPassed(argv: string[]): number {
const sep = argv.indexOf("--");
const command = sep < 0 ? "" : argv.slice(sep + 1).join(" ");
if (!command || sep !== 0) { console.error("usage: gstack-evidence passed -- <command>"); return 2; }
const wtree = currentWtree();
if (!wtree) { console.log("NOT_PASSED: no working-tree fingerprint"); return 1; }
let records: EvidenceRecord[] = [];
try { records = readJsonl<EvidenceRecord>(ledgerPath().file); } catch { records = []; }
const cmdSha = sha256(command);
const newest = records.findLast((r) => r.cmd_sha256 === cmdSha && r.wtree === wtree && r.source === undefined);
const local = records.filter((r) => r.cmd_sha256 === cmdSha && r.source === undefined);
if (!local.length) { console.log("NOT_PASSED: no local ledger row for this command"); return 1; }
const wtree = currentWtree();
if (!wtree) { console.log("NOT_PASSED: no working-tree fingerprint"); return 1; }
const newest = local.findLast((r) => r.wtree === wtree);
if (!newest) { console.log(`NOT_PASSED: no local ledger row for this command at tree ${wtree.slice(0, 12)}`); return 1; }
if (!countedPass(newest, 24)) { console.log(`NOT_PASSED: the newest row (label=${newest.label} ts=${newest.ts}) is not a counted pass within 24 h under this runtime`); return 1; }
console.log(`PASSED: label=${newest.label} source=local ts=${newest.reused_from ?? newest.ts}`);
+68 -45
View File
@@ -21,6 +21,9 @@
# <state root>/install-verdicts/<sha256(checkout realpath)>.json. An install
# profile's verdict (for=review-voice) at the keyed path yields to the launched
# GSTACK_INSTALL_VERDICT, so a reviewer's install never shadows it (GST-1161).
# Each workflow install also writes its own record, <verdict>.for/<workflow>.json,
# which admission tries when the shared verdict does not admit that workflow, so
# another workflow's later install never drops it (GST-1233).
#
# Admission (gstack-skill-start, after SESSION_KIND): an unattended session,
# or an explicit GSTACK_SESSION_KIND=spawned session with GSTACK_CAPY=1,
@@ -109,12 +112,62 @@ gstack_install_verdict_field() {
esac
}
# _gstack_ia_check — the verdict checks gstack_install_admission runs on $file
# for $skill (its locals: src, project, where, found): sets detail (empty when
# the verdict admits), fix, code, trailer and, for a Bun drift, _ia_status.
_gstack_ia_check() {
detail=""
code=INSTALL_UNVERIFIED
fix="run the Capy skill installer (scripts/install.sh --for $skill) on this machine, then launch with the GSTACK_LAUNCH line it prints"
if [ ! -f "$file" ]; then
detail="no install verdict at $file for $src"
fix="run the Capy skill installer (scripts/install.sh --for $skill) from ${project:-this directory}: the verdict is keyed by the project directory the installer runs in, so a run from the skill's own folder does not cover ${project:-it}; then launch with the GSTACK_LAUNCH line it prints"
else
trailer=$(gstack_install_verdict_field "$file" trailer)
v_head=$(gstack_install_verdict_field "$file" head)
head=$(git -C "$src" rev-parse HEAD 2>/dev/null)
v_schema=$(gstack_install_verdict_field "$file" schema_version)
if { [ -z "$(gstack_install_verdict_field "$file" ok)" ] && [ -z "$v_schema" ]; } || { [ "$v_schema" != 1 ] && [ "$v_schema" != 2 ]; }; then
detail="the install verdict at $file is unreadable or has an unsupported schema_version; rerun the installer"
elif [ "$(gstack_install_verdict_field "$file" ok)" != true ]; then
detail="the install verdict says: $trailer"
elif [ "$(gstack_install_verdict_field "$file" checkout)" != "$src" ]; then
detail="the install verdict is for checkout $(gstack_install_verdict_field "$file" checkout), not $src"
elif [ -z "$head" ] || [ "$v_head" != "$head" ]; then
detail="the install verdict is for another HEAD (${v_head:0:12}; $src is at ${head:0:12})"
elif [ "$(gstack_install_verdict_field "$file" pins_planning_only)" = true ] && ! gstack_doctor_skill_pins_advisory "$skill"; then
detail="the install verdict is planning-only (pins skipped for $(gstack_install_verdict_field "$file" for)) and $skill runs the repo's tests"
elif [ "$(gstack_install_verdict_field "$file" schema_version)" != 1 ] && ! gstack_doctor_skill_pins_advisory "$skill" \
&& v_project=$(gstack_install_verdict_field "$file" project) && [ "$v_project" != "$project" ]; then
detail="the install verdict checked ${v_project:-no project}, not ${project:-this directory (no project)}, and $skill runs the repo's tests"
fix="run the Capy skill installer (scripts/install.sh --for $skill) from ${project:-this directory}, then launch with the GSTACK_LAUNCH line it prints"
else
for id in $(gstack_doctor_skill_requires "$skill" 2>/dev/null); do
case " $(gstack_install_verdict_field "$file" required) " in *" $id "*) ;; *) detail="the install verdict certified for=$(gstack_install_verdict_field "$file" for), which does not cover $id that $skill needs"; break ;; esac
done
if [ -n "$detail" ] && gstack_doctor_install_profile "$(gstack_install_verdict_field "$file" for)"; then
detail="$detail ($(gstack_install_verdict_field "$file" for) certifies only the read-only helpers)"
fix="run GSTACK_CAPY_ROOT=$src bash $src/capy/skill/gstack/scripts/install.sh --for $skill (upgrades this checkout in place: ./setup and host profiles, no second clone), then launch with the GSTACK_LAUNCH line it prints"
fi
fi
v_bun=$(gstack_install_verdict_field "$file" bun_path)
if [ -z "$detail" ] && [ -n "$v_bun" ]; then
running=$(gstack_realpath "$where" 2>/dev/null)
_gstack_bun_is_shim "$where" && running=$(bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -p process.execPath </dev/null 2>/dev/null | head -1)
if [ -n "$running" ] && [ "$running" != "$v_bun" ]; then
_ia_status=runtime code=BUN_RESOLUTION_DRIFT
detail="bun $found at $running is running, but the install verdict recorded bun $(gstack_install_verdict_field "$file" bun_version) at $v_bun"
fix="launch with the GSTACK_LAUNCH line (PATH=$(dirname "$v_bun"):\$PATH), or re-run the Capy skill installer with --check (scripts/install.sh --check --for $skill) to see what changed"
fi
fi
fi
}
# gstack_install_admission BIN_DIR SKILL KIND — sets _ia_applies (0|1),
# _ia_refuse (0|1), _ia_warn (0|1), _ia_status (ok|runtime|verdict),
# _ia_line (the INSTALL: or INSTALL_VERDICT: line to print, may be empty).
# Needs gstack-bun-version.sh and gstack-doctor-components.sh sourced.
gstack_install_admission() {
local bin="$1" skill="$2" kind="$3" hard=0 capy=0 src where found detail="" fix="" code="" file trailer v_head head id project v_project running v_bun
local bin="$1" skill="$2" kind="$3" hard=0 capy=0 src where found detail="" fix="" code="" file trailer v_head head id project v_project running v_bun v_schema _ia_own _ia_m_file _ia_m_detail _ia_m_fix _ia_m_code _ia_m_status _ia_m_trailer
_ia_applies=0 _ia_refuse=0 _ia_warn=0 _ia_status=ok _ia_line=""
case "$kind" in unattended|spawned) _ia_applies=1 ;; *) return 0 ;; esac
[ "${GSTACK_CAPY:-}" = 1 ] && capy=1
@@ -136,51 +189,21 @@ gstack_install_admission() {
esac
[ -f "$file" ] || [ ! -f "${GSTACK_INSTALL_VERDICT:-}" ] || file="$GSTACK_INSTALL_VERDICT"
[ -f "$file" ] || file=$(gstack_install_verdict_legacy_path "${_GH:-$HOME/.gstack}" "$src")
code=INSTALL_UNVERIFIED
fix="run the Capy skill installer (scripts/install.sh --for $skill) on this machine, then launch with the GSTACK_LAUNCH line it prints"
if [ ! -f "$file" ]; then
detail="no install verdict at $file for $src"
fix="run the Capy skill installer (scripts/install.sh --for $skill) from ${project:-this directory}: the verdict is keyed by the project directory the installer runs in, so a run from the skill's own folder does not cover ${project:-it}; then launch with the GSTACK_LAUNCH line it prints"
else
trailer=$(gstack_install_verdict_field "$file" trailer)
v_head=$(gstack_install_verdict_field "$file" head)
head=$(git -C "$src" rev-parse HEAD 2>/dev/null)
v_schema=$(gstack_install_verdict_field "$file" schema_version)
if { [ -z "$(gstack_install_verdict_field "$file" ok)" ] && [ -z "$v_schema" ]; } || { [ "$v_schema" != 1 ] && [ "$v_schema" != 2 ]; }; then
detail="the install verdict at $file is unreadable or has an unsupported schema_version; rerun the installer"
elif [ "$(gstack_install_verdict_field "$file" ok)" != true ]; then
detail="the install verdict says: $trailer"
elif [ "$(gstack_install_verdict_field "$file" checkout)" != "$src" ]; then
detail="the install verdict is for checkout $(gstack_install_verdict_field "$file" checkout), not $src"
elif [ -z "$head" ] || [ "$v_head" != "$head" ]; then
detail="the install verdict is for another HEAD (${v_head:0:12}; $src is at ${head:0:12})"
elif [ "$(gstack_install_verdict_field "$file" pins_planning_only)" = true ] && ! gstack_doctor_skill_pins_advisory "$skill"; then
detail="the install verdict is planning-only (pins skipped for $(gstack_install_verdict_field "$file" for)) and $skill runs the repo's tests"
elif [ "$(gstack_install_verdict_field "$file" schema_version)" != 1 ] && ! gstack_doctor_skill_pins_advisory "$skill" \
&& v_project=$(gstack_install_verdict_field "$file" project) && [ "$v_project" != "$project" ]; then
detail="the install verdict checked ${v_project:-no project}, not ${project:-this directory (no project)}, and $skill runs the repo's tests"
fix="run the Capy skill installer (scripts/install.sh --for $skill) from ${project:-this directory}, then launch with the GSTACK_LAUNCH line it prints"
else
for id in $(gstack_doctor_skill_requires "$skill" 2>/dev/null); do
case " $(gstack_install_verdict_field "$file" required) " in *" $id "*) ;; *) detail="the install verdict certified for=$(gstack_install_verdict_field "$file" for), which does not cover $id that $skill needs"; break ;; esac
done
if [ -n "$detail" ] && gstack_doctor_install_profile "$(gstack_install_verdict_field "$file" for)"; then
detail="$detail ($(gstack_install_verdict_field "$file" for) certifies only the read-only helpers)"
fix="run GSTACK_CAPY_ROOT=$src bash $src/capy/skill/gstack/scripts/install.sh --for $skill (upgrades this checkout in place: ./setup and host profiles, no second clone), then launch with the GSTACK_LAUNCH line it prints"
fi
fi
v_bun=$(gstack_install_verdict_field "$file" bun_path)
if [ -z "$detail" ] && [ -n "$v_bun" ]; then
running=$(gstack_realpath "$where" 2>/dev/null)
_gstack_bun_is_shim "$where" && running=$(bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -p process.execPath </dev/null 2>/dev/null | head -1)
if [ -n "$running" ] && [ "$running" != "$v_bun" ]; then
_ia_status=runtime code=BUN_RESOLUTION_DRIFT
detail="bun $found at $running is running, but the install verdict recorded bun $(gstack_install_verdict_field "$file" bun_version) at $v_bun"
fix="launch with the GSTACK_LAUNCH line (PATH=$(dirname "$v_bun"):\$PATH), or re-run the Capy skill installer with --check (scripts/install.sh --check --for $skill) to see what changed"
fi
fi
[ -n "$detail" ] || { _ia_line="INSTALL_VERDICT: ok $trailer"; return 0; }
_gstack_ia_check
# GST-1233: each install also keeps its own verdict per workflow, so when a
# later install for another workflow replaced the shared one, this
# workflow's own record still admits it; never after a failed ./setup, which
# changed the checkout every record certified.
_ia_own="${file%.json}.for/$skill.json"
case "$skill" in ''|*[!A-Za-z0-9._-]*) _ia_own="" ;; esac
case "$trailer" in "gstack: fail setup"*) _ia_own="" ;; esac
if [ -n "$detail" ] && [ -n "$_ia_own" ] && [ -f "$_ia_own" ]; then
_ia_m_file=$file _ia_m_detail=$detail _ia_m_fix=$fix _ia_m_code=$code _ia_m_status=$_ia_status _ia_m_trailer=$trailer
file=$_ia_own _ia_status=ok
_gstack_ia_check
[ -z "$detail" ] || { file=$_ia_m_file detail=$_ia_m_detail fix=$_ia_m_fix code=$_ia_m_code _ia_status=$_ia_m_status trailer=$_ia_m_trailer; }
fi
[ -n "$detail" ] || { _ia_line="INSTALL_VERDICT: ok $trailer"; return 0; }
[ "$_ia_status" = runtime ] || _ia_status=verdict
else
return 0
+16 -2
View File
@@ -30,6 +30,10 @@
# gstack-lease release --pid <p> --run <id> [--root <checkout>]
# close run <id> on <pid>'s lease (gstack-skill-end); once every run it
# recorded is closed the lease is idle and live skips it (GST-1205)
# gstack-lease open --run <id> [--pid <p> | --from <pid>] [--root <checkout>]
# exit 0 while run <id> on a lease (<pid>'s, the harness's behind
# --from, or any) is open (not released, under 120 minutes) and its
# holder alive; 1 otherwise
# gstack-lease list [--root <checkout>]
# print `<pid> <age>m alive|idle|dead|stale` for every lease
# A pid alone is not a holder: pids are reused after a reboot or a machine
@@ -67,8 +71,8 @@ while [ $# -gt 0 ]; do
shift
done
case "$VERB" in
touch|live|list|release) ;;
-h|--help|'') sed -n '2,34p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
touch|live|list|release|open) ;;
-h|--help|'') sed -n '2,38p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "gstack-lease: unknown verb '$VERB'" >&2; exit 2 ;;
esac
[ -n "$ROOT" ] || ROOT=$(gstack_source_root "$BIN")
@@ -318,6 +322,16 @@ case "$VERB" in
fi
unlock
exit 0 ;;
open)
[ -z "$FROM" ] || PID=$(gstack_harness_pid "$FROM")
case "$PID" in *[!0-9]*) echo "gstack-lease: open needs --run <id> and an optional --pid <number>" >&2; exit 2 ;; esac
[ -n "$RUN" ] || { echo "gstack-lease: open needs --run <id> and an optional --pid <number>" >&2; exit 2; }
for f in "$DIR"/${PID:-*}; do
[ -f "$f" ] || continue
[ "$(state "${f##*/}" "$(head -1 "$f" 2>/dev/null)" "$(age_min "$f")")" = alive ] || continue
fresh "$f" | awk -v id="$RUN" '$1 == "run" && $2 == id { found = 1 } END { exit !found }' && exit 0
done
exit 1 ;;
list)
for f in "$DIR"/*; do
[ -f "$f" ] || continue
+2 -2
View File
@@ -17,7 +17,7 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import type { OutsideGate } from '../lib/outside-review-result';
import { findingsLabel, type OutsideGate } from '../lib/outside-review-result';
import { RUNNER_IDS, createRunner, familyConflict, modelFamily, outsideStatusLine, runnerTable, selectRunner, type RunnerId } from '../lib/outside-voice-runner';
import { outsideConsent } from '../lib/outside-consent';
import { apiTimeoutMs } from '../lib/outside-voice-api';
@@ -121,7 +121,7 @@ async function main(argv: string[]): Promise<number> {
fs.writeFileSync(`${path.resolve(outFile)}.meta.json`, JSON.stringify(result, null, 2) + '\n');
if (json) process.stdout.write(JSON.stringify(result) + '\n');
else {
if (result.verdict) process.stdout.write(`VERDICT: ${result.verdict.verdict}\nFINDINGS: ${result.verdict.findings.highest ?? 'none'}\n`);
if (result.verdict) process.stdout.write(`VERDICT: ${result.verdict.verdict}\nFINDINGS: ${findingsLabel(result.verdict)}\n`);
process.stdout.write(outsideStatusLine(result) + '\n');
}
if (result.status === 'completed' || result.status === 'unverified') return EXIT.ok;
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-release-gate — the full paid gate on main every cadence_hours, only
* when main moved since the last gate (docs/ship-policy.md#release-gate;
* logic: lib/release-gate.ts). The gate never moves the stable branch:
* scripts/release-cut.ts is its only writer and holds every cut while the
* newest gate is red.
*
* gstack-release-gate run [--dry-run] [--sha <main sha>] [--ignore-cadence]
* gstack-release-gate status
* gstack-release-gate bisect [--sha <red sha>] [--spend-cap <usd>] [--dry-run]
*
* Every subcommand takes --repo <owner/name> (default $GITHUB_REPOSITORY, else
* gh repo view), --policy <file> (a whole ship policy, validated; default the
* reviewed .gstack/ship-policy.json on origin/main), --remote <name> (origin),
* --summary <file> (appends each line as a bullet) and --outputs <file>
* (appends action=<action> and sha=<sha> for the workflow). run and bisect
* take --poll-seconds (60) and --budget-minutes (330): past the budget nothing
* waits, and the record stays resumable by the next run or bisect.
* Exit 0: inert, skipped, dry run, gated green, or status with no hold;
* 1: red (held), incomplete or could not finish; 2: usage.
*/
import { randomBytes } from 'node:crypto';
import * as fs from 'node:fs';
import { bisectGate, gateStatus, loadReleaseGate, runGate, type GateEnv, type GateOut } from '../lib/release-gate';
import { runGh } from '../scripts/recover-receipts';
import { gitIn } from '../scripts/train-gate';
const USAGE = 'usage: gstack-release-gate run [--dry-run] [--sha <sha>] [--ignore-cadence] | status | bisect [--sha <sha>] [--spend-cap <usd>] [--dry-run]\n'
+ ' common: [--repo <owner/name>] [--policy <file>] [--remote <name>] [--summary <file>] [--outputs <file>] [--poll-seconds <n>] [--budget-minutes <n>]';
const [command, ...rest] = process.argv.slice(2);
const flag = (name: string) => { const i = rest.indexOf(`--${name}`); return i >= 0 ? rest[i + 1] : undefined; };
const has = (name: string) => rest.includes(`--${name}`);
const usage = (why?: string): never => { console.error(why ? `${why}\n${USAGE}` : USAGE); process.exit(2); };
if (command === '--help' || command === '-h') { console.log(USAGE); process.exit(0); }
if (!['run', 'status', 'bisect'].includes(command ?? '')) usage();
const sha = flag('sha');
if (sha !== undefined && !/^[0-9a-f]{40}$/.test(sha)) usage('--sha needs a full 40-hex commit');
const spendCap = flag('spend-cap') === undefined ? undefined : Number(flag('spend-cap'));
if (spendCap !== undefined && !(spendCap >= 0)) usage('--spend-cap needs dollars');
const repoRoot = process.cwd();
const repo = flag('repo') ?? process.env.GITHUB_REPOSITORY ?? runGh(['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], 60_000).stdout.trim();
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) usage(`cannot name the repository (${JSON.stringify(repo)}); pass --repo <owner/name>`);
const loaded = loadReleaseGate(repoRoot, { policyFile: flag('policy') });
const env: GateEnv = {
repo, repoRoot, remote: flag('remote') ?? 'origin', gh: runGh, git: gitIn(repoRoot), now: Date.now, sleep: ms => Bun.sleepSync(ms),
random: () => randomBytes(3).toString('hex'), pollMs: Number(flag('poll-seconds') ?? 60) * 1000,
deadline: Date.now() + Number(flag('budget-minutes') ?? 330) * 60_000,
};
let out: GateOut;
if ('error' in loaded) out = { action: 'failed', sha: null, lines: [`RELEASE: policy refused: ${loaded.error}`] };
else if (command === 'run') out = runGate(env, { ...loaded, sha, dryRun: has('dry-run'), ignoreCadence: has('ignore-cadence') });
else if (command === 'bisect') out = bisectGate(env, { ...loaded, sha, spendCap, dryRun: has('dry-run') });
else out = gateStatus(env, loaded);
const summary = flag('summary');
for (const line of out.lines) {
console.log(line);
if (summary) fs.appendFileSync(summary, `- ${line}\n`);
}
const outputs = flag('outputs');
if (outputs) fs.appendFileSync(outputs, `action=${out.action}\nsha=${out.sha ?? ''}\ncheck=${'gate' in loaded && loaded.gate ? loaded.gate.check_name : ''}\n`);
process.exit(out.action === 'held' || out.action === 'failed' ? 1 : 0);
+16 -4
View File
@@ -57,7 +57,8 @@ const HELP = `gstack-review-dispatch — verbatim reviewer briefs and loss-free
judge --ledger <f> --merged <merged.jsonl> --judged <judged.jsonl>
judged lines {"id","severity","reason"?,"duplicate_of"?}: every merged id kept at its severity or above (a
duplicate folds into one at least as severe); added and promoted findings need a reason. JUDGMENT: ok …, or
JUDGMENT_REJECTED: dropped=… downgraded=… (exit 1)
JUDGMENT_REJECTED: dropped=… downgraded=… (exit 1). An empty merged.jsonl needs no judged file: judge writes an
empty one and prints JUDGMENT: ok nothing to judge; a merge with findings and no judged file is refused (exit 2)
verify --ledger <f> [--targeted-out <file>]
BRIEF, OUTPUT and JUDGE rows from the ledger alone, then VERIFY: clean (exit 0), VERIFY: dirty (exit 1) or
VERIFY: no brief records (exit 3: this run's ledger kept no prompts). A ledger with a targeted brief also prints
@@ -69,6 +70,7 @@ const HELP = `gstack-review-dispatch — verbatim reviewer briefs and loss-free
runs gstack-review-plan and writes <out>/pass-manifest.json: stage first (audit readers marked first in --extras),
specialists (with the other --extras readers), red-team, voices (Step 11). Renders first and specialists now; prints
the plan, MANIFEST: and one READER: line per reader. A plan whose NEXT is not full, scoped or fix-check prints PASS: none.
<id> is letters, digits and dashes, read as lowercase (P1 is p1): the manifest, reader titles and a resume use that form.
A fix-check reader (stage specialists) renders from decision 3.5's --fix-list, --hunks, --tests and --callers files.
--diff-base sets every reader's base, a scoped pass's Red Team included (the targeted reader keeps the merge base).
An existing manifest for the same --pass is reused (resumed). --extras: [{"name","prompt_file","first"?,"model"?}]
@@ -194,8 +196,9 @@ function ceiling(context: string, base: string, outDir: string, readers: Readonl
}
function pass(): number {
const base = flag('--base'), out = flag('--out'), id = flag('--pass');
if (!base || !out || !id || !/^[a-z0-9-]+$/.test(id)) { process.stderr.write('gstack-review-dispatch pass: needs --base <ref>, --out <dir> and --pass <id> (lowercase letters, digits, dashes)\n'); return EXIT.usage; }
const base = flag('--base'), out = flag('--out'), given = flag('--pass'), id = given?.toLowerCase();
if (!base || !out || !id) { process.stderr.write('gstack-review-dispatch pass: needs --base <ref>, --out <dir> and --pass <id>\n'); return EXIT.usage; }
if (!/^[a-z0-9-]+$/.test(id)) { process.stderr.write(`gstack-review-dispatch pass: --pass ${JSON.stringify(given)} is not a pass id: use letters, digits and dashes (read as lowercase, so P1 is p1)\n`); return EXIT.usage; }
const file = manifestPath(out);
if (fs.existsSync(file)) {
const m = readManifest(file);
@@ -425,7 +428,16 @@ function main(): number {
const merged = flag('--merged');
const judged = flag('--judged');
if (!merged || !judged) { process.stderr.write('gstack-review-dispatch judge: needs --merged <file> and --judged <file>\n'); return EXIT.usage; }
const r = recordJudgment(ledger, readJsonl<MergedLine>(merged), readJsonl<JudgedLine>(judged), judged, merged);
const lines = readJsonl<MergedLine>(merged);
const nothing = !lines.length && !fs.existsSync(judged);
if (lines.length && !fs.existsSync(judged)) {
process.stderr.write(`gstack-review-dispatch judge: ${judged} does not exist; ${merged} holds ${lines.length} finding${lines.length === 1 ? '' : 's'}, so lead judgment writes one judged line per finding first\n`);
return EXIT.usage;
}
if (nothing) fs.writeFileSync(judged, '', { mode: 0o600 });
const r = recordJudgment(ledger, lines, readJsonl<JudgedLine>(judged), judged, merged);
if (nothing && r.ok) { process.stdout.write(`JUDGMENT: ok nothing to judge (${merged} holds no findings; wrote an empty ${judged})\n`); return EXIT.ok; }
if (nothing) fs.rmSync(judged, { force: true });
process.stdout.write(r.ok ? `JUDGMENT: ok added=${r.added.length} promoted=${r.promoted.length} folded=${r.folded.length}${r.dismissed.length ? ` dismissed=${r.dismissed.length}` : ''}\n`
: `JUDGMENT_REJECTED: dropped=${JSON.stringify(r.dropped)} downgraded=${JSON.stringify(r.downgraded)} unexplained=${JSON.stringify(r.unexplained)} invalid=${JSON.stringify(r.invalid)} (lead judgment may add or promote a finding, never drop or downgrade one; a summary_candidate line may be lowered with a reason)\n`);
return r.ok ? EXIT.ok : EXIT.fail;
+1 -1
View File
@@ -216,7 +216,7 @@ function main(): number {
const gateScan = { tagged: gateTagged, head };
if (has('--json')) {
const { runtimeLines, stepDepth, depthReasons, ...rest } = plan;
process.stdout.write(JSON.stringify({ ...rest, review_policy: review, platform_review: platform ? (platform.ok ? platform.review : { none: platform.reason }) : null, targeted: { ...targeted, brief, targeted_patch_id: patch }, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, base: collected.base, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed, drawings: collected.drawings,
process.stdout.write(JSON.stringify({ ...rest, review_policy: review, platform_review: platform ? (platform.ok ? platform.review : { none: platform.reason }) : null, targeted: { ...targeted, brief, targeted_patch_id: patch }, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, base: collected.base, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed, drawings: collected.drawings, executables: collected.executables,
...(exclusion.state === 'head-only' ? { review_exclude: exclusion } : active ? { review_exclude: { state: 'active', ...active.record, specs: active.specs, keep: active.keep, kept_files: active.kept, content: active.content, references: active.references } } : {}) }) + '\n');
return EXIT.ok;
}
+1
View File
@@ -278,6 +278,7 @@ function cmdSummary(rest: string[], json: boolean): number {
const trailers = log.stdout.split('\x1e').flatMap(rec => { const [sha, values] = rec.trim().split('\0'); return sha && values ? parseFixTrailers(sha, values.split('\x1f').map(v => v.trim()).filter(Boolean)) : []; });
const summary = summarizeReviewLoop(rows, since, ledgers, files.map(f => ledgerPass(f, keyOf(f))), trailers);
for (const u of summary.unbound ?? []) process.stderr.write(`REVIEW_SCOPE_UNBOUND: ${u}\n`);
for (const v of summary.voided ?? []) process.stderr.write(`REVIEW_SCOPE_VOIDED: ${v}\n`);
const line = reviewLoopLine(summary);
// A /ship invocation always runs Step 9 once; zero matching rows means the wrong --since or a review log written elsewhere, never a loop to record.
if (summary.passes === 0) return refuse('summary', 'REVIEW_SCOPE_INPUT', `no review-log row of this branch is a via:ship review at or after ${since}; check REVIEW_SINCE (${line})`);
+11 -1
View File
@@ -14,6 +14,7 @@
* gstack-ship-queue gate-ahead --list --pr <n>
* gstack-ship-queue verify --pr <n> --screen (train policy: the head's screen verdict from its run)
* gstack-ship-queue verify --post-merge <sha> [--timeout <s>] (after a merge: main's push workflows at that commit until they settle)
* gstack-ship-queue land --pr <n> --pin <sha> [--dry-run] (merge: direct; lib/ship-queue-land-direct.ts)
* gstack-ship-queue land --pr <n> [--dry-run] [--no-wait] [--break-lock [--break-train-lock]] (the merge step; lib/ship-queue-land.ts)
* gstack-ship-queue land --pr <n> --repair (after QUEUE_LAND_DRIFT: open the outside merge's release PR)
* gstack-ship-queue land --census
@@ -57,6 +58,8 @@ const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (doc
verify --pr <n> --screen (train policy) the PR head's screen verdict read from its run (SCREEN_VERDICT)
verify --post-merge <sha> [--timeout <s>] wait for every check run at a merged commit to settle: POST_MERGE: green|red|pending
and one CHECK: line per workflow; resumable, default timeout 2700 s
land --pr <n> --pin <sha> [--dry-run] merge: direct: merge the PR at its own green head (receipt and READY at the pin, no stamp
files, checks green, mergeable clean), verify the tree, write a land note
land --pr <n> [--dry-run] [--no-wait] [--timeout <min>] [--break-lock [--break-train-lock]] [--allow-repo-commands]
the merge step: lock, merge the base, stamp (fragments folded), push the stamp commit,
wait for its checks, squash with --match-head-commit, confirm the base tree; resumable
@@ -86,7 +89,7 @@ function finish(cmd: string, out: StepOutput): number {
}
process.stdout.write(formatResult('ship-queue', out.ok ? 'complete' : 'refused', out.run) + '\n');
if (out.ok) return EXIT.ok;
return out.failure && ['QUEUE_VERIFY_MISMATCH', 'QUEUE_STAMP_NOT_STAMP_ONLY'].includes(out.failure.code) ? EXIT.fail : EXIT.refused;
return out.failure && ['QUEUE_VERIFY_MISMATCH', 'QUEUE_STAMP_NOT_STAMP_ONLY', 'QUEUE_LAND_UNVERIFIED'].includes(out.failure.code) ? EXIT.fail : EXIT.refused;
}
const shq = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
@@ -163,6 +166,8 @@ async function main(argv: string[]): Promise<number> {
if (cmd === 'train') {
const train = await import('../lib/ship-queue-train-cli');
if (!args[0] || args[0].startsWith('-')) { process.stdout.write(train.TRAIN_HELP + '\n'); return args.includes('--help') || args.includes('-h') ? EXIT.ok : EXIT.usage; }
const loaded = args[0] === 'build' || args[0] === 'land' ? loadBasePolicy(ctx) : null;
if (loaded && !('message' in loaded) && loaded.policy.merge === 'direct') return finish(`train ${args[0]}`, { ok: false, lines: [], run: 'none', failure: { code: 'QUEUE_NOT_READY', message: 'the base policy sets merge: direct, so trains are off', fix: 'land each READY PR with gstack-ship-queue land --pr <n> --pin <sha> (docs/ship-policy.md#land-direct)' } });
return train.trainMain(ctx, args);
}
if (cmd === 'wave') return (await import('../lib/ship-queue-wave')).waveMain(ctx, args);
@@ -171,6 +176,11 @@ async function main(argv: string[]): Promise<number> {
const l = await import('../lib/ship-queue-land');
if (args.includes('--census')) return finish('land', l.census(ctx));
if (!pr || !/^\d+$/.test(pr)) return usage('land needs --pr <number> (or --census)');
if (args.includes('--pin')) {
const pin = flag(args, '--pin') ?? '';
if (!/^[0-9a-f]{40,64}$/.test(pin)) return usage('--pin needs the full head sha the READY names');
return finish('land', (await import('../lib/ship-queue-land-direct')).landDirect(ctx, { pr, pin, dryRun: args.includes('--dry-run') }));
}
const timeout = flag(args, '--timeout');
return finish('land', l.land(ctx, { pr, repair: args.includes('--repair'), dryRun: args.includes('--dry-run'), noWait: args.includes('--no-wait'), breakLock: args.includes('--break-lock'), breakTrainLock: args.includes('--break-train-lock'), allowRepoCommands: args.includes('--allow-repo-commands'), waitTimeoutMin: timeout && /^\d+$/.test(timeout) ? Number(timeout) : undefined }));
}
+1 -1
View File
@@ -13,5 +13,5 @@ What seven Captain crews agreed on. Your crew's CREW.md and recorded approvals d
9. **Flakes: one rerun, a ledger, a daily fix.** Only the policy's rerun, never for a harness or infra red; a second red is real; never loosen a test. [docs/captain.md: The daily flaky pass]
10. **Quiet.** Message on a decision, a blocker, a READY or a finished phase. A broadcast needing no reply goes in the Captain's notes; never reply just to acknowledge.
11. **Ask once, plainly.** One ask per decision, explained like the owner is five: your Captain's name, then a lettered line each: "A: <code> hit problem p, which happens when h. We could do 1) x, 2) y or 3) z. Recommendation x because why. A1 to take recommendation." "do it" takes every recommendation; "A2" takes option 2. Owner tasks: the exact link (in Capy: path and field), numbered steps, the reply word. Never re-ask a standing rule; check what you can yourself. [docs/captain.md: The ask format]
12. **Fresh threads, closed out.** One thread per unit of work (crew threads ask their Captain); past the context limit, hand off. Its Captain archives only its crew's threads; threads outside every crew are the owner's, even its automation's. Size reviews to the diff; read the token counter. [docs/ship-budget.md]
12. **Fresh threads, closed out.** One thread per unit of work; past the context limit, hand off. Crew threads never create threads: ask your Captain (Captain to Captain for another project), or report one you made by code for crew.add. Its Captain archives only its crew's threads; threads outside every crew are the owner's, even its automation's. Size reviews to the diff; read the token counter. [docs/ship-budget.md; docs/captain.md: New threads join the crew]
13. **The owner's model preference.** `gstack-models resolve --role agent` picks agent models, except cross-family voices and eval subjects. [docs/model-policy.md: Your model preference]
+35 -4
View File
@@ -17,6 +17,12 @@
# checkout it prints `gstack: ok not-needed for=none (...)` and exits 0; with
# one it prints the same trailer with the checkout's version, running nothing
# from the checkout. That ok admits no workflow (no verdict is written).
# From revision 4 the clone and the floor fast-forward follow the update
# channel (docs/releases.md#channels): GSTACK_UPDATE_CHANNEL=stable|main, else
# the checkout's bin/gstack-update-channel.sh (auto: stable in team mode),
# else main. On stable, a fresh clone checks out origin's stable branch and
# the floor fast-forward goes to stable, each only when stable exists and
# meets the supported minimum; otherwise main, as before. --revision wins.
# Everything else (Bun, setup, the doctor, the verdict, the launch line) is
# the checkout's. The last line is the `gstack:` trailer; exit 0 only on
# `gstack: ok`. Contract: docs/capy-install.md; tests:
@@ -27,9 +33,10 @@
# bash install.sh --check --for none this machine runs no workflow: ok not-needed, nothing installed
# bash install.sh [--for <skill> | --require <id,id>] [--with-browser] [--revision <ref>] [--project <path>]
# Environment: GSTACK_CAPY_ROOT (default ~/.local/share/gstack), GSTACK_CAPY_REPO,
# GSTACK_CAPY_AUTO_UPGRADE=0 (never fast-forward), GSTACK_CAPY_FETCH_TIMEOUT (s).
# GSTACK_CAPY_AUTO_UPGRADE=0 (never fast-forward), GSTACK_CAPY_FETCH_TIMEOUT (s),
# GSTACK_UPDATE_CHANNEL=main (follow every merge even in team mode) or stable.
set -u
GSTACK_CAPY_WRAPPER=3
GSTACK_CAPY_WRAPPER=4
self="$(cd "$(dirname "$0")" && pwd -P)/$(basename "$0")"
root="${GSTACK_CAPY_ROOT:-$HOME/.local/share/gstack}"
@@ -74,6 +81,24 @@ below() {
return 1
}
support_get() { printf '%s\n' "$1" | sed -n "s/^$2=\([0-9][0-9.]*\)[[:space:]]*\$/\1/p" | head -1; }
# channel_stable ROOT MIN [FETCH_FLAG] — true, with origin's stable commit in $stable_sha, when
# this install follows stable and origin's stable exists and meets MIN; says why not otherwise.
# FETCH_FLAG is --depth=1 for a fresh clone only (never --depth into an existing checkout).
channel_stable() {
local ch="${GSTACK_UPDATE_CHANNEL:-}" ver
case "$ch" in stable|main) ;; *) ch=$(bash "$1/bin/gstack-update-channel.sh" "$1" 2>/dev/null | head -1) ;; esac
[ "$ch" = stable ] || return 1
if ! bounded git -C "$1" fetch --quiet ${3:+"$3"} origin stable 2>/dev/null; then
echo "gstack-capy-skill: channel stable, but origin has no stable branch yet (or the fetch failed); following main"
return 1
fi
stable_sha=$(git -C "$1" rev-parse FETCH_HEAD) ver=$(git -C "$1" show FETCH_HEAD:VERSION 2>/dev/null | head -1)
if [ -n "$2" ] && below "${ver:-0}" "$2"; then
echo "gstack-capy-skill: channel stable is at ${ver:-unknown}, below the supported minimum $2; following main"
return 1
fi
echo "gstack-capy-skill: channel stable ${ver:-unknown} (GSTACK_UPDATE_CHANNEL=main follows every merge)"
}
mkdir -p "$(dirname "$root")"
if [ "$check" -eq 0 ]; then
@@ -100,6 +125,10 @@ if [ ! -f "$root/setup" ]; then
fi
remote_min=$(support_get "$(cat "$root/capy/support.env" 2>/dev/null)" GSTACK_MIN_REVISION)
remote_version=$(head -1 "$root/VERSION" 2>/dev/null)
if [ -z "$revision" ] && channel_stable "$root" "$remote_min" --depth=1; then
git -C "$root" reset --quiet --hard "$stable_sha" \
|| { echo "gstack: fail installer — could not check out origin/stable in $root; fix: GSTACK_UPDATE_CHANNEL=main $fix_cmd"; exit 1; }
fi
export GSTACK_CAPY_CLONED="$repo"
else
# The remote's rules: fetch the default branch into FETCH_HEAD (never --depth
@@ -140,9 +169,11 @@ else
echo "gstack: fail revision — installed $installed is below the supported minimum $remote_min ($cause); fix: $fix (REVISION_BELOW_MINIMUM)"
exit 1
fi
if ! git -C "$root" merge --ff-only -q FETCH_HEAD 2>/dev/null; then
target=$(git -C "$root" rev-parse FETCH_HEAD)
if channel_stable "$root" "$remote_min" && git -C "$root" merge-base --is-ancestor HEAD "$stable_sha" 2>/dev/null; then target=$stable_sha; fi
if ! git -C "$root" merge --ff-only -q "$target" 2>/dev/null; then
git -C "$root" fetch --quiet --unshallow origin "$default" 2>/dev/null
git -C "$root" merge --ff-only -q FETCH_HEAD 2>/dev/null \
git -C "$root" merge --ff-only -q "$target" 2>/dev/null \
|| { echo "gstack: fail revision — installed $installed is below the supported minimum $remote_min (fast-forward failed); fix: git -C $root status, then $fix_cmd (REVISION_BELOW_MINIMUM)"; exit 1; }
fi
echo "gstack-capy-skill: upgraded $installed -> $(head -1 "$root/VERSION")"
@@ -0,0 +1,2 @@
- **The land stamp no longer rewrites other projects' versions.** A PR that wrote `QM v0.1.15` into a README listed in `stamp_files` landed as `QM v0.0.115`, because the stamper read any `vX` newer than the branch's fork point on a line the PR added as the PR's own tentative stamp, and the tree receipt called the rewrite stamp-only. Built-in `vX`, `since: X` and `(was X)` mentions above the version being stamped now move only when the branch itself recorded that version, so a third-party version above it survives byte for byte and a rewrite of one reads as a product change. A declared `stamp_patterns` slot keeps the old bound but moves only inside the slot. (GST-1245, reported by HARN-7)
- **`land` restamps after a stamp is reset.** When a PR branch was reset to its reviewed pin after land stamped it, the rerun resumed the old journal and refused with `QUEUE_TRAIN_MOVED` until the journal was deleted by hand. Land now discards that journal, says so on a `JOURNAL:` line, and restamps the pin. (GST-1245)
+12
View File
@@ -0,0 +1,12 @@
**A full `gstack-ship-preflight` no longer merges the base under an open /ship run's reviews (GST-1204).** Running the full preflight again in the middle of /ship merged the base into the working tree (Step 3) while review readers were diffing that tree, which voided a review pass (gbrain-evals #317, a Capy lane).
### Fixed
- **`gstack-ship-preflight`:** `--run <id>` records the /ship run once Steps 1-3 pass: a marker in the checkout's git dir, and an open run on the checkout's own lease, so the Capy driver needs no workflow lease. A later full preflight on that checkout stops with exit 3, before any fetch or merge, until `gstack-ship-release finish` clears the marker or the run is 120 minutes old (GST-1205's rule) or its holder is gone. The STOP names the run and the marker and says what to do: rerun Step 5 with `--tests-only`, let the /ship finish, or remove the marker of a run that stopped. `--tests-only` and `--audit` are never stopped. /ship's own first preflight, and a rerun after a Step 3 STOP, still work. A new `--run` id from the open run's own holder (the Claude Code session, or Capy's `capy-server`) replaces a run that stopped before finish; the same id, no `--run`, or another live session's run still stops. A `--run` the lease can't record (its lock held by a live process) stops before the fetch instead of going on unprotected. A `--run` value that isn't a plain id is a usage error (2).
- **`gstack-ship-release finish`:** clears the run's marker once Steps 15-16 pass.
- **`gstack-lease open --run <id> [--pid <p> | --from <pid>]`:** exits 0 while that run is open on a live holder and 1 otherwise, using the same released, 120-minute and dead or reused-pid rules as `live`.
- **/ship Step 1** passes `--run <SESSION_ID>`, and **`ship/DRIVER.md` Phase 2** passes `--run <run dir name>`.
- **Docs:** `docs/troubleshooting.md#ship-preflight-run-open` explains the new STOP; `docs/capy-install.md` lists `gstack-lease open`.
### Tests
- `test/ship-preflight.test.ts`: a full rerun with the run open stops before the fetch and leaves HEAD, `origin/main` and the tree untouched, with or without `--run`; the driver's Phase 2 sequence refuses a mid-run full preflight while `--audit` and `--tests-only` go on; a finished or aged-out run doesn't block; after a stopped run and `gstack-skill-end`'s real release, a new id from the same holder goes on while another live holder's run is still refused; an unrecordable `--run` stops before the fetch; /ship's first preflight and its rerun after a conflict STOP work; a bad `--run` is usage.
- `test/gstack-lease.test.ts`: `open` for an open, released, aged-out, reused-pid, dead and unrecorded run, with `--pid`, `--from` or neither, plus usage errors.
+11
View File
@@ -0,0 +1,11 @@
**Three /ship tooling fixes from the gbrain-evals F1 rollout (GST-1204).** Version strings quoted in lane reports no longer trip the secret scan, `gstack-pr-shots` renders on a fresh Capy machine, and `gstack-models` explains why it has no outside-voice role.
### Fixed
- **Secret scan (`pii.ip_public`):** a four-part version that gstack names right before it, shaped like a gstack release (major 0 to 2), is not read as a public IPv4 address. That covers `gstack 1.94.4.0`, `upgraded gstack from 1.93.14.0`, and the install check's trailers `gstack: ok 1.94.4.0 for=ship` and `gstack: fail claude,codex (1.93.37.0)`. It also covers the far end of a range whose first end is a declared version with the same major (`gstack 1.94.4.0 to 1.94.8.0`). Before this, every push and PR-body post that quoted those lines stopped on a MEDIUM finding. Real addresses still report: `gstack 8.8.8.8`, `gstack talks to 8.8.8.8`, `gstack-proxy 1.128.1.0`, `my-gstack 1.94.4.0`, `landed in 1.94.4.0`, `gstack 1.94.4.0 - 8.8.8.8`, and a range with no declared first end.
- **`gstack-pr-shots` and every renderer that uses gstack's own browser:** when Playwright's Chromium isn't installed (the Capy install defers it to first use), the renderer now runs the checkout's `bin/gstack-browser-ensure` once and retries. Before, it stopped with `browse newtab failed: [browse] Starting server...`, which hid the cause. When the install fails, the error names the launch failure and the installer's last line instead of the daemon's banner.
- **`gstack-models resolve --role outside-voice`:** it still exits as a usage error, but now says the outside voice follows the harness, not the model policy, and points to docs/model-policy.md.
### Tests
- `test/redact-context-exemptions.test.ts`: six gstack-named version shapes stay clean; twelve address controls still report MEDIUM.
- `test/aside-render.test.ts`: a missing Chromium runs gstack-browser-ensure once and the render proceeds; the install time doesn't count against the render deadline; a failed install names the launch error; the existing newtab cases are unchanged.
- `test/model-policy-cli.test.ts`: the outside-voice hint.
+1
View File
@@ -0,0 +1 @@
- **Three /ship papercuts from gstack-evals#191 are fixed (GST-1219).** `gstack-review-dispatch judge` no longer crashes when every reader came back clean: an empty `merged.jsonl` with no `judged.jsonl` now writes an empty judged set and prints `JUDGMENT: ok nothing to judge`, while a merge with findings and no judged file is refused with the finding count. `gstack-review-dispatch pass --pass` reads its id as lowercase, so `--pass P1` runs pass `p1`; an id with any character other than letters, digits and dashes gets its own error naming that rule instead of the missing-flag message. `gstack-docs-candidate snapshot --docs` takes a comma-separated list as well as repeated flags; a value that names an existing path is kept whole, and a space-separated list or an empty value is refused with a pointer to commas or repeated flags.
+13
View File
@@ -0,0 +1,13 @@
**A Ubicloud run survives a dead controller (GST-1220).** When the machine that ran `ubi-runner.sh up` died, its VM kept running but its ssh key and state lived only on that machine, so no other machine could pull the results or destroy the VM before its TTL. Paid runs were lost with the controller.
### Added
- **Encrypted recovery bundles and `recover`.** With `UBI_RECOVERY_DIR` set, `up` writes each VM's key, state and tags to `<dir>/<owner>/<vm>.bundle` (mode 600). The bundle is AES-256 through `openssl enc` under keys stretched with PBKDF2 from `UBI_RECOVERY_KEY`, or `UBICLOUD_API_KEY` when that is unset. An HMAC-SHA256 is checked before anything is decrypted, and a bundle renamed or moved to another VM or owner is refused. `recover [--owner OLD]` on another machine rebuilds the state, so `pull`, `ssh` and `down` work there, and prints `RECOVERED`, `RECOVER_GONE` or `RECOVER_FAILED` for each VM. A confirmed `down` deletes the bundle. Run `recover` only after the creating machine is dead, because it can't tell a paused controller from a dead one.
- **`--counted` runs require a bundle.** `up`, `run` and `verify` take `--counted` (or `UBI_COUNTED=1`) and refuse before any create with `UBI_RECOVERY_REQUIRED` or `UBI_RECOVERY_UNAVAILABLE` when no bundle can be written.
- **`--tag key=value`** is kept in the VM's state and bundle, and `recover` prints it, so a shard map can be rebuilt from the bundles.
- **A results sink on the VM, off by default.** `up --sink https-put:<presigned URL> --sink-path PATH` starts a pusher on the VM that uploads `heartbeat.json` and the PATH entries as `results.tgz` every `UBI_SINK_INTERVAL` seconds (300 by default), so results still arrive when every controller is gone. No account credential goes to the VM. The URL is written to a 600 curl config file over ssh, never into a command line or a log, and the run record never keeps it (pass it as `UBI_SINK`). Snapshots skip `~/.ubirun-*`, and one over `UBI_SINK_MAX_MB` is never uploaded, so the last good one stays. `down` stops the pusher and makes one last push, and a failed one prints `SINK_FLUSH_FAILED` and turns a passing `run` or `verify` of that VM into exit 1. A counted rerun refuses to reattach to a VM whose bundle is missing or doesn't open. No bucket exists yet, so nothing uses the sink until a run names one.
### Changed
- **`--pass NAME` forwards only what you exported.** The value is the one your environment held when the runner started, and the runner refuses `UBICLOUD_*`, `UBI_RECOVERY_KEY`, `UBI_SINK` and every variable it sets itself, so a variable inside the runner can't stand in for yours or carry its recovery secret or sink URL to a VM.
### Tests
- `test/ubicloud-runner-recovery.test.ts` (new, offline on the fake VM) covers a controller dying after `up` while the sink keeps pushing, recovery on a second controller followed by `pull` and `down`, wrong-secret, tampered, renamed and moved bundles, an API outage and a gone VM, the counted refusals, the sink-spec rules, a failed final push, an oversized snapshot, and a reaped VM's failed push not failing an unrelated run. It checks every output stream and the ssh log for key bytes and the presigned URL.
+11
View File
@@ -0,0 +1,11 @@
**`gstack-post pr-body` keeps a PR's opening paragraph and Capy badge, and a GitHub Actions run list no longer reads as a phone number (GST-1227).** Reported from truther#1174 on /ship 1.94.15.
### Fixed
- **A /ship post keeps the text above the PR's first heading.** Since GST-412, a body file with a receipt above its first `## ` section dropped everything above the PR's first heading, so a /ship run on a Capy-opened PR deleted the opening paragraph and the `<!-- capy-badge:start -->…<!-- capy-badge:end -->` badge. That text is now kept byte for byte and the new receipt goes right under it, so the next /ship post doesn't read it as a stale note under the receipt. Opening lines the file brings of its own still replace it, and a stale note between the receipt and the first heading is still dropped and named on stderr.
- **A Capy badge at the end of the body stays there.** A `<!-- capy-badge:start -->` … `<!-- capy-badge:end -->` block that ends the body after its first receipt or heading is now a piece of its own, so rewriting the section above it no longer deletes it, new sections go above it, and a badge only the file brings goes last, once, so reposting the same file gives the same bytes. Every other marker block, a badge with text after it and an unclosed marker stay inside their section, as before.
- **Every run id in a `runs=<id>/<attempt>,<id>/<attempt>` list is exempt from `pii.phone.e164`.** Only the id right after `runs=` was exempt (and only at 11 digits), so the CI carry and run clear receipts `lib/run-evidence.ts` prints needed `--confirm` from their second run on. The scanner now exempts each 11- or 12-digit id of that list (`run=` too) and an id after an `actions/runs/` path with no leading `/` when it starts the line or follows whitespace, `(`, `[`, `<` or a quote. Each id needs its `/<attempt>`, so `runs=4155550123`, `runs=4155550123/1`, `sales-actions/runs/4155550123` and `+1 415 555 0123` on the same line still report. See docs/troubleshooting.md#redact-run-id-as-phone.
### Tests
- `test/gstack-post.test.ts`: "a full post keeps the opening text and a badge block above the first heading or at the end of the body byte for byte" and "run ids in a runs= list or an actions/runs/ path are not phone numbers; a phone number beside them still is" both fail on main. Four GST-412 tests that expected the opening text to be dropped now expect it kept.
- `test/gstack-post.test.ts`: "a marker block that is not a trailing Capy badge stays inside its section", "a trailing badge the file brings lands at the end once and reposts byte for byte", "a fenced heading inside a marker block and an unclosed badge marker change nothing about the sections" and "two marker blocks with the same name merge by their sections" pin the main-branch handling of every other marker block.
- `test/redact-context-exemptions.test.ts`: run ids in a `runs=` list or an `actions/runs/` path stay clean (5 cases, all failing on main), and 10 controls, `call +1 415 555 0123` and `sales-actions/runs/4155550123` among them, still report.
+6
View File
@@ -0,0 +1,6 @@
**Design exits ease out, never ease in (GST-1240).** `/design-consultation` wrote `exit(ease-in)` into every DESIGN.md it made, and `/design-review` checked against the same rule, so an exit that lags was both generated and never flagged. Ease-in starts slow at the moment the user is watching, so a dismissal, menu close or toast exit reads as lag. Rewrite of #3900 by @nfitzsimmons.
### Fixed
- **`/design-review` motion checklist:** ease-out to enter and exit, ease-in-out to move, and flag plain ease-in. The new line is one byte longer than the old one, so /design-review stays inside its size cap.
- **`/design-consultation` DESIGN.md template:** `exit(ease-in)` becomes `exit(ease-out)`.
- **gstack's own `DESIGN.md`:** the motion easing matches, `exit(ease-out)`.
+8
View File
@@ -0,0 +1,8 @@
**`gstack-ubi sync` and `pack` work on a subdirectory of a git checkout (GST-935).** Packing any folder that sat inside a checkout but was not its root died with "does not appear to be a git repository", after the VM was already up, because pack fetched the checkout's refs from the subdirectory and git only fetches from a repository's root. The gstack-evals launcher syncs `quick/laneb/runner`, so every launch from a git checkout failed at its first sync.
### Fixed
- **A subdirectory packs its own files.** Pack ships the files git lists under that folder (tracked, edited and untracked, not ignored) with no `.git`. A whole-repository `.git` would show the VM every file outside the folder as deleted. Packing a checkout's root and packing a non-git folder work as before.
- **A subdirectory git lists no files under is refused.** When the checkout ignores the folder, pack exits with "pack: git lists no files under <dir>" instead of uploading an empty folder, which a run could otherwise pass on.
### Tests
- `test/ubicloud-runner.test.ts`: packing a subdirectory of a checkout and of a linked worktree ships its tracked, edited and untracked files without a `.git` and leaves out ignored ones, and an ignored subdirectory is refused. This test fails on 1.92.58.0.
+17
View File
@@ -0,0 +1,17 @@
---
bump: minor
---
**A release gate can now gate main every 8 hours instead of on every push, and it feeds the stable release.** It is off until main's `.gstack/ship-policy.json` declares a `release_gate` block, so this release changes nothing on its own. With the block, `.github/workflows/release-gate.yml` runs `gstack-release-gate run` every 8 hours. When main moved since the last gate, it runs the full paid gate on main's head once, records the verdict as a `release` note in `refs/notes/gstack-trains`, and publishes the `main-backstop` check run on that commit. A red gate bisects its red cases within `bisect_spend_cap_usd` and names the first bad commit. The per-push main backstop stands down while the block is set, so main is never gated twice. `scripts/release-cut.ts` is still the only thing that moves `stable`, and its 72-hour floor and exceptions are unchanged. It now reads a green release record as proof the commit was gated, and it refuses every cut while the newest release gate is red with no green gate on a later commit. That hold is the freeze. See docs/ship-policy.md#release-gate.
### Added
- **`release_gate` policy key** (`lib/release-gate-policy.ts`, which needs `min_gstack_version` at this release): `cadence_hours` (8 to 168), `gate_workflow`, `branch`, `stable_ref`, `check_name`, `on_red: hold-release` and `bisect_spend_cap_usd`, every field required.
- **`bin/gstack-release-gate`** (`lib/release-gate.ts`): `run [--dry-run] [--sha] [--ignore-cadence]`, `status` and `bisect`. `--policy <file>` reads the block from a file, for a dry run or a proof before adoption. Exit 0 means gated, skipped, inert or a dry run; 1 means held, failed or a refused policy; 2 is a usage error.
- **`.github/workflows/release-gate.yml`**: an 8-hour schedule and a manual dispatch. The check run comes from a separate job that holds `checks: write` and runs no repository code.
- **Capy skill installer follows the update channel** (`capy/skill/gstack/scripts/install.sh`, wrapper revision 4). With `GSTACK_UPDATE_CHANNEL=stable` or a stable `update_channel`, a fresh clone or a floor upgrade goes to `stable` when it meets the support floor, and to main otherwise. `--revision` still wins.
### Changed
- **`evals-main-backstop.yml`** stands down (no gate, no freeze, no check run) when main's policy declares `release_gate`.
- **`evals.yml`** routes a release gate bisect (`release-gate/<id>` refs) only under a `release_gate` block at the base revision, and that bisect publishes its prefix records as `gstack-release-record`.
### Tests
- `test/release-gate.test.ts` drives run, bisect and status against a fake `gh` and a scratch remote. `test/release-gate-workflow.test.ts` runs the workflows' policy steps on scratch repositories and pins the cron to the cadence floor. `test/release-cut.test.ts` proves a red release record refuses the cut and a green one on a later commit releases it. `test/train-gate.test.ts` covers the bisect route, and `test/capy-skill-wrapper.test.ts` covers the stable channel and its fallback to main.
+1
View File
@@ -0,0 +1 @@
- **The review summary leaves an abandoned review pass out of the receipt's counts** (`gstack-review-scope summary`, GST-1168). A pass whose dispatch ledger was never judged, that no review-log row names, and that a later judged or logged pass replaced is left out, and the summary names it on a `REVIEW_SCOPE_VOIDED:` line instead of counting its findings as found and unrecorded. A fix-check ledger and the last pass always count.
@@ -0,0 +1,4 @@
### Fixed
- **An outside review that says it could not verify the change is no longer read as a pass (#3925).** `lib/outside-review-result.ts` printed `VERDICT: clean` / `FINDINGS: none` for a Codex reply ending `Verdict: Can't verify (one changed file excluded and four required suites blocked)`. Now the reply's last `Verdict:` line has to approve the change (`ready`, `LGTM`, `approve`, `ship it`, `pass`, `clean`, `good to go`, or exactly `NO_FINDINGS`) or say `not ready`, which still blocks. Anything else, an approval with a question mark, or an approval that says something was excluded, blocked, skipped, not run, not executed or could not be verified, is `VERDICT: unverified` with the new `REASON: unverified_verdict` and exit 4. Which lines count as a verdict is unchanged. It is still a `Verdict:` line read with inline code blanked, so a verdict written only in code reads as unverified. See docs/troubleshooting.md#outside-review-unverified-verdict.
- **A numbered finding without a P0-P3 tag is no longer dropped (#3925).** A reply with `1. **Nonblocking — x**`, `Verdict: ready` and `END OF REPORT: 1 findings (0 blocking)` printed `FINDINGS: none`. In a reply with a `Verdict:` line that doesn't conclude it found nothing, every numbered item is now counted as a finding, and one with no readable severity makes `FINDINGS:` print `unspecified`, in the helper and in `gstack-outside-voice run`. The verdict is unchanged (`clean` here, since the reviewer approved). A consult answer with no `Verdict:` line, and a real clean reply, still read `clean` / `none`; a consult verdict that picks an option (`Verdict: go with B`) is `unverified`, since a consult isn't a pass/fail review.
+3
View File
@@ -0,0 +1,3 @@
### Fixed
- **The AskUserQuestion log hook no longer kills a slow first run on Windows, and its calls now share Claude Code's 5-second hook limit.** `question-log-hook` gave each `gstack-question-log` call a fixed 3-second timeout. On a cold Windows runner the first call takes just over 3 seconds (3,149 and 3,188 ms in windows-pregate runs 41605458462 and 41406459043), so the hook killed it and logged "exited null" even though Claude Code allows the hook 5 seconds. A fixed timeout per call also let several questions run well past those 5 seconds (six slow calls took 16 s). Now the calls share one budget: 5 seconds from when Bun starts the hook, less a 500 ms exit reserve. On Windows the hook finds Git Bash before it reads that budget, so the lookup comes out of the budget instead of adding to a call's time. When less than 250 ms is left, the hook skips the remaining questions and logs which ones; a killed call's log line names the signal and how long it ran. The budget doesn't cover the bash shim that starts Bun, and the first Git Bash lookup on a fresh install has no time limit of its own, so Claude Code can still stop the hook in those cases.
@@ -567,7 +567,7 @@ components:
## Motion
- **Approach:** [minimal-functional / intentional / expressive]
- **Easing:** enter(ease-out) exit(ease-in) move(ease-in-out)
- **Easing:** enter(ease-out) exit(ease-out) move(ease-in-out)
- **Duration:** micro(50-100ms) short(150-250ms) medium(250-400ms) long(400-700ms)
- **The one authored moment:** [what it is]
@@ -322,7 +322,7 @@ components:
## Motion
- **Approach:** [minimal-functional / intentional / expressive]
- **Easing:** enter(ease-out) exit(ease-in) move(ease-in-out)
- **Easing:** enter(ease-out) exit(ease-out) move(ease-in-out)
- **Duration:** micro(50-100ms) short(150-250ms) medium(250-400ms) long(400-700ms)
- **The one authored moment:** [what it is]
+1 -1
View File
@@ -676,7 +676,7 @@ Apply these at each page. Each finding gets an impact rating (high/medium/polish
- No `user-scalable=no` or `maximum-scale=1` in viewport meta
**7. Motion & Animation** (7 items)
- Easing: ease-out for entering, ease-in for exiting, ease-in-out for moving
- Easing: ease-out to enter and exit, ease-in-out to move; flag plain ease-in
- Duration: 50-700ms range (nothing slower unless page transition)
- Purpose: every animation communicates something (state change, attention, spatial relationship)
- `prefers-reduced-motion` respected (check: `await pg.evaluate(() => matchMedia("(prefers-reduced-motion: reduce)").matches)`)
+6
View File
@@ -1118,6 +1118,12 @@ machine swap, in this order:
1. `scripts/ubicloud/ubi-runner.sh records` and `list --mine`: a VM listed in
the record but absent from the project is already gone (forget it); a VM
in the project but not in the record belongs to another owner, leave it.
After a machine swap, `scripts/ubicloud/ubi-runner.sh recover` rebuilds the
state of every VM that `up` wrote a bundle for (`UBI_RECOVERY_DIR`; `--owner
OLD` adopts another owner's), so `pull` and `down NAME` work again. Run it
only once the creating machine is dead: it cannot tell a paused one. A VM
started with `--sink` pushes its own results, so they land even if no
controller survives.
2. Watched background operations: a runner started under `gstack-detach` keeps
its `### gstack-detach EXIT=<code> ###` sentinel in its log; a log without
the sentinel after the VM is gone is an interrupted run, not a pass.
+75 -1
View File
@@ -66,6 +66,13 @@ surface. Keep it small and predictable:
Scope instructions in the drive root (`AGENTS.md`, `CREW.md`) apply to every thread in
the crew, so a rule written there needs no relaying.
On Capy, `tools.capy.read` returns a view of a file, not the whole file: roughly the first
38,000 characters, with each line cut at 2,000. A program that reads a file, changes it
and writes it back with `tools.capy.write` therefore saves a truncated copy, and drive
files are no exception. Edit long files with `tools.capy.edit`, or read and write the
whole file in bash or Python and check the line count afterward. `tools.drive.history`
restores a recent version of a drive file.
## CREW.md: the lanes' contract
gstack holds how the work is done; a crew's `CREW.md` holds only what is specific to that
@@ -131,6 +138,43 @@ such decision with a plain-language explanation of every option: what it is, why
matters, what each answer does, and what it costs. A gate code or a tool's result line
alone is not an ask.
## New threads join the crew
A crew thread never creates a thread itself. It asks its Captain, and the Captain creates
the thread, which puts it in the crew from the start: the Captain briefs it, reads its
reports and archives it when its work is done. When a crew thread has already made a
thread, it reports the new thread's code to its Captain in that same turn, and the Captain
adds it to the crew (`crew.add` on Capy).
A thread in another project goes Captain to Captain, because `crew.add` refuses a thread
from another project and a Captain creates threads only in its own project. The crew
thread asks its Captain, who asks that project's Captain to create and own the thread, and
the results come back the same way. For the receiving Captain, that ask is work: it
creates the thread in its own crew, briefs it and returns the result. Only when the other
project has no Captain does the crew thread create the thread itself, and then it records
the new thread's code on the work's issue so the thread can still be found. A thread the
crew thread already created in another project goes the same way: it reports the code to
its Captain, who asks that project's Captain to adopt the thread, and the code goes on
the work's issue.
Why: a crew thread once created a thread in another project directly. That thread ran
outside every crew, so no Captain briefed it, tracked it or could archive it.
## Keep issues tidy
Issues show what the crew is really doing, so keep them true:
- Before archiving a thread, close or move to Backlog every issue assigned to it.
- On each wake, leave every issue you touched accurate: its status, assignee and links.
- An unassigned Todo that nobody will start goes to Backlog.
- A daily sweep closes, re-assigns or backlogs any open issue outside Backlog that nobody
has touched for 2 days and whose assignee is archived, or idle with no open PR.
- The sweep skips an issue assigned to a person (an ask waiting on the owner, say) and a
Waiting issue that names what it waits on, by a blocked-by link or a dated comment, while that is still open.
The target: the Overview's In progress count is about the number of working threads plus
the PRs waiting to merge.
## Plan before build
A lane that builds something plans first. Its first act after the session-start install
@@ -271,6 +315,33 @@ the same product diff; any other rebase is a full /ship
([ship-policy.md](ship-policy.md#receipt-carry)). A controller never asks a lane to
rebase a reviewed branch.
## Direct land (`merge: direct`)
When the base policy sets `"merge": "direct"`, there are no trains, slot calls or stamp
commits. Each PR merges at its own green head, and versions are set at release
([ship-policy.md](ship-policy.md#land-direct)):
1. Lane: /ship, then `gstack-ship-queue ready --pr <n> --no-screen` at the pinned head,
then READY to the Captain with that pin. The PR carries a `changelog.d` fragment and
leaves VERSION, the `package.json` version and CHANGELOG.md alone.
2. Captain: `gstack-ship-queue land --pr <n> --pin <sha>`. It merges with
`--match-head-commit`, checks main's tree and writes a land note.
3. Release: the release gate stamps the version and folds the fragments.
What to do with each refusal (nothing was merged unless the code says so):
| Code | Who acts | Action |
|---|---|---|
| `QUEUE_LAND_STAMPED` | lane | run the printed `git checkout $(git merge-base origin/main HEAD) -- …`, keep the fragment, commit, /ship and `ready` again |
| `QUEUE_LAND_PIN_MOVED` | lane | /ship and `ready` at the new head, new READY |
| `QUEUE_RECEIPT_MISSING` / `QUEUE_RECEIPT_STALE` | lane | /ship at the pin |
| `QUEUE_NOT_READY` | lane, or Captain | no READY note: `ready --pr <n> --no-screen` at the pin; `merge: train`: use the train; closed or retargeted PR: as named |
| `QUEUE_LAND_CHECKS` | Captain, then lane | pending: `gstack-pr-wait <n> --until green` and rerun; red: back to the lane (rule 16 rerun for a flaky case); missing: dispatch the workflow on the branch |
| `QUEUE_LAND_NOT_CLEAN` | lane, or Captain | `CONFLICTING`: the lane merges main (rule 19), /ship, `ready`; `UNKNOWN`: rerun in a minute |
| `QUEUE_TRAIN_FROZEN` / `QUEUE_LAND_LOCKED` | Captain | finish the repair or the holder first |
| `QUEUE_TRAIN_UNTRUSTED` | Captain | a fork head; rewrite on a crew branch |
| `QUEUE_LAND_UNVERIFIED` | Captain | merged, but main isn't the pin on its parent: revert the stray change, report it |
## Merge trains
gstack's queue tool drives trains when the repository's `.gstack/ship-policy.json` carries
@@ -299,7 +370,10 @@ through an urgent one-PR train (or `train build --repair` while the queue is fro
refuses `--urgent`); all prefixes green earns exactly one unchanged rebuild;
anything else goes to a repair lane. An infra-only census earns one re-dispatch. A
backstop gate runs on every push to the base branch and freezes the queue when it finds
a tree no green train covered; one repair PR at its exact head clears the freeze.
a tree no green train covered; one repair PR at its exact head clears the freeze. A
policy with a `release_gate` block swaps that per-push backstop for one full gate on main
every 8 hours, whose red verdict holds the stable release instead of freezing the queue
([ship-policy.md#release-gate](ship-policy.md#release-gate)).
**Wave PRs when the queue is long.** Every slot pays its own
stamp, its own round of required checks and its own version, one after another. When more
+23 -4
View File
@@ -4,7 +4,7 @@ Three layers, each owned by gstack:
| Layer | Where | Job |
|---|---|---|
| Bootstrapper | `capy/skill/gstack/scripts/install.sh` (copied once into the owner's Drive skill folder; frozen) | clone when absent; fetch origin's default branch and read the supported minimum from `FETCH_HEAD:capy/support.env`; in install mode fast-forward a clean, unpinned checkout below it; exec the full wrapper |
| Bootstrapper | `capy/skill/gstack/scripts/install.sh` (copied once into the owner's Drive skill folder; frozen) | clone when absent; fetch origin's default branch and read the supported minimum from `FETCH_HEAD:capy/support.env` (from wrapper 4 a fresh clone or the floor fast-forward follows `stable` instead when the update channel resolves to it and `stable` meets that minimum, see [releases.md](releases.md#channels)); in install mode fast-forward a clean, unpinned checkout below it; exec the full wrapper |
| Full wrapper | `bin/gstack-capy-session` (moves with the checkout) | map the session start onto the installer: `--check` certifies (and reports a checkout behind origin), otherwise fast-forward a checkout behind origin (`bin/gstack-upgrade --ff-only-no-reinstall`), then install or repair |
| Installer | `bin/gstack-capy-install` | Bun, both host profiles, the doctor, the install verdict, the launch line, the trailer |
@@ -232,7 +232,11 @@ rename can be broken and then publish into its successor's lock. While a live pr
holds the lock, or it cannot be taken at all, `touch` exits 3 without writing
(`gstack-skill-start` reports that the session holds no lease) and a reclaim
keeps the lease. `bin/gstack-lease list` shows `alive|idle|dead|stale`, and
`GSTACK_UPGRADE_FORCE=1` overrides.
`GSTACK_UPGRADE_FORCE=1` overrides. `bin/gstack-lease open --run <id> [--pid <p> | --from <pid>]`
answers for one run (exit 0 while it is open on a live holder);
`gstack-ship-preflight` opens /ship runs on the project checkout's own lease and
uses it to refuse a base merge under an open run
([troubleshooting](troubleshooting.md#ship-preflight-run-open)).
## What the installer does
@@ -330,7 +334,19 @@ keeps the lease. `bin/gstack-lease list` shows `alive|idle|dead|stale`, and
the installer prints `the install verdict at <file> keeps for=<old> and adds
<new> (for=<list>)`, and the trailer still names this run's workflow). A
failing run, a new HEAD or another Bun replaces the verdict, and so does any
workflow install over a review-voice verdict. Admission refuses a workflow whose
workflow install over a review-voice verdict. Each workflow install (and
`--check`) also writes that workflow's own verdict beside it, at
`<verdict>.for/<workflow>.json`, with only this run's result. When the shared
verdict does not admit a workflow, admission and the pregate try the
workflow's own record with the same checks, so a later install for another
workflow on a shared machine (a /ship docs child resolving another Bun, or a
failed child install) never drops /ship's admission (GST-1233). A new HEAD
still invalidates both, and a failed `./setup` (`gstack: fail setup`)
invalidates every workflow's record. `--check --for <workflow>` keeps the
Bun that workflow's own record holds. The launch file and launch directory
stay shared per checkout and project (GST-1239), so the pregate launches
from the recorded Bun's own directory when the launch directory's `bun`
resolves to another one. Admission refuses a workflow whose
running Bun is not the one the verdict recorded
([BUN_RESOLUTION_DRIFT](troubleshooting.md#bun-resolution-drift)), and so
do the pregate's lanes and guards, which also refuse an invalid verdict
@@ -594,7 +610,10 @@ Wrapper 2 only added optional exports (the trailer's `latest:` note and the
`cloned` line). Wrapper 3 answers `--check --for none` itself (a copy at 1 or 2
prints `fail not-installed` for a coordinator machine and still works for every
other call), so the minimum is 3: every older Drive copy prints the owner
action until the owner recopies `capy/skill/gstack/`.
action until the owner recopies `capy/skill/gstack/`. Wrapper 4 only adds the
update-channel choice for the clone and the floor fast-forward
(`GSTACK_UPDATE_CHANNEL=stable|main`, else the checkout's channel, else main);
the minimum stays 3, so a copy at 3 keeps working.
## Exit codes
+16 -2
View File
@@ -1402,8 +1402,22 @@ being pushed skips the guards, as `GUARDS: not run`. Outside /ship, run the
command above yourself before `git push`.
gstack declares its own list in `.gstack/pregate.json`: `typecheck`,
`skill-size`, `parity-suite`, `context-budget`, `module-size`, `goldens` and
`doc-caps`. Generated SKILL.md freshness is the `regen` check's job. Other
`skill-size`, `parity-suite`, `context-budget`, `module-size`, `goldens`,
`doc-caps` and `conflict-markers`. Generated SKILL.md freshness is the `regen`
check's job. `conflict-markers` runs `scripts/check-conflict-markers.ts` on a
change to code, templates or config, and reads every line the branch adds
against its merge base with `origin/main`, both in the committed HEAD (what the
push publishes) and in the working tree's tracked files. A line starting with
`<<<<<<<` or `>>>>>>>` (then a space or the line end), or a line that is exactly
`=======`, stops the push, even inside a template literal the parser accepts.
In Markdown (`.md`, `.mdx`, `.markdown`, `.md.tmpl`) it skips fences whose info
string is empty, `text`, `txt`, `plaintext`, `diff` or `console`, the fences a
doc uses to show git output, and counts `=======` only between a `<<<<<<<` and
its close, so such a code block and a heading underline both pass; a conflict
inside a `bash` or `ts` fence is still refused. A fixture that shows markers on
purpose puts a line that is only `gstack-conflict-markers: fixture` (a comment
leader such as `#` or `//` is fine) in its first five lines; the phrase
anywhere else exempts nothing. Other
repos add their own `guards` the same way; the key needs no newer gstack than
the one that already reads `guards`.
+17 -5
View File
@@ -29,11 +29,12 @@ it has completed and none failed, and `refs/notes/gstack-trains` holds no
active freeze. A cancelled check run counts as red. A commit with a check run
still going is skipped and the next older one is considered, back through 7
days or 200 first-parent commits. The workflow runs no tests and no paid evals
of its own; it only reads what the trains and the backstop already recorded.
of its own; it only reads what the trains, the backstop and the release gate
already recorded.
With nothing green in the window it fails and lists why each commit was
skipped.
**Gated** means one of two pieces of evidence. The first is the
**Gated** means one of three pieces of evidence. The first is the
`main-backstop` check run: the Main backstop gate workflow publishes it on
each push to main from a job of its own, success when a green train record
covers the tree or the backstop gated it green, neutral when main moved past
@@ -45,11 +46,17 @@ its tree: those entries are the main commits `gstack-ship-queue verify
--train` confirmed, so a commit a green train merged is gated even when trains
merged faster than the backstop could settle and its own backstop run ended
neutral or never ran. The `CANDIDATE` line says which: `gated by
main-backstop` or `gated by train <nonce>`. Train evidence relaxes nothing
main-backstop` or `gated by train <nonce>`. The third is a GREEN release
record (kind `release`) on the commit, written by the release gate
([docs/ship-policy.md#release-gate](ship-policy.md#release-gate)) when the
policy declares `release_gate`; the line then says `gated by release gate run
<id>`. A RED release record with no GREEN one on a later commit that
contains it refuses the cut like a freeze. Train evidence relaxes nothing
else: a red backstop run, a failed or missing `free-tests` run, any other red
check run or a run still going keeps the commit out, and a record in any other
state (`red`, `superseded`, `free-green`, …) is not evidence. A commit with
neither a successful backstop run nor a train record is not released: a
none of the three (no successful backstop run, train record or GREEN release
record) is not released: a
dependabot push, a policy without a train block, or a tree that was never
gated.
@@ -240,6 +247,10 @@ overrides the key for one command. The resolution lives in one helper,
- `bin/gstack-upgrade` and `/gstack-upgrade`: they fast-forward to the channel
ref (`UPSTREAM_REF=origin/stable` in the skill's output) and say which channel
they followed.
- `capy/skill/gstack/scripts/install.sh`, the Capy skill's installer (wrapper
revision 4): a fresh clone or a floor upgrade follows `stable` when the channel
resolves to it and `stable` meets the support floor, and follows main
otherwise. `--revision` still pins an exact commit.
Two edge cases are handled on purpose:
@@ -302,11 +313,12 @@ is exercised against real check runs before it merges.
| Line | Meaning |
|---|---|
| `CANDIDATE: <sha> (<date>) gated by main-backstop|train <nonce>` | the commit that will be (or was) released, and which evidence gated it (`[hotfix: --ref]` before `gated by` on a `--ref` cut) |
| `CANDIDATE: <sha> (<date>) gated by main-backstop|train <nonce>|release gate run <id>` | the commit that will be (or was) released, and which evidence gated it (`[hotfix: --ref]` before `gated by` on a `--ref` cut) |
| `NOTHING_NEW: origin/main <sha> is already released as v<X>` | nothing merged since the last release; exit 0 |
| `NO_GREEN_COMMIT: ...` with `SKIPPED:` lines | nothing in the window passed the gate; exit 1 (a dry run exits 0: the skips are its answer) |
| `REFUSED: non-fast-forward: ...` | the candidate is behind stable; stable only moves forward |
| `REFUSED: queue frozen since train ...` | the main backstop froze the queue; clear it first (docs/ship-policy.md) |
| `REFUSED: release gate red on <sha> (<cases>) and no gate on a later commit is green; ...` | the release gate's hold: land the revert or fix-forward, then `gstack-release-gate run --ignore-cadence` ([docs/ship-policy.md#release-gate](ship-policy.md#release-gate)) |
| `REFUSED: tag v<X> already names <other sha>` | a version was stamped twice; needs a look before anything is cut |
| `REFUSED_FLOOR: stable moved to v<X> at <time>, <h> h ago; ...` | inside the 72-hour floor; nothing changed. Exit 1 by hand, 0 on the schedule or a dry run |
| `NEXT_ALLOWED: <time> for a hand-run cut; <time> for a scheduled run` | follows every `REFUSED_FLOOR` (and `FLOOR_UNKNOWN`, where it says why there is no time) |
+13 -3
View File
@@ -230,7 +230,9 @@ content rules: data and config files (JSON, YAML, TOML, CSV and the like) and an
scripts under `docs/`, `results/`, `benchmarks/`, `analysis/` or `notebooks/` are
judged by their path, so a `lease_usd` value or an `os.path.join(sys.argv[1], …)` in a
stats script names no class (HARN-1, harness-evals#231), and binary data such as
`*.sqlite` is data wherever it sits; a docs line that can print a credential (an
`*.sqlite` is data wherever it sits, unless the file has the exec bit or starts with
`#!`: an executable stays code whatever its name or directory (`scripts/logs/prune`,
a runnable `*.out`); a docs line that can print a credential (an
`Authorization:` header, a bearer or key-shaped value, an environment key set to a value,
a get or show command for auth config such as `mcp get`) is security (gbrain#8417); code
that reserves, binds, leases or settles spend against a ledger, budget or cap (`bindCheck(ledger, …)`,
@@ -301,7 +303,9 @@ pairs: usage beside a token or cost word, proxy beside billing, and a client
beside a cost or charge word, so a CLI's usage text and a plain proxy stay out
(GST-850, from gbrain-evals #289, whose metering proxy and billing clients read
`required=none`). **security** is a trust-boundary path (trust, redact, sanitize, neutralize,
consent, escape) or line (`shell: true`, `exec(` and `execSync(`, `eval`, `new Function(`,
consent, escape, and sealed custody or egress: seal, sealed, custody, egress) or line (a
redaction call, an import of a redaction or outside-voice module, a `SECRET` or `CREDENTIAL`
constant set to a regex, since that code decides what reaches an outside model, GST-1177; `shell: true`, `exec(` and `execSync(`, `eval`, `new Function(`,
raw HTML sinks, a shell run through argv, recursive deletes), any call to an
outbound HTTP client (`fetch`, `http(s).get` or `.request`, axios, got, undici,
`requests`, `httpx`, urllib, with no exemption for a literal URL), `new URL` from
@@ -526,6 +530,7 @@ puts in the receipt:
| `review_loop.unrecorded` | blockers found that no row gives a disposition (fixed, deferred, noted, skipped, a gate answer); absent when 0 |
| `review_loop.unbound` | what could not bind (GST-1168), each printed as `REVIEW_SCOPE_UNBOUND: <what>: <why>`: a row with no start token and no readable time, a `pN` ledger with no review row of pass N, a row finding's `ledger_id` no ledger collected, a `Review-Finding` trailer naming nothing; absent when 0 |
| `review_loop.ledgers_read` | dispatch ledgers the count read; absent under `--no-ledger` |
| `REVIEW_SCOPE_VOIDED:` (stderr, not a receipt field) | an abandoned pass (GBEV-3): a `pN` ledger never judged that no row names, replaced by a later `pM` ledger (M > N) that was judged or is named. Its findings are left out of every count above instead of counting as unbound and unrecorded. A fix-check ledger and the last pass are never voided |
Findings bind by id first (GST-1168). A row finding that came from a dispatched reader carries `ledger_id` (`<pass dir>/<merged.jsonl id>`, such as `p2/adversarial-native#3`) and pairs with that ledger entry whatever its path:line says, so a native finding with no path:line, or a row logged after its fix moved the line, still pairs. Each fix commit names what it fixed in a `Review-Finding: <ledger_id or fingerprint> reader=<raised_by>` trailer, and a trailer counts the finding as fixed. A late defer re-logs its finding under the same id, so it counts once. Rows that record their head dedupe by kind, pass, head and finding ids, so a logger command run twice counts once. Rows and findings without an id keep the path:line pairing above. The native reader's own verdict line ("all FIXABLE, and none blocks", with no file:line, list mark, lead label or Severity/Blocks field) collects at FIXABLE, not CRITICAL; the findings it summarizes collect on their own lines.
| `review_loop.deferred` | blocking findings in unchanged code filed as issues (a Step 11 late defer, logged as a `review-late-defer` row so it never displaces the newest `review` pass, counts here and is not a pass) |
@@ -720,6 +725,10 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends:
finding, lowers its severity, folds it into a less severe duplicate, or adds or
promotes one without a reason (`JUDGMENT_REJECTED`). A merged file missing any id
the ledger collected is a drop. Each addition and promotion is logged with its reason.
When every reader came back clean, `merged.jsonl` is empty and there is nothing to
judge: `judge` writes an empty `judged.jsonl` itself and prints
`JUDGMENT: ok nothing to judge`. A merge that holds findings with no `judged.jsonl`
is refused (exit 2), naming the finding count.
5. `verify --ledger <f>` replays all of it from the run's ledger: rendered against
dispatched hashes per render (each render is one dispatch of its reader, so a
two-pass ledger checks pass-1 sends against pass-1 briefs; a later render prints as
@@ -748,7 +757,8 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends:
recorded judged set, covering every collected id. A ledger from a run that kept no
prompts prints `VERIFY: no brief records` (exit 3), never clean.
6. `pass --base <b> --out <dir> --pass <id> -- <plan flags>` runs one pass from a
staged manifest (`<dir>/pass-manifest.json`): audit readers marked first, then
staged manifest (`<dir>/pass-manifest.json`). The pass id is letters, digits and
dashes, read as lowercase (`P1` is `p1`); any other character is refused. Audit readers marked first, then
the specialists with the other audit readers, then the Red Team, then Step 11's
voices. A QA extra is refused before any reader is created: Step 9.2.1's probes
are the parent's. `--diff-base` sets every reader's base, a scoped pass's Red
+127 -3
View File
@@ -60,9 +60,11 @@ refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with
| `full_e2e` | `true` \| `false` | `false` | `true`: every merge needs a passing full-gate result bound to a tree equal to the merging one modulo stamps; `gstack-ship-queue ready`, `stamp` and `verify` refuse without one (`QUEUE_GATE_MISSING`): a gate-ahead record, or the lane's `--gate-exit 0 --gate-log <file>` from its run on the gated head. Needs `min_gstack_version`. |
| `review_scope` | `frozen` \| `diff-plus-blocking` \| `all` | `frozen` | The finding scope of `/ship`'s review loop; see [review_scope](#review-scope). `frozen` fixes the diff on pass 1, files blocking findings in unchanged code as issues, and later passes read only the range since the previous pass. `diff-plus-blocking` is the same but fixes a blocking finding in unchanged code in this PR instead of filing it. `all` is the earlier loop: every finding is fixed on every pass. Needs `min_gstack_version` when not `frozen`. |
| `review_exclude_paths` | git glob pathspecs | `[]` | Content files `/ship`'s reviewers leave out: data, docs, `.log`, and binaries under a content extension (images, fonts, archives and the like) that do not start with executable magic. Read from the base only. Code (native libraries and every other binary outside the content extensions included), gate paths, agent prose, config, executables, links, `.gstack/` and `.github/` files, gate-tagged, secret-line and named-by-code files stay in review. See [review_exclude_paths](#review-exclude-paths). Needs `min_gstack_version`. |
| `review` | `{ panel?, reader_model?, platform?, risk_paths? }` | `{ all, lane, off, [] }` | Who does `/ship`'s review, read from the base only; see [Who reads](review-policy.md#who-reads-r65-the-base-policys-review-key). `panel`: `all` (today's panel on every class) \| `risk` \| `off` (the opt-in one-reader shapes); `reader_model`: `lane`, `cross-family` or a model id; `platform`: `off` \| `on` (a docs or tests diff with no numeric, results or prereg claims whose PR carries capy-ai[bot]'s review at the exact head skips the readers); `risk_paths`: globs of code paths that count as risk class `policy`. Introduced at 1.94.30.0; needs `min_gstack_version` when set to a non-default value. |
| `min_gstack_version` | version or `null` | `null` | The oldest gstack that may read the file; see the compatibility rule above. |
| `docs` | `{ caps: [{ path, max_bytes }] }` or `null` | `null` | Documentation files with a byte cap. `/ship` Step 14.5 runs `gstack-ship-receipt docs-caps --base <base>` (the caps come from `origin/<base>`'s policy, never the branch's copy; `--policy <file>` is for a repository bootstrapping its policy) before the verification gate and prints one `DOCS_CAP: <path> <bytes>/<max> ok\|over` line per cap; any `over` (an absent file counts) exits 1 and blocks the ship until the file is trimmed or the cap raised on the base branch (`DOCS_AUDIT_BLOCKED`). At most 64 caps, each path contained. See [the ship receipt](#ship-receipt). Needs `min_gstack_version`. |
| `train` | `{ gate_workflow, free_workflow, full_gate_check, record_artifact, max_size, fill_hours, screen_selection, trial_reservation_usd, land? }`, `{ gates, land, max_size, fill_hours }` or `null` | `null` | Turns on [merge trains](#run-a-merge-train), read from the base revision only. Every field is required and an unknown field is `POLICY_INVALID`: `max_size` 1 to 6, `fill_hours` above 0 and at most 24, `screen_selection` `gstack-paid` \| `none`, `trial_reservation_usd` above 0 and at most 100. A block with `gates` ([any repository](#train-gates)) takes `max_size` 1 to 5 and needs `min_gstack_version` at least `TRAIN_GATES_KEY_VERSION`. `null` keeps the per-PR queue exactly as it is. Needs `min_gstack_version`. |
| `merge` | `train` \| `direct` | `train` | `direct` lands each READY PR at its own green head with [`land --pr <n> --pin <sha>`](#land-direct): no train and no stamp commit, and `train build` refuses. A PR then leaves VERSION, the `package.json` version and the CHANGELOG at its merge base, and the release sets them. Needs `changelog_fragments`, and the stamping `land --pr <n>` refuses under it. `train` keeps merge trains. Read from the base revision only. Needs `min_gstack_version`. |
| `base_red` | `{ setup, select, timeout_sec }` or `null` | `null` | How `/ship` Step 5 proves a failure is the base branch's own before pausing on it; see [base_red](#base-red). Read from the base revision only. Needs `min_gstack_version`. |
| `changelog_fragments` | `{ dir, required_checks? }` or `null` | `null` | PRs carry unversioned [CHANGELOG fragments](#changelog-fragments) in `dir` (one flat, repo-relative directory, no `..`) and the merge step ([`land`](#land)) assigns the version. `required_checks` (up to 50 distinct names) are the checks [`train land`](#stamped-trains-under-changelog_fragments-train-land) requires present and green on a stamp commit. `null` keeps `[Unreleased]` sections and per-PR stamps. Needs `min_gstack_version`. |
@@ -1120,6 +1122,88 @@ every lane to the per-PR queue at once. `--urgent` ships one PR alone,
`--no-screen` skips the paid screen, `--broad` widens it to the full PR selection,
and `train build --prs <list without it>` drops a PR at any step.
<a id="release-gate"></a>
## `release_gate`: one full gate on main every 8 hours
Trains gate each batch of PRs, and the main backstop gates every push. The
release gate replaces the backstop with one full paid gate on main's head at
most every `cadence_hours`, and only when main moved since the last gate. It
feeds the stable release (docs/releases.md) and never writes `stable` itself:
`scripts/release-cut.ts` stays the only writer, with its 72-hour floor and its
security and regression exceptions unchanged.
The surface is off unless main's `.gstack/ship-policy.json` declares the
block, with `min_gstack_version` at least the release that introduced the key
(validation names it):
```json
"release_gate": {
"cadence_hours": 8, "gate_workflow": "evals.yml", "branch": "main",
"stable_ref": "stable", "check_name": "main-backstop",
"on_red": "hold-release", "bisect_spend_cap_usd": 40
}
```
Every field is required. `cadence_hours` runs from 8 (the workflow's
schedule) to 168. `check_name` is the check run release-cut reads as gated,
so gstack keeps `main-backstop`. `bisect_spend_cap_usd` caps one red gate's
bisect (0 skips it).
**What runs.** `.github/workflows/release-gate.yml` fires every 8 hours and
on a manual dispatch from main. Without the block it stops after a git-and-jq
policy read, so no paid work starts and nothing is written. With the block it runs
`gstack-release-gate run`, which:
1. first finishes an open gate on an older commit (a run that outlived its
budget, or a red gate's unfinished bisect), so main moving never orphans a
verdict; only then does it look at main's head. An open gate older than
three cadences stops holding up the head;
2. skips when main has not moved since `stable` or since the last gate, or
when the last gate is younger than `cadence_hours` (`--ignore-cadence`
overrides only the age);
3. writes a `release` record (`gstack-trains:release:<repo>:<sha>`) to
`refs/notes/gstack-trains` before it dispatches, so a lost run resumes;
4. dispatches the full gate on that sha and reads its verdict and flake
records. A case that failed once and passed its one rule-16 rerun is green
and is listed as flaky;
5. records GREEN, RED or INCOMPLETE. An INCOMPLETE gate is re-dispatched once
by the next run, then left for a person;
6. on RED, bisects the red cases over main's first-parent commits since the
last green gate (at most 4 rounds, within the spend cap) and records the
first bad commit;
7. publishes `check_name` on the gated commit from a separate job that holds
`checks: write` and runs no repository code: success for a green gate,
failure for a red one, and neutral when the gate has no verdict yet (still
running at the budget, or incomplete), so a later green on the same commit
is not undercut by an earlier failure.
**What release-cut reads.** A GREEN release record on a commit counts as
gated, the same as a successful `main-backstop` check run. A RED record
with no GREEN record on a later commit that contains it holds every cut, like a
queue freeze: the `REFUSED` line names the red sha and cases. Land the revert
or the fix-forward, then gate again with `gstack-release-gate run
--ignore-cadence` (or dispatch the workflow). Nothing else clears a hold.
**The per-push backstop stands down.** `evals-main-backstop.yml` keeps its
push trigger. While the block is set, its policy step reports `train=false`,
so a push dispatches no gate, freezes nothing and publishes no check run. Main
is never gated twice. Trains keep running until the train lane retires them.
```bash
gstack-release-gate run [--dry-run] [--sha <sha>] [--ignore-cadence]
gstack-release-gate status # the newest record, the hold if any, pending commits
gstack-release-gate bisect [--sha <sha>] [--spend-cap <usd>] [--dry-run]
```
Common flags: `--repo`, `--policy <file>` (read the block from a file instead
of main, for a dry run or a proof before adoption), `--remote`,
`--summary`, `--outputs`, `--poll-seconds`, `--budget-minutes`. Every line
starts with `RELEASE:`. Exit 0 means gated, skipped, inert or a dry run; 1
means held (red), failed, incomplete or a refused policy; 2 is a usage error.
**Escape hatch.** Remove the block on main. The per-push backstop gates the
next push again, and release-cut reads its check runs as before.
<a id="ship"></a>
## What `/ship` reads from it
@@ -1188,7 +1272,8 @@ repair is trimming the file, never raising the cap in the same PR.
**`review_depth` and `gstack_version`.** `write` records the gstack that
wrote the receipt and the review depth at the checked-out head
([review-policy.md#review-depth](review-policy.md#review-depth)):
`{ depth, class, runtime_lines, rules, base, registry, gate_scan: { tagged, head }, reasons }`,
`{ depth, class, runtime_lines, rules, base, registry, gate_scan: { tagged, head }, reasons, risk, mode }`
(`mode` is lean, panel or platform; `declared` and `detected` appear with `write --declare-risk`, and the platform depth adds `platform_review`, see [review-policy.md](review-policy.md#review-depth)),
computed from git objects for the range against `origin/<base>`
(`gstack-diff-scope <base> --head <sha>` for the scope signals), with the gate
scan run on that head. `--head` must be the checked-out HEAD
@@ -1711,7 +1796,7 @@ Stages, each journaled in `.gstack/tmp/restamp-journal.json`:
1. **version**: `gstack-version-bump write --version <X>` in the staging worktree (VERSION, the manifest, npm lockfiles); skipped when `restamp_command` owns the version. The manifest gets the npm-valid three-part form, except when the policy lists it in `stamp_paths` or `stamp_files` and its copy at the base holds four parts (gbrain's convention): then it keeps the full version (`--manifest-form full`) and the stage detail says so.
2. **release_tool**: the policy command, under the contract below.
3. **changelog**: the PR's `[Unreleased]` or old-version section moves to the top under `<X>`, above its predecessor's entry. A duplicate heading the PR introduces (`CHANGELOG_DUPLICATE_HEADING`: the headings are counted against the base's CHANGELOG at `origin/<base>` and at the fork point, so a version the base already heads twice passes as long as the PR adds no further copy) and a top entry that is not `<X>` (`CHANGELOG_TOP_MISMATCH`) are errors; the section being re-headed itself may exist only once.
4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites, plus every `stamp_patterns` template, within `stamp_paths` and `stamp_files`. A quote around X in the first two forms is part of the form and is kept as written: `since: 'X'`, `since: "X"`, `(was 'X')` and `(was "X")` move like the bare ones (the shape of a quoted source row such as gbrain's `{ since: '0.60.468.0', … }`), an opened quote must close right after X (`since: '1.2.3-beta'` and `since: '1.2.3"` are not the form, in the writer and in the tree receipt alike), and `vX` already accepts a quote before the `v`. Which X moves is decided by provenance. The branch's own tentative stamp (its VERSION when it moved past base) and `--was <X>`, when given, are each rewritten on every line of every stamp file. Beyond that, only lines the branch itself added are read, by their position in the file (`git diff --unified=0` from the fork point), so a base line with the same text, or a CRLF checkout whose diff is LF-normalized, is told apart: a stamp-form mention there of a version newer than the version at the branch's fork point is a tentative stamp and moves (this is what finds a quoted row after a lost slot and a merge of the base, since the base's release is newer than the fork point), and so does one of the fork-point version itself (a row the branch wrote at the version it forked from instead of a next version is its own stale stamp; a branch-added mention older than the fork point stays), and when the branch has no stamp of its own because its VERSION equals the base's and its fork point is the base itself (a branch rebased onto the base after it reached X, or one never stamped) a mention of the base version on a line it added is its stale stamp and moves too, while the same version on a line the base already carries is the base's release and stays. The stage detail names both counts per file (`VERSION equals base X, so X moved only on lines this branch added and stayed on the base's own lines (<file>: N moved, M kept)`), so the stamp commit shows which lines moved and why. A line the base added since the fork point is the base's even when that diff shows it (a merge of the base on the branch, committed or still staged, brings every line the base added since the fork into the diff from the fork point, main's own release keys included): it is left out, the detail says `N line(s) the base added since the fork point are the base's, not this branch's, and stay (<file>: N)`, and a base diff git cannot run refuses the stage (`POLICY_SOURCE_UNAVAILABLE`, `cannot diff <since>..<base> for the stamp_paths stage`) rather than reading as an empty set that would move the base's lines again. The rule reads line ownership, not meaning: a branch-added line inside `stamp_paths` that means the base's own release in a stamp form (`Requires vX or later` written on a branch forked at X) moves with it, which the `--dry-run` diff and the per-file counts show before anything is written; write such a reference outside a stamp form, or keep that file out of `stamp_paths`. `--was X` is the opposite tool: it rewrites X everywhere, the base's own lines included, and re-heads the CHANGELOG section `## [X]`. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate. The checks that accept a stamp commit read the same bound as the writer (`stampOnlyLine` with the branch's side in `lib/stamp-only.ts`): the tree receipt behind `stamp`, `verify` and the gate-ahead replay, the merge train's stamp and verify checks (each reading the slot's predecessor, the gated commit's first parent, as the base), the /ship receipt carry, and finish's route each take a moved version on a line the branch added at or above the fork-point version, and on any other line at or above the gated head's VERSION. A branch line is named by its position in the gated file, so a base line whose text duplicates a line the branch added keeps the gated head's bound (GST-979). When the stamp keeps the VERSION (a slot that keeps its claim), only the branch's own lines may move, so a base line moved by hand is refused. The carry follows a stamp on the branch's own rows in a stamp file the branch also changed as product when every changed line of that file sits at a branch-added position, the file keeps its mode, the base did not touch it and the rest of the product diff is unchanged (`a stamp on the branch's own rows` in the carry reason, GST-980).
4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites, plus every `stamp_patterns` template, within `stamp_paths` and `stamp_files`. A quote around X in the first two forms is part of the form and is kept as written: `since: 'X'`, `since: "X"`, `(was 'X')` and `(was "X")` move like the bare ones (the shape of a quoted source row such as gbrain's `{ since: '0.60.468.0', … }`), an opened quote must close right after X (`since: '1.2.3-beta'` and `since: '1.2.3"` are not the form, in the writer and in the tree receipt alike), and `vX` already accepts a quote before the `v`. Which X moves is decided by provenance. The branch's own tentative stamp (its VERSION when it moved past base) and `--was <X>`, when given, are each rewritten on every line of every stamp file. Beyond that, only lines the branch itself added are read, by their position in the file (`git diff --unified=0` from the fork point), so a base line with the same text, or a CRLF checkout whose diff is LF-normalized, is told apart: a stamp-form mention there of a version newer than the version at the branch's fork point is a tentative stamp and moves (this is what finds a quoted row after a lost slot and a merge of the base, since the base's release is newer than the fork point), with one limit on the built-in forms: a version newer than the one being stamped moves only when the branch itself recorded it (its version source at the fork point or at any commit in `<base>..HEAD`, every parent walked, so a train or land merge of the pin still reads the pin's own claims), because no queue handed the branch a version above the one it gets now, so a third party's `QM v0.1.15` on a line the branch added to a README in `stamp_files` stays exactly as written (GST-1245). A `stamp_patterns` slot keeps the plain fork-point bound (#3512), and a version only a slot vouches for moves inside that slot alone, never in a built-in form beside it. A third-party version between the fork point and the version being stamped is still read as a tentative stamp: keep such references out of stamp files, or out of a stamp form. So does one of the fork-point version itself (a row the branch wrote at the version it forked from instead of a next version is its own stale stamp; a branch-added mention older than the fork point stays), and when the branch has no stamp of its own because its VERSION equals the base's and its fork point is the base itself (a branch rebased onto the base after it reached X, or one never stamped) a mention of the base version on a line it added is its stale stamp and moves too, while the same version on a line the base already carries is the base's release and stays. The stage detail names both counts per file (`VERSION equals base X, so X moved only on lines this branch added and stayed on the base's own lines (<file>: N moved, M kept)`), so the stamp commit shows which lines moved and why. A line the base added since the fork point is the base's even when that diff shows it (a merge of the base on the branch, committed or still staged, brings every line the base added since the fork into the diff from the fork point, main's own release keys included): it is left out, the detail says `N line(s) the base added since the fork point are the base's, not this branch's, and stay (<file>: N)`, and a base diff git cannot run refuses the stage (`POLICY_SOURCE_UNAVAILABLE`, `cannot diff <since>..<base> for the stamp_paths stage`) rather than reading as an empty set that would move the base's lines again. The rule reads line ownership, not meaning: a branch-added line inside `stamp_paths` that means the base's own release in a stamp form (`Requires vX or later` written on a branch forked at X) moves with it, which the `--dry-run` diff and the per-file counts show before anything is written; write such a reference outside a stamp form, or keep that file out of `stamp_paths`. `--was X` is the opposite tool: it rewrites X everywhere, the base's own lines included, and re-heads the CHANGELOG section `## [X]`. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate. The checks that accept a stamp commit read the same bound as the writer (`stampOnlyLine` with the branch's side in `lib/stamp-only.ts`): the tree receipt behind `stamp`, `verify` and the gate-ahead replay, the merge train's stamp and verify checks (each reading the slot's predecessor, the gated commit's first parent, as the base), the /ship receipt carry, and finish's route each take a moved version on a line the branch added at or above the fork-point version, and on any other line at or above the gated head's VERSION; in a built-in form a moved version above the new one must also be one the writer moves there (the gated head's VERSION on any line, a version the branch recorded on its own lines), so a rewritten third-party version reads as product (GST-1245). A branch line is named by its position in the gated file, so a base line whose textLine truncated
5. **mirror**: tier 2; declared and validated, reported as not run.
6. **apply**: every changed file is written to the branch only after all stages validated, each against the fingerprinted preimage; a file edited meanwhile is `RESTAMP_CONFLICT` and the applied writes roll back (rollback refuses to overwrite a file whose bytes differ from the recorded postimage).
@@ -1842,7 +1927,10 @@ checks green, Version Gate included.
lane's stamp round trip. Nobody stamps by hand; the tool makes and pushes the
stamp commit. Steps, each journaled in
`.gstack/tmp/ship-queue/land/<n>.json` and resumable by rerunning the same
command:
command. A journal that pushed a stamp whose branch was then reset to the reviewed
pin is discarded and the pin is restamped
(`JOURNAL: #<n>'s head is the reviewed pin …; discarding the journal and
restamping`, GST-1245), so a reset stamp needs no hand cleanup:
1. **Refuse** a fork head, a closed PR, a PR whose base is another branch, a
base policy with `changelog_fragments` off (`QUEUE_NOT_READY`), a pin
@@ -1902,6 +1990,42 @@ command:
`--dry-run` merges and stamps in a scratch worktree and prints the version and
tree, taking no lock and pushing nothing.
<a id="land-direct"></a>
### `land --pin`: merge at the PR's own green head (`merge: direct`)
Under `"merge": "direct"` in the base policy, the Captain merges one READY PR
at a time with `gstack-ship-queue land --pr <n> --pin <sha>`, where `<sha>`
is the head the READY names. There's no train, no stamp commit, no base merge
and no extra CI round. GitHub's required checks plus `--match-head-commit`
stay the gate. Versions aren't set per PR: a PR carries only its
`changelog.d` fragment, VERSION stays where it is, and the release stamps the
version and folds the fragments. Land refuses, and merges nothing, unless all
of these hold:
| Check | Refusal |
|---|---|
| the base policy sets `merge: direct` | `QUEUE_NOT_READY` |
| the PR is open, on the base, from this repository | `QUEUE_NOT_READY`, `QUEUE_TRAIN_UNTRUSTED` |
| the PR head is the pin | [`QUEUE_LAND_PIN_MOVED`](troubleshooting.md#queue-land-pin-moved) |
| the PR body's /ship receipt is bound to the pin | `QUEUE_RECEIPT_MISSING`, `QUEUE_RECEIPT_STALE` |
| a READY note from `ready` exists at the pin | `QUEUE_NOT_READY` |
| no freeze is active and no land lock is held | `QUEUE_TRAIN_FROZEN`, `QUEUE_LAND_LOCKED` |
| VERSION, the `package.json` version, the CHANGELOG and `.gstack/version-path` are unchanged since the merge base | [`QUEUE_LAND_STAMPED`](troubleshooting.md#queue-land-stamped) |
| every check at the pin is green with none pending, and each `changelog_fragments.required_checks` entry passed there | [`QUEUE_LAND_CHECKS`](troubleshooting.md#queue-land-checks) |
| GitHub reports the PR `MERGEABLE` and `CLEAN` | [`QUEUE_LAND_NOT_CLEAN`](troubleshooting.md#queue-land-not-clean) |
Release outputs (the agents digest) are checked by the Version Gate, which must
be green at the pin: it accepts an output only when it equals what the release
tool generates at the merge base's VERSION. If the base moved and GitHub still
reports the PR clean, land merges it as it is. Then it runs `gh pr merge
--squash --match-head-commit <pin>` (`--merge` under `history: merge-only`),
reads the merge commit, and checks that its tree is the pin merged onto the
merge commit's parent ([`QUEUE_LAND_UNVERIFIED`](troubleshooting.md#queue-land-unverified)
if not). Last, it writes a `land` note on `refs/notes/gstack-trains`. A rerun
after GitHub merged goes straight to the tree check. `--dry-run` runs every
check and merges nothing. Under `merge: direct`, `train build` refuses and
names this command.
<a id="migrating-to-fragments"></a>
### Migrating to fragments
+216 -11
View File
@@ -179,7 +179,23 @@ the readiness dashboard and the PR body, and never count it as a pass.
A reply whose last `Verdict:` line says `not ready` is `findings` and exits
3 even when readable advisory severities (P2 or P3) were parsed; `FINDINGS:`
names the highest one. A not-ready reply with no readable severity still
prints `FINDINGS: unspecified` and exits 3.
prints `FINDINGS: unspecified` and exits 3. A last `Verdict:` line that is
not an approval (`ready`, `LGTM`, `approve`, `ship it`, `pass`, `clean`,
`good to go`, or exactly `NO_FINDINGS`), such as `Can't verify`, `blocked`,
`revise` or an empty one, or an approval that says something was excluded,
blocked, skipped, not run, not executed or could not be verified (or "not
yet", or holds a `?` such as `Ready? No`), is
[`unverified`](#outside-review-unverified-verdict), never `clean`. Inline
code in the verdict is blanked like any other quote, so write the verdict as
plain text: `Verdict: \`ready\`` is unverified. Only a line that starts
with `Verdict:` (after optional bold, a heading mark or a bullet) is read; a
verdict under a bare `Verdict` heading, after `>`, or as `Overall verdict`
is not.
- **Numbered findings.** In a reply with a `Verdict:` line, every numbered item
at the start of a line (`1. x`, `1. **Nonblocking — x**`) is a finding,
unless the reply concludes that it found nothing. When one has no readable
severity, `FINDINGS:` prints `unspecified` instead of `none` (here and in
`gstack-outside-voice run`); the verdict still comes from the rules above.
- **No findings.** A review with no tag and no label is `clean` only when its
conclusion says so: the last sentence (or its last ", and" clause), or the
`because` clause of its `Recommendation:` line, opens with `NO_FINDINGS`,
@@ -380,6 +396,33 @@ bun lib/outside-review-result.ts --verdict execution reply.md
**Expected result.** `VERDICT: findings` or `VERDICT: clean` with a P0-P3
`FINDINGS:` value, and no `UNPARSED:` line.
<a id="outside-review-unverified-verdict"></a>
### `... outside review: ran, verdict unverified (the reviewer's own verdict does not say the change is ready (Verdict: ...))` / `REASON: unverified_verdict`
**Meaning.** The reply's last `Verdict:` line is neither an approval nor
`not ready`, for example `Verdict: Can't verify (one changed file excluded
...)`, or it approves but also says something was excluded, blocked, skipped,
not run or could not be verified, or asks a question. The
reviewer did not say the review finished and found the change ready, so gstack
does not count it as `clean`. The quoted verdict is in parentheses. Besides
the approvals, only the exact `NO_FINDINGS` token counts as a clean verdict.
A `/codex` consult or challenge answer whose verdict picks an option
(`Verdict: go with B`, `Verdict: Proceed with the plan`) is `unverified` on
purpose: a consult is advice, not a pass/fail review, so there is no pass to
report. Read the answer itself; an answer with no verdict line stays `clean`.
**What is kept.** The full reply is shown above.
**Fix.** Read the verdict and the reply, and decide; it is not a pass. Fix
what the verdict names (a file left out of the review, a suite that could not
run), then re-run the review. To re-check a saved reply, run
`bun lib/outside-review-result.ts --verdict execution reply.md` from your
gstack checkout.
**Expected result.** `VERDICT: clean` or `VERDICT: findings` with a last
verdict that approves the change or says `not ready`.
<a id="codex-model-unusable"></a>
### `CODEX_MODE: model_unusable` / `MODEL_UNUSABLE`
@@ -588,6 +631,49 @@ fix-check record as Step 9's. Rerun Step 11's native pass on the same frozen tre
**Expected result.** `gstack-review-read` shows both records at one tree with
`review_freshness` `CURRENT`, and Step 11.5 continues to Step 12.
<a id="ship-preflight-run-open"></a>
### `STOP: /ship run <id> is open on this checkout and past its preflight (<marker>; open until its finish or for 120 minutes) ...`
**Meaning.** A full `gstack-ship-preflight` (Steps 1-3) ran on a checkout where a
/ship run already passed them and has not finished. A full preflight merges the
base into the working tree, and that run's review readers diff this tree, so the
merge would void their pass (gbrain-evals#317). /ship passes `--run <id>` (Step 1:
the preamble's `SESSION_ID`; `ship/DRIVER.md` Phase 2: the run dir's name). Once
Steps 1-3 succeed, preflight records the run in the checkout's git dir
(`gstack-ship-run`) and opens it on the checkout's own lease
(`<git dir>/gstack-leases`, so the driver needs no workflow lease). The run
counts as open while the marker exists, which `gstack-ship-release finish` removes
when Steps 15-16 pass, and while `bin/gstack-lease open` says so: under 120
minutes old and its holder (the harness that ran preflight) alive. A finished,
aged-out or dead run never blocks, and /ship's own first preflight (or a rerun
after a Step 3 STOP) is never refused. A different `--run` from the open run's
holder replaces it: that is a new /ship after one that stopped before finish.
The holder is the first non-shell process above preflight: the Claude Code
session, or on Capy the machine's one `capy-server`, so there only the same id or
a missing `--run` is refused. The same id, no `--run`, or an open run held by
another live process (another session) stops.
**What is kept.** Nothing ran: no fetch, no merge, the tree is untouched.
**Fix.** Rerun Step 5 with `gstack-ship-preflight --tests-only`, which never
merges, or let that /ship finish. If that run is no longer running (it stopped
before finish), remove the marker the STOP names (`rm <marker>`), then rerun.
**Expected result.** The preflight continues to `STATUS:` and `MERGE:`.
<a id="ship-preflight-run-unrecorded"></a>
### `STOP: cannot record /ship run <id> on this checkout's lease (...)`
**Meaning.** `--run` was given, but `gstack-lease touch` could not open the run
on the checkout's lease, for example while a live process holds its lock.
Unrecorded, a later full preflight could not be refused, so preflight stops
before the fetch instead of going on unprotected.
**What is kept.** Nothing ran: no fetch, no merge.
**Fix.** Do what the parenthesized `gstack-lease` line names (wait for the
process holding `<git dir>/gstack-leases/.lock`), then rerun.
<a id="review-fingerprint-tmpdir"></a>
### `gstack-wtree: cannot create a temp index under <dir>; set TMPDIR to a writable directory` / `gstack-review-log: cannot create a private object directory under <dir>; no working-tree fingerprint. Set TMPDIR to a writable directory.`
@@ -2706,6 +2792,8 @@ exempt wherever they appear, so there the finding means the quad is not a
release of this repository (or the post ran outside the checkout).
**Fix.** Write it as `version: 1.2.3.4`, `"version": "..."` or `v1.2.3.4`.
A gstack release named right after `gstack` (`gstack 1.94.4.0`, the install
check's `gstack: ok 1.94.4.0 for=ship`) is exempt too, but only for majors 0 to 2.
MEDIUM findings do not block pushes.
<a id="redact-run-id-as-phone"></a>
@@ -2720,6 +2808,16 @@ exact tokens it finds in these positions:
- a 12-digit value right after `tree`, `gated_tree`, `head` or `predecessor`
with `=` or a space (`tree=704992822996`, `tree 704992822996`);
- an 11-digit id inside an `/actions/runs/<id>` URL or as that link's text;
- any id inside an `actions/runs/<id>` path, with or without a host, when
`actions/runs/` starts the line or follows whitespace, `/`, `(`, `[`,
`<` or a quote (`see actions/runs/41629861820`; `sales-actions/runs/…`
is not one), and every 11- or 12-digit id of a `runs=<id>/<attempt>` list
a CI carry or run clear prints (`runs=41629861819/1,41629861820/2`;
`run=` too). The scanner itself exempts both (`isCiRunIdentifier`), and
only that shape counts: each id needs its `/<attempt>` of one to three
digits, so `runs=4155550123`, `runs=4155550123/1`,
`runs=+14155550123/1` and a phone number elsewhere on the line still
report (GST-1227);
- an 11-digit id right after a run word on the same line, with an optional
`:`, `#` or `=` between: `run`, `runs`, `screen`, `screens`, `gate`,
`gates`, `backstop`, `bisect`, `rerun`, `dispatched`, `dispatch`,
@@ -2763,20 +2861,30 @@ same-titled section (title compared case-insensitively), the
rewrite replaces only the receipt, sections the file does not rewrite stay in
place, and new sections are appended after them. The text above the first
`## ` heading belongs to the file when the file is a full post: a
receipt block above its first `## ` section, as /ship composes it. Then the current body's text there (the opening lines, and
any lines between the receipt and the first heading, or the whole body when it
has no `## ` heading) is replaced by the file's own text above its first
heading, or dropped when the file has none. A partial post (`## ` sections
receipt block above its first `## ` section, as /ship composes it. Then any
lines between the current receipt and the first heading are replaced by the
file's own text under its receipt, or dropped when the file has none. The
opening text above the first receipt or heading (a paragraph, a bot's badge
such as `<!-- capy-badge:start -->…<!-- capy-badge:end -->`, or the whole
body when it has no `## ` heading) is kept byte for byte, and a new receipt
lands right under it; only opening lines the file brings of its own replace
it (GST-1227). A Capy badge that ends the body after its first receipt or
heading (`<!-- capy-badge:start -->` through `<!-- capy-badge:end -->`, outside
a code fence, with only blank lines after it) is a piece of its own: it stays
at the end when the section above it is rewritten, new sections go above it,
and a badge only the file brings goes last, once. Every other marker block, a
badge with text after it and an unclosed badge marker stay inside the section
they sit in. A partial post (`## ` sections
with no receipt above them, such as a lane posting `## Screenshots`) and a
file with no `## ` section (a receipt-only or text-only post) leave that text alone, so a human
intro or a `Review-loop exception:` line under the receipt survives; only
opening lines the file brings of its own replace the PR's opening lines. One stderr line names what was replaced, kept, added
intro or a `Review-loop exception:` line under the receipt survives. One stderr line names what was replaced, kept, added
and dropped, a dropped piece with its first line, or with that line withheld
when the redaction scan flags it (`gstack-post: pr-body merged
by section: replaced What changed; kept ship receipt, Why, Approvals; added
Tests; dropped leading text ("Draft from gh pr create")`). A body file holding
only its first section therefore no longer wipes the rest of the PR, and an
old intro no longer sits above a fresh /ship body.
by section: replaced ship receipt, What changed; kept leading text, Why;
added Tests; dropped trailing text ("Old note under the receipt")`). A body
file holding only its first section therefore no longer wipes the rest of the
PR, and a stale note no longer sits under a fresh receipt. To retire an old
intro, post the whole body with `--replace` or bring new opening lines.
The redaction scan runs on the merged bytes, so a MEDIUM or HIGH finding can
come from a kept section; the `RULE:` line's `LINE:` is in the merged text.
When the current body cannot be read (`gh pr view --json body` fails) nothing
@@ -5073,6 +5181,59 @@ the two allowed restamps, so `land` stopped instead of restamping again.
it), then rerun `gstack-ship-queue land --pr <n>`; the lock was released and
the rerun restamps from the pin.
<a id="queue-land-stamped"></a>
### `QUEUE_LAND_STAMPED`
**Meaning.** Under `merge: direct`, the PR changes VERSION, the
`package.json` version or the CHANGELOG. Versions and the CHANGELOG are set at
release, so a PR carries only its `changelog.d` fragment. Nothing was merged.
**Fix.** The lane runs the command the refusal prints, for example
`git checkout $(git merge-base origin/main HEAD) -- VERSION CHANGELOG.md`
(and sets the `package.json` version back by hand when it's named), keeps or
adds the fragment with the entry text, commits, then runs /ship and
`gstack-ship-queue ready --pr <n> --no-screen` at the new head.
<a id="queue-land-pin-moved"></a>
### `QUEUE_LAND_PIN_MOVED`
**Meaning.** The PR head isn't the pin `land --pin` was given, so the READY
doesn't describe what would merge. Nothing was merged.
**Fix.** The lane runs /ship and `ready --pr <n> --no-screen` at the new head
and sends a new READY; land that pin.
<a id="queue-land-checks"></a>
### `QUEUE_LAND_CHECKS`
**Meaning.** A check at the pin is red or pending, or a required check never
ran there. Nothing was merged.
**Fix.** Pending: `gstack-pr-wait <n> --until green`, then rerun land. Red:
the lane fixes it at a new head (a flaky case gets its one rerun under rule
16). Missing: dispatch that workflow on the branch, then rerun land.
<a id="queue-land-not-clean"></a>
### `QUEUE_LAND_NOT_CLEAN`
**Meaning.** GitHub doesn't report the PR `MERGEABLE` and `CLEAN`. Nothing
was merged.
**Fix.** `CONFLICTING`: the lane merges the base into its branch, resolves,
runs /ship and `ready` at the new head. `UNKNOWN`: GitHub is still
computing, so rerun land in a minute. `BLOCKED` or `BEHIND`: meet the branch
rule it names.
<a id="queue-land-unverified"></a>
### `QUEUE_LAND_UNVERIFIED`
**Meaning.** GitHub merged the PR, but the base branch's merge commit tree
isn't the pin merged onto that commit's parent, so something besides the
reviewed pin reached main. No land note was written.
**Fix.** Compare the merge commit with `git merge-tree --write-tree <parent>
<pin>`, revert what doesn't belong to the PR, and report it to the Captain.
<a id="queue-train-stamp-mismatch"></a>
### `QUEUE_TRAIN_STAMP_MISMATCH`
@@ -6600,6 +6761,50 @@ These codes come from `bin/gstack-modules` and `gen-skill-docs --loadout/--modul
**Meaning.** `--out-dir` is a non-empty directory that an earlier `gstack-modules render` did not write, so replacing it could delete someone's files. **Fix.** Pass a new or empty directory, or the directory of an earlier render.
## ubi-runner recovery and results sink
These codes come from `scripts/ubicloud/ubi-runner.sh` (GST-1220). `RECOVERED`, `RECOVER_GONE` and `RECOVER_FAILED` lines go to stdout, one per bundle. Every `RECOVER_FAILED` keeps its bundle, so rerunning `recover` after the fix is always safe.
<a id="ubi-recovery-required"></a>
### `UBI_RECOVERY_REQUIRED`
**Meaning.** A counted run (`--counted` or `UBI_COUNTED=1`) or `recover` needs a recovery directory, and neither `UBI_RECOVERY_DIR` nor `GSTACK_CAPY_RECOVERY_DIR` (nor `recover --dir`) names one. Nothing was created. A counted rerun also refuses with this code when it would reattach to a live VM whose bundle under the recovery directory is missing or doesn't open under this secret. **Fix.** Set `UBI_RECOVERY_DIR` to a directory another controller can read (on Capy, a drive folder), then rerun. For a VM without a bundle that opens, destroy it with the printed `down` command, then rerun.
<a id="ubi-recovery-unavailable"></a>
### `UBI_RECOVERY_UNAVAILABLE`
**Meaning.** The bundle can't be written or opened: `openssl` is missing, there is no secret (`UBI_RECOVERY_KEY` or `UBICLOUD_API_KEY`), or the directory isn't writable. A counted run stops: before any create when the check runs up front, or through `up`'s teardown, which destroys the VM, when a later write fails. An uncounted run prints a warning and goes on without a bundle. **Fix.** Install openssl and make the directory writable, then rerun.
<a id="recover-decrypt-failed"></a>
### `RECOVER_FAILED: vm=<name> code=RECOVER_DECRYPT_FAILED`
**Meaning.** The bundle didn't authenticate under this machine's secret, or it was sealed for a different VM name or owner than its file name and folder say, so no state was written. Either the secret differs from the creating controller's (another `UBI_RECOVERY_KEY`, or a different Ubicloud token), or the file was changed, renamed or moved. **Fix.** Set `UBI_RECOVERY_KEY` (or `UBICLOUD_API_KEY`) to the value the creating controller used and rerun `recover`. If the secret is right, treat the bundle as tampered and destroy the VM from the Ubicloud console instead.
<a id="recover-api-unavailable"></a>
### `RECOVER_FAILED: vm=<name> code=RECOVER_API_UNAVAILABLE`
**Meaning.** The Ubicloud API answered something other than 200 or 404 for the VM, or the VM's create was sent less than `UBI_CREATE_GRACE` seconds ago and may still be in flight. No state was written. **Fix.** Rerun `recover` once the API answers, or after the grace period.
<a id="recover-exists"></a>
### `RECOVER_FAILED: vm=<name> code=RECOVER_EXISTS`
**Meaning.** This machine already has state for the VM, and `recover` never overwrites it. **Fix.** Use that state (`pull`, `ssh`, `down <name>`). Remove the state directory only if you know it's stale.
<a id="recover-gone"></a>
### `RECOVER_GONE: vm=<name>`
**Meaning.** The VM is confirmed gone, so its bundle was deleted. **Fix.** None. Its results are wherever its sink put them, if it had one.
<a id="ubi-sink-invalid"></a>
### `UBI_SINK_INVALID`
**Meaning.** `--sink` or `UBI_SINK` names something other than `https-put:<presigned https URL>`. `file:<absolute dir>` works only with `UBI_SINK_ALLOW_FILE=1`, which is meant for tests, and there is no `git:` sink because it would put a repository credential on the VM. Nothing was created, and the line never prints the URL. **Fix.** Pass a presigned PUT URL for one object.
<a id="sink-flush-failed"></a>
### `SINK_FLUSH_FAILED: vm=<name> seq=<n> last_push=<epoch|never> [sink_skipped=size|tar_failed]`
**Meaning.** `down` stopped the VM's pusher and ran one last push, and that push failed, took longer than 150 seconds, or was skipped because the results were over `UBI_SINK_MAX_MB` (`size`) or couldn't be read (`tar_failed`). A skipped snapshot is never uploaded, so the sink keeps the last good one. The VM was destroyed anyway, so the sink holds results only up to the push made at `last_push`. A `run` or `verify` of that VM exits 1 instead of 0. **Fix.** Treat the run's results as incomplete. Check that the presigned URL hasn't expired and still allows a PUT, then rerun. For a tiered `verify`, rerun with `--no-resume`: a plain rerun reuses the tiers that already passed under the same key, creates no VM and pushes nothing, so the sink would still hold the incomplete snapshot.
## Per-repo cleanup (gstack-clean)
These codes come from `bin/gstack-clean`, which reclaims old codex scratch in `<repo>/.gstack/tmp/`, browse logs over 1 MB and, with `--audit-days N`, old browse audit entries. It changes nothing unless you pass `--apply`, and `gstack-clean --help` lists what it touches.
+31 -9
View File
@@ -35,9 +35,11 @@
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import { runBin } from './spawn-bin';
import { HOOK_TIMEOUT_MS, hookChildBudgetMs, runBin } from './spawn-bin';
import { logHookError as sharedLogHookError } from './hook-log';
import { parseQidMarker } from '../../../scripts/qid-marker';
import { resolveWindowsBashCached } from '../../../lib/script-invocation';
import { resolveStateRoot } from '../../../lib/state-root';
interface HookStdin {
session_id?: string;
@@ -67,6 +69,15 @@ interface ExtractedQuestion {
}
const RECOMMENDED_LABEL_RE = /\(recommended\)\s*$/i;
const HOOK_STARTED_AT = Date.now() - Math.round(process.uptime() * 1000);
/** Below this much budget a child cannot launch and finish, so the hook skips it instead of starting a call it must kill. */
const MIN_LAUNCH_BUDGET_MS = 250;
/** Why the next gstack-question-log call is skipped with `budgetMs` left, or null when it may launch. */
export function launchSkipReason(budgetMs: number): string | null {
if (budgetMs >= MIN_LAUNCH_BUDGET_MS) return null;
return `the hook's ${HOOK_TIMEOUT_MS} ms budget is spent (${Math.max(0, budgetMs)} ms left, under the ${MIN_LAUNCH_BUDGET_MS} ms a call needs)`;
}
function logHookError(msg: string): void {
sharedLogHookError('question-log-hook', msg);
@@ -238,17 +249,19 @@ function detectSkill(cwd: string | undefined): string {
return 'unknown';
}
function spawnLog(payload: Record<string, unknown>, cwd?: string): void {
function spawnLog(payload: Record<string, unknown>, budgetMs: number, cwd?: string): void {
const startedAt = Date.now();
const res = runBin('gstack-question-log', [JSON.stringify(payload)], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 3000,
timeout: budgetMs,
// Run from the originating tool call's cwd so gstack-slug resolves to
// the project the user is actually in, not the hook script's location.
cwd: cwd && fs.existsSync(cwd) ? cwd : undefined,
});
if (res.status !== 0) {
logHookError(`gstack-question-log exited ${res.status}: ${res.stderr || res.stdout}`);
const how = res.signal ? ` (${res.signal} after ${Date.now() - startedAt} of ${budgetMs} ms)` : '';
logHookError(`gstack-question-log exited ${res.status}${how}: ${res.stderr || res.stdout}`);
}
}
@@ -283,6 +296,7 @@ async function main(): Promise<void> {
const choices = extractUserChoices(stdin.tool_response, questions, (msg) =>
logHookError(`${msg} (tool_use_id=${stdin.tool_use_id || 'n/a'})`),
);
if (process.platform === 'win32') resolveWindowsBashCached(path.join(resolveStateRoot(), 'cache', 'windows-bash.json'));
for (let i = 0; i < questions.length; i++) {
const q = questions[i];
@@ -309,13 +323,21 @@ async function main(): Promise<void> {
if (signal_key) payload.signal_key = signal_key;
if (choice.free_text) payload.free_text = String(choice.free_text);
spawnLog(payload, stdin.cwd);
const budgetMs = hookChildBudgetMs(HOOK_STARTED_AT);
const skip = launchSkipReason(budgetMs);
if (skip) {
logHookError(`gstack-question-log skipped for questions ${i + 1}-${questions.length}: ${skip}`);
break;
}
spawnLog(payload, budgetMs, stdin.cwd);
}
process.exit(0);
}
main().catch((e) => {
logHookError(`main crash: ${(e as Error).message}`);
process.exit(0);
});
if (import.meta.main) {
main().catch((e) => {
logHookError(`main crash: ${(e as Error).message}`);
process.exit(0);
});
}
+15
View File
@@ -41,6 +41,21 @@ export function runBin(name: string, args: string[], opts: SpawnSyncOptions) {
});
}
/** Claude Code's timeout for a gstack hook, as setup registers it (`--timeout 5`). */
export const HOOK_TIMEOUT_MS = 5000;
/** Kept back from HOOK_TIMEOUT_MS so a hook logs and exits before Claude Code kills it. */
const HOOK_EXIT_RESERVE_MS = 500;
/**
* What is left of a hook's registered budget for its next child. Every child
* a hook spawns shares one deadline: a fixed per-child timeout either kills a
* cold child that would have finished in time, or lets several children run
* past the point where Claude Code kills the hook itself.
*/
export function hookChildBudgetMs(startedAtMs: number, nowMs: number = Date.now()): number {
return HOOK_TIMEOUT_MS - HOOK_EXIT_RESERVE_MS - (nowMs - startedAtMs);
}
/** Kept tail of the child's stderr, for the caller's error log. */
const STDERR_TAIL_BYTES = 500;
/** After a group kill, how long to wait for 'exit'/'close' before resolving anyway (kept under the callers' post-spawn reserve). */
+27 -6
View File
@@ -519,6 +519,13 @@ function screenshotName(i: number, step: ScreenshotStep): string {
* Aside path, one CLI call per action, artifacts staged under /tmp and copied
* to the caller's paths. The tab is closed in a finally; the daemon stays up.
*/
/** The checkout's bin/gstack-browser-ensure next to a browse/dist/browse binary; it installs gstack's Chromium on first use. */
function browserEnsureBin(bin: string): string | null {
const root = /^(.*)[\\/]browse[\\/]dist[\\/]browse(?:\.exe)?$/.exec(bin)?.[1];
const ensure = root ? path.join(root, 'bin', 'gstack-browser-ensure') : null;
return ensure && fs.existsSync(ensure) ? ensure : null;
}
export async function renderWithBrowse(spec: RenderSpec, bin: string | null = resolveBrowseBin()): Promise<RenderResult> {
const outputs: string[] = [];
const evals: Record<number, string> = {};
@@ -528,13 +535,14 @@ export async function renderWithBrowse(spec: RenderSpec, bin: string | null = re
const target = pageTarget(spec);
if (target.kind === 'error') return fail(target.error);
const deadline = Date.now() + (spec.timeoutMs ?? DEFAULT_TIMEOUT_MS);
let deadline = Date.now() + (spec.timeoutMs ?? DEFAULT_TIMEOUT_MS);
const browseEnv = { ...process.env, BROWSE_SESSION: '' };
const run = async (args: string[]): Promise<string> => {
const r = await runProc(bin, args, Math.max(1_000, Math.min(120_000, deadline - Date.now())), browseEnv);
log.push(`$ browse ${args.join(' ').slice(0, 300)}\n${r.stdout}${r.stderr}`.trim());
if (r.error || r.code !== 0) {
const first = (r.stderr || r.stdout || r.error || '').trim().split('\n')[0];
const lines = (r.stderr || r.stdout || r.error || '').split('\n').map((l) => l.trim()).filter(Boolean);
const first = lines.find((l) => /Executable doesn't exist/.test(l)) ?? lines.find((l) => !/^\[browse\] (Starting server|Server failed to start)/.test(l) && !/^[\u2554\u2551\u255a]/.test(l)) ?? lines[0] ?? '';
if (/JS execution blocked/.test(`${r.stderr}${r.stdout}`)) {
// After `$B cookie-import` the daemon refuses page JS on every other
// origin, 127.0.0.1 included; a local-HTML render cannot proceed in it.
@@ -565,10 +573,23 @@ export async function renderWithBrowse(spec: RenderSpec, bin: string | null = re
try {
opened = await openTab();
} catch (e) {
if (!/Unable to connect/.test((e as Error).message)) throw e;
log.push('newtab: daemon not up yet — retrying once');
await Bun.sleep(1_500);
opened = await openTab();
const message = (e as Error).message;
const ensure = /Executable doesn't exist/.test(message) ? browserEnsureBin(bin) : null;
if (ensure) {
log.push('newtab: Chromium is not installed — running gstack-browser-ensure once');
const installStart = Date.now();
const installed = await runProc('bash', [ensure, '--quiet'], 600_000, browseEnv);
deadline += Date.now() - installStart;
const last = installed.stdout.trim().split('\n').at(-1) ?? '';
log.push(`${ensure} --quiet\n${installed.stdout}${installed.stderr}`.trim());
if (installed.error || installed.code !== 0) throw new Error(`${message} (gstack-browser-ensure: ${last || installed.error || `exit ${installed.code}`})`);
opened = await openTab();
} else {
if (!/Unable to connect/.test(message)) throw e;
log.push('newtab: daemon not up yet — retrying once');
await Bun.sleep(1_500);
opened = await openTab();
}
}
tab = opened ? JSON.parse(opened).tabId : undefined;
if (typeof tab !== 'number') throw new Error('browse newtab --json returned no tabId');
+4 -2
View File
@@ -16,6 +16,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { OUTSIDE_ROLE, sectionLoadFor, type Phase, type Scope } from './autoplan-prompts';
import { RunError, type RunState } from './autoplan-state';
import { defaultBaseBranch, noDefaultBaseLine } from './default-base';
import { redactFindingSpans } from './redact-engine';
export const KINDS = ['fix', 'feature'] as const;
@@ -66,8 +67,9 @@ export function captureDraft(cwd: string, dir: string, out: string, base?: strin
const top = git(['rev-parse', '--show-toplevel']);
if (top.status !== 0) return { base: base ?? 'origin/HEAD', none: 'not a git checkout' };
const root = top.stdout.trim();
const origin = git(['rev-parse', '--abbrev-ref', 'origin/HEAD'], root);
const ref = base ?? (origin.status === 0 && origin.stdout.trim() ? origin.stdout.trim() : 'origin/main');
const found = base ? undefined : defaultBaseBranch(root);
if (found && !found.branch) return { base: 'origin/HEAD', none: found.skipped ? noDefaultBaseLine(found.skipped) : 'no default base (no origin/HEAD, PR base, origin/main or origin/master); pass --base' };
const ref = base ?? `${found!.remote ?? 'origin'}/${found!.branch}`;
const mb = git(['merge-base', 'HEAD', ref], root);
if (mb.status !== 0) return { base: ref, none: `no merge base with ${ref}` };
const mergeBase = mb.stdout.trim();
+5
View File
@@ -68,6 +68,11 @@ export const GATE_OUTCOMES = {
summary: 'the review labels a finding with a P0-P3 severity in a form the parser cannot read',
fix: 'read the quoted line and the full reply above and decide; it is not a pass. Re-run the review asking for [P0]-[P3] or "P1:" labels, or re-check a corrected reply with bun lib/outside-review-result.ts --verdict <gate> <reply-file> from your gstack checkout (https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#outside-review-unparsed-severity)',
},
unverified_verdict: {
state: 'ran', anchor: 'outside-review-unverified-verdict',
summary: "the reviewer's own verdict does not say the change is ready",
fix: 'read the quoted verdict and the full reply above and decide; it is not a pass. Fix what the verdict names (an excluded file, a suite that could not run), then re-run the review (https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#outside-review-unverified-verdict)',
},
model_unusable: {
state: 'unavailable', anchor: 'codex-model-unusable',
summary: 'Codex could not use the selected model',
+43 -23
View File
@@ -10,7 +10,8 @@
* the same runner): a section in the file replaces the same-titled one, the
* ship-receipt block is a section of its own, sections the file does not
* rewrite are kept, new ones are appended, and a file with a receipt above
* its first `## ` section replaces the text above the first heading;
* its first `## ` section replaces the text between the receipt and the
* first heading while the opening text above the receipt is kept;
* `--replace` overwrites instead.
* The scan runs on the merged bytes, which are the bytes sent.
*
@@ -159,7 +160,7 @@ function parseArgs(argv: string[]): Request {
return req;
}
/** One piece of a PR body: the leading text, a `## ` section, the ship-receipt block, or text after a receipt block. */
/** One piece of a PR body: the leading text, a `## ` section, the ship-receipt block, text after a receipt block, or a Capy badge that ends the body. */
interface Section { key: string; label: string; text: string }
/**
@@ -174,26 +175,43 @@ interface Section { key: string; label: string; text: string }
* nothing; a receipt fence that never closes makes the body from that line
* on one section the merge never matches (`unclosed-receipt`), so it is kept
* byte for byte instead of being read as a receipt or as replaceable text.
* A Capy badge (`<!-- capy-badge:start -->` through `<!-- capy-badge:end -->`,
* both outside a fence) that ends the body after its first receipt or
* heading, with only blank lines after it, is a piece of its own
* (`badge:capy-badge`), so it never leaves with the section above it
* (GST-1227). A badge anywhere else, an unclosed badge marker and every other
* marker block stay inside the section they sit in.
*/
function splitSections(body: string): Section[] {
const out: Section[] = [];
let fence: string | null = null;
let badgeAt = -1;
let inBadge = false;
let plain = 0;
const open = (key: string, label: string) => out.push({ key, label, text: "" });
const open = (key: string, label: string) => { badgeAt = -1; inBadge = false; out.push({ key, label, text: "" }); };
const openText = () => out.length === 0 ? open("text:lead", "leading text") : open(`text:${plain++}`, "trailing text");
const closes = (run: string) => new RegExp(`^ {0,3}${run[0] === "`" ? "`" : "~"}{${run.length},}\\s*$`).test(bare);
let bare = "";
for (const line of body.split(/(?<=\n)/)) {
bare = line.replace(/\r?\n$/, "");
const fenceOpen: RegExpExecArray | null = fence === null ? /^ {0,3}(`{3,}|~{3,})/.exec(bare) : null;
const outside = fence === null && !fenceOpen;
if (fenceOpen && bare.trim() === "```" + RECEIPT_FENCE) { open("receipt", "ship receipt"); fence = "receipt"; }
else if (fence === "receipt") { out.at(-1)!.text += line; if (closes("```")) { fence = null; openText(); } continue; }
else if (fenceOpen) fence = fenceOpen[1]!;
else if (fence && closes(fence)) fence = null;
else if (fence === null && /^ {0,3}## /.test(bare)) { const title = bare.replace(/^ {0,3}## /, "").trim(); open(`h2:${title.toLowerCase()}`, title); }
if (out.length === 0) openText();
if (outside && /^ {0,3}<!-- capy-badge:start -->/.test(bare)) { badgeAt = out.at(-1)!.text.length; inBadge = true; }
else if (inBadge && outside && /<!-- capy-badge:end -->/.test(bare)) inBadge = false;
else if (!inBadge && bare.trim() !== "") badgeAt = -1;
out.at(-1)!.text += line;
}
const last = out.at(-1);
if (last && badgeAt >= 0 && !inBadge && fence === null && last.key !== "text:lead") {
out.push({ key: "badge:capy-badge", label: "capy-badge block", text: last.text.slice(badgeAt) });
last.text = last.text.slice(0, badgeAt);
}
if (fence === "receipt") Object.assign(out.at(-1)!, { key: "unclosed-receipt", label: "unclosed receipt fence" });
return out.filter(s => s.text !== "");
}
@@ -202,8 +220,10 @@ function splitSections(body: string): Section[] {
* The body to send for `pr-body` without `--replace`: each section of the
* file replaces the same-keyed section of the current body in place, current
* sections the file does not rewrite are kept byte for byte, new `## `
* sections are appended after them, and a new receipt or leading text goes
* first (where lib/ship-receipt.ts puts the receipt). Text outside sections
* sections are appended after them (above a Capy badge that ends the body;
* a badge only the file brings goes last), new leading text goes first, and a new receipt goes right after the
* current leading text, or first when there is none (where
* lib/ship-receipt.ts puts the receipt). Text outside sections
* matches by role (the file's leading text replaces the leading text; text
* after a receipt block never replaces an intro), and whitespace-only text in
* the file is dropped, so reposting the body /ship composes (receipt, blank
@@ -214,15 +234,17 @@ function splitSections(body: string): Section[] {
* description pasted under the new one) matches no single section, so the
* merge refuses instead of replacing one copy and keeping the stale other.
* A full post (a file with a receipt block above its first `## ` section, as
* /ship composes it) manages the preamble (GST-412): every
* non-blank text section before the current body's first heading (the leading
* text, text after a receipt block) is dropped, the file's own text before its
* first heading takes its place (leading text first, text after the file's
* receipt right after the receipt), a dropped piece the file brings no text for
* /ship composes it) manages the text under the receipt (GST-412): every
* non-blank text section after a receipt block and before the current body's
* first heading is dropped, the file's own text after its receipt takes its
* place, right after the receipt, and a dropped piece the file brings no text for
* is listed in `dropped` with its first line (withheld when the redaction scan
* flags it, since the merged bytes no longer hold it), and the receipt keeps the rules
* above. Any other file (`## ` sections with no receipt above them, or no
* `## ` section) merges as described above, so the current intro is kept.
* flags it, since the merged bytes no longer hold it). The current leading text
* (the opening paragraph and a bot's badge above the first receipt or heading)
* is kept byte for byte unless the file brings leading text of its own, which
* replaces it as in any merge (GST-1227). Any other file (`## ` sections with
* no receipt above them, or no `## ` section) merges as described above, so
* the text under the receipt is kept too.
*/
export function mergeBodySections(current: string, incoming: string): { body: string; replaced: string[]; kept: string[]; added: string[]; dropped: string[] } {
const now = splitSections(current);
@@ -231,18 +253,13 @@ export function mergeBodySections(current: string, incoming: string): { body: st
if (ambiguous.length) throw new UsageError(`the PR's current body holds ${[...new Set(ambiguous)].join(", ")} more than once, so the file's copy matches no single section; nothing was posted. Rerun with --replace and the whole body`);
const preamble = (list: Section[]) => {
const end = list.findIndex(s => s.key.startsWith("h2:"));
return list.slice(0, end < 0 ? list.length : end).filter(s => s.key.startsWith("text:") && s.text.trim() !== "");
return list.slice(0, end < 0 ? list.length : end).filter(s => s.key.startsWith("text:") && s.key !== "text:lead" && s.text.trim() !== "");
};
const firstHeading = next.findIndex(n => n.key.startsWith("h2:"));
const managed = firstHeading > 0 && next.slice(0, firstHeading).some(n => n.key === "receipt");
const stale = managed ? preamble(now) : [];
const ours = managed ? preamble(next) : [];
const role = (s: Section) => (s.key === "text:lead" ? "lead" : "trailing");
const paired = new Set<Section>();
for (const r of ["lead", "trailing"]) {
const theirs = ours.filter(n => role(n) === r);
stale.filter(s => role(s) === r).slice(0, theirs.length).forEach((s, i) => { paired.add(s); paired.add(theirs[i]!); });
}
const paired = new Set<Section>([...stale.slice(0, ours.length), ...ours.slice(0, stale.length)]);
const used = new Set<Section>(ours);
const replaced: string[] = [], kept: string[] = [], added: string[] = [], dropped: string[] = [];
const merged: Section[] = [];
@@ -260,10 +277,13 @@ export function mergeBodySections(current: string, incoming: string): { body: st
}
const fresh = next.filter(n => !used.has(n));
for (const n of next) if (fresh.includes(n) || (ours.includes(n) && !paired.has(n))) added.push(n.label);
const first = fresh.filter(n => !n.key.startsWith("h2:"));
const badge = (s: Section) => s.key === "badge:capy-badge";
const first = fresh.filter(n => !n.key.startsWith("h2:") && !badge(n));
if (now.length === 0) return { body: incoming, replaced, kept, added, dropped };
const all = [...ours.filter(n => role(n) === "lead"), ...first, ...merged, ...fresh.filter(n => n.key.startsWith("h2:"))];
for (const t of ours.filter(n => role(n) === "trailing").reverse()) all.splice(all.indexOf(next[next.indexOf(t) - 1]!) + 1, 0, t);
const lead = merged[0]?.key === "text:lead" && merged[0].text.trim() ? 1 : 0;
const tail = merged.length && badge(merged.at(-1)!) ? merged.length - 1 : merged.length;
const all = [...merged.slice(0, lead), ...first, ...merged.slice(lead, tail), ...fresh.filter(n => n.key.startsWith("h2:")), ...merged.slice(tail), ...fresh.filter(badge)];
for (const t of [...ours].reverse()) all.splice(all.indexOf(next[next.indexOf(t) - 1]!) + 1, 0, t);
const fromFile = (s: Section) => next.includes(s);
const body = all.map((s, i) => {
const after = all[i + 1];
+18 -1
View File
@@ -142,7 +142,24 @@ export interface VerdictProblem { detail: string; code: ResultCodeName; fix?: st
export function verdictProblem(skill: string, gstackRoot: string, env: Env, bunPath: string, bunVersion: string, cwd?: string): VerdictProblem | null {
const src = sourceRoot(gstackRoot);
const project = installProject(src, cwd);
const file = findInstallVerdict(src, project, env);
return admittedVerdict(skill, src, findInstallVerdict(src, project, env), project, bunPath, bunVersion).problem;
}
/**
* The verdict file that admits `skill`: `file` itself, else the workflow's own record
* <verdict>.for/<skill>.json that every install also writes, so a later install for another
* workflow never drops this one (GST-1233), except after a failed ./setup, which changed the
* checkout every record certified. When neither admits, `file` with its problem.
*/
export function admittedVerdict(skill: string, src: string, file: string, project: string | null, bunPath: string, bunVersion: string): { file: string; problem: VerdictProblem | null } {
const problem = verdictFileProblem(file, skill, src, project, bunPath, bunVersion);
const own = file.replace(/\.json$/, `.for/${skill}.json`);
if (problem && /^[A-Za-z0-9._-]+$/.test(skill) && !readInstallVerdict(file)?.trailer?.startsWith('gstack: fail setup') && fs.existsSync(own) && !verdictFileProblem(own, skill, src, project, bunPath, bunVersion)) return { file: own, problem: null };
return { file, problem };
}
/** Why the verdict in `file` does not admit `skill`; null when it does. */
function verdictFileProblem(file: string, skill: string, src: string, project: string | null, bunPath: string, bunVersion: string): VerdictProblem | null {
const v = readInstallVerdict(file);
const problem = (detail: string): VerdictProblem => ({ detail, code: 'INSTALL_UNVERIFIED' });
if (!v && fs.existsSync(file)) return problem(`the install verdict at ${file} is unreadable or has an unsupported schema_version; rerun the installer`);
+1 -1
View File
@@ -298,7 +298,7 @@ export function modelsMain(args: string[], env: StateRootEnv = process.env, plat
const providerArg = options['--provider'];
const provider = parseProvider(providerArg);
if (command === 'resolve') {
if (role !== 'plan-review' && role !== 'implementation' && role !== 'eval-arms' && role !== 'agent') return usage(role === undefined ? 'resolve needs --role plan-review|implementation|eval-arms|agent' : `unknown role '${role}': use plan-review, implementation, eval-arms or agent`, json);
if (role !== 'plan-review' && role !== 'implementation' && role !== 'eval-arms' && role !== 'agent') return usage(role === undefined ? 'resolve needs --role plan-review|implementation|eval-arms|agent' : role === 'outside-voice' ? "unknown role 'outside-voice': the outside voice follows the harness, not the model policy (docs/model-policy.md#your-model-preference); use plan-review, implementation, eval-arms or agent" : `unknown role '${role}': use plan-review, implementation, eval-arms or agent`, json);
if (role === 'agent') {
if (providerArg !== undefined) return usage('resolve --role agent takes no --provider; the preference names its provider', json);
return agentResult(env, platform, options['--request'], json);
+24 -8
View File
@@ -24,7 +24,8 @@ export interface OutsideReviewInput {
export interface OutsideReviewClassification {
execution: { state: 'ran' } | { state: 'unavailable'; reason: GateReason; detail?: string };
findings: { highest: Severity | null };
/** unlabeled: numbered findings with no readable severity, in a reply with a `Verdict:` line; set only when non-zero. */
findings: { highest: Severity | null; unlabeled?: number };
verdict: OutsideVerdict;
/** Set for every verdict except clean/findings. */
reason?: GateReason;
@@ -249,11 +250,16 @@ function firstUnparsed(text: string): string | undefined {
return at < 0 ? undefined : `line ${at + 1}: "${lines[at]!.trim().slice(0, 160)}"`;
}
/** The reply's last `Verdict:` line says it is not ready. */
function verdictNotReady(plain: string): boolean {
const last = [...plain.matchAll(/^[\t ]*Verdict[\t ]*:[\t ]*([^\r\n]*)$/gim)].at(-1)?.[1];
return last !== undefined && /^not[\t ]+ready\b/i.test(last.trim());
/** The value of the reply's last `Verdict:` line, or undefined when it has none. */
function lastVerdict(plain: string): string | undefined {
return [...plain.matchAll(/^[\t ]*Verdict[\t ]*:[\t ]*([^\r\n]*)$/gim)].at(-1)?.[1]!.trim();
}
/** A verdict that approves the change; the exact NO_FINDINGS token (its underscore is gone after plainReview) is clean too. */
const READY_VERDICT = /^(?:ready|lgtm|approved?|ship(?:[\t ]+it)?|pass(?:ed)?|clean|good to (?:go|merge|ship))\b(?![-?/])/i;
/** A verdict that says the reviewer could not check, run or see something is never a ready one (#3925). */
const UNVERIFIED_CLAIM = /\b(?:can['\u2019]?t|cannot|can not|couldn['\u2019]t|could not|unable to|not able to|did not|didn['\u2019]t)\s+(?:be\s+)?(?:verif|run|test|check|open|read|review|execut)|\bunverifi|\bnot (?:yet|until)\b|\bnot (?:been )?(?:verified|run|tested|checked|reviewed|executed)\b|\b(?:excluded|blocked|skipped)\b/i;
/** A numbered item at the start of a line; in a reply with a `Verdict:` line it is a finding (#3925). */
const NUMBERED_ITEM = /^(?:\*\*|__)?\(?\d{1,3}[.)][\t ]+\S.*$/gm;
export function classifyOutsideReview(input: OutsideReviewInput): OutsideReviewClassification {
const plain = plainReview(input.text);
@@ -263,7 +269,10 @@ export function classifyOutsideReview(input: OutsideReviewInput): OutsideReviewC
const levelsOf = (c: string, p: string) => [...[...p.matchAll(/\[(P[0-3])\]|^(P[0-3]):/gm)].map(m => (m[1] ?? m[2]) as Severity),
...severityTokens(c), ...(reviewed ? severityWords(c) : [])];
const levels = levelsOf(read, plainRead);
const findings = { highest: levels.length ? levels.sort()[0]! : null };
const verdicts = [lastVerdict(plainCode), lastVerdict(plainRead)];
const unlabeled = reviewed && verdicts[1] !== undefined && !concludesNoFindings(code)
? [...read.matchAll(NUMBERED_ITEM)].filter(m => !levelsOf(m[0], plainReview(m[0])).length).length : 0;
const findings = { highest: levels.length ? levels.sort()[0]! : null, ...(unlabeled ? { unlabeled } : {}) };
const cleanEvidence = levelsOf(code, plainCode).length > 0;
const ran = execution(input);
if (ran.state === 'unavailable') return { execution: ran, findings, verdict: 'unavailable', reason: ran.reason, detail: ran.detail };
@@ -283,15 +292,22 @@ export function classifyOutsideReview(input: OutsideReviewInput): OutsideReviewC
if (!reviewed) return result(blocking ? 'findings' : 'clean');
const unparsed = firstUnparsed(read);
// A last explicit not-ready blocks even when the only parsed severities are advisory.
const notReady = verdictNotReady(plainCode) || verdictNotReady(plainRead);
const notReady = verdicts.some(v => v !== undefined && /^not[\t ]+ready\b/i.test(v));
if (blocking || notReady) return { ...result('findings'), ...(unparsed ? { unparsed } : {}) };
if (unparsed) return { ...result('unverified', 'unparsed_severity', unparsed), unparsed };
const unready = verdicts.find(v => v !== undefined && !/^NO_?FINDINGS$/.test(v) && (!READY_VERDICT.test(v) || v.includes('?') || UNVERIFIED_CLAIM.test(v)));
if (unready !== undefined) return result('unverified', 'unverified_verdict', `Verdict: ${unready}`.trim().slice(0, 160));
if ((input.gate === 'structured' || input.gate === 'review') && !cleanEvidence && !concludesNoFindings(code)) {
return result('unverified', 'untagged_review', 'missing severity or explicit no-findings conclusion');
}
return result('clean');
}
/** The FINDINGS: value: the highest severity, `unspecified` when findings carry none, else `none`. */
export function findingsLabel(checked: OutsideReviewClassification): string {
return checked.findings.highest ?? (checked.verdict === 'findings' || checked.findings.unlabeled ? 'unspecified' : 'none');
}
const LEGACY_REASONS: Partial<Record<GateReason, string>> = {
empty_response: 'empty response',
review_refused: 'review refused',
@@ -388,7 +404,7 @@ if (import.meta.main) {
const checked = classifyOutsideReview({ text, gate: gate as OutsideGate, exit, stderr: await readOptional(flags['--stderr']), events });
const label = flags['--label'] ?? 'Outside review';
console.log(`VERDICT: ${checked.verdict}`);
console.log(`FINDINGS: ${checked.findings.highest ?? (checked.verdict === 'findings' ? 'unspecified' : 'none')}`);
console.log(`FINDINGS: ${findingsLabel(checked)}`);
if (checked.unparsed) console.log(`UNPARSED: ${checked.unparsed}`);
if (flags['--events'] !== undefined) console.log(formatUsageLine(usageFromEvents(events)));
if (checked.reason) {
+23
View File
@@ -0,0 +1,23 @@
/** Containment of a repository-relative write path (moved from lib/ship-policy.ts, which re-exports it, to keep that module under its size ratchet). */
import * as fs from 'node:fs';
import * as path from 'node:path';
/**
* A working-tree path a stamp may write: inside `root` after realpath (a
* symlink out of the repository is refused even when the glob matched).
*/
export function assertInsideRoot(root: string, rel: string): string | null {
const realRoot = fs.realpathSync(root);
const abs = path.resolve(realRoot, rel);
if (abs !== realRoot && !abs.startsWith(realRoot + path.sep)) return `${rel} resolves outside ${root}`;
let probe = abs;
while (!fs.existsSync(probe)) {
const parent = path.dirname(probe);
if (parent === probe) break;
probe = parent;
}
let real: string;
try { real = fs.realpathSync(probe); } catch { return null; }
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) return `${rel} resolves through a symlink to outside ${root}`;
return null;
}
+8 -6
View File
@@ -22,7 +22,7 @@
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { findInstallVerdict, installProject, readInstallVerdict, sourceRoot, verdictProblem } from '../install-verdict';
import { admittedVerdict, findInstallVerdict, installProject, readInstallVerdict, sourceRoot } from '../install-verdict';
import { resultCodeSuffix } from '../result-codes';
import { collectPins, evaluatePins, exactPlan, parseVersion, satisfies } from '../runtime-pins';
import { resolveStateRoot } from '../state-root';
@@ -152,15 +152,15 @@ export interface LaunchRuntime {
* The Bun the lanes and guards run under, its dir first on PATH; the env is
* unchanged when nothing qualifies. Under the admission condition the install
* verdict for (checkout, project) is validated as workflow admission does
* (verdictProblem: ok, HEAD, pins, project, coverage, for /ship) and its
* (admittedVerdict: ok, HEAD, pins, project, coverage, for /ship; else /ship's own record) and its
* recorded Bun is the one launched; the gstack checkout itself binds the
* 'none' verdict (installProject).
*/
export function launchRuntime(repoRoot: string, env: NodeJS.ProcessEnv): LaunchRuntime {
const checkout = sourceRoot(GSTACK_ROOT);
const project = installProject(checkout, repoRoot);
const file = findInstallVerdict(checkout, project, env);
const recorded = readInstallVerdict(file);
const admitted = admittedVerdict('ship', checkout, findInstallVerdict(checkout, project, env), project, '', '');
const recorded = readInstallVerdict(admitted.file);
let pinsRoot: string | null = null;
try { pinsRoot = fs.realpathSync(repoRoot); } catch { /* unresolvable: no pins */ }
const { pinExact, accept } = projectBunPins(pinsRoot, env);
@@ -174,14 +174,16 @@ export function launchRuntime(repoRoot: string, env: NodeJS.ProcessEnv): LaunchR
if (env.GSTACK_CAPY !== '1' && !recorded) {
verdictLine = 'install verdict: not checked (no GSTACK_CAPY=1 and no verdict for this checkout and project)';
} else {
const problem = verdictProblem('ship', GSTACK_ROOT, env, '', '', repoRoot);
const { problem } = admitted;
if (problem) return refuse('INSTALL_UNVERIFIED', problem.detail, problem.fix ?? installer);
verdict = 'ok';
if (recorded!.bun_path) {
const now = fs.existsSync(recorded!.bun_path) ? bunVersionOf(recorded!.bun_path, env) : '';
const why = !now ? 'is gone' : now !== recorded!.bun_version ? `now reports ${now}` : !accept(now) ? 'no longer satisfies the project pins' : '';
if (why) return refuse('BUN_RESOLUTION_DRIFT', `recorded bun ${recorded!.bun_version} at ${recorded!.bun_path} ${why}`, 're-run the Capy skill installer with --check (scripts/install.sh --check --for ship) and follow its fix');
const launch = recorded!.launch_path && fs.existsSync(recorded!.launch_path) ? recorded!.launch_path : path.dirname(recorded!.bun_path);
let launchBun = '';
try { launchBun = fs.realpathSync(path.join(recorded!.launch_path, 'bun')); } catch { /* no Bun in the launch dir */ }
const launch = recorded!.launch_path && fs.existsSync(recorded!.launch_path) && (!launchBun || launchBun === recorded!.bun_path) ? recorded!.launch_path : path.dirname(recorded!.bun_path);
return result(launch, now, `bun ${now} at ${recorded!.bun_path} (recorded by the install verdict)`, 'ok', '');
}
}
+38 -9
View File
@@ -356,8 +356,14 @@ export function isNumericMetadataValue(span: string, match: RegExpExecArray): bo
* (`bun run eval:pass-rates --run 37235771700`), and redacting it breaks that
* command. Only the position decides: a run/job flag (`--run 37235771700`,
* `--job=111534615007`), a `gh run view|watch|rerun|download|cancel`
* argument, an `actions/runs/<id>` or `/job/<id>` URL segment, or a
* `run_id` / `job_id` key. An 11-digit id (every GitHub Actions run id since
* argument, an `actions/runs/<id>` or `/job/<id>` URL segment (with or
* without a host: `actions/runs/` at the line start or after whitespace,
* `/`, `(`, `[`, `<` or a quote, so `sales-actions/runs/<n>` is not one),
* a `run_id` / `job_id` key, or any 11- or 12-digit id of a
* `runs=<id>/<attempt>[,<id>/<attempt>…]` list (`run=` too), the receipt a
* CI carry or run clear prints (lib/run-evidence.ts runReceiptFields); the
* `/<attempt>` of one to three digits after every id is what tells that list
* from a number (GST-1227). An 11-digit id (every GitHub Actions run id since
* 2025) also counts right after a run word on the same line, the way a ship
* receipt, a READY block or an approval record names one (`screen 40477638211
* GREEN`, `gate=40323989742`, `rerun passed 40287239206`, `run #19368712345`);
@@ -369,7 +375,8 @@ export function isNumericMetadataValue(span: string, match: RegExpExecArray): bo
* else still report.
*/
const CI_ID_POSITION_BEFORE =
/(?:--(?:run|job)(?:-id)?(?:[ \t]+|[ \t]*=[ \t]*)|\bgh[ \t]+run[ \t]+(?:view|watch|rerun|download|cancel)[ \t]+|\/actions\/runs\/|\/jobs?\/|(?:run|job)_id["']?[ \t]*[:=][ \t]*["']?)$/i;
/(?:--(?:run|job)(?:-id)?(?:[ \t]+|[ \t]*=[ \t]*)|\bgh[ \t]+run[ \t]+(?:view|watch|rerun|download|cancel)[ \t]+|(?:^|[\s\/(\[<"'])actions\/runs\/|\/jobs?\/|(?:run|job)_id["']?[ \t]*[:=][ \t]*["']?)$/i;
const CI_RUN_LIST = /(?<![\w-])runs?=\d{11,12}\/\d{1,3}(?:,\d{11,12}\/\d{1,3})*(?![\w\/])/gi;
/** A run word with an optional ':' '#' '=' right before an 11-digit token; shared with gstack-post's chained-id allowlist. */
export const CI_RUN_WORD_BEFORE = /\b(?:runs?|screens?|gates?|backstop|bisect|rerun|dispatch(?:ed)?|failed|passed)\s*[:#=]?\s*$/i;
export function isCiRunIdentifier(span: string, match: RegExpExecArray): boolean {
@@ -378,7 +385,11 @@ export function isCiRunIdentifier(span: string, match: RegExpExecArray): boolean
const { start } = spanBounds(match);
const lineStart = input.lastIndexOf("\n", start - 1) + 1;
const before = input.slice(Math.max(lineStart, start - 80), start);
return CI_ID_POSITION_BEFORE.test(before) || (span.length === 11 && CI_RUN_WORD_BEFORE.test(before));
if (CI_ID_POSITION_BEFORE.test(before) || (span.length === 11 && CI_RUN_WORD_BEFORE.test(before))) return true;
const lineEnd = input.indexOf("\n", start);
const line = input.slice(lineStart, lineEnd < 0 ? input.length : lineEnd);
const at = start - lineStart;
return [...line.matchAll(CI_RUN_LIST)].some(m => m.index! < at && at + span.length <= m.index! + m[0].length);
}
/**
@@ -395,12 +406,30 @@ export function isCiRunIdentifier(span: string, match: RegExpExecArray): boolean
const VERSION_DECLARATION_BEFORE =
/(?:\b[Vv]ersion|\bVERSION|[a-z0-9_]Version|[_-][Vv]ersion|_VERSION)["'\]]?(?:[ \t]*[:=(>][ \t]*|[ \t]+)["']?$/;
const CHANGELOG_HEADING_BEFORE = /^#{1,6}[ \t]+\[$/;
export function isDeclaredVersion(match: RegExpExecArray): boolean {
const input = match.input ?? "";
const { start, end } = spanBounds(match);
/**
* gstack names its own release right before the quad: `gstack 1.94.4.0`, `upgraded gstack from 1.93.14.0`, and the
* install check's trailer `gstack: ok 1.94.4.0 for=ship` or `gstack: fail claude,codex (1.93.37.0)`, which every lane
* report and PR body quotes (GST-1204). Only a quad shaped like a gstack release (major 0-2) counts, so `gstack 8.8.8.8`,
* `my-gstack 1.94.4.0`, `gstack-proxy 1.128.1.0` or `gstack talks to 8.8.8.8` still report.
*/
const GSTACK_RELEASE = /^[0-2]\./;
const GSTACK_NAMED_BEFORE = /(?<![\w./-])gstack(?:[ \t]*:[ \t]*(?:ok|fail)(?:[ \t]+[a-z0-9_,-]+)?[ \t]+\(?|[ \t]+(?:from[ \t]+)?)$/;
/** The second end of a range (`X to Y`, `X-Y`) whose first end is a declared version with the same major. */
const RANGE_END_BEFORE = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})[ \t]*(?:to|through|-|\u2013)[ \t]*$/;
function declaredAt(input: string, start: number, end: number, range: boolean): boolean {
const lineStart = input.lastIndexOf("\n", start - 1) + 1;
if (VERSION_DECLARATION_BEFORE.test(input.slice(Math.max(lineStart, start - 48), start))) return true;
return CHANGELOG_HEADING_BEFORE.test(input.slice(lineStart, start)) && input[end] === "]";
const before = input.slice(Math.max(lineStart, start - 48), start);
const span = input.slice(start, end);
if (VERSION_DECLARATION_BEFORE.test(before) || (GSTACK_NAMED_BEFORE.test(before) && GSTACK_RELEASE.test(span))) return true;
if (CHANGELOG_HEADING_BEFORE.test(input.slice(lineStart, start)) && input[end] === "]") return true;
const first = range ? RANGE_END_BEFORE.exec(before) : null;
if (!first || first[1]!.split(".")[0] !== span.split(".")[0]) return false;
const firstStart = start - before.length + first.index;
return declaredAt(input, firstStart, firstStart + first[1]!.length, false);
}
export function isDeclaredVersion(match: RegExpExecArray): boolean {
const { start, end } = spanBounds(match);
return declaredAt(match.input ?? "", start, end, true);
}
// ── Placeholder suppression (per-matched-span, NOT per-line) ─────────────────
+57
View File
@@ -0,0 +1,57 @@
/**
* The `release_gate` block of .gstack/ship-policy.json
* (docs/ship-policy.md#release-gate): the full paid gate on the base branch
* every few hours, run by bin/gstack-release-gate from
* .github/workflows/release-gate.yml, instead of a gate per train or
* per push. The gate never writes the stable branch: scripts/release-cut.ts
* stays its only writer, reads a green gate as "gated" and holds every cut
* while the newest gate is red. Validated here so lib/ship-policy.ts stays
* one schema file.
*/
import type { PolicyError } from './ship-policy';
export interface ReleaseGatePolicy {
/** Least hours between two gate dispatches; at least RELEASE_GATE_SCHEDULE_HOURS, the workflow's schedule. */
cadence_hours: number;
/** Workflow file dispatched for the full paid gate and the bisect; it accepts the eval-bg dispatch inputs. */
gate_workflow: string;
/** The branch the gate tests (its head at each run). */
branch: string;
/** The branch scripts/release-cut.ts moves; the gate only reads it to count pending commits. */
stable_ref: string;
/** The check run published on a gated commit, read by release-cut as gated. */
check_name: string;
/** What a red gate does. hold-release: the red record keeps release-cut from cutting until a later gate is green. */
on_red: 'hold-release';
/** Cap over one red gate's bisect, at RELEASE_TRIAL_RESERVATION_USD per case trial; 0 skips the bisect. */
bisect_spend_cap_usd: number;
}
/** The cron step of release-gate.yml's schedule (test/release-gate-workflow.test.ts pins it): no cadence gates more often. */
export const RELEASE_GATE_SCHEDULE_HOURS = 8;
const REF = /^(?!.*\.\.)(?!\/)(?!.*\/$)[A-Za-z0-9._/-]{1,100}$/;
const FIELDS: Record<keyof ReleaseGatePolicy, (v: unknown) => string | null> = {
cadence_hours: v => (Number.isInteger(v) && (v as number) >= RELEASE_GATE_SCHEDULE_HOURS && (v as number) <= 168 ? null : `must be a whole number of hours from ${RELEASE_GATE_SCHEDULE_HOURS} (the workflow's schedule) to 168`),
gate_workflow: v => (typeof v === 'string' && /^[A-Za-z0-9_.-]{1,64}\.ya?ml$/.test(v) ? null : 'must be a workflow file name such as evals.yml'),
branch: v => (typeof v === 'string' && REF.test(v) ? null : 'must be a branch name'),
stable_ref: v => (typeof v === 'string' && REF.test(v) ? null : 'must be a branch name'),
check_name: v => (typeof v === 'string' && /^[A-Za-z0-9_./-]{1,64}$/.test(v) ? null : 'must be a check-run name ([A-Za-z0-9_./-]{1,64})'),
on_red: v => (v === 'hold-release' ? null : 'must be hold-release'),
bisect_spend_cap_usd: v => (typeof v === 'number' && v >= 0 && v <= 500 ? null : 'must be dollars from 0 to 500'),
};
export const RELEASE_GATE_EXPLANATION = '{ cadence_hours, gate_workflow, branch, stable_ref, check_name, on_red, bisect_spend_cap_usd } or null: the release gate (docs/ship-policy.md#release-gate). bin/gstack-release-gate dispatches the full paid gate (gate_workflow) on the head of branch at most once per cadence_hours, only when branch moved since the last gate, records the verdict as a release note in refs/notes/gstack-trains and publishes check_name on the gated commit; a red gate bisects its red cases (bisect_spend_cap_usd, 0 skips) and, with on_red hold-release, keeps scripts/release-cut.ts from moving stable_ref until a later gate is green. It never writes stable_ref. While the block is set, the per-push main backstop stands down. null keeps the per-push backstop.';
export function releaseGateErrors(raw: unknown): PolicyError[] {
const g = raw as Record<string, unknown> | null;
if (!g || typeof g !== 'object' || Array.isArray(g)) return [{ code: 'POLICY_INVALID', path: 'release_gate', message: `must be { ${Object.keys(FIELDS).join(', ')} } or null` }];
const out: PolicyError[] = [];
for (const k of Object.keys(g)) if (!(k in FIELDS)) out.push({ code: 'POLICY_INVALID', path: `release_gate.${k}`, message: 'unknown key' });
for (const [k, check] of Object.entries(FIELDS)) {
if (!(k in g)) { out.push({ code: 'POLICY_INVALID', path: `release_gate.${k}`, message: 'missing (every release_gate field is required)' }); continue; }
const why = check(g[k]);
if (why) out.push({ code: 'POLICY_INVALID', path: `release_gate.${k}`, message: `${JSON.stringify(g[k])} ${why}` });
}
if (typeof g.branch === 'string' && g.branch === g.stable_ref) out.push({ code: 'POLICY_INVALID', path: 'release_gate.stable_ref', message: 'must differ from branch' });
return out;
}
+413
View File
@@ -0,0 +1,413 @@
/**
* The release gate (docs/ship-policy.md#release-gate; plan ship-90-90 choice A):
* the full paid gate on the gated branch's head every cadence_hours, only when
* the branch moved since the last gate, instead of a gate per train or per push.
*
* run skip when the branch head equals the stable branch, already has a
* green or red record, or the last gate is younger than the cadence;
* otherwise write a PENDING `release` record to refs/notes/gstack-trains,
* dispatch gate_workflow on the head (eval-bg's backstop nonce), follow
* it, and record GREEN, RED or INCOMPLETE. Rule-16 reruns happen inside
* the gate run (scripts/lib/paid-rerun.ts): a case that failed once and
* passed its one rerun is green and listed as flaky here.
* bisect a red record's red verdict cases, one trial each, on up to
* MAX_PREFIXES first-parent commits between the last green gate (or
* the stable branch) and the red head, dispatched on release-gate/<id>
* and narrowed round by round until first_bad is the commit right after
* a green one; spend-capped and resumable.
* status stable, the last gate, pending commits and the hold.
*
* The gate never writes the stable branch. scripts/release-cut.ts is its only
* writer: it reads a green record (or the check_name check run this gate's
* workflow publishes) as gated, and refuses every cut while a red record is not
* followed by a green gate on a later commit (the hold, on_red hold-release).
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { DISPATCH_INPUTS, makeNonce } from '../scripts/eval-bg';
import { ghFailureReason, type GhRunner } from '../scripts/recover-receipts';
import { MAX_BISECT_CASES, MAX_PREFIXES, RELEASE_BISECT_ARTIFACT, RELEASE_BISECT_REF, type Git } from '../scripts/train-gate';
import type { ReleaseGatePolicy } from './release-gate-policy';
import { loadPolicyFromBase, mergePolicy, validatePolicy } from './ship-policy';
import {
NOTES_REF, fetchNotes, nowIso, persist, repoIdentity,
type PrefixResult, type ReleaseBisect, type ReleaseBisectRound, type ReleaseRecord,
} from './ship-queue-train-record';
export const RELEASE_TRIAL_RESERVATION_USD = 1.5;
/** The gate plus the one re-dispatch EVAL_POLICY.infraRedispatch allows an incomplete census. */
export const MAX_RELEASE_ATTEMPTS = 2;
/** Cron start jitter the cadence forgives. */
export const CADENCE_GRACE_HOURS = 1;
export const MAX_BISECT_ROUNDS = 4;
/** An open gate (a run past its budget, or a red gate's running bisect) on an older commit is finished first for this many cadences; after that it stops holding up main's head. */
export const OPEN_GATE_CADENCES = 3;
const SHA = /^[0-9a-f]{40}$/;
const CASE_ID = /^[A-Za-z0-9 _.:/-]{1,128}$/;
const HOUR = 3_600_000;
const GH_MS = 60_000;
export interface Run { id: number; status: string; conclusion: string | null; head_sha: string; display_title?: string; html_url?: string; run_attempt?: number }
export interface GateEnv {
repo: string; repoRoot: string; remote: string; gh: GhRunner; git: Git;
now: () => number; sleep: (ms: number) => void; random: () => string; pollMs: number;
/** Epoch ms after which nothing waits on a run any more; the record stays resumable. */
deadline: number;
}
export type GateAction = 'inert' | 'skipped' | 'dry-run' | 'gated' | 'held' | 'failed';
export interface GateOut { action: GateAction; sha: string | null; lines: string[] }
const short = (sha: string | null | undefined) => (sha ? sha.slice(0, 12) : 'none');
const usd = (n: number | null) => (n === null ? 'unknown' : n.toFixed(2));
/** The release_gate block from origin/<base>'s reviewed policy, or from --policy <file> (validated the same way). */
export function loadReleaseGate(repoRoot: string, o: { policyFile?: string; base?: string }): { gate: ReleaseGatePolicy | null; label: string } | { error: string } {
if (o.policyFile) {
let raw: unknown;
try { raw = JSON.parse(fs.readFileSync(o.policyFile, 'utf8')); } catch (e: any) { return { error: `${o.policyFile} is not readable JSON: ${e.message}` }; }
const errors = validatePolicy(raw);
if (errors.length) return { error: `${o.policyFile}: ${errors.map(e => `${e.path} ${e.message}`).join('; ')}` };
return { gate: mergePolicy(raw as Record<string, unknown>, o.policyFile).policy.release_gate, label: o.policyFile };
}
const loaded = loadPolicyFromBase(repoRoot, o.base ?? 'main');
if ('unavailable' in loaded) return { error: `no reviewed policy: ${loaded.detail}` };
if (loaded.errors.length) return { error: `${loaded.source.label}: ${loaded.errors.map(e => `${e.path} ${e.message}`).join('; ')}` };
return { gate: loaded.policy.release_gate, label: loaded.source.label };
}
/** This repository's release records from the local notes ref, newest first. */
export function releaseRecords(git: Git, repoKey: string): ReleaseRecord[] {
const listed = git(['notes', `--ref=${NOTES_REF}`, 'list']);
if (!listed.ok || !listed.out.trim()) return [];
return listed.out.split('\n').map(line => line.split(' ')[0]!).filter(Boolean).flatMap(blob => {
try { const r = JSON.parse(git(['cat-file', 'blob', blob]).out) as ReleaseRecord; return r?.kind === 'release' && r.repo_key === repoKey && SHA.test(r.sha) ? [r] : []; } catch { return []; }
}).sort((a, b) => b.created_at.localeCompare(a.created_at));
}
/** The red record that holds releases: red, and no green record sits on a later commit that contains it. Null when none. */
export function activeHold(records: readonly ReleaseRecord[], isAncestor: (older: string, newer: string) => boolean): ReleaseRecord | null {
const greens = records.filter(r => r.verdict === 'GREEN');
return records.filter(r => r.verdict === 'RED').find(red => !greens.some(g => g.sha !== red.sha && isAncestor(red.sha, g.sha))) ?? null;
}
/** The newest green record on an ancestor of sha: where pending commits and a bisect start. */
export function greenBase(records: readonly ReleaseRecord[], sha: string, isAncestor: (older: string, newer: string) => boolean): ReleaseRecord | null {
return records.find(r => r.verdict === 'GREEN' && r.sha !== sha && isAncestor(r.sha, sha)) ?? null;
}
export interface ReportFacts { verdict: 'GREEN' | 'RED' | 'NONE'; red: string[]; other: string[]; cost: number | null }
/** report-verdict's report.txt: the verdict, each failed case (a verdict red, or another class), and the known cost. */
export function parseReport(text: string): ReportFacts {
const verdict = (/VERDICT (GREEN|RED)/.exec(text)?.[1] as 'GREEN' | 'RED' | undefined) ?? 'NONE';
const red: string[] = [];
const other: string[] = [];
for (const m of text.matchAll(/^\s+✗ (\S(?:.*?\S)?) {2,}(\w+) {2,}(\w+)(?: {2,}([A-Z_]+))?/gm)) {
(m[3] === 'FAIL' && (!m[4] || m[4] === 'VERDICT') ? red : other).push(m[1]!);
}
const cost = /· cost \$(\d+(?:\.\d+)?)/.exec(text)?.[1];
return { verdict, red: [...new Set(red)].sort(), other: [...new Set(other)].sort(), cost: cost ? Number(cost) : null };
}
/** A finished gate run as one verdict; green needs the run's own success, which follows its rule-16 report. */
export function classifyRun(run: Pick<Run, 'status' | 'conclusion'>, report: ReportFacts | null): { verdict: ReleaseRecord['verdict']; red: string[]; detail: string } {
if (run.status !== 'completed') return { verdict: 'PENDING', red: [], detail: `run is ${run.status}` };
if (run.conclusion === 'success') {
if (report?.verdict === 'RED') return { verdict: 'INCOMPLETE', red: [], detail: 'the run concluded success but its report says RED' };
return { verdict: 'GREEN', red: [], detail: report ? 'run and report green' : 'run concluded success (no report artifact)' };
}
if (report?.verdict === 'RED' && report.red.length) {
return { verdict: 'RED', red: report.red, detail: `${report.red.length} red case(s)${report.other.length ? `; also not green: ${report.other.join(', ')}` : ''}` };
}
return { verdict: 'INCOMPLETE', red: [], detail: `run concluded ${run.conclusion ?? 'unknown'}; report ${report ? `${report.verdict}${report.other.length ? ` (${report.other.join(', ')})` : ''}` : 'missing'}` };
}
/** Up to max commits spread evenly over the interior of a range (oldest first), never the endpoints. */
export function planPrefixes(interior: readonly string[], max = MAX_PREFIXES): string[] {
if (interior.length <= max) return [...interior];
const picked = Array.from({ length: max }, (_, i) => interior[Math.round(((i + 1) * (interior.length + 1)) / (max + 1)) - 1]!);
return [...new Set(picked)];
}
/**
* One round's results narrow (good, bad]: the earliest prefix where any case is
* red becomes bad, the prefix before it good. Every case must be monotone
* (green, then red) over the tested prefixes and have a result on each.
*/
export function narrow(range: { good: string; bad: string }, tested: ReadonlyArray<{ sha: string; results: Record<string, PrefixResult> }>, cases: readonly string[]): { good: string; bad: string } | { unknown: string } {
let firstRed = tested.length;
for (const c of cases) {
const seq = tested.map(p => (p.results[c] === 'red' ? 'R' : p.results[c] === 'green' ? 'G' : '?')).join('');
if (seq.includes('?')) return { unknown: `no result for ${c} on ${tested.filter(p => !p.results[c]).map(p => short(p.sha)).join(', ')}` };
const at = seq.indexOf('R');
if (at >= 0 && seq.slice(at).includes('G')) return { unknown: `${c} is not monotone over the tested commits (${seq})` };
if (at >= 0) firstRed = Math.min(firstRed, at);
}
return { good: firstRed > 0 ? tested[firstRed - 1]!.sha : range.good, bad: firstRed < tested.length ? tested[firstRed]!.sha : range.bad };
}
function ghJson<T>(env: GateEnv, args: string[]): T | null {
const r = env.gh(args, GH_MS);
if (r.status !== 0) return null;
try { return JSON.parse(r.stdout) as T; } catch { return null; }
}
/** One file of a run's artifact, or null when the artifact or the file is missing. */
function artifactFile(env: GateEnv, run: number, name: string, file: string): string | null {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'release-gate-'));
try {
if (env.gh(['run', 'download', String(run), '-R', env.repo, '-n', name, '-D', dir], GH_MS).status !== 0) return null;
const find = (d: string): string | null => {
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, e.name);
if (e.isDirectory()) { const hit = find(p); if (hit) return hit; } else if (e.name === file) return p;
}
return null;
};
const hit = find(dir);
return hit ? fs.readFileSync(hit, 'utf8') : null;
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
}
function dispatch(env: GateEnv, workflow: string, ref: string, inputs: Record<string, string>): string | null {
const r = env.gh(['workflow', 'run', workflow, '--repo', env.repo, '--ref', ref, ...Object.entries(inputs).flatMap(([k, v]) => ['-f', `${k}=${v}`])], GH_MS);
return r.status === 0 ? null : ghFailureReason(r.stderr);
}
/** Find a dispatch's run by its nonce, then follow it until it completes or the deadline passes. */
function follow(env: GateEnv, workflow: string, nonce: string, known: number | null, lines: string[]): Run | null {
let run: Run | null = known ? ghJson<Run>(env, ['api', `repos/${env.repo}/actions/runs/${known}`]) : null;
for (;;) {
if (!run) {
const runs = ghJson<{ workflow_runs?: Run[] }>(env, ['api', '-X', 'GET', `repos/${env.repo}/actions/workflows/${workflow}/runs`, '-f', 'event=workflow_dispatch', '-f', 'per_page=30']);
run = runs?.workflow_runs?.find(r => (r.display_title ?? '').includes(`eval-bg ${nonce}]`)) ?? null;
if (run) lines.push(`RELEASE: run ${run.id} ${run.html_url ?? ''}`.trim());
} else if (run.status === 'completed') return run;
if (env.now() >= env.deadline) break;
env.sleep(env.pollMs);
if (run) run = ghJson<Run>(env, ['api', `repos/${env.repo}/actions/runs/${run.id}`]) ?? run;
}
lines.push(`RELEASE: ${run ? `run ${run.id} is still ${run.status}` : `no run for nonce ${nonce} yet`} at the deadline; the record stays resumable`);
return run;
}
const runRef = (env: GateEnv, run: Run) => ({ id: run.id, url: run.html_url ?? `https://github.com/${env.repo}/actions/runs/${run.id}` });
/** Writes the record and pushes it to origin's notes; false when the push failed, so no paid dispatch may follow. */
function save(env: GateEnv, rec: ReleaseRecord, lines: string[]): boolean {
rec.updated_at = nowIso();
const p = persist(env.repoRoot, [rec], { remote: env.remote });
if (!p.pushed) lines.push(`RELEASE: record ${p.detail}`);
return p.pushed;
}
interface Context { gate: ReleaseGatePolicy; repoKey: string; stable: string | null; records: ReleaseRecord[]; isAncestor: (a: string, b: string) => boolean }
function context(env: GateEnv, gate: ReleaseGatePolicy): Context | { error: string } {
const f = fetchNotes(env.repoRoot, env.remote);
if (!f.ok) return { error: f.detail };
const stable = env.git(['ls-remote', env.remote, `refs/heads/${gate.stable_ref}`]).out.split(/\s/)[0] || null;
const isAncestor = (a: string, b: string) => env.git(['merge-base', '--is-ancestor', a, b]).ok;
const repoKey = repoIdentity(env.repoRoot).key;
return { gate, repoKey, stable: stable && SHA.test(stable) ? stable : null, records: releaseRecords(env.git, repoKey), isAncestor };
}
/** Make sure the gated branch's history is here (a shallow checkout gets it), so ancestry and ranges read true. */
function ensureHistory(env: GateEnv, gate: ReleaseGatePolicy, sha: string): void {
if (env.git(['rev-parse', '--is-shallow-repository']).out === 'true') env.git(['fetch', '--no-tags', '-q', '--unshallow', env.remote, gate.branch, gate.stable_ref]);
if (!env.git(['cat-file', '-e', `${sha}^{commit}`]).ok) env.git(['fetch', '--no-tags', '-q', env.remote, gate.branch]);
}
const firstParents = (env: GateEnv, from: string, to: string): string[] | null => {
const r = env.git(['rev-list', '--first-parent', '--reverse', `${from}..${to}`]);
return r.ok ? r.out.split('\n').filter(Boolean) : null;
};
export interface RunOptions { gate: ReleaseGatePolicy | null; label: string; sha?: string; dryRun: boolean; ignoreCadence: boolean }
/** `run`: gate the branch head once when it moved; bisect a red. */
export function runGate(env: GateEnv, o: RunOptions): GateOut {
const lines: string[] = [];
const out = (action: GateAction, sha: string | null): GateOut => ({ action, sha, lines });
if (!o.gate) { lines.push(`RELEASE: inert (${o.label} declares no release_gate block; the per-push main backstop gates main)`); return out('inert', null); }
const gate = o.gate;
const c = context(env, gate);
if ('error' in c) { lines.push(`RELEASE: cannot read refs/notes/gstack-trains (${c.error}); nothing dispatched`); return out('failed', null); }
const head = o.sha ?? env.git(['ls-remote', env.remote, `refs/heads/${gate.branch}`]).out.split(/\s/)[0] ?? '';
if (!SHA.test(head)) { lines.push(`RELEASE: cannot resolve ${gate.branch} on ${env.remote} (${JSON.stringify(head)})`); return out('failed', null); }
const fresh = (r: ReleaseRecord) => env.now() - Date.parse(r.created_at) < OPEN_GATE_CADENCES * gate.cadence_hours * HOUR;
const open = o.sha ? null : c.records.find(r => r.sha !== head && fresh(r) && ((r.verdict === 'PENDING' && r.run) || (r.verdict === 'RED' && r.bisect?.state === 'running')));
if (open && o.dryRun) { lines.push(`RELEASE: dry run: would finish the open gate on ${short(open.sha)} (${open.verdict}, run ${open.run?.id ?? '-'}) before gating ${gate.branch}@${short(head)}; nothing written`); return out('dry-run', open.sha); }
if (open) lines.push(`RELEASE: finishing the open gate on ${short(open.sha)} (${open.verdict}, run ${open.run?.id ?? '-'}) before gating ${gate.branch}@${short(head)}`);
const sha = open?.sha ?? head;
ensureHistory(env, gate, sha);
const prior = c.records.find(r => r.sha === sha) ?? null;
const skip = (why: string) => { lines.push(`RELEASE: skip sha=${sha} (${why})`); return out('skipped', sha); };
if (!open && sha === c.stable) return skip(`${gate.branch} has not moved since ${gate.stable_ref}`);
if (prior?.verdict === 'GREEN' || prior?.verdict === 'RED') {
if (prior.verdict === 'RED' && prior.bisect?.state === 'running' && !o.dryRun) return finishRed(env, c, prior, lines, out);
return skip(`${gate.branch} has not moved since the last gate: ${prior.verdict} run ${prior.run?.id ?? '-'}`);
}
if (prior?.verdict === 'INCOMPLETE' && prior.attempts >= MAX_RELEASE_ATTEMPTS) return skip(`the gate on this commit was incomplete ${prior.attempts} times (${prior.detail}); diagnose run ${prior.run?.url ?? prior.run?.id ?? '-'}`);
const newest = c.records[0];
const age = newest ? (env.now() - Date.parse(newest.created_at)) / HOUR : Infinity;
if (!o.ignoreCadence && prior?.verdict !== 'PENDING' && age < gate.cadence_hours - CADENCE_GRACE_HOURS) {
return skip(`the last gate was dispatched ${age.toFixed(1)} h ago on ${short(newest!.sha)}; cadence ${gate.cadence_hours} h`);
}
const since = greenBase(c.records, sha, c.isAncestor)?.sha ?? c.stable;
const pending = since ? firstParents(env, since, sha)?.length ?? null : null;
lines.push(`RELEASE: gate sha=${sha} since=${short(since)} (${since === c.stable ? gate.stable_ref : 'last green gate'}) pending=${pending ?? 'unknown'} stable=${short(c.stable)}`);
if (o.dryRun) { lines.push(`RELEASE: dry run: would dispatch ${gate.gate_workflow} on ${gate.branch}@${short(sha)} (full gate, about $37); nothing written`); return out('dry-run', sha); }
const now = nowIso();
const rec: ReleaseRecord = prior?.verdict === 'PENDING' ? prior : {
schema: 1, kind: 'release', repo_key: c.repoKey, sha, tree: env.git(['rev-parse', `${sha}^{tree}`]).out, branch: gate.branch, stable: c.stable, since,
nonce: makeNonce('gate', sha, sha, env.random(), { purpose: 'backstop' }), attempts: (prior?.attempts ?? 0) + 1, run: null, verdict: 'PENDING',
red_cases: [], flaky: [], cost_usd: null, detail: 'dispatching', bisect: null, created_at: now, updated_at: now,
};
if (rec !== prior) {
if (!save(env, rec, lines)) { lines.push(`RELEASE: incomplete sha=${sha} run=- (the record did not reach ${env.remote}; nothing dispatched, so no run repeats a paid gate)`); return out('failed', sha); }
const failed = dispatch(env, gate.gate_workflow, gate.branch, { [DISPATCH_INPUTS.evalsAll]: 'true', [DISPATCH_INPUTS.expectedSha]: sha, [DISPATCH_INPUTS.nonce]: rec.nonce });
if (failed) { Object.assign(rec, { verdict: 'INCOMPLETE', detail: `the gate dispatch failed (${failed})` }); save(env, rec, lines); lines.push(`RELEASE: incomplete sha=${sha} run=- (${rec.detail})`); return out('failed', sha); }
lines.push(`RELEASE: dispatched ${gate.gate_workflow} on ${gate.branch}@${short(sha)} (nonce ${rec.nonce})`);
} else lines.push(`RELEASE: resuming the pending gate on ${short(sha)} (nonce ${rec.nonce})`);
const run = follow(env, gate.gate_workflow, rec.nonce, rec.run?.id ?? null, lines);
if (run) rec.run = runRef(env, run);
if (!run || run.status !== 'completed') { rec.detail = run ? `run ${run.id} ${run.status}` : 'no run found for the nonce yet'; save(env, rec, lines); return out('failed', sha); }
const attempt = run.run_attempt ?? 1;
const reportText = artifactFile(env, run.id, `report-verdict-a${attempt}`, 'report.txt');
const report = reportText === null ? null : parseReport(reportText);
const flakes = artifactFile(env, run.id, `flake-records-a${attempt}`, 'flake-records.jsonl') ?? '';
const k = classifyRun(run, report);
Object.assign(rec, {
verdict: k.verdict, red_cases: k.red, detail: k.detail, cost_usd: report?.cost ?? null,
flaky: [...new Set(flakes.split('\n').flatMap(l => { try { const f = JSON.parse(l); return typeof f?.case === 'string' ? [f.case] : []; } catch { return []; } }))].sort(),
});
save(env, rec, lines);
if (rec.flaky.length) lines.push(`RELEASE: flaky (failed once, passed its one rule-16 rerun in run ${run.id}): ${rec.flaky.join(', ')}`);
if (rec.verdict === 'GREEN') {
lines.push(`RELEASE: green sha=${sha} stable=${c.stable ?? 'none'} run=${run.id} cost=${usd(rec.cost_usd)}`,
`RELEASE: ${gate.check_name} success goes on ${short(sha)}; scripts/release-cut.ts moves ${gate.stable_ref} on its own schedule and floor`);
return out('gated', sha);
}
if (rec.verdict === 'RED') return finishRed(env, c, rec, lines, out);
lines.push(`RELEASE: incomplete sha=${sha} run=${run.id} (${rec.detail})${rec.attempts < MAX_RELEASE_ATTEMPTS ? '; the next run re-dispatches it once' : ''}`);
return out('failed', sha);
}
function finishRed(env: GateEnv, c: Context, rec: ReleaseRecord, lines: string[], out: (a: GateAction, s: string | null) => GateOut): GateOut {
bisectRecord(env, c, rec, { spendCap: c.gate.bisect_spend_cap_usd, dryRun: false }, lines);
lines.push(`RELEASE: red sha=${rec.sha} first_bad=${rec.bisect?.first_bad ?? 'unknown'} run=${rec.run?.id ?? '-'}`,
`RELEASE: hold: scripts/release-cut.ts refuses to move ${c.gate.stable_ref} until a gate on a later commit is green (red: ${rec.red_cases.join(', ')}); land the revert or the fix-forward`);
return out('held', rec.sha);
}
/**
* Collect a bisect round's prefix records into its results. Each case on each prefix adds its reported cost to the spend, or
* RELEASE_TRIAL_RESERVATION_USD when the cost is null or the prefix record is missing, so the cap holds across rounds.
*/
function collectRound(env: GateEnv, b: ReleaseBisect, round: ReleaseBisectRound, run: Run): void {
for (const p of round.prefixes) {
const text = artifactFile(env, run.id, `${RELEASE_BISECT_ARTIFACT}-bisect-${p.sha.slice(0, 12)}`, 'bisect-prefix.json');
let rec: { kind?: string; nonce?: string; prefix_sha?: string; checked_out_sha?: string; panels?: Array<{ case: string; status: string; red_class: string | null; cost_usd: number | null }> } | null = null;
try { rec = text ? JSON.parse(text) : null; } catch { rec = null; }
const valid = rec && rec.kind === 'train-bisect-prefix' && rec.nonce === round.nonce && rec.prefix_sha === p.sha && rec.checked_out_sha === p.sha && Array.isArray(rec.panels);
for (const c of b.cases) {
const panel = valid ? rec!.panels!.find(x => x.case === c) : undefined;
b.spent_usd = Math.round((b.spent_usd + (typeof panel?.cost_usd === 'number' ? panel.cost_usd : RELEASE_TRIAL_RESERVATION_USD)) * 100) / 100;
if (!panel) continue;
if (panel.status === 'PASS') p.results[panel.case] = 'green';
else if (panel.status === 'FAIL' && panel.red_class === 'VERDICT') p.results[panel.case] = 'red';
}
}
}
/**
* Bisect a red record (resumable): finish a round still running, then narrow
* (good, bad] with up to MAX_PREFIXES commits a round until they are adjacent,
* the pattern is not monotone, the cap is spent, or MAX_BISECT_ROUNDS pass.
*/
export function bisectRecord(env: GateEnv, c: Context, rec: ReleaseRecord, o: { spendCap: number; dryRun: boolean }, lines: string[]): void {
const b: ReleaseBisect = rec.bisect ?? { cases: rec.red_cases, good: rec.since, bad: rec.sha, first_bad: null, spend_cap: o.spendCap, spent_usd: 0, state: 'running', detail: '', rounds: [] };
b.spend_cap = Math.max(b.spend_cap, o.spendCap);
const stop = (state: ReleaseBisect['state'], detail: string) => { Object.assign(b, { state, detail }); };
if (!o.dryRun) rec.bisect = b;
if (b.state === 'running' && o.spendCap <= 0 && !b.rounds.length) stop('unknown', 'bisect_spend_cap_usd is 0: no bisect');
else if (b.state === 'running' && (!b.cases.length || b.cases.length > MAX_BISECT_CASES || b.cases.some(id => !CASE_ID.test(id)))) stop('unknown', `the red cases cannot be bisected (${b.cases.length} case ids, at most ${MAX_BISECT_CASES})`);
else if (b.state === 'running' && !b.good) stop('unknown', 'no green gate and no stable branch before the red commit to bisect from');
while (b.state === 'running') {
const open = b.rounds.at(-1);
if (open && open.prefixes.some(p => b.cases.some(cs => !p.results[cs])) && open.run?.id !== -1) {
if (o.dryRun) { lines.push(`RELEASE: dry run: round ${b.rounds.length} (nonce ${open.nonce}) still has prefixes to collect`); return; }
const run = follow(env, c.gate.gate_workflow, open.nonce, open.run?.id ?? null, lines);
if (run) open.run = runRef(env, run);
if (!run || run.status !== 'completed') { b.detail = `round ${b.rounds.length} is still running`; save(env, rec, lines); return; }
collectRound(env, b, open, run);
env.git(['push', '-q', env.remote, '--delete', `refs/heads/${open.ref}`]);
const n = narrow({ good: b.good!, bad: b.bad }, open.prefixes, b.cases);
if ('unknown' in n) { stop('unknown', n.unknown); break; }
Object.assign(b, n);
lines.push(`RELEASE: bisect round ${b.rounds.length}: good=${short(b.good)} bad=${short(b.bad)} spent=$${b.spent_usd.toFixed(2)}`);
save(env, rec, lines);
}
const interior = firstParents(env, b.good!, b.bad);
if (!interior) { stop('unknown', `cannot list ${short(b.good)}..${short(b.bad)}`); break; }
interior.pop();
if (!interior.length) { b.first_bad = b.bad; stop('found', `${short(b.bad)} is the first red commit after green ${short(b.good)}`); break; }
if (b.rounds.length >= MAX_BISECT_ROUNDS) { stop('unknown', `${MAX_BISECT_ROUNDS} rounds left ${interior.length} commit(s) between ${short(b.good)} and ${short(b.bad)}`); break; }
const perPrefix = b.cases.length * RELEASE_TRIAL_RESERVATION_USD;
const fits = Math.floor((b.spend_cap - b.spent_usd + 1e-9) / perPrefix);
const prefixes = planPrefixes(interior, Math.min(MAX_PREFIXES, fits));
if (!prefixes.length) { stop('capped', `the next round needs $${perPrefix.toFixed(2)} a prefix; $${(b.spend_cap - b.spent_usd).toFixed(2)} of the $${b.spend_cap.toFixed(2)} cap is left`); break; }
lines.push(`RELEASE: bisect round ${b.rounds.length + 1}: ${b.cases.length} case(s) on ${prefixes.length} of ${interior.length} commit(s) between ${short(b.good)} and ${short(b.bad)}, $${(perPrefix * prefixes.length).toFixed(2)} reserved`);
if (o.dryRun) { lines.push('RELEASE: dry run: nothing dispatched'); return; }
const nonce = makeNonce('gate', b.bad, b.good!, env.random(), { purpose: 'bisect' });
const ref = `${RELEASE_BISECT_REF}${nonce.slice(-6)}-${b.bad.slice(0, 12)}`;
const round: ReleaseBisectRound = { nonce, ref, run: null, prefixes: prefixes.map(sha => ({ sha, results: {} })) };
b.rounds.push(round);
if (!save(env, rec, lines)) { b.rounds.pop(); lines.push('RELEASE: bisect round not dispatched: the record did not reach ' + env.remote); return; }
const pushed = env.git(['push', '-q', env.remote, `${b.bad}:refs/heads/${ref}`]);
const failed = pushed.ok ? dispatch(env, c.gate.gate_workflow, ref, {
[DISPATCH_INPUTS.cases]: b.cases.join(','), [DISPATCH_INPUTS.prefixShas]: prefixes.join(','), [DISPATCH_INPUTS.expectedSha]: b.bad, [DISPATCH_INPUTS.nonce]: nonce,
}) : `git push ${ref} failed: ${pushed.out.slice(0, 120)}`;
if (failed) { round.run = { id: -1, url: '' }; env.git(['push', '-q', env.remote, '--delete', `refs/heads/${ref}`]); stop('unknown', `round ${b.rounds.length} dispatch failed: ${failed}`); }
}
lines.push(`RELEASE: bisect ${b.state}: ${b.detail} (spent $${b.spent_usd.toFixed(2)} of $${b.spend_cap.toFixed(2)})`);
if (!o.dryRun) save(env, rec, lines);
}
/** `bisect`: resume or start the bisect of the newest red record (or the one at sha). */
export function bisectGate(env: GateEnv, o: { gate: ReleaseGatePolicy | null; label: string; sha?: string; spendCap?: number; dryRun: boolean }): GateOut {
const lines: string[] = [];
if (!o.gate) { lines.push(`RELEASE: inert (${o.label} declares no release_gate block)`); return { action: 'inert', sha: null, lines }; }
const c = context(env, o.gate);
if ('error' in c) { lines.push(`RELEASE: cannot read refs/notes/gstack-trains (${c.error})`); return { action: 'failed', sha: null, lines }; }
const rec = o.sha ? c.records.find(r => r.sha === o.sha) : c.records.find(r => r.verdict === 'RED');
if (!rec || rec.verdict !== 'RED') { lines.push(`RELEASE: no red release record${o.sha ? ` at ${o.sha}` : ''} to bisect`); return { action: 'failed', sha: o.sha ?? null, lines }; }
ensureHistory(env, o.gate, rec.sha);
if (rec.bisect?.state === 'capped' && (o.spendCap ?? 0) > rec.bisect.spend_cap) rec.bisect.state = 'running';
bisectRecord(env, c, rec, { spendCap: o.spendCap ?? o.gate.bisect_spend_cap_usd, dryRun: o.dryRun }, lines);
lines.push(`RELEASE: red sha=${rec.sha} first_bad=${rec.bisect?.first_bad ?? 'unknown'} run=${rec.run?.id ?? '-'}`);
return { action: o.dryRun ? 'dry-run' : 'held', sha: rec.sha, lines };
}
/** `status`: never spends and never writes. */
export function gateStatus(env: GateEnv, o: { gate: ReleaseGatePolicy | null; label: string }): GateOut {
const lines: string[] = [];
if (!o.gate) { lines.push(`RELEASE: inert (${o.label} declares no release_gate block; the per-push main backstop gates main)`); return { action: 'inert', sha: null, lines }; }
const c = context(env, o.gate);
if ('error' in c) { lines.push(`RELEASE: cannot read refs/notes/gstack-trains (${c.error})`); return { action: 'failed', sha: null, lines }; }
const head = env.git(['ls-remote', env.remote, `refs/heads/${o.gate.branch}`]).out.split(/\s/)[0] ?? '';
if (SHA.test(head)) ensureHistory(env, o.gate, head);
const last = c.records[0];
const base = SHA.test(head) ? greenBase(c.records, head, c.isAncestor)?.sha ?? c.stable : null;
const pending = base && SHA.test(head) ? (base === head ? 0 : firstParents(env, base, head)?.length ?? null) : null;
const hold = activeHold(c.records, c.isAncestor);
lines.push(
`STABLE: ${o.gate.stable_ref} ${c.stable ?? 'absent'} (scripts/release-cut.ts moves it; the gate never does)`,
`LAST_GATE: ${last ? `${last.verdict} sha=${last.sha} run=${last.run?.id ?? '-'} at=${last.created_at} cost=${usd(last.cost_usd)}${last.flaky.length ? ` flaky=${last.flaky.join(',')}` : ''}` : 'none'}`,
`PENDING: ${pending ?? 'unknown'} commit(s) on ${o.gate.branch} ${short(head)} since ${short(base)}`,
`HOLD: ${hold ? `red on ${hold.sha} since ${hold.created_at} (red: ${hold.red_cases.join(', ')}; first_bad=${hold.bisect?.first_bad ?? 'unknown'}, bisect ${hold.bisect?.state ?? 'not run'}); release-cut refuses until a gate on a later commit is green` : 'none'}`,
);
return { action: hold ? 'held' : 'skipped', sha: SHA.test(head) ? head : null, lines };
}
+33 -14
View File
@@ -160,7 +160,7 @@ export function correctedOrder(order: readonly string[], mine: string | null, ac
interface Plan {
policy: ShipPolicy; policyLabel: string; queueMode: QueueMode; oldVersion: string; newVersion: string; versionRel: string;
baseVersion: string; branchStamp: string | null; forkVersion: string; baseSha: string; baseRef: string; claims: string; level: Bump; predecessor?: { pr: string; head: string };
baseVersion: string; branchStamp: string | null; forkVersion: string; recorded: ReadonlySet<string>; baseSha: string; baseRef: string; claims: string; level: Bump; predecessor?: { pr: string; head: string };
}
function planVersions(o: RestampOptions, loaded: LoadedPolicy, policyLabel: string): Plan {
@@ -214,7 +214,7 @@ function planVersions(o: RestampOptions, loaded: LoadedPolicy, policyLabel: stri
}
// The branch's own tentative stamp: its VERSION when it moved past base; else nothing (mentions come from the lines it added).
const branchStamp = oldVersion !== atBase.version ? oldVersion : null;
return { policy, policyLabel, queueMode, oldVersion, newVersion, versionRel: atBase.rel, baseVersion: atBase.version, branchStamp, forkVersion: forkVersion(o.repoRoot, baseRef, atBase.version), baseSha: baseSha.out, baseRef, claims, level, predecessor };
return { policy, policyLabel, queueMode, oldVersion, newVersion, versionRel: atBase.rel, baseVersion: atBase.version, branchStamp, forkVersion: forkVersion(o.repoRoot, baseRef, atBase.version), recorded: recordedVersions(o.repoRoot, baseRef, atBase.version), baseSha: baseSha.out, baseRef, claims, level, predecessor };
}
/** The version writer's own files: the version source, the manifest (root or pinned) and the npm lockfiles beside it. */
@@ -337,10 +337,13 @@ function templateMention(template: string): RegExp {
* added itself, newer than the version at its fork point and not the
* allocated one. A released version is never newer than the fork point of a
* branch written against it, and the repo's own version width must match, so
* a 3-digit dependency pin in a 4-digit repo is never a candidate.
* a 3-digit dependency pin in a 4-digit repo is never a candidate. With
* `recorded`, a built-in form above the allocated version counts only when the
* branch recorded it (GST-1245); a `stamp_patterns` slot keeps the fork bound.
*/
export function tentativeMentions(added: Map<string, Set<string>>, forkVersion: string, newVersion: string, templates: readonly string[] = []): string[] {
export function tentativeMentions(added: Map<string, Set<string>>, forkVersion: string, newVersion: string, templates: readonly string[] = [], recorded?: ReadonlySet<string>): string[] {
const fork = parseVersion(forkVersion);
const ceiling = parseVersion(newVersion);
if (!fork) return [];
const width = versionWidth(forkVersion);
const scanners = [...BUILT_IN_MENTIONS, ...templates.map(templateMention)];
@@ -352,6 +355,7 @@ export function tentativeMentions(added: Map<string, Set<string>>, forkVersion:
const raw = m.groups!.v!;
const v = parseVersion(raw);
if (!v || versionWidth(raw) !== width || raw === newVersion || cmpVersion(v, fork) <= 0) continue;
if (recorded && !recorded.has(raw) && BUILT_IN_MENTIONS.includes(re) && (!ceiling || cmpVersion(v, ceiling) > 0)) continue;
found.add(raw);
}
}
@@ -360,10 +364,10 @@ export function tentativeMentions(added: Map<string, Set<string>>, forkVersion:
return [...found];
}
/** The versions one line mentions in a stamp form (the built-in forms and the policy's `stamp_patterns` templates). */
export function stampMentions(text: string, templates: readonly string[] = []): Set<string> {
/** The versions one line mentions in a stamp form (the built-in forms unless `builtIn` is false, and the policy's `stamp_patterns` templates). */
export function stampMentions(text: string, templates: readonly string[] = [], builtIn = true): Set<string> {
const found = new Set<string>();
for (const re of [...BUILT_IN_MENTIONS, ...templates.map(templateMention)]) for (const m of text.matchAll(re)) found.add(m.groups!.v!);
for (const re of [...(builtIn ? BUILT_IN_MENTIONS : []), ...templates.map(templateMention)]) for (const m of text.matchAll(re)) found.add(m.groups!.v!);
return found;
}
@@ -375,6 +379,19 @@ export function forkVersion(repoRoot: string, baseRef: string, fallback: string,
return 'version' in v ? v.version : fallback;
}
/**
* The versions the branch itself recorded (GST-1245): its version source at its fork point and at each commit in
* `baseRef..head`, every parent walked, so a train or land merge (`-p <base> -p <pin>`) still reads the pin's own claims.
*/
export function recordedVersions(repoRoot: string, baseRef: string, fallback: string, head = 'HEAD'): Set<string> {
const out = new Set([forkVersion(repoRoot, baseRef, fallback, head)]);
for (const c of git(repoRoot, ['rev-list', `${baseRef}..${head}`]).out.split('\n').filter(Boolean)) {
const v = versionAtRef(repoRoot, c);
if ('version' in v) out.add(v.version);
}
return out;
}
/**
* Exact `(was X)`, `since: X`, `vX` rewrites plus the policy's literal
* `stamp_patterns` templates (one `{version}` slot, matched at a version
@@ -384,7 +401,7 @@ export function forkVersion(repoRoot: string, baseRef: string, fallback: string,
* X (`since: '1.2.3-beta'` and `since: '1.2.3"` are not the form); `vX`
* already accepts a quote before the `v`.
*/
export function rewriteStamps(text: string, oldVersion: string, newVersion: string, templates: readonly string[] = []): { text: string; count: number } {
export function rewriteStamps(text: string, oldVersion: string, newVersion: string, templates: readonly string[] = [], builtIn = true): { text: string; count: number } {
const v = escapeRegExp(oldVersion);
const patterns: Array<[RegExp, (...m: string[]) => string]> = [
[new RegExp(`\\(was (['"]?)${v}\\1\\)`, 'g'), (_, q) => `(was ${q}${newVersion}${q})`],
@@ -394,7 +411,7 @@ export function rewriteStamps(text: string, oldVersion: string, newVersion: stri
];
let count = 0;
let out = text;
for (const [re, rep] of patterns) {
for (const [re, rep] of builtIn ? patterns : patterns.slice(3)) {
count += (out.match(re) ?? []).length;
out = out.replace(re, rep);
}
@@ -505,7 +522,8 @@ function runStages(o: RestampOptions, plan: Plan, staging: string, since: string
* `--was`) is rewritten on every line of every stamp file; a tentative one
* (a mention newer than the fork version, or of the fork version itself: a
* row written at the version the branch forked from instead of a next one,
* GST-963) only on lines the branch added.
* GST-963) only on lines the branch added; above the new version an unrecorded
* one moves only inside a `stamp_patterns` slot (GST-1245): a third party's `vX` stays.
* With no explicit version the branch's VERSION equals the base's: either it
* never stamped, or it stamped X and the base reached X on its own (a PR that
* lost its slot and merged the base). Then a stamp-form mention of the base
@@ -525,10 +543,11 @@ function rewriteStampPaths(o: RestampOptions, plan: Plan, staging: string, since
const baseAdded = branchAddedLines(o.repoRoot, since, files, plan.baseSha);
const added = branchAddedLines(staging, since, files, undefined, baseAdded);
const explicit = new Set([...(o.was ? [o.was] : []), ...(plan.branchStamp ? [plan.branchStamp] : [])].filter(v => v !== plan.newVersion));
const tentative = new Set(tentativeMentions(new Map([...added].map(([rel, own]) => [rel, own.text])), plan.forkVersion, plan.newVersion, templates).filter(v => !explicit.has(v)));
const tentative = new Set(tentativeMentions(new Map([...added].map(([rel, own]) => [rel, own.text])), plan.forkVersion, plan.newVersion, templates, plan.recorded).filter(v => !explicit.has(v)));
const equalBase = !o.was && !plan.branchStamp && plan.baseVersion !== plan.newVersion ? plan.baseVersion : null;
if (equalBase) tentative.add(equalBase);
if (plan.forkVersion !== plan.newVersion && !explicit.has(plan.forkVersion)) tentative.add(plan.forkVersion);
const slotOnly = new Set([...tentative].filter(v => !plan.recorded.has(v) && cmpVersion(parseVersion(v)!, parseVersion(plan.newVersion)!) > 0));
const moved = new Map<string, number>();
const baseSide: string[] = [];
let touched = 0;
@@ -541,8 +560,8 @@ function rewriteStampPaths(o: RestampOptions, plan: Plan, staging: string, since
let count = 0;
let ownBase = 0;
let keptBase = 0;
const rewrite = (text: string, old: string): string => {
const r = rewriteStamps(text, old, plan.newVersion, templates);
const rewrite = (text: string, old: string, builtIn = true): string => {
const r = rewriteStamps(text, old, plan.newVersion, templates, builtIn);
count += r.count;
if (r.count) moved.set(old, (moved.get(old) ?? 0) + r.count);
return r.text;
@@ -555,7 +574,7 @@ function rewriteStampPaths(o: RestampOptions, plan: Plan, staging: string, since
}
let text = line;
for (const old of explicit) text = rewrite(text, old);
if (mine) for (const old of tentative) text = rewrite(text, old);
if (mine) for (const old of tentative) text = rewrite(text, old, !slotOnly.has(old));
return text;
}).join('\n');
if (ownBase || keptBase) baseSide.push(`${rel}: ${ownBase} moved, ${keptBase} kept`);
+26
View File
@@ -472,6 +472,32 @@ export const RELEASE_RESULT_CODES = {
summary: 'the base branch moved during the stamp commit\'s CI on each of the allowed restamp attempts, so land stopped instead of restamping again',
fix: 'find what is merging outside land (every merge should go through it), then rerun gstack-ship-queue land --pr <n>; it resumes from its journal',
},
// gstack-ship-queue land --pin (lib/ship-queue-land-direct.ts): merge: direct refusals and its post-merge check.
QUEUE_LAND_STAMPED: {
anchor: 'queue-land-stamped',
summary: 'under merge: direct the PR changes VERSION, the package.json version or the CHANGELOG; versions and the CHANGELOG are set at release, so a PR carries only its changelog fragment',
fix: 'put the named files back to the merge base (git checkout $(git merge-base origin/main HEAD) -- <files>), keep or add the changelog.d fragment, commit, then /ship and ready at the new head',
},
QUEUE_LAND_PIN_MOVED: {
anchor: 'queue-land-pin-moved',
summary: 'the PR head is not the pin land was given, so the READY it was given does not describe what would merge',
fix: 'the lane runs /ship and gstack-ship-queue ready --pr <n> --no-screen at the new head and reports READY with it; land that pin',
},
QUEUE_LAND_CHECKS: {
anchor: 'queue-land-checks',
summary: 'a check at the pin is red or pending, or a required check never ran there',
fix: 'wait for pending checks (gstack-pr-wait <n> --until green), fix a red one at a new head, or dispatch a missing required workflow on the branch; then rerun land',
},
QUEUE_LAND_NOT_CLEAN: {
anchor: 'queue-land-not-clean',
summary: 'GitHub does not report the PR mergeable and clean (a conflict, a branch rule, or a state GitHub is still computing)',
fix: 'CONFLICTING: the lane merges the base into the branch, resolves, runs /ship and ready at the new head; UNKNOWN: rerun land in a minute; BLOCKED or BEHIND: meet the branch rule it names',
},
QUEUE_LAND_UNVERIFIED: {
anchor: 'queue-land-unverified',
summary: 'after the merge, the base branch tree is not the pin merged onto the merge commit\'s parent, so something besides the reviewed pin reached main',
fix: 'compare the merge commit with the pin merged onto its parent, revert what does not belong to the PR, and report it to the Captain',
},
// gstack-pr-wait (lib/pr-wait.ts): the landing-path PR watcher's two terminal codes.
PR_WAIT_BLOCKED: {
anchor: 'pr-wait-blocked',
+9 -2
View File
@@ -43,14 +43,21 @@ export class BriefError extends Error {}
*/
export function sliceDiff(text: string, cap: number): { text: string; sliced: boolean; files: number; kept: number; bytes: number; total: number; left: string[] } {
const total = Buffer.byteLength(text);
const starts = [...text.matchAll(/^diff --git a\/(\S+)/gm)];
const starts = [...text.matchAll(/^diff --git (.*)$/gm)];
const name = (rest: string) => {
const quoted = /^"a\/((?:[^"\\]|\\.)*)"/.exec(rest);
if (quoted) return `"${quoted[1]}"`;
const half = (rest.length - 1) / 2;
if (rest.startsWith('a/') && rest.slice(half) === ` b/${rest.slice(2, half)}`) return rest.slice(2, half);
return rest.startsWith('a/') ? rest.slice(2).split(' b/')[0]! : rest;
};
if (total <= cap || !starts.length) return { text, sliced: false, files: starts.length, kept: starts.length, bytes: total, total, left: [] };
let out = '', bytes = 0;
const left: string[] = [];
starts.forEach((m, k) => {
const section = text.slice(m.index!, k + 1 < starts.length ? starts[k + 1]!.index! : text.length);
const n = Buffer.byteLength(section);
if (bytes + n <= cap) { out += section; bytes += n; } else left.push(m[1]!);
if (bytes + n <= cap) { out += section; bytes += n; } else left.push(name(m[1]!));
});
return { text: out, sliced: true, files: starts.length, kept: starts.length - left.length, bytes, total, left };
}
+46 -6
View File
@@ -13,7 +13,7 @@ import * as path from 'node:path';
import { resolveBase, type ResolvedBase } from './gate-diff/git';
import { GENERATED_PATH, parseRegistry } from './regen';
import { RESULT_CODES } from './result-codes';
import { classifyPath, dataCandidate, svgCandidate, svgDrawing, type ClassInput, type DepthError, type PathLines, type RegistryInfo } from './review-depth';
import { BINARY_DATA_PATH, classifyPath, dataCandidate, svgCandidate, svgDrawing, type ClassInput, type DepthError, type PathLines, type RegistryInfo } from './review-depth';
export interface Collected extends ClassInput {
perPath: PathLines[];
@@ -32,6 +32,7 @@ export interface Collected extends ClassInput {
untracked: string[];
consumed: string[] | 'all';
drawings: string[];
executables: string[];
/** collectLive only: the ref resolveBase read for the base name (GST-1036), null when nothing resolved. */
base?: ResolvedBase | null;
}
@@ -232,9 +233,36 @@ function drawingsAmong(perPath: readonly PathLines[], classRoot: string | readon
}).sort();
}
function result(diff: ReturnType<typeof readDiff>, extra: Pick<Collected, 'root' | 'registry' | 'errors' | 'mergeBase' | 'untracked' | 'consumed'> & { drawings?: string[] }): Collected {
/**
* The changed paths a docs, data or run-log rule could move out of code that are executables: the exec bit, a symlink, or text
* that starts with "#!" (GSTA-258 L7). `isExec` answers for one path and fails closed (true) when it cannot read it.
*/
function executablesAmong(perPath: readonly PathLines[], classRoot: string | readonly string[], isExec: (e: PathLines) => boolean): string[] {
return perPath.filter(e => (BINARY_DATA_PATH.test(e.path) && classifyPath(e.path, classRoot) === 'code') || dataCandidate(e.path, classRoot) || svgCandidate(e.path, classRoot) || classifyPath(e.path, classRoot) === 'docs').filter(isExec).map(e => e.path).sort();
}
/**
* The executables among the changed paths at `headSha`: tree mode 100755 or 120000 (a symlink, as collectLive sees it), or a text
* blob whose first two bytes are "#!". A path the head tree lacks is a deletion, never an executable; an unreadable tree or blob counts as one.
*/
function executablesAt(root: string, headSha: string, perPath: readonly PathLines[], classRoot: readonly string[]): string[] {
const tree = git(root, ['ls-tree', '-r', '-z', headSha]);
const entries = new Map(tree.out.split('\0').flatMap(rec => { const m = /^(\d+) blob ([0-9a-f]+)\t(.*)$/s.exec(rec); return m ? [[m[3]!, { mode: m[1]!, sha: m[2]! }] as const] : []; }));
return executablesAmong(perPath, classRoot, e => {
if (!tree.ok) return true;
const t = entries.get(e.path);
if (!t) return false;
if (t.mode === '100755' || t.mode === '120000') return true;
if (e.binary) return false;
const r = spawnSync('git', ['cat-file', 'blob', t.sha], { cwd: root, timeout: 30_000, maxBuffer: 2 });
const read = r.status === 0 || (r.error as NodeJS.ErrnoException | undefined)?.code === 'ENOBUFS';
return !read || (r.stdout ?? Buffer.alloc(0)).subarray(0, 2).toString('latin1') === '#!';
});
}
function result(diff: ReturnType<typeof readDiff>, extra: Pick<Collected, 'root' | 'registry' | 'errors' | 'mergeBase' | 'untracked' | 'consumed'> & { drawings?: string[]; executables?: string[] }): Collected {
const paths = diff.perPath.map(e => e.path).sort();
return { ...diff, drawings: [], ...extra, paths, gateTagged: null, diffLines: diff.perPath.reduce((n, e) => n + e.lines, 0) };
return { ...diff, drawings: [], executables: [], ...extra, paths, gateTagged: null, diffLines: diff.perPath.reduce((n, e) => n + e.lines, 0) };
}
/** The range `baseSha`...`headSha` from git objects alone; the registry is the base commit's, never the head's. */
@@ -255,7 +283,7 @@ export function collectCommit(root: string, baseSha: string, headSha: string): C
const blob = spawnSync('git', ['cat-file', 'blob', `${headSha}:${p}`], { cwd: root, timeout: 60_000, maxBuffer: DRAWING_READ_CAP + 1 });
return blob.status === 0 && !blob.error ? blob.stdout : null;
});
return result(diff, { root: skillDirs, registry, errors, mergeBase, untracked: [], consumed, drawings });
return result(diff, { root: skillDirs, registry, errors, mergeBase, untracked: [], consumed, drawings, executables: executablesAt(root, headSha, diff.perPath, skillDirs) });
}
/**
@@ -289,7 +317,19 @@ export function collectLive(root: string, base: string): Collected {
}
const consumed = scanConsumers(root, [...diff.perPath.map(e => e.path), ...diff.deleted], [mergeBase], true, root, errors);
const drawings = drawingsAmong(diff.perPath, root, p => {
try { return fs.statSync(path.join(root, p)).size <= DRAWING_READ_CAP ? fs.readFileSync(path.join(root, p)) : null; } catch { return null; }
try { const st = fs.lstatSync(path.join(root, p)); return st.isFile() && st.size <= DRAWING_READ_CAP ? fs.readFileSync(path.join(root, p)) : null; } catch { return null; }
});
return { ...result(diff, { root, registry, errors, mergeBase, untracked, consumed, drawings }), base: resolved };
const executables = executablesAmong(diff.perPath, root, e => {
try {
const f = path.join(root, e.path);
const st = fs.lstatSync(f);
if (!st.isFile() || st.mode & 0o111) return true;
if (e.binary) return false;
const fd = fs.openSync(f, 'r');
const buf = Buffer.alloc(2);
try { fs.readSync(fd, buf, 0, 2, 0); } finally { fs.closeSync(fd); }
return buf.toString('latin1') === '#!';
} catch (err) { return (err as NodeJS.ErrnoException).code !== 'ENOENT'; }
});
return { ...result(diff, { root, registry, errors, mergeBase, untracked, consumed, drawings, executables }), base: resolved };
}
+7 -5
View File
@@ -237,6 +237,8 @@ export interface ClassInput {
created?: readonly string[];
/** The SVG candidates (svgCandidate) whose whole text the collector read as a drawing (svgDrawing); any other SVG stays code. */
drawings?: readonly string[];
/** Docs-, data- or log-named paths the collector found executable (exec bit, a symlink or a "#!" first line): code whatever their name or directory, never judged by path alone. */
executables?: readonly string[];
/** Added and deleted lines per path (lib/review-collect.ts): the risk classes read them; without them no line signal applies. */
added?: Record<string, readonly string[]>;
removed?: Record<string, readonly string[]>;
@@ -252,9 +254,9 @@ export interface ClassResult { class: DiffClass; reasons: string[]; errors: Dept
const sanitize = (s: string) => s.replace(/[\0-\x1f]+/g, ' ').slice(0, 200);
const isOutput = (i: ClassInput, p: string) => !!i.registry?.outputs.length && matchesAny(i.registry.outputs, p);
const unconsumedData = (i: ClassInput, p: string) => Array.isArray(i.consumed) && !i.consumed.includes(p) && (dataCandidate(p, i.root) || (!!i.drawings?.includes(p) && svgCandidate(p, i.root)));
const binaryData = (i: ClassInput, p: string) => BINARY_DATA_PATH.test(p) && classifyPath(p, i.root) === 'code' && ![...AGENT_PROSE, ...GATE_PATH, ...DATA_CONFIG].some(r => r.test(p));
const pathClass = (i: ClassInput, p: string): PathClass => (i.excluded?.has(p) || (i.stampOnly ?? []).includes(p) || unconsumedData(i, p) || binaryData(i, p) ? 'docs' : classifyPath(p, i.root));
const unconsumedData = (i: ClassInput, p: string) => !i.executables?.includes(p) && Array.isArray(i.consumed) && !i.consumed.includes(p) && (dataCandidate(p, i.root) || (!!i.drawings?.includes(p) && svgCandidate(p, i.root)));
const binaryData = (i: ClassInput, p: string) => !i.executables?.includes(p) && BINARY_DATA_PATH.test(p) && classifyPath(p, i.root) === 'code' && ![...AGENT_PROSE, ...GATE_PATH, ...DATA_CONFIG].some(r => r.test(p));
const pathClass = (i: ClassInput, p: string): PathClass => i.executables?.includes(p) && !i.excluded?.has(p) ? 'code' : (i.excluded?.has(p) || (i.stampOnly ?? []).includes(p) || unconsumedData(i, p) || binaryData(i, p) ? 'docs' : classifyPath(p, i.root));
/** The diff's class, raised to gate by any gate signal; `errors` lists every input that fails the depth closed; `runtimeLines` counts code-class lines outside the base registry's outputs. */
export function classifyDiff(i: ClassInput): ClassResult {
@@ -285,8 +287,8 @@ export function classifyDiff(i: ClassInput): ClassResult {
for (const p of i.paths) if (GATE_PATH.some(r => r.test(p))) { raise('gate', `gate path: ${p}`); break; }
if (i.registry?.error) errors.push(i.registry.error);
}
const riskHits = CLASS_RANK[cls] >= CLASS_RANK.code ? matchRisk({ codePaths: i.paths.filter(p => pathClass(i, p) === 'code'), added: i.added, removed: i.removed, scopeAuth: s.SCOPE_AUTH === 'true', ...(i.riskPaths?.length ? { isRiskPath: (p: string) => matchesAny(i.riskPaths!, p) && !DATA_PATH.test(p) } : {}) }) : new Map<RiskClass, string>();
const docSecret = i.paths.find(p => pathClass(i, p) === 'docs' && DOC_PATH.test(p) && (i.added?.[p] ?? []).some(l => DOC_SECRET_LINE.some(r => r.test(l))));
const riskHits = CLASS_RANK[cls] >= CLASS_RANK.code ? matchRisk({ codePaths: i.paths.filter(p => pathClass(i, p) === 'code'), executables: i.executables, added: i.added, removed: i.removed, scopeAuth: s.SCOPE_AUTH === 'true', ...(i.riskPaths?.length ? { isRiskPath: (p: string) => matchesAny(i.riskPaths!, p) && !DATA_PATH.test(p) } : {}) }) : new Map<RiskClass, string>();
const docSecret = i.paths.find(p => DOC_PATH.test(p) && (i.added?.[p] ?? []).some(l => DOC_SECRET_LINE.some(r => r.test(l))));
if (docSecret && !riskHits.has('security')) riskHits.set('security', `docs that can print a credential: ${docSecret}`);
const byRank = (a: RiskClass, b: RiskClass) => RISK_CLASSES.indexOf(a) - RISK_CLASSES.indexOf(b);
const detected = [...riskHits.keys()].sort(byRank);
+9 -3
View File
@@ -51,7 +51,8 @@ export const SECRET_LINE = [
/\b(?:keychain|keytar|find-generic-password|find-internet-password|secret-tool|git-credential|credential\.helper)\b/i, /\bgh auth token\b/, /(?:^|[\s'"/])\.netrc\b/,
];
const SECURITY_PATH_TOKENS = ['trust', 'redact', 'redaction', 'redactor', 'sanitize', 'sanitizer', 'neutralize', 'neutralizer', 'consent', 'escape'];
/** Sealed custody (harness-evals#237: scripts/sealed/seal.ts) and egress code are trust boundaries too (GST-1177). */
const SECURITY_PATH_TOKENS = ['trust', 'redact', 'redaction', 'redactor', 'sanitize', 'sanitizer', 'neutralize', 'neutralizer', 'consent', 'escape', 'seal', 'sealed', 'custody', 'egress'];
/** Coarse, linear-time rules (GST-850 B+): any changed line naming the child_process module, any outbound HTTP client call; quantifiers bounded. */
export const SECURITY_LINE = [
/\bshell\s*:\s*true\b/, /\bshell\s*=\s*True\b/, /(?<![.\w$])exec(?:Sync)?\s*\(/, /\b(?:child_process|childProcess|cp)\.exec(?:Sync)?\s*\(/, /\bos\.system\s*\(/,
@@ -67,6 +68,8 @@ export const SECURITY_LINE = [
/(?<![.\w])eval\s*\(/, /(?:^|[;&|])\s*eval\s+["'$]/, /\bnew\s+Function\s*\(/,
/\b(?:spawn|spawnSync|execFile|execFileSync)\s*\(\s*['"](?:\/bin\/)?(?:ba|z)?sh['"]/, /\bBun\.spawn(?:Sync)?\s*\(\s*\[\s*['"](?:\/bin\/)?(?:ba|z)?sh['"]/,
/\brmSync\s*\((?:.{0,500}?recursive\s*:\s*true|[^)]{501})/, /\brm\s*\((?:.{0,500}?recursive\s*:\s*true|[^)]{501})/, /\brm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]*[rf]\b/,
/\bredact\w{0,40}\s{0,20}\(/, /(?:\b(?:from|import)\s{1,20}|\b(?:require|import)\s{0,20}\(\s{0,20})['"][^'"]{0,300}(?:redact|outside-voice)[^'"]{0,300}['"]/, /\b(?:from|import)\s{1,20}[\w.]{0,200}(?:redact|outside_voice)/,
/\b[A-Z0-9_]{0,60}(?:SECRET|CREDENTIAL)[A-Z0-9_]{0,60}\s{0,20}=\s{0,20}(?:\[\s{0,20})?(?:\/|new\s{1,20}RegExp\b)/,
/\b(?:innerHTML|outerHTML)\s*\+?=/, /\bdangerouslySetInnerHTML\b/, /\bv-html\b/, /\{@html\b/, /\binsertAdjacentHTML\s*\(/, /\bdocument\.write\s*\(/,
];
/** A recursive delete split across lines (a formatter puts `recursive: true` on its own line), read over a file's changed lines joined. */
@@ -131,6 +134,8 @@ export interface RiskInput {
removed?: Record<string, readonly string[]>;
/** gstack-diff-scope's SCOPE_AUTH: the auth class needs it and an auth token in the path. */
scopeAuth: boolean;
/** Executables (lib/review-collect.ts): never a config file or results script, so their lines are always read. */
executables?: readonly string[];
/** The base policy's review.risk_paths matcher: a code path it matches is risk class `policy`. */
isRiskPath?: (p: string) => boolean;
}
@@ -141,9 +146,10 @@ export function matchRisk(i: RiskInput): Map<RiskClass, string> {
const hit = (c: RiskClass, reason: string) => { if (!why.has(c)) why.set(c, reason); };
for (const p of i.codePaths) {
if (i.isRiskPath?.(p)) hit('policy', 'risk_paths: ' + p);
const stats = STATS_SCRIPT.test(p);
const exe = !!i.executables?.includes(p);
const stats = !exe && STATS_SCRIPT.test(p);
const t = stats ? [] : tokens(p);
const config = CONFIG_DATA_PATH.test(p);
const config = !exe && CONFIG_DATA_PATH.test(p);
const pathOnly = config || stats;
const prose = PROSE_PATH.test(p);
if (i.scopeAuth && t.some(x => AUTH_PATH_TOKENS.includes(x) || AUTH_PATH_PREFIXES.some(a => x.startsWith(a)))) hit('auth', 'auth path: ' + p);
+16 -3
View File
@@ -316,6 +316,8 @@ export interface ReviewLoopSummary {
ledgers_read?: number;
/** What could not bind (GST-1168), each named with its reason: a row with no start token or readable time, a row finding's ledger_id no ledger holds, a Review-Finding trailer naming no finding. Never counted as zero silently. */
unbound?: string[];
/** Abandoned passes left out of every count, each named (GBEV-3). */
voided?: string[];
}
/** Review-log rows of one /ship invocation (skill review, via ship, at or after `since`) → the loop summary. */
@@ -379,13 +381,16 @@ function rowLocus(f: Record<string, any>): string | null {
* When a dispatch ledger started (its first event) and whether it holds a fix-check reader's review: a fix-check run through
* gstack-review-dispatch (the pass program's `fix-check` step) leaves this ledger even when no review-fix-check row was logged.
*/
export interface LedgerPass { started: number; fixCheck: boolean; ids?: string[]; /** The ledger's pass directory (p1, p2, fc): a pN with no review row of pass N is named unbound. */ name?: string }
export interface LedgerPass {
started: number; fixCheck: boolean; ids?: string[]; /** The ledger's pass directory (p1, p2, fc): a pN with no review row of pass N is named unbound. */ name?: string;
/** Whether judge ran on it at all (a rejected judgment counts): a pN pass never judged that no row names, replaced by a later judged or named pass, was abandoned (voided) and counts nothing (GBEV-3); a fix-check ledger and the last pass are never voided. */ judged?: boolean;
}
export function ledgerPass(ledger: string, key = (id: string) => id): LedgerPass {
const events = readLedger(ledger);
const started = Date.parse(String(events[0]?.at ?? ''));
const fixCheck = events.some(e => e.event === 'output' && e.reader === 'fix-check' && e.tree_ok !== false && ((Array.isArray(e.ids) && e.ids.length > 0) || e.review === true));
const ids = events.flatMap(e => (e.event === 'output' && Array.isArray(e.ids) ? (e.ids as string[]) : e.event === 'judgment' && e.kind === 'added' && typeof e.id === 'string' ? [e.id] : [])).map(key);
return { started: Number.isFinite(started) ? started : NaN, fixCheck, ids, name: path.basename(path.dirname(path.resolve(ledger))) };
return { started: Number.isFinite(started) ? started : NaN, fixCheck, ids, name: path.basename(path.dirname(path.resolve(ledger))), judged: events.some(e => e.event === 'judge') };
}
/** A fix commit's `Review-Finding: <ledger_id or fingerprint> reader=<role>` trailer (receipt attribution): the finding that commit fixed. */
@@ -413,7 +418,7 @@ export function parseFixTrailers(commit: string, values: readonly string[]): Fix
* decision 3.5 runs a fix-check only after the loop applied that pass's fixes, and a blocker the loop does not fix is deferred
* or noted by a row. A blocker the last pass found stays unrecorded, and with every fix-check logged the rows decide.
*/
export function summarizeReviewLoop(rows: readonly Record<string, any>[], since: string, ledgers: readonly (readonly LedgerBlocker[])[] = [], ledgerPasses: readonly LedgerPass[] = [], trailers: readonly FixTrailer[] = []): ReviewLoopSummary {
export function summarizeReviewLoop(rows: readonly Record<string, any>[], since: string, allLedgers: readonly (readonly LedgerBlocker[])[] = [], allPasses: readonly LedgerPass[] = [], trailers: readonly FixTrailer[] = []): ReviewLoopSummary {
const sinceMs = Date.parse(since);
const loopSkill = (r: Record<string, any>) => r.skill === 'review' || r.skill === LATE_DEFER_SKILL || r.skill === FIX_CHECK_SKILL;
const inRun = (r: Record<string, any>) => r && r.via === 'ship' && Number.isFinite(rowTime(r)) && (!Number.isFinite(sinceMs) || rowTime(r) >= sinceMs);
@@ -423,6 +428,13 @@ export function summarizeReviewLoop(rows: readonly Record<string, any>[], since:
const rowKey = (r: Record<string, any>) => JSON.stringify([r.skill, r.pass ?? null, r.commit_full ?? r.commit ?? '', (Array.isArray(r.findings) ? r.findings : []).map((f: any) => (f && typeof f === 'object' ? (typeof f.ledger_id === 'string' ? f.ledger_id : findingIdentity(f)) : '')).sort()]);
const loopRows = [...new Map(rows.filter(r => inRun(r) && loopSkill(r)).map((r, i) => [r.commit_full || r.commit ? rowKey(r) : `#${i}`, r] as const)).values()];
const passes = loopRows.filter(r => r.skill === 'review');
const named = (dir: string) => { const n = /^p(\d+)$/.exec(dir)?.[1]; return loopRows.some(r => (n !== undefined && r.skill === 'review' && String(r.pass) === n) || (Array.isArray(r.findings) && r.findings.some((f: any) => typeof f?.ledger_id === 'string' && f.ledger_id.startsWith(`${dir}/`)))); };
const passNo = (p: LedgerPass) => (p.fixCheck ? undefined : Number(/^p(\d+)$/.exec(p.name ?? '')?.[1] ?? NaN));
const replaced = (n: number) => allPasses.some(q => (passNo(q) ?? NaN) > n && (q.judged !== false || named(q.name!)));
const voidedAt = new Set(allPasses.flatMap((p, i) => { const n = passNo(p); return n !== undefined && Number.isFinite(n) && p.judged === false && p.ids?.length && !named(p.name!) && replaced(n) ? [i] : []; }));
const voided = [...voidedAt].map(i => `pass ${allPasses[i]!.name}: its ledger was never judged and no review-log row names it (an abandoned pass), so its ${allPasses[i]!.ids!.length} collected findings are left out`);
const ledgers = allLedgers.filter((_, i) => !voidedAt.has(i));
const ledgerPasses = allPasses.filter((_, i) => !voidedAt.has(i));
type Seen = { locus: string | null; raiser: string | null; classified: boolean; blocks: boolean; candidate: boolean; disposed: boolean; ledgerId?: string; fingerprint?: string };
const seen = new Map<string, Seen>();
const deferred = new Set<string>();
@@ -494,6 +506,7 @@ export function summarizeReviewLoop(rows: readonly Record<string, any>[], since:
if (unrecorded) out.unrecorded = unrecorded;
if (ledgers.length) out.ledgers_read = ledgers.length;
if (unbound.length) out.unbound = unbound;
if (voided.length) out.voided = voided;
const fixChecks = Math.max(rowFixChecks, fixCheckStarts.length);
if (fixChecks) out.fix_checks = fixChecks;
out.rounds = passes.length + fixChecks;
+5
View File
@@ -25,6 +25,11 @@ const done = (r: { status: number | null; stdout?: string | Buffer | null; stder
export function git(cwd: string, args: string[], timeout = 120_000): Run {
return done(spawnSync('git', args, { cwd, encoding: 'utf8', timeout, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }));
}
/** GST-1204: the /ship run that passed Steps 1-3 on this checkout, recorded in its own git dir (per worktree) until finish clears it; empty outside a checkout. */
export function shipRunMarker(cwd: string): string {
const p = git(cwd, ['rev-parse', '--git-path', 'gstack-ship-run']);
return p.status === 0 && p.stdout.trim() ? path.resolve(cwd, p.stdout.trim()) : '';
}
export function bin(cwd: string, name: string, args: string[], timeout = 600_000): Run {
return done(runScriptSync(path.join(BIN_DIR, name), args, { cwd, encoding: 'utf8', timeout, maxBuffer: 64 * 1024 * 1024 }));
}
+15 -22
View File
@@ -16,6 +16,7 @@ import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { defaultBaseBranch, noDefaultBaseLine } from './default-base';
import { RELEASE_GATE_EXPLANATION, releaseGateErrors, type ReleaseGatePolicy } from './release-gate-policy';
import { RESULT_CODES, type ResultCodeName } from './result-codes';
import { reviewErrors, type ReviewPolicy } from './ship-policy-review';
import { cmpVersion, parseVersion } from './version-source';
@@ -86,6 +87,9 @@ export interface ShipPolicy {
docs: { caps: Array<{ path: string; max_bytes: number }> } | null;
/** How /ship reproduces a Step 5 failure on the base branch before calling it base-red (docs/ship-policy.md#base-red); null never calls one base-red. */
base_red: BaseRedPolicy | null;
/** The release gate on the base branch (docs/ship-policy.md#release-gate); null keeps the per-push main backstop. */
release_gate: ReleaseGatePolicy | null;
/** `direct` lands each READY PR at its own green head (`land --pin`, docs/ship-policy.md#land-direct); `train` keeps merge trains. */ merge: 'direct' | 'train';
}
/** `base_red`: `setup` installs dependencies in a scratch checkout (null when none are needed); `select` runs the failing test files, named by its `{files}` slot. */
@@ -176,7 +180,8 @@ export const DEFAULT_POLICY: ShipPolicy = {
min_gstack_version: null,
train: null,
docs: null,
base_red: null,
base_red: null, merge: 'train',
release_gate: null,
};
export type PolicyKey = Exclude<keyof ShipPolicy, '$schema'>;
@@ -210,6 +215,8 @@ export const POLICY_EXPLANATIONS: Record<PolicyKey, string> = {
train: '{ gate_workflow, free_workflow, full_gate_check, record_artifact, max_size, fill_hours, screen_selection, trial_reservation_usd, land? } (the gstack-evals gate) or { gates, land, max_size, fill_hours } (any repo: gates lists { kind: workflows, workflows, required_jobs, record?, post_merge_checks? } and { kind: command, command, timeout_sec, spend_cap_usd, pass_env? }, each GREEN on every prefix; land is { merge_method: squash|merge|rebase, required_checks? }) or null: turns on merge trains in gstack-ship-queue (train build|status|verdict|land|watch|init|doctor|prune, ready --screen|--no-screen, verify --train). gates and land need min_gstack_version at the release that reads them (validate names it). null keeps today\'s per-PR queue unchanged.',
docs: '{ caps: [{ path, max_bytes }] } or null: documentation files with a byte cap. /ship Step 14.5 runs `gstack-ship-receipt docs-caps` before the verification gate and prints one DOCS_CAP: line per cap; a file over its cap (or absent) blocks the ship until the doc is trimmed or the cap raised on the base branch. null declares none.',
base_red: '{ setup, select, timeout_sec } or null: how /ship Step 5 proves a failure is the base branch\'s own (docs/ship-policy.md#base-red). gstack-base-red checks out the base head and the PR head in scratch worktrees, runs setup (dependency install, or null), then select with the failing test files in its {files} slot; only the same failures in both, in files the PR does not touch, are base-red, and /ship then pauses before READY instead of failing. Read from the base branch only. null keeps every failure in-branch.',
release_gate: RELEASE_GATE_EXPLANATION,
merge: 'train (default) | direct. direct: the Captain lands each READY PR at its own green head with `gstack-ship-queue land --pr <n> --pin <sha>` (no train, no stamp commit; the PR leaves VERSION, the manifest version and the CHANGELOG at the base and the release stamps them), and train build refuses. train: merge trains as before (docs/ship-policy.md#land-direct).',
min_gstack_version: 'The oldest gstack that may read this file. Any key newer than 1.91.98.0 set to a non-default value requires it at that key’s introducing version (validate prints each); a gstack older than it refuses the file (POLICY_GSTACK_TOO_OLD).',
};
@@ -248,7 +255,9 @@ export const BASE_RED_KEY_VERSION = '1.91.374.0';
export const TRAIN_GATES_KEY_VERSION = '1.92.0.0';
export const STAMP_PLACEHOLDERS_KEY_VERSION = '1.94.16.0';
export const REVIEW_EXCLUDE_KEY_VERSION = '1.93.33.0';
export const RELEASE_GATE_KEY_VERSION = '1.96.0.0';
export const REVIEW_POLICY_KEY_VERSION = '1.94.30.0';
export const MERGE_KEY_VERSION = '1.95.0.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',
@@ -256,8 +265,8 @@ export const POLICY_KEY_INTRODUCED: Record<PolicyKey, string> = {
migrations: '1.91.98.0', gate_command: SHIP_QUEUE_KEYS_VERSION, full_e2e: SHIP_QUEUE_KEYS_VERSION, min_gstack_version: SHIP_QUEUE_KEYS_VERSION,
train: TRAIN_KEYS_VERSION, review_scope: REVIEW_SCOPE_KEY_VERSION, docs: DOCS_CAPS_KEY_VERSION,
changelog_fragments: CHANGELOG_FRAGMENTS_KEY_VERSION,
base_red: BASE_RED_KEY_VERSION, review_exclude_paths: REVIEW_EXCLUDE_KEY_VERSION, review: REVIEW_POLICY_KEY_VERSION,
stamp_placeholders: STAMP_PLACEHOLDERS_KEY_VERSION,
base_red: BASE_RED_KEY_VERSION, review_exclude_paths: REVIEW_EXCLUDE_KEY_VERSION, review: REVIEW_POLICY_KEY_VERSION, release_gate: RELEASE_GATE_KEY_VERSION,
stamp_placeholders: STAMP_PLACEHOLDERS_KEY_VERSION, merge: MERGE_KEY_VERSION,
};
const newer = (a: string, b: string): boolean => cmpVersion(parseVersion(a)!, parseVersion(b)!) > 0;
@@ -481,6 +490,7 @@ export function validatePolicy(raw: unknown, opts: { gstackVersion?: string } =
oneOf('queue_mode', ['claim-at-open', 'stamp-at-merge']);
oneOf('history', ['merge-only', 'squash-ok']);
oneOf('review_scope', ['frozen', 'diff-plus-blocking', 'all']);
oneOf('merge', ['direct', 'train']);
if ('review_exclude_paths' in obj) excludeErrors(obj.review_exclude_paths, errors);
if ('review' in obj) reviewErrors(obj.review, errors, (k, v) => listErrors(k, v, errors, globError));
if ('release_tool' in obj && obj.release_tool !== null) {
@@ -509,6 +519,7 @@ export function validatePolicy(raw: unknown, opts: { gstackVersion?: string } =
if ('docs' in obj && obj.docs !== null) docsErrors(obj.docs, errors);
if ('changelog_fragments' in obj && obj.changelog_fragments !== null) fragmentsErrors(obj.changelog_fragments, errors);
if ('base_red' in obj && obj.base_red !== null) baseRedErrors(obj.base_red, errors);
if ('release_gate' in obj && obj.release_gate !== null) errors.push(...releaseGateErrors(obj.release_gate));
if ('stamp_paths' in obj) listErrors('stamp_paths', obj.stamp_paths, errors, globError);
for (const key of ['changelog', 'mirror'] as const) {
if (!(key in obj) || obj[key] === null) continue;
@@ -775,22 +786,4 @@ export function stampFilesInTree(repoRoot: string, treeish: string, globs: reado
return listTreeFiles(repoRoot, treeish).filter(e => e.mode !== '120000' && matchesAny(globs, e.path)).map(e => e.path);
}
/**
* A working-tree path a stamp may write: inside `root` after realpath (a
* symlink out of the repository is refused even when the glob matched).
*/
export function assertInsideRoot(root: string, rel: string): string | null {
const realRoot = fs.realpathSync(root);
const abs = path.resolve(realRoot, rel);
if (abs !== realRoot && !abs.startsWith(realRoot + path.sep)) return `${rel} resolves outside ${root}`;
let probe = abs;
while (!fs.existsSync(probe)) {
const parent = path.dirname(probe);
if (parent === probe) break;
probe = parent;
}
let real: string;
try { real = fs.realpathSync(probe); } catch { return null; }
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) return `${rel} resolves through a symlink to outside ${root}`;
return null;
}
export { assertInsideRoot } from './path-containment';
+37 -5
View File
@@ -15,11 +15,14 @@ import { buildRunCheck } from './pregate/run-check';
import { classifyDiff } from './review-depth';
import { wtreeFingerprint } from './script-invocation';
import { audit } from './ship-audit';
import { bin, carryNoneLine, firstLine, flagReader, gh, git, hostedOrigin, laneConcurrency, laneLine, lanes, Report, runLanes, shipPolicy, short } from './ship-bin-common';
import { bin, carryNoneLine, firstLine, flagReader, gh, git, hostedOrigin, laneConcurrency, laneLine, lanes, Report, runLanes, shipPolicy, shipRunMarker, short } from './ship-bin-common';
export const PREFLIGHT_HELP = `gstack-ship-preflight — /ship Steps 1-3 and Step 5's pregate and lanes in one call (ship/SKILL.md)
gstack-ship-preflight --base <base> [--lane <label>=<command>]... Steps 1-3; with lanes also Step 5
--run <id> the /ship run (SESSION_ID, or the driver's run dir name): once Steps 1-3 pass it is recorded in
this checkout's git dir and opened on its lease, and a later full preflight stops until finish
clears it or 120 minutes pass, because its merge would move the tree the run's readers diff (GST-1204)
gstack-ship-preflight --tests-only --base <base> --lane <label>=<command>... Step 5 only (after a repair)
--lanes serial|parallel how Step 5 runs its lanes; unset, one at a time on 4 or fewer cores, all at once above
--policy-from head Step 5's pregate reads the branch's .gstack/pregate.json, which may only add test lanes
@@ -42,11 +45,35 @@ passed at this tree, command and runtime is reused: reused=<original time>), the
decide or repair; nothing after it ran. Exit codes: 0 ok · 2 usage · 3 STOP`;
const DIST = /(^|\/)(cmd\/[^/]+\/main\.go|bin\/[^/]+|Cargo\.toml|setup\.py|package\.json)$/;
const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
function integrate(cwd: string, base: string, r: Report): boolean {
/**
* GST-1204: a full preflight merges the base, so it stops while the /ship run that already passed Steps 1-3 on this checkout
* is open: its marker is in the git dir (finish removes it) and its run is open on the checkout's own lease (gstack-lease
* open: GST-1205's released, 120-minute and dead-holder rules). The run's readers diff this tree. A different --run whose
* caller is the open run's holder (the harness behind preflight: the Claude Code session, or Capy's one capy-server) is a
* new /ship after one that stopped before finish, so it goes on; the same id, no --run, or another live holder's run stops.
* A --run is opened on the lease here, before the fetch, and one that cannot be is a STOP, never an unprotected run.
*/
function shipRunClear(cwd: string, base: string, run: string | undefined, r: Report): boolean {
const marker = shipRunMarker(cwd);
const open = marker && fs.existsSync(marker) ? fs.readFileSync(marker, 'utf8').trim() : '';
const lease = (verb: string, id: string, from = false) => bin(cwd, 'gstack-lease', [verb, '--root', cwd, '--run', id, ...(from ? ['--from', String(process.ppid)] : [])], 60_000);
if (RUN_ID.test(open) && lease('open', open).status === 0 && !(run && run !== open && lease('open', open, true).status === 0)) {
r.stop(`/ship run ${open} is open on this checkout and past its preflight (${marker}; open until its finish or for 120 minutes): a full preflight would merge origin/${base} into the tree its review readers diff; rerun Step 5 with --tests-only, or let that /ship finish; if it is no longer running, rm ${marker}, then rerun`);
return false;
}
const touch = run ? lease('touch', run, true) : undefined;
if (!touch || touch.status === 0) return true;
r.stop(`cannot record /ship run ${run} on this checkout's lease (${firstLine(touch.stderr) || `gstack-lease touch exit ${touch.status}`}): unrecorded, a later full preflight could merge origin/${base} under this run's reviews; fix that, then rerun`);
return false;
}
function integrate(cwd: string, base: string, run: string | undefined, r: Report): boolean {
const branch = git(cwd, ['branch', '--show-current']).stdout.trim();
r.add(`BRANCH: ${branch || '(detached)'} base=${base}`);
if (!branch || branch === base) { r.stop(`on the base branch (${branch || 'detached HEAD'}): ship from a feature branch`); return false; }
if (!shipRunClear(cwd, base, run, r)) return false;
const status = git(cwd, ['status', '--porcelain']).stdout.split('\n').filter(Boolean);
r.add(`STATUS: modified=${status.filter(l => !l.startsWith('??')).length} untracked=${status.filter(l => l.startsWith('??')).length} (uncommitted changes ship with this run)`);
const fetch = git(cwd, ['fetch', 'origin', `+refs/heads/${base}:refs/remotes/origin/${base}`], 300_000);
@@ -87,7 +114,10 @@ function integrate(cwd: string, base: string, r: Report): boolean {
const scopeError = /^SCOPE_ERROR=(?!unmatched)(\S+)/m.exec(scope)?.[1];
if (scopeError) r.add(`SCOPE: unknown (gstack-diff-scope origin/${base}: SCOPE_ERROR=${scopeError})`);
else r.add(`SCOPE: frontend=${/SCOPE_FRONTEND=true/.test(scope)}${/SCOPE_FRONTEND=true/.test(scope) ? ' (Step 9 runs the design lite check; mention /design-review when no design review exists)' : ''}`);
return merge(cwd, base, r);
if (!merge(cwd, base, r)) return false;
const marker = run && shipRunMarker(cwd);
try { if (marker) fs.writeFileSync(marker, `${run}\n`); } catch (e) { r.stop(`cannot record /ship run ${run} in ${marker} (${firstLine((e as Error).message)}): the base is merged, but a later full preflight could merge again under this run's reviews; fix that, then rerun`); return false; }
return true;
}
/** The diff against origin/<base> must be about the PR's size: far more files (a stale or wrong base, HARN-15's 17,000-file review of main's history) or fewer (a fork's origin ahead of the PR's base, which would leave commits out of the gate scan) stops before any reader runs. Checked only when HEAD is the PR's pushed head. */
@@ -152,7 +182,7 @@ function merge(cwd: string, base: string, r: Report): boolean {
let runtime = 'unknown';
try {
const j = JSON.parse(plan.stdout);
const c = classifyDiff({ paths: j.paths, perPath: j.per_path, stampOnly: j.stamp_only, registry: j.registry, consumed: j.consumed, drawings: j.drawings, root: cwd, gateTagged: 0 });
const c = classifyDiff({ paths: j.paths, perPath: j.per_path, stampOnly: j.stamp_only, registry: j.registry, consumed: j.consumed, drawings: j.drawings, executables: j.executables, root: cwd, gateTagged: 0 });
runtime = String(c.runtimeLines ?? 'unknown');
} catch { /* unknown */ }
r.add(`RUNTIME_LINES: ${runtime} (gstack-ship-budget check --runtime-lines at the Step 9 and 12 boundaries)`);
@@ -228,13 +258,15 @@ export async function preflight(argv: readonly string[], cwd: string): Promise<{
const timeout = flag('--timeout');
if (has('--timeout') && !(timeout && /^[1-9]\d*$/.test(timeout) && Number(timeout) <= 3600)) return { text: 'gstack-ship-preflight: --timeout needs whole seconds from 1 to 3600\n', exit: 2 };
if (!base || typeof list === 'string') return { text: `gstack-ship-preflight: ${typeof list === 'string' ? list : '--base <base> is required'}\n`, exit: 2 };
const run = flag('--run');
if (has('--run') && !(run && RUN_ID.test(run))) return { text: 'gstack-ship-preflight: --run needs a run id: letters, digits, dot, underscore or dash, at most 128\n', exit: 2 };
if (has('--tests-only') && !list.length) return { text: 'gstack-ship-preflight: --tests-only needs at least one --lane <label>=<command>\n', exit: 2 };
if (has('--simulate')) return has('--tests-only') && policyFrom === 'head' && list.length ? simulate(cwd, base, list, has('--allow-repo-commands')) : { text: 'gstack-ship-preflight: --simulate needs --tests-only --policy-from head and at least one --lane <label>=<command>\n', exit: 2 };
const mode = flag('--lanes');
const concurrency = has('--lanes') && mode === undefined ? '--lanes needs serial or parallel' : laneConcurrency(mode);
if (typeof concurrency === 'string') return { text: `gstack-ship-preflight: ${concurrency}\n`, exit: 2 };
const r = new Report();
if (has('--tests-only') || integrate(cwd, base, r)) {
if (has('--tests-only') || integrate(cwd, base, run, r)) {
if (list.length) await tests(cwd, base, list, policyFrom, timeout, { concurrency, line: `LANES: ${concurrency === 1 ? 'serial' : 'parallel'} (${mode ? '--lanes' : `${os.availableParallelism()} cores`})` }, r);
else if (r.exit === 0) r.next('Step 4: confirm the test framework, then rerun with --tests-only --lane <label>=<command> per suite (Step 5)');
}
+136
View File
@@ -0,0 +1,136 @@
/**
* ship-queue-land-direct — B1's merge step, behind `gstack-ship-queue land --pr <n> --pin <sha>` under the base
* policy's merge: direct (docs/ship-policy.md#land-direct). The PR merges at its own green head: no train, no stamp
* commit, no base merge and no extra CI round. It refuses unless the /ship receipt and the READY note are bound to the
* pin, the pin is the PR's head, the diff leaves VERSION, the manifest version and the CHANGELOG at its merge base
* (versions are set at release), every check at the pin is green with none pending and each required check present,
* and GitHub reports the PR mergeable and clean. Then gh pr merge --squash --match-head-commit <pin>, a check that the
* merge commit's tree is the pin merged onto that commit's parent, and a land note on refs/notes/gstack-trains. A rerun
* after GitHub merged goes straight to the tree check.
*/
import { versionAtRef } from './restamp';
import type { ShipPolicy } from './ship-policy';
import { ciAtHead, loadBasePolicy, type QueueContext, type StepOutput } from './ship-queue';
import { git, type QueueFailure } from './ship-queue-gate';
import { lockLabel, readLock } from './ship-queue-land-lock';
import { activeFreeze, loadReady, persist, rebuildFromNotes, repoIdentity } from './ship-queue-train-record';
import { bindShippedReceipt } from './ship-receipt-depth';
interface DirectPr { state: string; baseRefName?: string; headRefName: string; headRefOid: string; isCrossRepository: boolean; body: string; mergeable?: string; mergeStateStatus?: string; mergeCommit?: { oid: string } | null }
export interface DirectOptions { pr: string; pin: string; dryRun?: boolean; now?: () => Date }
const isSha = (s: string | undefined) => /^[0-9a-f]{40,64}$/.test(s ?? '');
function view(ctx: QueueContext, pr: string): DirectPr | QueueFailure {
const r = ctx.gh(['pr', 'view', pr, '--json', 'state,baseRefName,headRefName,headRefOid,isCrossRepository,body,mergeable,mergeStateStatus,mergeCommit']);
try { return JSON.parse(r.out) as DirectPr; } catch { return { code: 'QUEUE_NOT_READY', message: `gh pr view ${pr} failed: ${(r.err ?? r.out).trim().slice(0, 160)}` }; }
}
/** Release files the pin changed since its merge base: the version file, the manifest version and the CHANGELOG; an unreadable version is an error, never a pass. */
export function stampedFiles(root: string, policy: ShipPolicy, mergeBase: string, pin: string): string[] | { error: string } {
const out: string[] = [];
const [was, now] = [versionAtRef(root, mergeBase), versionAtRef(root, pin)];
if ('error' in was || 'error' in now) return { error: 'error' in was ? was.error : (now as { error: string }).error };
if (was.version !== now.version) out.push(now.rel);
const npm = (rev: string) => { const r = git(root, ['show', `${rev}:package.json`]); try { return r.ok ? JSON.parse(r.out).version ?? null : null; } catch { return null; } };
if (!('rel' in now && now.rel === 'package.json') && npm(mergeBase) !== npm(pin)) out.push('package.json version');
if (policy.changelog && git(root, ['diff', '--quiet', mergeBase, pin, '--', policy.changelog]).ok === false) out.push(policy.changelog);
if (!git(root, ['diff', '--quiet', mergeBase, pin, '--', '.gstack/version-path']).ok) out.push('.gstack/version-path');
return out;
}
/** The pin's commit is local, fetched from its branch or GitHub's refs/pull/<n>/head (which outlives a deleted branch). */
function havePin(root: string, pr: string, pin: string, branch?: string): boolean {
const have = () => git(root, ['cat-file', '-e', `${pin}^{commit}`]).ok;
if (have()) return true;
if (branch) git(root, ['fetch', '-q', 'origin', `refs/heads/${branch}`]);
if (!have()) git(root, ['fetch', '-q', 'origin', `refs/pull/${pr}/head`]);
return have();
}
/** The merge commit's tree is the pin merged onto the merge commit's first parent: GitHub merged exactly the pin. A read that fails is "cannot verify", never a mismatch. */
function verifyMerge(ctx: QueueContext, root: string, pr: string, pin: string, merge: string, lines: string[], run: string, now: Date): StepOutput {
const cannot = (why: string): StepOutput => ({ ok: false, run, lines, failure: { code: 'QUEUE_NOT_READY', message: `#${pr} merged as ${merge.slice(0, 12)}, but land cannot verify it: ${why}`, fix: `fix the read named, then rerun gstack-ship-queue land --pr ${pr} --pin ${pin}; it goes straight to the tree check` } });
if (!git(root, ['fetch', '-q', 'origin', ctx.base]).ok) return cannot(`git fetch origin ${ctx.base} failed`);
if (!havePin(root, pr, pin)) return cannot(`the pin ${pin.slice(0, 12)} is not in this clone and refs/pull/${pr}/head did not fetch it`);
if (!git(root, ['merge-base', '--is-ancestor', merge, `origin/${ctx.base}`]).ok) return { ok: false, run, lines, failure: { code: 'QUEUE_LAND_UNVERIFIED', message: `the merge commit ${merge.slice(0, 12)} is not on origin/${ctx.base}` } };
const parent = git(root, ['rev-parse', `${merge}^1`]).out;
const tree = git(root, ['rev-parse', `${merge}^{tree}`]).out;
const mt = git(root, ['merge-tree', '--write-tree', parent, pin]);
const expected = mt.out.split('\n')[0] ?? '';
if (!isSha(expected)) return cannot(`git merge-tree ${parent.slice(0, 12)} ${pin.slice(0, 12)} failed: ${mt.err.split('\n')[0]}`);
if (!mt.ok || tree !== expected) return { ok: false, run, lines: [...lines, `MAIN: ${merge} tree=${tree} parent=${parent} expected=${expected || 'conflict'}`], failure: { code: 'QUEUE_LAND_UNVERIFIED', message: `origin/${ctx.base} ${merge.slice(0, 12)} is not the pin ${pin.slice(0, 12)} merged onto ${parent.slice(0, 12)}`, fix: `compare git diff ${expected || parent} ${merge} and revert what does not belong to #${pr}; report it to the Captain` } };
const keys = repoIdentity(root);
const at = now.toISOString();
const note = persist(root, [{ schema: 1, kind: 'land', repo_key: keys.key, pr, pin, base_sha: parent, merge_sha: merge, tree, landed_at: at, updated_at: at }], { remote: ctx.remote });
return { ok: true, run, lines: [...lines, `MERGED: #${pr} as ${merge}`, `LANDED: origin/${ctx.base} tree ${tree} equals the pin merged onto ${parent}`, `NOTE: land #${pr}@${pin.slice(0, 12)} (${note.detail})`] };
}
/** `gstack-ship-queue land --pr <n> --pin <sha>`: one pass; a refusal merges nothing. */
export function landDirect(ctx: QueueContext, o: DirectOptions): StepOutput {
const root = ctx.repoRoot;
const run = 'none';
const now = o.now ?? (() => new Date());
const fail = (lines: string[], failure: QueueFailure): StepOutput => ({ ok: false, lines, failure, run });
if (!git(root, ['fetch', '-q', 'origin', ctx.base]).ok) return fail([], { code: 'QUEUE_NOT_READY', message: `cannot fetch origin/${ctx.base}; land reads the policy and the required checks from the current base`, fix: 'fix the fetch, then rerun land' });
const loaded = loadBasePolicy(ctx);
if ('message' in loaded) return fail([], loaded);
const policy = loaded.policy;
if (policy.merge === 'direct' && !policy.changelog_fragments) return fail([], { code: 'QUEUE_NOT_READY', message: 'merge: direct needs changelog_fragments: a PR carries its entry as a fragment, and ready records READY notes only under that key', fix: 'add "changelog_fragments": { "dir": "changelog.d" } to .gstack/ship-policy.json on the base' });
if (policy.merge !== 'direct') return fail([], { code: 'QUEUE_NOT_READY', message: `the base policy sets merge: ${policy.merge}; land --pin merges only under merge: direct`, fix: 'land through the train (gstack-ship-queue train build), or set "merge": "direct" in .gstack/ship-policy.json on the base' });
const info = view(ctx, o.pr);
if ('code' in info) return fail([], info);
const lines = [`LAND: #${o.pr} pin=${o.pin} direct`];
if (info.isCrossRepository) return fail(lines, { code: 'QUEUE_TRAIN_UNTRUSTED', message: `#${o.pr} is a fork head; land merges only crew branches` });
if (info.baseRefName && info.baseRefName !== ctx.base) return fail(lines, { code: 'QUEUE_NOT_READY', message: `#${o.pr} targets ${info.baseRefName}, not ${ctx.base}`, fix: `retarget it to ${ctx.base}, or land its parent first` });
if (info.state === 'MERGED' && info.headRefOid === o.pin && isSha(info.mergeCommit?.oid)) {
lines.push(`#${o.pr} is already merged as ${info.mergeCommit!.oid.slice(0, 12)}; checking the tree`);
return verifyMerge(ctx, root, o.pr, o.pin, info.mergeCommit!.oid, lines, run, now());
}
if (info.state !== 'OPEN') return fail(lines, { code: 'QUEUE_NOT_READY', message: `#${o.pr} is ${info.state}` });
if (info.headRefOid !== o.pin) return fail(lines, { code: 'QUEUE_LAND_PIN_MOVED', message: `#${o.pr}'s head is ${info.headRefOid.slice(0, 12)}, not the pin ${o.pin.slice(0, 12)}`, fix: `the lane runs /ship and gstack-ship-queue ready --pr ${o.pr} at the new head and reports READY with it` });
if (!havePin(root, o.pr, o.pin, info.headRefName)) return fail(lines, { code: 'QUEUE_NOT_READY', message: `cannot fetch the pin ${o.pin.slice(0, 12)} from ${info.headRefName} or refs/pull/${o.pr}/head`, fix: 'fix the fetch, then rerun land' });
const receipt = bindShippedReceipt(root, `origin/${ctx.base}`, info.body ?? '', o.pr, [o.pin]);
lines.push(receipt.line, ...(receipt.depthLine ? [receipt.depthLine] : []), ...(receipt.targetedLine ? [receipt.targetedLine] : []));
if (!receipt.ok) return fail(lines, { code: receipt.code, message: receipt.message, fix: `run /ship at the pin, then gstack-ship-queue ready --pr ${o.pr}` });
const keys = repoIdentity(root).keys;
rebuildFromNotes(root, ctx.remote ?? 'origin');
if (!loadReady(keys, o.pr, o.pin)) return fail(lines, { code: 'QUEUE_NOT_READY', message: `no READY note for #${o.pr} at the pin ${o.pin.slice(0, 12)}`, fix: `the lane runs gstack-ship-queue ready --pr ${o.pr} --no-screen at the pin` });
const freeze = activeFreeze(keys);
if (freeze) return fail(lines, { code: 'QUEUE_TRAIN_FROZEN', message: `main is frozen by train ${freeze.train} (${freeze.reason})` });
const lock = readLock(root, ctx.remote ?? 'origin');
if (lock.state === 'held') return fail(lines, { code: 'QUEUE_LAND_LOCKED', message: `${lockLabel(lock.lock)} holds the land lock; a direct land would race it onto main` });
const mergeBase = git(root, ['merge-base', `origin/${ctx.base}`, o.pin]).out;
if (!isSha(mergeBase)) return fail(lines, { code: 'QUEUE_NOT_READY', message: `cannot compute the merge base of the pin ${o.pin.slice(0, 12)} and origin/${ctx.base}, so the release-file check cannot run`, fix: `git fetch origin ${ctx.base} and the PR head (an unshallow clone), then rerun land` });
const stamped = stampedFiles(root, policy, mergeBase, o.pin);
if ('error' in stamped) return fail(lines, { code: 'QUEUE_NOT_READY', message: `cannot read the version at the pin or its merge base: ${stamped.error}` });
if (stamped.length) {
const files = stamped.filter(f => f !== 'package.json version');
const restore = files.length ? `git checkout $(git merge-base origin/${ctx.base} HEAD) -- ${files.join(' ')}` : '';
const npm = stamped.includes('package.json version') ? 'set package.json "version" back to its merge base value' : '';
return fail(lines, { code: 'QUEUE_LAND_STAMPED', message: `#${o.pr} changes ${stamped.join(', ')}; under merge: direct the release sets versions and the CHANGELOG, a PR carries only its changelog fragment`, fix: `${[restore, npm].filter(Boolean).join(' and ')}, keep (or add) the changelog.d fragment, commit, then /ship and ready at the new head` });
}
const ci = ciAtHead(ctx, o.pin);
lines.push(`CI: ${ci.line}`);
if (!ci.green) return fail(lines, { code: 'QUEUE_LAND_CHECKS', message: `checks are ${ci.line}`, fix: `wait for pending checks (gstack-pr-wait ${o.pr} --until green), or fix the red one at a new head; then rerun land` });
const passed = new Set([...ci.checks.filter(c => c.conclusion === 'success').map(c => c.name), ...ci.contexts.filter(c => c.state === 'success').map(c => c.name)]);
const missing = (policy.changelog_fragments?.required_checks ?? []).filter(n => !passed.has(n));
if (missing.length) return fail(lines, { code: 'QUEUE_LAND_CHECKS', message: `required check(s) ${missing.join(', ')} did not pass at the pin ${o.pin.slice(0, 12)}`, fix: `dispatch them on the branch (gh workflow run <workflow> --ref ${info.headRefName}) and rerun land once they pass` });
if (info.mergeable !== 'MERGEABLE' || info.mergeStateStatus !== 'CLEAN') return fail(lines, { code: 'QUEUE_LAND_NOT_CLEAN', message: `GitHub reports #${o.pr} mergeable=${info.mergeable ?? 'unknown'} mergeStateStatus=${info.mergeStateStatus ?? 'unknown'}`, fix: info.mergeable === 'CONFLICTING' ? `the lane merges origin/${ctx.base} into the branch, resolves, runs /ship and ready at the new head` : 'UNKNOWN means GitHub is still computing: rerun land in a minute; BLOCKED or BEHIND names the branch rule to meet first' });
if (o.dryRun) return { ok: true, run, lines: [...lines, `DRY-RUN: nothing merged (would run gh pr merge ${o.pr} --squash --match-head-commit ${o.pin})`] };
const method = policy.history === 'merge-only' ? '--merge' : '--squash';
const m = ctx.gh(['pr', 'merge', o.pr, method, '--match-head-commit', o.pin]);
let merge: string | undefined;
for (let i = 0; i < (m.ok ? 4 : 1) && !isSha(merge); i++) {
if (i) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 3_000);
const after = view(ctx, o.pr);
merge = 'code' in after || after.state !== 'MERGED' ? undefined : after.mergeCommit?.oid;
}
if (!isSha(merge) && m.ok) return fail(lines, { code: 'QUEUE_NOT_READY', message: `gh pr merge accepted #${o.pr}, but GitHub does not show it merged yet; nothing is verified`, fix: `rerun gstack-ship-queue land --pr ${o.pr} --pin ${o.pin} in a minute; once GitHub shows the merge it goes straight to the tree check` });
if (!isSha(merge)) return fail(lines, { code: 'QUEUE_NOT_READY', message: `gh pr merge refused: ${(m.err ?? m.out).trim().slice(0, 200) || 'the PR is not merged'}`, fix: `read the refusal; rerun land --pr ${o.pr} --pin ${o.pin} once it is fixed` });
return verifyMerge(ctx, root, o.pr, o.pin, merge!, lines, run, now());
}
+4 -2
View File
@@ -325,6 +325,7 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput {
if ('message' in loaded) return fail([], loaded, run);
const policy = loaded.policy;
if (o.repair) return repair(ctx, policy, readJson<LandJournal>(file), file, run);
if (policy.merge === 'direct') return fail([], { code: 'QUEUE_NOT_READY', message: 'the base policy sets merge: direct, where land never stamps', fix: `gstack-ship-queue land --pr ${o.pr} --pin <the READY's head sha>` }, run);
const info = prInfo(ctx, o.pr);
if ('code' in info) return fail([], info, run);
if (info.isCrossRepository) return fail([], { code: 'QUEUE_TRAIN_UNTRUSTED', message: `#${o.pr} is a fork head; land stamps and pushes only crew branches` }, run);
@@ -332,11 +333,12 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput {
const mergedResume = info.state === 'MERGED' && !!j && ['pushed', 'merged', 'landed'].includes(j.phase) && j.stamp_commit === info.headRefOid;
if (info.state !== 'OPEN' && !mergedResume) return fail([], { code: 'QUEUE_NOT_READY', message: `#${o.pr} is ${info.state}` }, run);
if (info.baseRefName && info.baseRefName !== ctx.base) return fail([], { code: 'QUEUE_NOT_READY', message: `#${o.pr} targets ${info.baseRefName}, not ${ctx.base}; land merges only PRs on the base it stamps (retarget it, or land its parent first)` }, run);
if (j && !mergedResume && (j.phase === 'landed' || ![j.reviewed_pin, j.last_pushed_head, j.stamp_commit].includes(info.headRefOid))) j = null;
const reset = j && !mergedResume && j.stamp_commit && j.last_pushed_head && j.last_pushed_head !== j.reviewed_pin && info.headRefOid === j.reviewed_pin ? `JOURNAL: #${o.pr}'s head is the reviewed pin ${j.reviewed_pin.slice(0, 12)}, not the stamp commit ${j.stamp_commit.slice(0, 12)} (phase ${j.phase}); discarding the journal and restamping` : null;
if (j && !mergedResume && (reset || j.phase === 'landed' || ![j.reviewed_pin, j.last_pushed_head, j.stamp_commit].includes(info.headRefOid))) j = null;
if (j && mergedResume) j.phase = 'merged';
const recovered = j ? null : stampPin(root, info.headRefName, info.headRefOid, o.pr);
const pin = j?.reviewed_pin ?? recovered ?? info.headRefOid;
const lines = [`LAND: #${o.pr} branch=${info.headRefName} pin=${pin}${j ? ` (resuming at ${j.phase})` : recovered ? ` (the head ${info.headRefOid.slice(0, 12)} is land's stamp commit; restamping from its pin)` : ''}`];
const lines = [`LAND: #${o.pr} branch=${info.headRefName} pin=${pin}${j ? ` (resuming at ${j.phase})` : recovered ? ` (the head ${info.headRefOid.slice(0, 12)} is land's stamp commit; restamping from its pin)` : ''}`, ...(reset ? [reset] : [])];
git(root, ['fetch', '-q', 'origin', ctx.base]);
const receipt = bindShippedReceipt(root, `origin/${ctx.base}`, info.body ?? '', o.pr, [pin], mergedResume);
lines.push(receipt.line, ...(receipt.depthLine ? [receipt.depthLine] : []), ...(receipt.targetedLine ? [receipt.targetedLine] : []));
+24 -1
View File
@@ -117,7 +117,22 @@ export interface ReadyReceipt {
schema: 1; kind: 'ready'; repo_key: string; pr: string; head: string; base_sha: string; ready_at: string; screen: ScreenChoice; updated_at: string;
base_red?: BaseRedCondition; fixes_base_red?: string[];
}
export type AnyRecord = TrainRecord | AttemptRecord | BisectRecord | FreezeRecord | ReadyReceipt | WaveRecord;
/** One release-gate bisect round (lib/release-gate.ts): the prefixes it dispatched on release-gate/<id> and what each case did there. */
export interface ReleaseBisectRound { nonce: string; ref: string; run: { id: number; url: string } | null; prefixes: Array<{ sha: string; results: Record<string, PrefixResult> }> }
/** A red release gate's bisect over the first-parent commits after the last green gate: first_bad once good and bad are adjacent. */
export interface ReleaseBisect {
cases: string[]; good: string | null; bad: string; first_bad: string | null; spend_cap: number; spent_usd: number;
state: 'running' | 'found' | 'unknown' | 'capped'; detail: string; rounds: ReleaseBisectRound[];
}
/** The release gate's verdict on one commit of the gated branch (lib/release-gate.ts), the evidence scripts/release-cut.ts reads. */
export interface ReleaseRecord {
schema: 1; kind: 'release'; repo_key: string; sha: string; tree: string; branch: string; stable: string | null; since: string | null;
nonce: string; attempts: number; run: { id: number; url: string } | null; verdict: 'PENDING' | 'GREEN' | 'RED' | 'INCOMPLETE';
red_cases: string[]; flaky: string[]; cost_usd: number | null; detail: string; bisect: ReleaseBisect | null; created_at: string; updated_at: string;
}
/** What `land --pin` merged (docs/ship-policy.md#land-direct): the pin, the base it landed on and the verified merge commit. */
export interface LandNote { schema: 1; kind: 'land'; repo_key: string; pr: string; pin: string; base_sha: string; merge_sha: string; tree: string; landed_at: string; updated_at: string }
export type AnyRecord = TrainRecord | AttemptRecord | BisectRecord | FreezeRecord | ReadyReceipt | WaveRecord | ReleaseRecord | LandNote;
/** Clock seam for fill-time and record tests (`GSTACK_SHIP_QUEUE_NOW`, ISO-8601). */
export function nowIso(): string {
@@ -141,6 +156,8 @@ const bisectPath = (nonce: string) => path.join(trainDir(nonce), 'bisect.json');
const freezePath = (repoKey: string) => path.join(queueRoot(), 'freeze', `${repoKey}.json`);
export const readyPath = (repoKey: string, pr: string, head: string) => path.join(queueRoot(), 'ready', repoKey, `${pr}-${head}.json`);
export const wavePath = (nonce: string) => path.join(queueRoot(), 'waves', `${nonce}.json`);
export const releasePath = (repoKey: string, sha: string) => path.join(queueRoot(), 'release', repoKey, `${sha}.json`);
export const landPath = (repoKey: string, pr: string, pin: string) => path.join(queueRoot(), 'landed', repoKey, `${pr}-${pin}.json`);
/** Hosts that serve GitHub repositories at /<owner>/<repo> (git.capy.ai is Capy's GitHub proxy, set through url.insteadOf). */
const GITHUB_HOSTS = ['github.com', 'www.github.com', 'ssh.github.com', 'git.capy.ai'];
@@ -226,6 +243,8 @@ export function recordPath(r: AnyRecord): string {
if (r.kind === 'train-bisect') return bisectPath(r.train);
if (r.kind === 'freeze') return freezePath(r.repo_key);
if (r.kind === 'wave') return wavePath(r.nonce);
if (r.kind === 'release') return releasePath(r.repo_key, r.sha);
if (r.kind === 'land') return landPath(r.repo_key, r.pr, r.pin);
return readyPath(r.repo_key, r.pr, r.head);
}
@@ -235,6 +254,8 @@ export function noteName(r: AnyRecord): string {
if (r.kind === 'train-bisect') return `gstack-trains:bisect:${r.train}`;
if (r.kind === 'freeze') return `gstack-trains:freeze:${r.repo_key}`;
if (r.kind === 'wave') return `gstack-trains:wave:${r.nonce}`;
if (r.kind === 'release') return `gstack-trains:release:${r.repo_key}:${r.sha}`;
if (r.kind === 'land') return `gstack-trains:land:${r.repo_key}:${r.pr}:${r.pin}`;
return `gstack-trains:ready:${r.repo_key}:${r.pr}:${r.head}`;
}
@@ -299,6 +320,8 @@ function parseNote(text: string): AnyRecord | null {
if ((r.kind === 'train-attempt' && ids.test(r.train) && ids.test(r.id)) || (r.kind === 'train-bisect' && ids.test(r.train))) return r;
if (r.kind === 'freeze' && ids.test(r.repo_key)) return r;
if (r.kind === 'wave' && ids.test(r.nonce) && ids.test(r.repo_key) && Array.isArray(r.constituents)) return r;
if (r.kind === 'release' && ids.test(r.repo_key) && /^[0-9a-f]{40,64}$/.test(r.sha)) return r;
if (r.kind === 'land' && ids.test(r.repo_key) && /^\d+$/.test(r.pr) && /^[0-9a-f]{40,64}$/.test(r.pin)) return r;
if (r.kind === 'ready' && ids.test(r.repo_key) && /^\d+$/.test(r.pr) && /^[0-9a-f]{40,64}$/.test(r.head)) return r;
} catch {}
return null;
+9 -1
View File
@@ -24,7 +24,7 @@ import { bumpVersion, cmpVersion, fmtVersion, parseVersion, versionWidth, type B
import { waitForRun } from './ci-carry';
import { PREPUSH_CHECK_IDS } from './pregate/index';
import { buildRunCheck } from './pregate/run-check';
import { bin, carryLane, carryNoneLine, evidenceExempt, firstLine, flagReader, fragmentPath, gh, git, hostedOrigin, laneLine, lanes, mappedLanes, releasePaths, Report, runLanes, sh, shipPolicy, short, stampedFiles } from './ship-bin-common';
import { bin, carryLane, carryNoneLine, evidenceExempt, firstLine, flagReader, fragmentPath, gh, git, hostedOrigin, laneLine, lanes, mappedLanes, releasePaths, Report, runLanes, sh, shipPolicy, shipRunMarker, short, stampedFiles } from './ship-bin-common';
export const RELEASE_HELP = `gstack-ship-release — /ship Steps 12 and 15-17 (ship/SKILL.md)
@@ -382,7 +382,15 @@ function docsFresh(cwd: string, base: string, postFile: string, moved: readonly
return true;
}
/** Steps 15-16; once they pass, the /ship run's preflight marker is cleared, so a full preflight may merge the base again (GST-1204). */
export async function finish(argv: readonly string[], cwd: string): Promise<Report> {
const r = await finishStages(argv, cwd);
const marker = r.exit === 0 && shipRunMarker(cwd);
if (marker) fs.rmSync(marker, { force: true });
return r;
}
async function finishStages(argv: readonly string[], cwd: string): Promise<Report> {
const { flag, all, has } = flagReader(argv);
const base = flag('--base')!;
const r = new Report();
+22 -10
View File
@@ -4,7 +4,7 @@
* lines the branch wrote itself. Split from restamp.ts (module-size ratchet).
*/
import { spawnSync } from 'node:child_process';
import { branchAddedLines, escapeRegExp, forkVersion, rewriteStamps, stampMentions } from './restamp';
import { branchAddedLines, escapeRegExp, forkVersion, recordedVersions, rewriteStamps, stampMentions } from './restamp';
import { VERSION_SLOT } from './ship-policy';
import { cmpVersion, parseVersion, versionWidth } from './version-source';
@@ -33,21 +33,28 @@ import { cmpVersion, parseVersion, versionWidth } from './version-source';
* `stamp_patterns` template with literal text around its slot, never a built-in
* form: with at least one such rewrite, the line must then be the added line,
* or one whose remaining stamp-form moves pass the fork-version bound.
* Within either bound a built-in form (`vX`, `since: X`, `(was X)`) moves a
* version above `newV` only when the writer would: `oldV` on any line, one the
* branch recorded (`side.recorded`) on its own lines. Any other version moves
* only inside a `stamp_patterns` slot (#3512), so a third party's `QM v0.1.15`
* rewritten on a line the branch added, even beside such a slot, is product (GST-1245).
*/
export function stampOnlyLine(removed: string, added: string, oldV: string, newV: string, templates: readonly string[] = [], side?: BranchSide, at?: number, placeholders: readonly string[] = []): boolean {
if (removed === added) return false;
if (oldV !== newV && stampOnlyWithin(removed, added, oldV, newV, templates)) return true;
if (oldV !== newV && stampOnlyWithin(removed, added, oldV, newV, templates, new Set([oldV]))) return true;
if (!side || at === undefined || !side.at.has(at) || !side.added.has(removed)) return false;
if (stampOnlyWithin(removed, added, side.fork, newV, templates)) return true;
const recorded = side.recorded ?? new Set([side.fork]);
if (stampOnlyWithin(removed, added, side.fork, newV, templates, recorded)) return true;
const filled = fillPlaceholders(removed, newV, templates, placeholders);
return filled.count > 0 && (filled.text === added || stampOnlyWithin(filled.text, added, side.fork, newV, templates));
return filled.count > 0 && (filled.text === added || stampOnlyWithin(filled.text, added, side.fork, newV, templates, recorded));
}
/**
* Lines the branch added to one file (since its merge base with the base, less the lines the base added since): their
* text and their 1-based positions in the side's file, and its fork point's version.
* text and their 1-based positions in the side's file, its fork point's version, and the versions it recorded
* (recordedVersions; the fork version alone when absent).
*/
export interface BranchSide { fork: string; added: ReadonlySet<string>; at: ReadonlySet<number> }
export interface BranchSide { fork: string; added: ReadonlySet<string>; at: ReadonlySet<number>; recorded?: ReadonlySet<string> }
/**
* Each file's BranchSide for `head` against `baseRef`, read lazily and cached: the lines come from merge-base..`to`
@@ -58,6 +65,7 @@ export function branchSides(repoRoot: string, baseRef: string, head: string, fal
const sides = new Map<string, BranchSide | undefined>();
let since: string | undefined;
let fork: string | undefined;
let recorded: Set<string> | undefined;
return rel => {
if (sides.has(rel)) return sides.get(rel);
since ??= (spawnSync('git', ['merge-base', baseRef, head], { cwd: repoRoot, encoding: 'utf8', timeout: 60_000 }).stdout ?? '').trim();
@@ -65,7 +73,7 @@ export function branchSides(repoRoot: string, baseRef: string, head: string, fal
if (since) {
try {
const own = branchAddedLines(repoRoot, since, [rel], to, branchAddedLines(repoRoot, since, [rel], baseRef)).get(rel);
if (own?.text.size) side = { fork: (fork ??= forkVersion(repoRoot, baseRef, fallback, head)), added: own.text, at: own.at };
if (own?.text.size) side = { fork: (fork ??= forkVersion(repoRoot, baseRef, fallback, head)), added: own.text, at: own.at, recorded: (recorded ??= recordedVersions(repoRoot, baseRef, fallback, head)) };
} catch {}
}
sides.set(rel, side);
@@ -88,14 +96,18 @@ function fillPlaceholders(removed: string, newV: string, templates: readonly str
return { text, count };
}
function stampOnlyWithin(removed: string, added: string, oldV: string, newV: string, templates: readonly string[]): boolean {
function stampOnlyWithin(removed: string, added: string, oldV: string, newV: string, templates: readonly string[], recorded: ReadonlySet<string>): boolean {
const floor = parseVersion(oldV);
const width = versionWidth(oldV);
const ceiling = parseVersion(newV);
const free = (v: string) => { const p = parseVersion(v); return recorded.has(v) || (!!p && !!ceiling && cmpVersion(p, ceiling) <= 0); };
const after = stampMentions(added, templates);
const moved = [...stampMentions(removed, templates)].filter(v => v !== newV && !after.has(v));
const afterSlots = stampMentions(added, templates, false);
const slotted = stampMentions(removed, templates, false);
const moved = [...stampMentions(removed, templates)].filter(v => v !== newV && (free(v) ? !after.has(v) : slotted.has(v) && !afterSlots.has(v)));
if (!moved.length || !floor) return false;
if (moved.some(v => { const p = parseVersion(v); return !p || versionWidth(v) !== width || cmpVersion(p, floor) < 0; })) return false;
let text = removed;
for (const v of moved) text = rewriteStamps(text, v, newV, templates).text;
for (const v of moved) text = rewriteStamps(text, v, newV, templates, free(v)).text;
return text === added;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.94.41",
"version": "1.95.5",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* check-conflict-markers — refuse merge conflict markers on the lines a branch changed (GST-1190).
*
* bun scripts/check-conflict-markers.ts [--base <ref>]
*
* Reads every line the branch adds against its merge base with <ref> (default origin/main) twice: in the
* committed HEAD, which is what a push publishes, and in the working tree's tracked files (deleted files are
* skipped; untracked files are never pushed, so they are not read). A line starting with `<<<<<<<` or
* `>>>>>>>` followed by a space or the line end, or a line that is exactly `=======`, is a marker. This
* catches a marker the parser accepts, such as one inside a template literal (#3927). Markdown files
* (`.md`, `.mdx`, `.markdown`, `.md.tmpl`) skip lines inside a ``` or ~~~ fence whose info string is empty,
* text, txt, plaintext, diff or console, the fences a doc uses to show git output; a conflict inside a bash,
* ts or other code fence is still refused. They count `=======` only between a `<<<<<<<` and its close, so a
* setext heading underline passes. A file whose first five lines include one that is only the declaration
* `gstack-conflict-markers: fixture` (after an optional comment leader such as `#`, `//` or `<!--`) is
* skipped. Prints one CONFLICT_MARKER: <path>:<line> <marker> per hit and exits 1, or CONFLICT_MARKERS: none
* and exits 0; a git failure exits 2.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
const OPEN = /^<{7}( |$)/, CLOSE = /^>{7}( |$)/, MID = /^={7}$/;
const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([^\s`]*)/;
const SHOWN = /^(|text|txt|plaintext|diff|console)$/i;
const MARKDOWN = /\.(md|mdx|markdown|md\.tmpl)$/i;
const DECLARATION = /^\s*(?:#|\/\/|\/\*|\*|<!--|--)?\s*gstack-conflict-markers: fixture\s*(?:\*\/|-->)?\s*$/;
const GIT = ['-c', 'core.quotePath=false', '-c', 'diff.noprefix=false', '-c', 'diff.mnemonicPrefix=false'];
function git(args: string[], allowFail = false): string | null {
const r = spawnSync('git', [...GIT, ...args], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, timeout: 120_000 });
if (r.status === 0) return r.stdout;
if (allowFail) return null;
console.error(`check-conflict-markers: git ${args.join(' ')} failed: ${(r.stderr || r.error?.message || '').trim()}`);
process.exit(2);
}
/** The added line numbers per path, from a zero-context diff whose headers name paths with fixed a/ and b/ prefixes. */
function addedLines(diff: string): Map<string, Set<number>> {
const added = new Map<string, Set<number>>();
let file = '', header = false;
for (const line of diff.split('\n')) {
if (line.startsWith('diff --git ')) { header = true; file = ''; continue; }
if (header && line.startsWith('+++ ')) { file = line.startsWith('+++ b/') ? line.slice(6).replace(/\t$/, '') : ''; header = false; continue; }
if (header) continue;
const hunk = /^@@ -\S+ \+(\d+)(?:,(\d+))? @@/.exec(line);
if (!hunk || !file) continue;
const start = Number(hunk[1]), count = hunk[2] === undefined ? 1 : Number(hunk[2]);
const set = added.get(file) ?? new Set<number>();
for (let n = start; n < start + count; n++) set.add(n);
added.set(file, set);
}
return added;
}
const seen = new Set<string>();
function scan(f: string, text: string, lines: Set<number>, where: string, hits: Set<string>): void {
const all = text.split('\n');
if (text.includes('\0') || all.slice(0, 5).some(l => DECLARATION.test(l))) return;
const md = MARKDOWN.test(f);
let fence = '', shown = false, open = false;
all.forEach((raw, idx) => {
const l = raw.replace(/\r$/, '');
if (md) {
const m = FENCE.exec(l);
if (m && !fence) { fence = m[1]!; shown = SHOWN.test(m[2]!); return; }
if (m && !m[2] && m[1]![0] === fence[0] && m[1]!.length >= fence.length) { fence = ''; shown = false; return; }
if (shown) return;
}
const marker = OPEN.test(l) ? '<'.repeat(7) : CLOSE.test(l) ? '>'.repeat(7) : MID.test(l) && (!md || open) ? '='.repeat(7) : '';
if (OPEN.test(l)) open = true;
if (CLOSE.test(l)) open = false;
if (!marker || !lines.has(idx + 1) || seen.has(`${f}:${idx + 1}`)) return;
seen.add(`${f}:${idx + 1}`);
hits.add(`CONFLICT_MARKER: ${f}:${idx + 1} ${marker}${where}`);
});
}
const i = process.argv.indexOf('--base');
const base = i > 0 ? process.argv[i + 1]! : 'origin/main';
const root = git(['rev-parse', '--show-toplevel'])!.trim();
process.chdir(root);
const fork = git(['merge-base', base, 'HEAD'])!.trim();
const DIFF = ['diff', '-U0', '--no-color', '--no-ext-diff', '--no-renames', '--src-prefix=a/', '--dst-prefix=b/', '--diff-filter=AM'];
const hits = new Set<string>();
const committed = addedLines(git([...DIFF, fork, 'HEAD', '--'])!);
for (const [f, lines] of committed) {
const text = git(['show', `HEAD:${f}`], true);
if (text !== null) scan(f, text, lines, '', hits);
}
const working = addedLines(git([...DIFF, fork, '--'])!);
for (const [f, lines] of working) {
let text: string;
try { text = fs.readFileSync(path.join(root, f), 'utf8'); } catch { continue; }
scan(f, text, lines, ' (working tree)', hits);
}
const files = new Set([...committed.keys(), ...working.keys()]).size;
if (hits.size) {
console.log([...hits].sort().join('\n'));
console.log('fix: finish the merge in each file above (keep the right side, delete the marker lines) and commit; a fixture that shows markers on purpose declares the fixture line documented in docs/pregate.md#prepush in its first five lines');
process.exit(1);
}
console.log(`CONFLICT_MARKERS: none (${files} changed file(s) against ${fork.slice(0, 12)})`);
@@ -1 +1 @@
{"blob":"b552b8825d693493decef76b05b976858cab087e","since":6,"nonBehavioral":"test-pr-profile.ts lists .github/workflows/windows-scoped-tests.yml as free-lane only; the workflow runs no paid case, so no case selection or verdict can change"}
{"blob":"89a688ffeb7f000e7341062e45bf93e36a0119ce","since":6,"nonBehavioral":"test-pr-profile.ts lists .github/workflows/release-gate.yml and .github/workflows/windows-scoped-tests.yml as free-lane only; neither workflow runs a paid case through this list, so no case selection or verdict can change"}
+1
View File
@@ -51,6 +51,7 @@ const WINDOWS_FRAGILE_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [
// when possible; this list is for environment-/runtime-specific tests where
// the failure mode is structural rather than detectable via source-file scan.
export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }> = [
{ file: 'test/ship-queue-land-direct.test.ts', reason: 'the same extensionless node gh shim as test/ship-queue-land.test.ts; windows-latest launches the runner\'s gh.exe instead (no GH_TOKEN), so every land case fails (windows-scoped-shard, run 41720760327); land is a controller command run on Linux and macOS' },
{ file: 'test/ship-queue-land.test.ts', reason: 'its gh shim is an extensionless #!/usr/bin/env node script on a colon-joined PATH, which windows-latest cannot launch, so land reaches the real gh (no GH_TOKEN) and every case fails (windows-free-shard 6, run 40824965988); land is a controller command run on Linux and macOS' },
// Like test/paid-rerun.test.ts (excluded by pattern), it replays a gate capture through the paid lane's --report and --rerun-plan CLI.
{ file: 'test/shared-libs-index-flags-readonly.test.ts', reason: 'its assume-unchanged replay seeds the review through test/helpers/shared-libs-path-fixture.ts, which launches bin/gstack-review-log (a bash script) directly; windows-latest refuses it as not found in $PATH (windows-free-shard 5, run 40727825859)' },
+26 -7
View File
@@ -19,7 +19,7 @@
* the window (7 days, 200 first-parent commits) is the candidate; a commit
* with a check run still going is skipped, not waited for.
*
* Gated means one of two kinds of evidence. Either the backstop check run
* Gated means one of three kinds of evidence. Either the backstop check run
* (--backstop-check, default main-backstop) succeeded: evals-main-backstop.yml
* publishes it, success only when a green train record covers the tree or the
* backstop gated it green, neutral when main moved past the push before it was
@@ -27,8 +27,11 @@
* or merged) lists the commit's sha or its tree in merged[]: those entries are
* the main commits `gstack-ship-queue verify --train` confirmed, so a neutral
* or absent backstop run on such a commit is not held against it, and the
* CANDIDATE line says "gated by train <nonce>". A red backstop run stays red
* either way. A commit with neither is not green.
* CANDIDATE line says "gated by train <nonce>". Or a GREEN release record
* (kind release, lib/release-gate.ts) names the commit: "gated by release gate
* run <id>". A red backstop run stays red either way. A commit with none of
* these is not green. A RED release record with no GREEN one on a later
* commit that contains it holds every cut, like a freeze.
*
* stable moves only forward: a candidate behind the current stable is refused
* (REFUSED non-fast-forward), as is a tag v<VERSION> that already names another
@@ -99,8 +102,8 @@ export function compareVersions(a: string, b: string): number {
export type Verdict = { green: true; via: string } | { green: false; why: string; pending: boolean };
interface CheckRun { name: string; status: string; conclusion: string | null }
/** Main commits a green, merging or merged train record lists in merged[], by sha and by tree, each naming the train. */
export interface TrainEvidence { bySha: Map<string, string>; byTree: Map<string, string> }
/** Main commits a green, merging or merged train record lists in merged[], by sha and by tree, each naming the train; `release` maps a commit a green release gate passed to its run (lib/release-gate.ts). */
export interface TrainEvidence { bySha: Map<string, string>; byTree: Map<string, string>; release?: Map<string, string> }
export interface GateOptions { requiredCheck?: string; backstopCheck?: string; evidence?: TrainEvidence; treeOf?: (sha: string) => string | null }
/** Why a commit is not releasable, or green and through which evidence. */
@@ -118,6 +121,8 @@ export function gateVerdict(gh: Gh, repo: string, sha: string, o: GateOptions =
if (named.some(c => c.conclusion === 'success')) continue;
const train = name === backstop && name !== required && o.evidence ? o.evidence.bySha.get(sha) ?? o.evidence.byTree.get(o.treeOf?.(sha) ?? '') : undefined;
if (train) { via = `train ${train}`; continue; }
const gate = name === backstop && name !== required ? o.evidence?.release?.get(sha) : undefined;
if (gate) { via = `release gate run ${gate}`; continue; }
if (named.length === 0) return { green: false, why: `no ${name} check run`, pending: false };
return { green: false, why: `${name} concluded ${named.map(c => c.conclusion).join(',')}`, pending: false };
}
@@ -129,14 +134,20 @@ export function gateVerdict(gh: Gh, repo: string, sha: string, o: GateOptions =
}
export interface TrainNotes { error: string | null; freeze: string | null; evidence: TrainEvidence }
interface NoteRecord { kind?: string; state?: string; nonce?: string; merged?: Array<{ sha?: string; tree?: string }>; cleared_at?: string | null; reason?: string; train?: string }
interface NoteRecord {
kind?: string; state?: string; nonce?: string; merged?: Array<{ sha?: string; tree?: string }>; cleared_at?: string | null; reason?: string; train?: string;
sha?: string; verdict?: string; run?: { id?: number } | null; red_cases?: string[];
}
/**
* One read of the trains notes ref (fetched from origin): the active freeze
* record (kind freeze, cleared_at null) and the train evidence (lib/
* ship-queue-train-record.ts TrainRecord: kind train, state green, merging or
* merged, merged[].sha and .tree). A fetch failure other than a missing ref is
* an error; a missing ref holds nothing.
* an error; a missing ref holds nothing. Release-gate records (kind release,
* lib/release-gate.ts) add evidence when GREEN, and a RED one with no GREEN
* record on a later commit that contains it is the hold: it refuses the cut
* like a freeze (a train freeze, when both exist, is the one named).
*/
export function readTrainNotes(git: Git, notesRef = NOTES_REF): TrainNotes {
const notes: TrainNotes = { error: null, freeze: null, evidence: { bySha: new Map(), byTree: new Map() } };
@@ -147,6 +158,8 @@ export function readTrainNotes(git: Git, notesRef = NOTES_REF): TrainNotes {
}
const listed = git(['notes', `--ref=${NOTES_LOCAL}`, 'list']);
if (!listed.ok || !listed.out.trim()) return notes;
const gates: Array<{ sha: string; verdict: string; red: string[] }> = [];
notes.evidence.release = new Map();
for (const line of listed.out.split('\n')) {
const blob = line.split(' ')[0];
if (!blob) continue;
@@ -155,6 +168,10 @@ export function readTrainNotes(git: Git, notesRef = NOTES_REF): TrainNotes {
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue;
const r = parsed as NoteRecord;
if (r.kind === 'freeze' && !r.cleared_at) notes.freeze = `queue frozen since train ${r.train ?? '?'}: ${r.reason ?? 'red backstop'}`;
if (r.kind === 'release' && typeof r.sha === 'string' && /^[0-9a-f]{40}$/.test(r.sha)) {
gates.push({ sha: r.sha, verdict: String(r.verdict), red: Array.isArray(r.red_cases) ? r.red_cases.filter(c => typeof c === 'string') : [] });
if (r.verdict === 'GREEN') notes.evidence.release.set(r.sha, String(r.run?.id ?? '?'));
}
if (r.kind !== 'train' || typeof r.nonce !== 'string' || !r.nonce || !['green', 'merging', 'merged'].includes(r.state ?? '')) continue;
for (const m of Array.isArray(r.merged) ? r.merged : []) {
if (!m || typeof m !== 'object') continue;
@@ -162,6 +179,8 @@ export function readTrainNotes(git: Git, notesRef = NOTES_REF): TrainNotes {
if (typeof m.tree === 'string' && m.tree) notes.evidence.byTree.set(m.tree, r.nonce);
}
}
const held = gates.find(red => red.verdict === 'RED' && !gates.some(g => g.verdict === 'GREEN' && g.sha !== red.sha && git(['merge-base', '--is-ancestor', red.sha, g.sha]).ok));
if (held && !notes.freeze) notes.freeze = `release gate red on ${held.sha.slice(0, 12)} (${held.red.join(', ') || 'red cases not recorded'}) and no gate on a later commit is green; land the revert or fix-forward, then run gstack-release-gate run --ignore-cadence`;
return notes;
}
+1 -1
View File
@@ -344,7 +344,7 @@ Apply these at each page. Each finding gets an impact rating (high/medium/polish
- No \`user-scalable=no\` or \`maximum-scale=1\` in viewport meta
**7. Motion & Animation** (7 items)
- Easing: ease-out for entering, ease-in for exiting, ease-in-out for moving
- Easing: ease-out to enter and exit, ease-in-out to move; flag plain ease-in
- Duration: 50-700ms range (nothing slower unless page transition)
- Purpose: every animation communicates something (state change, attention, spatial relationship)
- \`prefers-reduced-motion\` respected (check: \`await pg.evaluate(() => matchMedia("(prefers-reduced-motion: reduce)").matches)\`)
+1
View File
@@ -288,6 +288,7 @@ export const FREE_ONLY_PR_FILES = [
'.github/workflows/test-health.yml',
'.github/workflows/eval-sweep.yml', // The weekly off-ship sweep on main; no PR-gate case reads it, and it never gates a PR.
'.github/workflows/evals-main-backstop.yml', // The merge-train backstop on main: it dispatches evals.yml after a push to main; no PR-gate case reads it, and it never gates a PR.
'.github/workflows/release-gate.yml', // The 8-hour release gate on main: it dispatches evals.yml on main's head; no PR-gate case reads it, and it never gates a PR.
'.github/workflows/weekly-release.yml', // The Monday release cut (tag, stable, GitHub Release): reads check runs on main; no paid case reads it, and it never gates a PR.
'scripts/release-cut.ts', // The release cut's logic, run only by weekly-release.yml; covered by the free test/release-cut.test.ts.
'.github/actionlint.yaml', // Runner labels for actionlint only.
+12 -4
View File
@@ -61,8 +61,14 @@ export type RunMode = 'screen' | 'train' | 'bisect' | 'dispatch';
export interface RouteInput {
nonce: string; refName: string; head: string; expectedSha: string; evalsAll: boolean; trainPolicy: boolean;
cases: string; prefixShas: string; screenPr: string; screenMergeSha: string; screenPolicy: string;
/** The default branch's policy declares a release_gate block (lib/release-gate-policy.ts). */
releasePolicy?: boolean;
}
export interface Route { mode: RunMode; cases: string[]; prefixes: string[] }
/** artifact: the prefix of a release bisect's per-prefix records (a train bisect uses the train block's record_artifact). */
export interface Route { mode: RunMode; cases: string[]; prefixes: string[]; artifact?: string }
/** The release gate's bisect runs on its own ref and names its own prefix records (lib/release-gate.ts RELEASE_REF_PREFIX, RELEASE_BISECT_ARTIFACT). */
export const RELEASE_BISECT_REF = 'release-gate/';
export const RELEASE_BISECT_ARTIFACT = 'gstack-release-record';
const list = (text: string) => text.split(',').map(item => item.trim()).filter(Boolean);
@@ -88,7 +94,8 @@ export function routeDispatch(i: RouteInput, deps: { isAncestor(commit: string,
const cases = list(i.cases);
const prefixes = list(i.prefixShas);
if (scope?.purpose !== 'bisect') return { error: `a bisect needs a bisect nonce (got ${JSON.stringify(i.nonce)})` };
if (!i.refName.startsWith('train/')) return { error: `a bisect runs on train/<nonce>, not ${i.refName}` };
const release = i.refName.startsWith(RELEASE_BISECT_REF);
if (!release && !i.refName.startsWith('train/')) return { error: `a bisect runs on train/<nonce> (or the release gate's ${RELEASE_BISECT_REF}<id>), not ${i.refName}` };
if (i.expectedSha !== i.head) return { error: `expected_sha ${i.expectedSha.slice(0, 12) || '(empty)'} is not the train head ${i.head.slice(0, 12)} checked out from ${i.refName}` };
if (!cases.length || cases.length > MAX_BISECT_CASES || new Set(cases).size !== cases.length) return { error: `cases must name 1 to ${MAX_BISECT_CASES} distinct case ids` };
if (!prefixes.length || prefixes.length > MAX_PREFIXES || new Set(prefixes).size !== prefixes.length) return { error: `prefix_shas must name 1 to ${MAX_PREFIXES} distinct commits` };
@@ -99,6 +106,7 @@ export function routeDispatch(i: RouteInput, deps: { isAncestor(commit: string,
const plan = deps.gateCases();
const unknown = cases.filter(id => !CASE_ID.test(id) || !plan.has(id));
if (unknown.length) return { error: `case(s) not in the full gate's plan: ${unknown.slice(0, 5).join(', ')}` };
if (release) return i.releasePolicy ? { mode: 'bisect', cases, prefixes, artifact: RELEASE_BISECT_ARTIFACT } : { error: "a release bisect needs a release_gate block in the default branch's .gstack/ship-policy.json" };
return trainOnly('bisect') ?? { mode: 'bisect', cases, prefixes };
}
if (scope?.purpose === 'train') {
@@ -359,12 +367,12 @@ if (import.meta.main) {
if (command === 'route') {
const env = process.env;
const route = routeDispatch({ nonce: env.NONCE ?? '', refName: env.GITHUB_REF_NAME ?? '', head: git(['rev-parse', 'HEAD']).out, expectedSha: env.EXPECTED_SHA ?? '',
evalsAll: env.EVALS_ALL === 'true', trainPolicy: env.TRAIN_POLICY === 'true', cases: env.CASES ?? '', prefixShas: env.PREFIX_SHAS ?? '',
evalsAll: env.EVALS_ALL === 'true', trainPolicy: env.TRAIN_POLICY === 'true', releasePolicy: env.RELEASE_POLICY === 'true', cases: env.CASES ?? '', prefixShas: env.PREFIX_SHAS ?? '',
screenPr: env.SCREEN_PR ?? '', screenMergeSha: env.SCREEN_MERGE_SHA ?? '', screenPolicy: env.SCREEN_POLICY ?? '' },
{ isAncestor: (commit, head) => git(['merge-base', '--is-ancestor', commit, head]).ok, gateCases: () => fullGateCases(process.cwd()) });
if ('error' in route) fail(`dispatch refused before any paid work: ${route.error}`);
const ok = route as Route;
output([`mode=${ok.mode}`, `cases=${ok.cases.join(',')}`, `prefixes=${ok.prefixes.join(',')}`]);
output([`mode=${ok.mode}`, `cases=${ok.cases.join(',')}`, `prefixes=${ok.prefixes.join(',')}`, ...(ok.artifact ? [`record_artifact=${ok.artifact}`] : [])]);
console.log(`- Run mode: ${ok.mode}${ok.prefixes.length ? ` (${ok.cases.length} case(s) on ${ok.prefixes.length} prefix(es))` : ''}`);
} else if (command === 'bisect-plan') {
const [cases, prefixes, nonce, out] = [list(flag('cases') ?? ''), list(flag('prefixes') ?? ''), flag('nonce'), flag('out')];
+433 -23
View File
@@ -102,7 +102,34 @@
# reads .gstack/ubicloud.json (setup, command, size, location, env, pass, pull)
# and runs it through `run`, so lanes share one committed definition instead of
# per-lane wrappers.
#
# Surviving a dead controller (GST-1220). With UBI_RECOVERY_DIR set (on Capy,
# GSTACK_CAPY_RECOVERY_DIR names a drive folder), `up` writes an encrypted
# recovery bundle of each VM's local state (ssh key, env, tags) to
# <dir>/<owner>/<vm>.bundle, and `recover [--owner OLD]` on another machine
# rebuilds that state so pull, ssh and down work there. The bundle is AES-256
# (openssl enc, pbkdf2) under a key derived from UBI_RECOVERY_KEY or else
# UBICLOUD_API_KEY, with an HMAC-SHA256 checked before anything is decrypted;
# the secret travels only on a pipe, never in argv, a child's environment or a
# file. A counted run (--counted or UBI_COUNTED=1) refuses before any create
# without a bundle (UBI_RECOVERY_REQUIRED, UBI_RECOVERY_UNAVAILABLE). Run
# recover only once the creating machine is dead: a paused one is not detected.
# `up --sink https-put:<presigned URL> --sink-path PATH` starts a pusher on the
# VM that uploads heartbeat.json and the PATH entries as results.tgz every
# UBI_SINK_INTERVAL seconds, so results land even when every controller dies;
# the URL reaches the VM only in a 600 curl config file. The sink is off unless
# --sink or UBI_SINK names one, and `down` makes one last push first
# (SINK_FLUSH_FAILED turns a passing run into exit 1).
set -euo pipefail
# The caller's exported names and values, captured before the runner sets any
# variable of its own: --pass forwards from here, never from a live variable.
INHERITED_NAMES=() INHERITED_VALUES=()
for _ubi_n in $(compgen -e); do
[ -n "${!_ubi_n+x}" ] || continue
INHERITED_NAMES+=("$_ubi_n") INHERITED_VALUES+=("${!_ubi_n}")
done
unset _ubi_n
INHERITED_ENV=" ${INHERITED_NAMES[*]-} "
# Keep heredoc bodies on temp files, not the pipe window (test/heredoc-pipe-deadlock.test.ts).
BASH_COMPAT=50
@@ -142,6 +169,15 @@ for bin in curl python3 ssh ssh-keygen tar; do
command -v "$bin" >/dev/null || die "$bin is required"
done
UBICLOUD_API_KEY="${UBICLOUD_API_KEY:-${UBICLOUD_API_TOKEN:-}}"
RECOVERY_DIR="${UBI_RECOVERY_DIR:-${GSTACK_CAPY_RECOVERY_DIR:-}}"
RECOVERY_SECRET="${UBI_RECOVERY_KEY:-$UBICLOUD_API_KEY}"
unset UBI_RECOVERY_KEY
COUNTED="${UBI_COUNTED:-0}"
SINK_INTERVAL="${UBI_SINK_INTERVAL:-300}"
SINK_MAX_MB="${UBI_SINK_MAX_MB:-512}"
SINK_FLUSH_FAILED=""
[[ "$SINK_INTERVAL" =~ ^[1-9][0-9]*$ ]] || die "UBI_SINK_INTERVAL must be a whole number of seconds, at least 1"
[[ "$SINK_MAX_MB" =~ ^[1-9][0-9]*$ ]] || die "UBI_SINK_MAX_MB must be a whole number of megabytes, at least 1"
[[ "$GC_HOURS" =~ ^[0-9]+$ ]] || die "UBI_GC_HOURS must be a whole number of hours (unset or 0 disables the sweep)"
for v in IDLE_TIMEOUT RUN_TIMEOUT HEARTBEAT QUOTA_WAIT CHECKPOINT_MAX_AGE REATTACH_MARGIN; do
[[ "${!v}" =~ ^[0-9]+$ ]] || die "UBI_$v must be a whole number of seconds (0 disables it)"
@@ -854,10 +890,10 @@ write_state() {
"$1" "$2" "$3" "$4" "$5" "$6" "$$" >"$(state_dir "$1")/env"
}
# record_vm NAME LOCATION: append to the scratch VM-id file (record-before-create).
# record_vm NAME LOCATION [OWNER]: append to the scratch VM-id file (record-before-create).
record_vm() {
mkdir -p "$(dirname "$VM_RECORD")" 2>/dev/null || return 0
printf '%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$OWNER" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$$" >>"$VM_RECORD"
printf '%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "${3:-$OWNER}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$$" >>"$VM_RECORD"
}
# forget_vm NAME: drop the VM from the scratch file once it is confirmed gone.
@@ -875,6 +911,285 @@ cmd_records() {
awk -F'\t' '{ printf "%-40s %-14s %-12s %-20s %s\n", $1, $2, $3, $4, $5 }' "$VM_RECORD"
}
# vm_gone_final SEEN CREATED CREATE_AT CREATE_PID: a "not found" for the VM is
# final: it was seen, its create was answered, or the create was sent more than
# UBI_CREATE_GRACE seconds ago by a process that is gone (or is this one).
vm_gone_final() {
[ "$1" = 1 ] || [ "$2" != unknown ] || { [ $(( $(date +%s) - $3 )) -ge "$CREATE_GRACE" ] \
&& { [ "$4" = "$$" ] || ! kill -0 "$4" 2>/dev/null; }; }
}
# Recovery bundles. RB_PY reads the secret on stdin and stretches it with
# PBKDF2-SHA256 (200000 rounds, a random salt per bundle) into an encryption key
# and a MAC key; the encryption key reaches openssl on a pipe fd (-pass fd:N).
# A bundle is UBIRB1, the salt, the ciphertext of a JSON map of the state files
# with the VM name and owner, and an HMAC over all of it, checked with
# compare_digest before anything is decrypted. open NAME OWNER exits 3 on any
# failure, a sealed name or owner other than NAME and OWNER included.
RB_PY='
import base64, hashlib, hmac, json, os, subprocess, sys
MAGIC = b"UBIRB1\n"
FILES = ("key", "key.pub", "env", "tags", "sink")
secret = sys.stdin.buffer.read()
if not secret:
sys.exit(2)
def keys(salt):
k = hashlib.pbkdf2_hmac("sha256", secret, b"ubi-runner recovery bundle v1" + salt, 200000, 64)
return k[:32].hex().encode(), k[32:]
def ossl(mode, data, enc_key):
r, w = os.pipe()
os.write(w, enc_key + b"\n")
os.close(w)
try:
p = subprocess.run(["openssl", "enc", mode, "-aes-256-cbc", "-pbkdf2", "-iter", "1000", "-salt", "-pass", "fd:%d" % r],
input=data, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, pass_fds=(r,))
finally:
os.close(r)
return p.returncode, p.stdout
if sys.argv[1] == "seal":
src, out, name, owner = sys.argv[2:6]
salt = os.urandom(16)
enc_key, mac_key = keys(salt)
files = {}
for n in FILES:
if os.path.isfile(os.path.join(src, n)):
with open(os.path.join(src, n), "rb") as f:
files[n] = base64.b64encode(f.read()).decode()
rc, ct = ossl("-e", json.dumps({"v": 1, "name": name, "owner": owner, "files": files}).encode(), enc_key)
if rc != 0 or not ct:
sys.exit(1)
body = MAGIC + salt + ct
os.makedirs(os.path.dirname(out), mode=0o700, exist_ok=True)
tmp = "%s.tmp.%d" % (out, os.getpid())
with os.fdopen(os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "wb") as f:
f.write(body + hmac.new(mac_key, body, hashlib.sha256).digest())
os.rename(tmp, out)
else:
bundle, dest, name, owner = sys.argv[2:6]
with open(bundle, "rb") as f:
data = f.read()
body, mac = data[:-32], data[-32:]
if len(data) < len(MAGIC) + 16 + 33 or not body.startswith(MAGIC):
sys.exit(3)
enc_key, mac_key = keys(body[len(MAGIC):len(MAGIC) + 16])
if not hmac.compare_digest(mac, hmac.new(mac_key, body, hashlib.sha256).digest()):
sys.exit(3)
rc, plain = ossl("-d", body[len(MAGIC) + 16:], enc_key)
try:
sealed = json.loads(plain) if rc == 0 else {}
except ValueError:
sealed = {}
files = sealed.get("files") if isinstance(sealed, dict) else None
if sealed.get("name") != name or sealed.get("owner") != owner or not isinstance(files, dict) \
or "env" not in files or "key" not in files or any(n not in FILES for n in files):
sys.exit(3)
for n, b in files.items():
with os.fdopen(os.open(os.path.join(dest, n), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "wb") as f:
f.write(base64.b64decode(b))
'
# bundle_save NAME: (re)write NAME's recovery bundle when UBI_RECOVERY_DIR is
# set. A counted run stops on a failed write; any other run warns and goes on.
bundle_save() {
[ -n "$RECOVERY_DIR" ] || return 0
printf %s "$RECOVERY_SECRET" | python3 -c "$RB_PY" seal "$(state_dir "$1")" "$RECOVERY_DIR/$OWNER/$1.bundle" "$1" "$OWNER" && return 0
[ "$COUNTED" != 1 ] || die "UBI_RECOVERY_UNAVAILABLE: could not write the recovery bundle for $1 under $RECOVERY_DIR (needs openssl and a writable directory); a counted run needs it"
log "WARNING: could not write the recovery bundle for $1 under $RECOVERY_DIR (UBI_RECOVERY_UNAVAILABLE); continuing without one"
}
# recovery_preflight: a counted run needs a bundle another controller can open.
recovery_preflight() {
[ -n "$RECOVERY_DIR" ] || die "UBI_RECOVERY_REQUIRED: a counted run (--counted or UBI_COUNTED=1) needs a recovery bundle; set UBI_RECOVERY_DIR to a directory another controller can read (on Capy, a drive folder), then rerun"
command -v openssl >/dev/null && [ -n "$RECOVERY_SECRET" ] \
|| die "UBI_RECOVERY_UNAVAILABLE: a counted run needs openssl and a secret (UBI_RECOVERY_KEY or UBICLOUD_API_KEY) to write its recovery bundle"
}
# recover_failed NAME CODE FIX: one RECOVER_FAILED line; the bundle is kept.
recover_failed() { echo "RECOVER_FAILED: vm=$1 code=$2 fix=$3"; RECOVER_FAILS=$(( RECOVER_FAILS + 1 )); }
# recover [--owner OLD] [--dir DIR]: rebuild local state for every VM with a
# bundle under DIR/OLD (default UBI_RECOVERY_DIR and this owner). A live VM
# prints RECOVERED, a VM confirmed gone drops its bundle (RECOVER_GONE), and
# anything else prints RECOVER_FAILED and keeps the bundle. State already on
# this machine is never overwritten.
RECOVER_FAILS=0
cmd_recover() {
local from=$OWNER dir=$RECOVERY_DIR b name dest tmp code out ip tags
while [ $# -gt 0 ]; do
case $1 in
--owner) from=$2; shift 2 ;;
--dir) dir=$2; shift 2 ;;
*) die "recover: unknown option $1" ;;
esac
done
[[ "$from" =~ ^[a-z][a-z0-9]{0,11}$ ]] || die "recover: --owner must be an owner tag as the owner command prints it"
[ -n "$dir" ] || die "UBI_RECOVERY_REQUIRED: recover reads bundles from UBI_RECOVERY_DIR (or --dir DIR), and neither is set"
command -v openssl >/dev/null || die "UBI_RECOVERY_UNAVAILABLE: openssl is required to open recovery bundles"
mkdir -p "$STATE_ROOT"
out=$(mktemp)
for b in "$dir/$from"/*.bundle; do
[ -f "$b" ] || { log "recover: no bundles under $dir/$from"; continue; }
name=$(basename "$b" .bundle)
valid_name "$name" || continue
dest=$(state_dir "$name")
if [ -e "$dest" ]; then
recover_failed "$name" RECOVER_EXISTS "this machine already has state for $name; use it, or run down $name"
continue
fi
tmp=$(mktemp -d "$STATE_ROOT/.recover.XXXXXX")
local NAME="" LOCATION="" SIZE="" IP="" CREATED="" CREATE_AT=0 CREATE_PID="" line
if printf %s "$RECOVERY_SECRET" | python3 -c "$RB_PY" open "$b" "$tmp" "$name" "$from"; then
while IFS= read -r line; do
[[ "$line" =~ ^(NAME|LOCATION|SIZE|IP|CREATED|CREATE_AT|CREATE_PID)=([A-Za-z0-9._:/-]*)$ ]] || continue
printf -v "${BASH_REMATCH[1]}" %s "${BASH_REMATCH[2]}"
done <"$tmp/env"
fi
if [ "$NAME" != "$name" ] || ! [[ "$CREATE_AT" =~ ^[0-9]+$ && "$LOCATION" =~ ^[a-z0-9-]+$ ]]; then
rm -rf "$tmp"
recover_failed "$name" RECOVER_DECRYPT_FAILED "the bundle does not authenticate under this secret, or it was sealed for another VM or owner; set UBI_RECOVERY_KEY (or UBICLOUD_API_KEY) to the creating controller's"
continue
fi
code=$(api "$out" vm "$LOCATION/$name" show)
case $code in
200)
ip=$(sed -n 's/^ip4: //p' "$out")
printf 'NAME=%q\nLOCATION=%q\nSIZE=%q\nIP=%q\nCREATED=yes\nCREATE_AT=%q\nCREATE_PID=\nADOPTED_BY=%q\n' \
"$name" "$LOCATION" "$SIZE" "${ip:-$IP}" "$CREATE_AT" "$OWNER" >"$tmp/env"
if [ -e "$dest" ]; then
rm -rf "$tmp"
recover_failed "$name" RECOVER_EXISTS "state for $name appeared on this machine while recovering; use it"
continue
fi
mv "$tmp" "$dest"
record_vm "$name" "$LOCATION" "$from"
tags=$(grep -E '^[A-Za-z0-9_.-]+=[A-Za-z0-9_.:/@+-]*$' "$dest/tags" 2>/dev/null | paste -s -d, - || true)
echo "RECOVERED: vm=$name ip=${ip:-$IP} owner=$from tags=$tags" ;;
404)
rm -rf "$tmp"
if vm_gone_final 0 "$CREATED" "$CREATE_AT" ""; then
rm -f "$b"
echo "RECOVER_GONE: vm=$name"
else
recover_failed "$name" RECOVER_API_UNAVAILABLE "its create may still be in flight; rerun recover after UBI_CREATE_GRACE (${CREATE_GRACE}s)"
fi ;;
*)
rm -rf "$tmp"
recover_failed "$name" RECOVER_API_UNAVAILABLE "the Ubicloud API answered HTTP $code; nothing changed, rerun recover" ;;
esac
done
rm -f "$out"
[ "$RECOVER_FAILS" -eq 0 ]
}
# sink_check SPEC: https-put:<https URL> is the only network sink; file:<dir>
# only under UBI_SINK_ALLOW_FILE=1 (tests). The URL is never printed.
sink_check() {
case $1 in
https-put:https://*) [[ "${1#https-put:}" =~ ^https://[^[:space:]]+$ ]] && return 0 ;;
file:/*) [ "${UBI_SINK_ALLOW_FILE:-}" = 1 ] && return 0 ;;
esac
die "UBI_SINK_INVALID: a sink is https-put:<presigned https URL> (file:<absolute dir> only with UBI_SINK_ALLOW_FILE=1); got a '${1%%:*}:' spec. Nothing was created"
}
# SINK_SH: the VM-side pusher. Every SINK_INTERVAL seconds it packs
# heartbeat.json and the SINK_PATHS entries under ~ (never ~/.ubirun-*, which
# hold --pass values and the URL) into results.tgz and pushes it when they
# changed, or every fifth interval regardless. A snapshot tar cannot read, or
# one over UBI_SINK_MAX_MB, is skipped, so the last good one stays in the sink.
# A failed or skipped push backs off and retries; it never ends the loop.
# --once pushes one time and exits 1 with "seq= last_push= [sink_skipped=]".
SINK_SH='#!/usr/bin/env bash
. ~/.ubirun-sink.conf || exit 1
once=0
[ "${1:-}" != --once ] || once=1
[ "$once" = 1 ] || echo $$ > ~/.ubirun-sink.pid
seq=$(cat ~/.ubirun-sink.seq 2>/dev/null || echo 0)
last=$(cat ~/.ubirun-sink.last 2>/dev/null || echo never)
present() { P=(); local p; for p in ${SINK_PATHS[@]+"${SINK_PATHS[@]}"}; do [ ! -e "$HOME/$p" ] || P+=("$p"); done; }
digest() { present; [ ${#P[@]} -gt 0 ] || { echo none; return 0; }; tar -cf - --exclude=".ubirun-*" -C "$HOME" "${P[@]}" 2>/dev/null | cksum; }
beat() { printf "{\"vm\": \"%s\", \"owner\": \"%s\", \"seq\": %d, \"time\": \"%s\", \"last_push\": \"%s\", \"sink_skipped\": \"%s\"}\n" \
"$SINK_VM" "$SINK_OWNER" "$seq" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$last" "$2" > "$1/heartbeat.json"; }
push() {
local d rc=0
skipped=""
d=$(mktemp -d "$HOME/.ubirun-sink.XXXXXX") || return 1
seq=$((seq + 1)); echo "$seq" > ~/.ubirun-sink.seq
present
beat "$d" ""
if [ ${#P[@]} -gt 0 ]; then
tar -czf "$d/results.tgz" --exclude=".ubirun-*" -C "$d" heartbeat.json -C "$HOME" "${P[@]}" 2>/dev/null || rc=$?
[ "$rc" -le 1 ] || skipped=tar_failed
[ -n "$skipped" ] || [ "$(wc -c < "$d/results.tgz")" -le $((SINK_MAX_MB * 1048576)) ] || skipped=size
else
tar -czf "$d/results.tgz" -C "$d" heartbeat.json
fi
if [ -n "$skipped" ]; then rm -rf "$d"; return 2; fi
rc=0
case $SINK_KIND in
file) { mkdir -p "$SINK_DIR/$SINK_VM" && cp "$d/results.tgz" "$SINK_DIR/$SINK_VM/.results.tgz.$$" \
&& mv -f "$SINK_DIR/$SINK_VM/.results.tgz.$$" "$SINK_DIR/$SINK_VM/results.tgz"; } 2>/dev/null || rc=1 ;;
https-put) curl -sS --fail --max-time 120 -K "$HOME/.ubirun-sink.curl" -T "$d/results.tgz" >/dev/null 2>&1 || rc=1 ;;
*) rc=1 ;;
esac
rm -rf "$d"
[ "$rc" -ne 0 ] || { last=$(date +%s); echo "$last" > ~/.ubirun-sink.last; }
return "$rc"
}
if [ "$once" = 1 ]; then push && exit 0; echo "seq=$seq last_push=$last${skipped:+ sink_skipped=$skipped}"; exit 1; fi
h="" beats=0 backoff=0
while :; do
n=$(digest)
if [ "$n" != "$h" ] || [ "$beats" -ge 5 ]; then
if push; then h=$n; beats=0; backoff=0
else backoff=$(( backoff ? backoff * 2 : SINK_INTERVAL )); [ "$backoff" -le 900 ] || backoff=900; fi
else
beats=$((beats + 1))
fi
sleep $(( SINK_INTERVAL + backoff ))
done
'
# SINK_STOP: end the running pusher (its process group, so an upload in
# flight dies with it) and wait for it, so no older snapshot lands later.
SINK_STOP='p=$(cat ~/.ubirun-sink.pid 2>/dev/null); if [ -n "$p" ]; then kill -TERM -- "-$p" 2>/dev/null || kill -TERM "$p" 2>/dev/null; for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do kill -0 "$p" 2>/dev/null || break; sleep 0.5; done; kill -KILL -- "-$p" 2>/dev/null; rm -f ~/.ubirun-sink.pid; fi;'
# sink_start NAME SPEC PATH...: write the pusher's config (600; the URL only in
# ~/.ubirun-sink.curl, over ssh stdin), upload it and start it outside any ssh
# session, and mark the state dir so down flushes it.
sink_start() {
local name=$1 spec=$2 kind interval=$SINK_INTERVAL url sdir=""
shift 2
kind=${spec%%:*}
[ "$kind" != file ] || sdir=${spec#file:}
[ "$kind" = file ] || [ "$interval" -ge 60 ] || interval=60
{ printf 'SINK_KIND=%q\nSINK_DIR=%q\nSINK_VM=%q\nSINK_OWNER=%q\nSINK_INTERVAL=%q\nSINK_MAX_MB=%q\n' \
"$kind" "$sdir" "$name" "$OWNER" "$interval" "$SINK_MAX_MB"
printf 'SINK_PATHS=(%s)\n' "$(qwords "$@")"; } | remote "$name" 'umask 077 && cat > ~/.ubirun-sink.conf && chmod 600 ~/.ubirun-sink.conf'
if [ "$kind" = https-put ]; then
url=${spec#https-put:}; url=${url//\\/\\\\}; url=${url//\"/\\\"}
printf 'url = "%s"\n' "$url" | remote "$name" 'umask 077 && cat > ~/.ubirun-sink.curl && chmod 600 ~/.ubirun-sink.curl'
fi
printf %s "$SINK_SH" | remote "$name" 'umask 077 && cat > ~/.ubirun-sink.sh'
remote "$name" "$SINK_STOP"' if command -v setsid >/dev/null 2>&1; then setsid -f bash ~/.ubirun-sink.sh </dev/null >>~/.ubirun-sink.log 2>&1; else nohup bash ~/.ubirun-sink.sh </dev/null >>~/.ubirun-sink.log 2>&1 & fi' </dev/null >/dev/null
: >"$(state_dir "$name")/sink"
log "sink: pushing $# path(s) to a $kind sink every ${interval}s (pid in ~/.ubirun-sink.pid on $name)"
}
# sink_flush NAME: stop the pusher, then one last push before down destroys
# NAME (150 s cap, above curl's 120). A failure prints SINK_FLUSH_FAILED and
# adds NAME to SINK_FLUSH_FAILED, which turns a passing run or verify of that
# VM into exit 1; the VM is destroyed either way.
sink_flush() {
local out
if out=$(remote "$1" "$SINK_STOP"' timeout 150 bash ~/.ubirun-sink.sh --once' </dev/null 2>/dev/null); then
log "sink: final push done for $1"
return 0
fi
SINK_FLUSH_FAILED="$SINK_FLUSH_FAILED $1 "
out=$(tail -n 1 <<<"$out")
echo "SINK_FLUSH_FAILED: vm=$1 ${out:-seq=unknown last_push=unknown} (results in the sink may be incomplete)" >&2
}
new_name() {
echo "$PREFIX-$OWNER-$(date +%s)-$(head -c4 /dev/urandom | od -An -tx1 | tr -d ' \n')-t$TTL_HOURS"
}
@@ -943,7 +1258,7 @@ UP_NAME=""
UP_READY=""
cmd_up() {
require_owner
local size=$DEFAULT_SIZE location=$DEFAULT_LOCATION name="" storage="" image=ubuntu-noble
local size=$DEFAULT_SIZE location=$DEFAULT_LOCATION name="" storage="" image=ubuntu-noble sink=${UBI_SINK:-} tags=() sink_paths=()
while [ $# -gt 0 ]; do
case $1 in
-s|--size) size=$2; shift 2 ;;
@@ -951,11 +1266,17 @@ cmd_up() {
-n|--name) name=$2; shift 2 ;;
-S|--storage) check_storage "$2"; storage=$2; shift 2 ;;
-b|--image) image=$2; shift 2 ;;
--tag) [[ "$2" =~ ^[A-Za-z0-9_.-]+=[A-Za-z0-9_.:/@+-]*$ ]] || die "up: --tag $2: expected key=value (letters, digits and _.:/@+- only)"; tags+=("$2"); shift 2 ;;
--sink) sink=$2; shift 2 ;;
--sink-path) [[ "$2" != /* && "/$2/" != */../* && -n "$2" ]] || die "up: --sink-path $2: must be a path under ~ on the VM"; sink_paths+=("$2"); shift 2 ;;
--counted) COUNTED=1; shift ;;
*) die "up: unknown option $1" ;;
esac
done
[ -n "$name" ] || name=$(new_name)
valid_name "$name" || die "invalid VM name '$name' (Ubicloud allows 1-63 of a-z, 0-9 and '-', starting and ending with a letter or digit)"
[ "$COUNTED" != 1 ] || recovery_preflight
[ -z "$sink" ] || sink_check "$sink"
if [ "$REAP" != 0 ]; then
cmd_reap || log "reap failed; continuing"
fi
@@ -969,12 +1290,14 @@ cmd_up() {
mkdir -p "$dir"
chmod 700 "$dir"
ssh-keygen -q -t ed25519 -N '' -C "$name" -f "$dir/key"
[ ${#tags[@]} -eq 0 ] || printf '%s\n' "${tags[@]}" >"$dir/tags"
local args=(vm "$location/$name" create -s "$size" -b "$image")
[ -z "$storage" ] || args+=(-S "$storage")
args+=("$(cat "$dir/key.pub")")
create_at=$(date +%s)
write_state "$name" "$location" "$size" "" unknown "$create_at"
bundle_save "$name"
record_vm "$name" "$location"
UP_NAME=$name
log "creating $location/$name ($size)"
@@ -984,6 +1307,7 @@ cmd_up() {
trap 'interrupted=1' INT TERM HUP QUIT
cli "${args[@]}" >/dev/null 2>"$dir/create.err" || created=no
write_state "$name" "$location" "$size" "" "$created" "$create_at"
[ "$created" = no ] || bundle_save "$name"
trap 'exit 130' INT TERM HUP QUIT
[ -z "$interrupted" ] || exit 130
if [ "$created" = no ]; then
@@ -1010,6 +1334,7 @@ cmd_up() {
sleep "$POLL"
done
write_state "$name" "$location" "$size" "$ip" yes "$create_at"
bundle_save "$name"
load "$name"
deadline=$(( $(date +%s) + 300 ))
@@ -1019,6 +1344,10 @@ cmd_up() {
sleep "$POLL"
done
remote "$name" 'cloud-init status --wait >/dev/null 2>&1 || true'
if [ -n "$sink" ]; then
sink_start "$name" "$sink" ${sink_paths[@]+"${sink_paths[@]}"}
bundle_save "$name"
fi
UP_READY=1
log "ready: $name ($IP)"
echo "$name"
@@ -1044,6 +1373,7 @@ cmd_down() {
if [ -f "$dir/env" ]; then
load "$name"
loc=$LOCATION
[ ! -f "$dir/sink" ] || [ -z "$IP" ] || sink_flush "$name"
if [ -S "$dir/ctl" ] && [ -n "$IP" ]; then
# shellcheck disable=SC2046
ssh $(ssh_opts) -O exit "ubi@$IP" >/dev/null 2>&1 || true
@@ -1075,11 +1405,11 @@ cmd_down() {
log "destroy requested: $loc/$name"
fi
elif [ "$code" = 404 ]; then
if [ "$seen" = 1 ] || [ "$CREATED" != unknown ] || { [ $(( now - CREATE_AT )) -ge "$CREATE_GRACE" ] \
&& { [ "$CREATE_PID" = "$$" ] || ! kill -0 "$CREATE_PID" 2>/dev/null; }; }; then
if vm_gone_final "$seen" "$CREATED" "$CREATE_AT" "$CREATE_PID"; then
rm -f "$out"
rm -rf "$dir"
forget_vm "$name"
[ -z "$RECOVERY_DIR" ] || rm -f "$RECOVERY_DIR"/*/"$name.bundle"
if [ "$seen" = 1 ]; then log "destroyed ${loc:-?}/$name"; else log "gone: ${loc:-?}/$name (never existed or already destroyed)"; fi
return 0
fi
@@ -1112,11 +1442,31 @@ cmd_down() {
# options at the first operand and resolves -T names after a -C, so no single
# tar command line works on both it and GNU tar (#3680). COPYFILE_DISABLE=1
# keeps Apple's tar from adding ._* AppleDouble entries for files with
# extended attributes, which the VM would see as untracked files.
# extended attributes, which the VM would see as untracked files. A
# subdirectory of a work tree ships the same file set without a .git: git's
# local transport fetches only from a repository's root, and the whole
# repository's .git would show the VM every file outside the subdirectory as
# deleted (GST-935). A subdirectory git lists no files under (one the parent
# ignores) is refused rather than shipped empty: cmd_run keys the run on the
# local files and skips the tree check for a subdirectory, so an empty upload
# would pass on content that never ran.
cmd_pack() {
local src tmp gitdir branch head entry
src=$(cd "${1:-.}" && pwd)
if git -C "$src" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
if git -C "$src" rev-parse --is-inside-work-tree >/dev/null 2>&1 && [ -n "$(git -C "$src" rev-parse --show-prefix)" ]; then
[ -n "$(git -C "$src" ls-files -co --exclude-standard | head -n 1)" ] \
|| die "pack: git lists no files under $src (ignored by its checkout, or empty); nothing to ship"
(
cd "$src"
export COPYFILE_DISABLE=1
git ls-files -z -co --exclude-standard \
| while IFS= read -r -d '' f; do
if [ -e "$f" ] || [ -L "$f" ]; then printf '%s\0' "$f"; fi
done \
| tar --null -T - -cf - \
| gzip -c
)
elif git -C "$src" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
head=$(git -C "$src" rev-parse --verify -q HEAD) || die "pack: $src has no commits; the VM needs a HEAD to fingerprint"
branch=$(git -C "$src" symbolic-ref -q --short HEAD || true)
tmp=$(mktemp -d)
@@ -1201,11 +1551,27 @@ cmd_pull() {
remote "$name" "cd $dir && tar -czf - $(basename "$from")" | tar -xzf - -C "$to"
}
# pass_name NAME: a variable this runner may forward to a VM. Its own
# credentials (UBICLOUD_*) never leave this machine, whoever asks.
# pass_name NAME: a variable this runner may forward to a VM: one the caller's
# environment held when the runner started. Its own credentials (UBICLOUD_*),
# its recovery secret and sink URL, and every variable it sets itself
# (RECOVERY_SECRET holds the Ubicloud key by default) never leave this machine.
pass_name() {
[[ "$1" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || die "--pass $1: not a variable name"
case $1 in UBICLOUD_*) die "--pass $1: the runner never forwards its own Ubicloud credentials to a VM" ;; esac
case $1 in RECOVERY_SECRET|UBI_RECOVERY_KEY|UBI_SINK) die "--pass $1: the runner never forwards its recovery secret or sink URL to a VM" ;; esac
case $1 in INHERITED_NAMES|INHERITED_VALUES|INHERITED_ENV|PASS_VALUE|_ubi_n) die "--pass $1: the runner never forwards its own variables" ;; esac
[[ "$INHERITED_ENV" == *" $1 "* ]] || die "--pass $1: not in this shell's environment; the runner forwards only variables you exported, never its own"
}
# inherited NAME: PASS_VALUE becomes NAME's value as the caller exported it.
inherited() {
local i
for i in "${!INHERITED_NAMES[@]}"; do
[ "${INHERITED_NAMES[i]}" = "$1" ] || continue
PASS_VALUE=${INHERITED_VALUES[i]}
return 0
done
die "--pass $1: not in this shell's environment"
}
# inside_dir DIR ROOT: DIR resolves, symlinks followed, to ROOT or below it.
@@ -1244,7 +1610,10 @@ run_teardown() {
left=unknown others=unknown
fi
leftover "left=$left owner=$OWNER vm=$RUN_VM others=$others"
[ "$left" = 0 ] && return 0
if [ "$left" = 0 ]; then
[[ "$SINK_FLUSH_FAILED" != *" $RUN_VM "* ]] || [ "$rc" -ne 0 ] || exit 1
return 0
fi
log "VM cleanup not confirmed for $RUN_VM (left=$left); destroy it with: $0 down $RUN_VM"
[ "$rc" -ne 0 ] || exit 1
}
@@ -1253,16 +1622,21 @@ TIER_NAMES=() TIER_CMDS=() RESUME=1 RERUN_FAILED=0 FORCE_ATTACH="" RUN_ARGS=()
cmd_run() {
require_owner
local up_args=() src=. dest="" setup="" keep=0 pulls=() envs=() passes=() key_inputs=() probe=0
RUN_ARGS=("$@")
# The run record replays RUN_ARGS on attach. Each option's words are copied as
# it is parsed, so a --sink URL stays out of it (pass it as UBI_SINK).
local all=("$@") pos=0 opt left
RUN_ARGS=()
while [ $# -gt 0 ]; do
opt=$1 left=$#
case $1 in
-S|--storage) check_storage "$2"; up_args+=("$1" "$2"); shift 2 ;;
-s|--size|-l|--location|-n|--name|-b|--image) up_args+=("$1" "$2"); shift 2 ;;
-s|--size|-l|--location|-n|--name|-b|--image|--tag|--sink|--sink-path) up_args+=("$1" "$2"); shift 2 ;;
--counted) COUNTED=1; shift ;;
--src) src=$2; shift 2 ;;
--dest) dest=$2; shift 2 ;;
--setup) setup=$2; shift 2 ;;
--env) envs+=("$2"); shift 2 ;;
--pass) pass_name "$2"; [ -n "${!2+x}" ] || die "--pass $2: not set locally"; passes+=("$2"); shift 2 ;;
--pass) pass_name "$2"; passes+=("$2"); shift 2 ;;
--pull) pulls+=("$2"); shift 2 ;;
--keep) keep=1; shift ;;
--key-input) key_inputs+=("$2"); shift 2 ;;
@@ -1273,16 +1647,21 @@ cmd_run() {
--) shift; break ;;
*) die "run: unknown option $1" ;;
esac
[ "$opt" = --sink ] || RUN_ARGS+=("${all[@]:pos:left-$#}")
pos=$(( pos + left - $# ))
done
[ "$pos" -ge ${#all[@]} ] || RUN_ARGS+=("${all[@]:pos}")
[ $# -gt 0 ] || [ ${#TIER_NAMES[@]} -gt 0 ] || die "run: missing command after --"
[ -z "$setup" ] || [ -f "$setup" ] || die "setup script not found: $setup"
command -v git >/dev/null || die "git is required: the run key fingerprints the checkout with it"
src=$(cd "$src" && pwd)
[ -n "$dest" ] || dest="work/$(basename "$src")"
local i name="" size=$DEFAULT_SIZE location=$DEFAULT_LOCATION image=ubuntu-noble storage="" e n
local i name="" size=$DEFAULT_SIZE location=$DEFAULT_LOCATION image=ubuntu-noble storage="" e n sink=${UBI_SINK:-} sink_paths=()
for (( i = 0; i < ${#up_args[@]}; i += 2 )); do
case ${up_args[i]} in
--sink) sink=${up_args[i+1]} ;;
--sink-path) sink_paths+=("${up_args[i+1]}") ;;
-n|--name) name=${up_args[i+1]} ;;
-s|--size) size=${up_args[i+1]} ;;
-l|--location) location=${up_args[i+1]} ;;
@@ -1300,7 +1679,7 @@ cmd_run() {
printf 'command %q\n' "$*"
if [ -n "$setup" ]; then echo "setup $(sha256 <"$setup")"; else echo "setup none"; fi
for e in ${envs[@]+"${envs[@]}"}; do printf 'env %q\n' "$e"; done
for n in ${passes[@]+"${passes[@]}"}; do printf 'pass %s %s\n' "$n" "$(printf %s "${!n}" | sha256)"; done
for n in ${passes[@]+"${passes[@]}"}; do inherited "$n"; printf 'pass %s %s\n' "$n" "$(printf %s "$PASS_VALUE" | sha256)"; done
printf 'vm %q %q %q %q\n' "$size" "$location" "$image" "$storage"
echo "base $(base_sha "$src")"
echo "runner $(sha256 <"${BASH_SOURCE[0]}")"
@@ -1352,6 +1731,16 @@ cmd_run() {
cmd_down "$attach" >/dev/null || log "WARNING: failed to destroy $attach; run: $0 down $attach"
attach=""
fi
if [ -n "$attach" ] && [ "$COUNTED" = 1 ]; then
recovery_preflight
local opened rbt
rbt=$(mktemp -d)
opened=0
printf %s "$RECOVERY_SECRET" | python3 -c "$RB_PY" open "$RECOVERY_DIR/$OWNER/$attach.bundle" "$rbt" "$attach" "$OWNER" 2>/dev/null && opened=1
rm -rf "$rbt"
[ "$opened" = 1 ] \
|| die "UBI_RECOVERY_REQUIRED: a counted run would reattach to $attach, which has no recovery bundle under $RECOVERY_DIR/$OWNER that opens under this secret; destroy it ($0 down $attach) and rerun"
fi
[ -z "$attach" ] || name=$attach
[ -n "$name" ] || { name=$(new_name); up_args+=(-n "$name"); }
RUN_VM=$name
@@ -1363,6 +1752,11 @@ cmd_run() {
if [ -n "$attach" ]; then
load "$name"
log "reattached to $name created_at=$CREATE_AT key=${RUN_KEY:0:12}"
if [ -n "$sink" ]; then
sink_check "$sink"
sink_start "$name" "$sink" ${sink_paths[@]+"${sink_paths[@]}"}
bundle_save "$name"
fi
else
cmd_up "${up_args[@]}" >/dev/null
trap 'exit 130' INT TERM QUIT
@@ -1390,11 +1784,13 @@ cmd_run() {
qdest=$(printf %q "$dest")
if [ ! -f "$dir/synced" ]; then
{ for e in ${envs[@]+"${envs[@]}"}; do printf 'export %s=%q\n' "${e%%=*}" "${e#*=}"; done
for n in ${passes[@]+"${passes[@]}"}; do printf 'export %s=%q\n' "$n" "${!n}"; done; } \
for n in ${passes[@]+"${passes[@]}"}; do inherited "$n"; printf 'export %s=%q\n' "$n" "$PASS_VALUE"; done; } \
| remote "$name" 'umask 077 && cat > ~/.ubirun-env'
cmd_sync "$name" "$src" "$dest"
# A subdirectory of a checkout uploads the whole .git with only its own
# files, so its tree is compared VM to VM (tiers) but not to this side.
# A subdirectory of a checkout ships without a .git, and its local tree
# (a scratch repository, blind to the parent's ignore rules) can count
# files pack leaves out, so it is compared VM to VM (tiers) but not to
# this side.
if [ -z "$(git -C "$src" rev-parse --show-prefix 2>/dev/null)" ]; then
vmt=$(vm_tree "$name" "$qdest") || vmt=unavailable
if [ "$vmt" != "$TREE" ]; then
@@ -1526,8 +1922,8 @@ print("cfg_cmd=" + q(c.get("command", "")))
case $1 in
--) shift; user_cmd=("$@"); break ;;
--trust-config) trust=1; shift ;;
--keep|--resume|--no-resume|--rerun-failed|--probe) user_pre+=("$1"); shift ;;
--pass|-S|--storage|-s|--size|-l|--location|-n|--name|-b|--image|--src|--dest|--setup|--env|--pull)
--keep|--resume|--no-resume|--rerun-failed|--probe|--counted) user_pre+=("$1"); shift ;;
--pass|-S|--storage|-s|--size|-l|--location|-n|--name|-b|--image|--src|--dest|--setup|--env|--pull|--tag|--sink|--sink-path)
[ $# -ge 2 ] || die "verify: $1 needs a value"
[ "$1" != --pass ] || granted+=("$2")
user_pre+=("$1" "$2"); shift 2 ;;
@@ -1569,9 +1965,19 @@ usage: ubi-runner.sh <command> [args]
here (--pass NAME, or all: --trust-config). With tiers, a pass checkpoint
under the same run key is reused (resume is the default)
attach NAME resume the run recorded for NAME on this machine, as a rerun would
up [-s SIZE] [-l LOCATION] [-n NAME] [-S STORAGE_GIB] [-b IMAGE]
up [-s SIZE] [-l LOCATION] [-n NAME] [-S STORAGE_GIB] [-b IMAGE] [--tag K=V]...
[--sink https-put:URL] [--sink-path PATH]... [--counted]
create a VM and wait for SSH; prints its name
(STORAGE_GIB: one of $ALLOWED_STORAGE)
(STORAGE_GIB: one of $ALLOWED_STORAGE). --tag is kept in the VM's
state and recovery bundle; --sink starts a pusher on the VM that
uploads heartbeat.json and the PATH entries under ~ as results.tgz;
--counted (or UBI_COUNTED=1) refuses without a recovery bundle
(UBI_RECOVERY_REQUIRED, UBI_RECOVERY_UNAVAILABLE). run and verify
take the same four options
recover [--owner OLD] [--dir DIR]
rebuild this machine's state for the VMs with a bundle under
DIR/OLD (UBI_RECOVERY_DIR, this owner); prints RECOVERED, RECOVER_GONE
or RECOVER_FAILED per VM. Run it only once the creating machine is dead
ssh NAME [COMMAND] shell or login-shell command on the VM (user ubi, passwordless sudo)
sync NAME [SRC] [DEST] stream a checkout (tracked + untracked-unignored + .git)
pack [SRC] write that checkout tarball to stdout
@@ -1605,7 +2011,10 @@ env: UBI_TTL_HOURS (${TTL_HOURS}h in the name, 1-999), UBI_REAP (0: up skips the
UBI_GC_HOURS (unset/0: no sweep on up), UBI_CREATE_GRACE (${CREATE_GRACE}s),
UBI_DOWN_TIMEOUT (${DOWN_TIMEOUT}s), UBI_API_TIMEOUT (${API_TIMEOUT}s), UBI_VM_RECORD (scratch VM-id file),
UBI_ALLOWED_STORAGE (the -S sizes Ubicloud accepts; default "320 640"),
UBI_CHECKPOINT_DIR (tier checkpoints; $CHECKPOINT_DIR), UBI_CHECKPOINT_MAX_AGE (${CHECKPOINT_MAX_AGE}s a pass is reused)
UBI_CHECKPOINT_DIR (tier checkpoints; $CHECKPOINT_DIR), UBI_CHECKPOINT_MAX_AGE (${CHECKPOINT_MAX_AGE}s a pass is reused),
UBI_RECOVERY_DIR (recovery bundles; GSTACK_CAPY_RECOVERY_DIR on Capy), UBI_RECOVERY_KEY (bundle secret;
else UBICLOUD_API_KEY), UBI_COUNTED, UBI_SINK (a sink spec), UBI_SINK_INTERVAL (${SINK_INTERVAL}s, at least 60
for https-put), UBI_SINK_MAX_MB (${SINK_MAX_MB}: a bigger results.tgz is skipped, sink_skipped=size)
EOF
}
@@ -1630,6 +2039,7 @@ case $cmd in
unpack) cmd_unpack "$@" ;;
pull) cmd_pull "$@" ;;
down) cmd_down "$@" ;;
recover) cmd_recover "$@" ;;
list) cmd_list "$@" ;;
usage) cmd_usage ;;
owner) echo "$OWNER" ;;
+1 -1
View File
@@ -60,7 +60,7 @@ Install check, this file.
## 2. Preflight, tests, evals and reads: Steps 0.9, 1, 2, 3, 4, 5, 6, 6.5, 10
`gstack-ship-budget start --record <rec> --mode observe [--token-cap <n>] --reading R --credits C`, then
`gstack-ship-preflight --base <base> --lane <label>=<command>...` (repo test commands). `CARRY: carried` ends the run on its `GSTACK_RESULT:`
`gstack-ship-preflight --base <base> --run <run dir name> --lane <label>=<command>...` (repo test commands). `CARRY: carried` ends the run on its `GSTACK_RESULT:`
line. `ship_lanes_from`: push early (`sections/tests.md#ci-carry`). A lane passes on `tests_ran: N` with exit 0; `0` is ZERO-RUN (failed); `unknown`
is `count unavailable`. In-branch failures stop; after a repair rerun `--tests-only`.
`requires-remote` needs `gstack-pregate clear` before Step 18. When prompts, templates, judges or eval
+1 -1
View File
@@ -58,7 +58,7 @@ Install check, this file.
## 2. Preflight, tests, evals and reads: Steps 0.9, 1, 2, 3, 4, 5, 6, 6.5, 10
`gstack-ship-budget start --record <rec> --mode observe [--token-cap <n>] --reading R --credits C`, then
`gstack-ship-preflight --base <base> --lane <label>=<command>...` (repo test commands). `CARRY: carried` ends the run on its `GSTACK_RESULT:`
`gstack-ship-preflight --base <base> --run <run dir name> --lane <label>=<command>...` (repo test commands). `CARRY: carried` ends the run on its `GSTACK_RESULT:`
line. `ship_lanes_from`: push early (`sections/tests.md#ci-carry`). A lane passes on `tests_ran: N` with exit 0; `0` is ZERO-RUN (failed); `unknown`
is `count unavailable`. In-branch failures stop; after a repair rerun `--tests-only`.
`requires-remote` needs `gstack-pregate clear` before Step 18. When prompts, templates, judges or eval
+2 -2
View File
@@ -324,8 +324,8 @@ repository-landing asks, including on Apple repos.
## Step 1: Pre-flight
Run `~/.claude/skills/gstack/bin/gstack-ship-preflight --base <base>`: Steps 1–3's mechanics in one call;
act on every `STOP:` and `NEXT:` line.
Run `~/.claude/skills/gstack/bin/gstack-ship-preflight --base <base> --run <SESSION_ID>`: Steps 1–3 at once;
act on each `STOP:` and `NEXT:` line.
1. It saves the current branch as `<branch-name>`. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
2. Uncommitted changes are always included; do not ask (`STATUS:`).
+2 -2
View File
@@ -130,8 +130,8 @@ repository-landing asks, including on Apple repos.
## Step 1: Pre-flight
Run `~/.claude/skills/gstack/bin/gstack-ship-preflight --base <base>`: Steps 1–3's mechanics in one call;
act on every `STOP:` and `NEXT:` line.
Run `~/.claude/skills/gstack/bin/gstack-ship-preflight --base <base> --run <SESSION_ID>`: Steps 1–3 at once;
act on each `STOP:` and `NEXT:` line.
1. It saves the current branch as `<branch-name>`. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
2. Uncommitted changes are always included; do not ask (`STATUS:`).
+45
View File
@@ -867,6 +867,51 @@ describe.skipIf(!HERMETIC)('aside-render: renderWithBrowse — daemon CLI contra
expect(r.evals[0]).toBe('Fake Title');
});
// Value: protects=a lazy Capy install (Chromium skipped until first use) renders on the first call: Playwright's missing-browser launch
// error runs the checkout's gstack-browser-ensure once and retries newtab; fails_when=the render stops on "browse newtab failed: [browse]
// Starting server..." with the cause hidden; why_new=GST-1204 (gbrain-evals#317: gstack-pr-shots on a Capy machine); seam=bin/gstack-browser-ensure
const MISSING = "echo '[browse] Starting server...' >&2; echo '\u2554\u2550 Looks like Playwright was just installed or updated. \u2557' >&2; echo \"launch: Executable doesn't exist at /x/chrome-headless-shell\" >&2; exit 1";
const lazyCheckout = (ensureBody: string, installedNewtab = ''): { browse: string; ensureLog: string; marker: string } => {
const root = fs.mkdtempSync(path.join(tmp, 'lazy-'));
const dist = path.join(root, 'browse', 'dist');
fs.mkdirSync(dist, { recursive: true });
fs.mkdirSync(path.join(root, 'bin'));
const ensureLog = path.join(root, 'ensure.log');
const marker = path.join(root, 'chromium');
writeExecutable(path.join(root, 'bin', 'gstack-browser-ensure'), 'printf "%s\\n" "$*" >> ' + JSON.stringify(ensureLog) + '\n' + ensureBody.replace('MARKER', JSON.stringify(marker)) + '\n');
fs.rmSync(log, { force: true });
const browse = writeFakeBrowse(dist, log, { newtab: 'if [ ! -f ' + JSON.stringify(marker) + ' ]; then ' + MISSING + '; fi; ' + installedNewtab + ' echo \'{"tabId":7}\'' });
return { browse, ensureLog, marker };
};
test('a missing Playwright Chromium runs gstack-browser-ensure once, then the render proceeds', async () => {
const { browse, ensureLog } = lazyCheckout(": > MARKER; echo 'BROWSER_OK playwright 140.0'");
const r = await renderWithBrowse({ file: doc, steps: [{ kind: 'eval', expression: 'document.title' }] }, browse);
expectOk(r);
expect(readLines(ensureLog)).toEqual(['--quiet']);
expect(readLines(log).filter((l) => l === 'newtab --json').length).toBe(2);
expect(r.stdout).toContain('newtab: Chromium is not installed');
});
// Value: protects=a Chromium download longer than the render budget leaves the retried newtab its own time; fails_when=the retry runs on
// the 1 s floor and the first render on a fresh machine times out; why_new=GST-1204 review (outside voice + adversarial P2); seam=deadline
test('the install time does not count against the render deadline', async () => {
const { browse, ensureLog } = lazyCheckout("sleep 3; : > MARKER", 'sleep 1.5;');
const r = await renderWithBrowse({ file: doc, timeoutMs: 2_500, steps: [{ kind: 'eval', expression: 'document.title' }] }, browse);
expectOk(r);
expect(readLines(ensureLog)).toEqual(['--quiet']);
}, 20_000);
test('when gstack-browser-ensure cannot install Chromium, the error names the launch failure, not the daemon banner', async () => {
const { browse, ensureLog } = lazyCheckout("echo 'BROWSER_UNAVAILABLE offline'; exit 1");
const r = await renderWithBrowse({ file: doc, steps: [] }, browse);
expect(r.ok).toBe(false);
expect(r.error).toContain("browse newtab failed: launch: Executable doesn't exist");
expect(r.error).toContain('BROWSER_UNAVAILABLE offline');
expect(readLines(ensureLog).length).toBe(1);
expect(readLines(log).filter((l) => l === 'newtab --json').length).toBe(1);
});
test('any other newtab failure is not retried', async () => {
const r = await renderWithBrowse({ file: doc, steps: [] }, fake({ newtab: `echo 'browser launch failed: no display' >&2; exit 1` }));
expect(r.ok).toBe(false);
+40
View File
@@ -16,6 +16,7 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { ROOT, SMALL_PLAN, autoplan, env, journal, readRun } from './helpers/autoplan-run-fixture';
import { capyRunner } from './helpers/autoplan-capy';
import { captureDraft } from '../lib/autoplan-lean';
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-lean-'));
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
@@ -488,3 +489,42 @@ describe('draft capture edges', () => {
expect(prompt.indexOf('IGNORE THE PLAN')).toBeLessThan(prompt.indexOf(`\n${open[1]}\n`, open.index + 1));
});
});
describe('draft capture base on a PR-branch clone (GST-1241)', () => {
/** A clone of `feature`, its draft committed and pushed, whose origin/HEAD names `head` while origin's own HEAD stays main: a Capy machine started on a PR branch names that branch. */
const clone = (name: string, head: string) => {
const src = repo(name, false);
fs.writeFileSync(path.join(src, 'lib', 'worker.ts'), 'export const retry = (n: number) => Math.min(30_000, 500 * 2 ** n);\n');
git(src, 'commit', '-q', '-am', 'draft backoff');
const bare = path.join(TMP, name, 'origin.git');
git(TMP, 'clone', '-q', '--bare', src, bare);
git(bare, 'symbolic-ref', 'HEAD', 'refs/heads/main');
const cwd = path.join(TMP, name, 'clone');
git(TMP, 'clone', '-q', '-b', 'feature', bare, cwd);
git(cwd, 'remote', 'set-head', 'origin', head);
expect(git(cwd, 'rev-parse', '--abbrev-ref', 'origin/HEAD').trim()).toBe(`origin/${head}`);
return cwd;
};
const capture = (name: string, cwd: string) => {
const dir = path.join(TMP, name, 'snap');
const out = path.join(TMP, name, 'run');
fs.mkdirSync(dir);
fs.mkdirSync(out);
const draft = captureDraft(cwd, dir, out);
return { draft, diff: draft.sha256 ? fs.readFileSync(path.join(dir, 'draft.diff'), 'utf8') : '' };
};
test('origin/HEAD naming the PR branch still captures the committed draft against main', () => {
const { draft, diff } = capture('pr-head', clone('pr-head', 'feature'));
expect(draft.none).toBeUndefined();
expect(draft.base).toBe('origin/main');
expect(draft.files).toEqual(['lib/worker.ts']);
expect(diff).toContain('+export const retry = (n: number) => Math.min(30_000, 500 * 2 ** n);');
});
test('control: origin/HEAD naming main captures the same draft', () => {
const main = capture('main-head', clone('main-head', 'main'));
expect(main.draft.base).toBe('origin/main');
expect(main.draft.files).toEqual(['lib/worker.ts']);
expect(main.diff).toContain('+export const retry = (n: number) => Math.min(30_000, 500 * 2 ** n);');
});
});
+29
View File
@@ -138,4 +138,33 @@ describe('one Bun resolution contract, two twins', () => {
expect(viaLaunch.dir).toBe(launch);
expect(viaLaunch.env.PATH?.split(path.delimiter)[0]).toBe(launch);
});
// GST-1233: a docs child's install replaced the shared verdict and republished the shared launch
// directory with its own Bun; the finish pregate admits from ship's own record and launches ship's Bun.
test("launchRuntime admits /ship from its own record when another workflow's install replaced the shared verdict, and never launches that install's Bun", () => {
const l = layout({ storeVersions: ['1.4.4'] });
const { installVerdictPath } = require('../lib/install-verdict') as typeof import('../lib/install-verdict');
const checkout = fs.realpathSync(ROOT);
const file = installVerdictPath(checkout, l.state, null);
const head = spawnSync('git', ['-C', ROOT, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 10_000 }).stdout.trim();
const childBun = path.join(bunStore(l.state), '1.4.4/bun');
const launch = file.replace(/\.json$/, '.launch');
fs.mkdirSync(launch, { recursive: true });
fs.symlinkSync(childBun, path.join(launch, 'bun'));
const record = { schema_version: 2, checkout, project: '', head, pins_planning_only: false, ok: true, min_revision: '', launch_path: launch, time: '' };
fs.writeFileSync(file, JSON.stringify({ ...record, for: 'document-release', required: ['claude'], trailer: 'gstack: ok for=document-release', bun_path: fs.realpathSync(childBun), bun_version: '1.4.4' }));
const refused = launchRuntime(ROOT, l.env);
expect(refused.refusal).toMatchObject({ code: 'INSTALL_UNVERIFIED' });
expect(refused.refusal?.detail).toContain('certified for=document-release');
const own = file.replace(/\.json$/, '.for/ship.json');
fs.mkdirSync(path.dirname(own), { recursive: true });
fs.writeFileSync(own, JSON.stringify({ ...record, for: 'ship', required: ['claude', 'codex', 'patch'], trailer: 'gstack: ok for=ship', bun_path: REAL_BUN, bun_version: Bun.version }));
const r = launchRuntime(ROOT, l.env);
expect(r.refusal).toBeNull();
expect(r.verdict).toBe('ok');
expect(r.label).toBe(`bun ${Bun.version} at ${REAL_BUN} (recorded by the install verdict)`);
expect(r.dir).toBe(path.dirname(REAL_BUN));
fs.writeFileSync(file, JSON.stringify({ ...JSON.parse(fs.readFileSync(file, 'utf8')), ok: false, trailer: 'gstack: fail setup — ./setup failed' }));
expect(launchRuntime(ROOT, l.env).refusal?.detail).toBe('install verdict: the install verdict says: gstack: fail setup — ./setup failed');
});
});
+65 -11
View File
@@ -53,14 +53,18 @@ const UPGRADE_DEPS = [
'bin/gstack-upgrade', 'bin/gstack-bun-version.sh', 'bin/gstack-paths', 'bin/gstack-state-root.sh',
'bin/gstack-egress-lib.sh', 'bin/gstack-egress-receipt', 'lib/egress-receipt.ts', 'lib/state-root.ts',
'bin/gstack-lease', 'bin/gstack-harness-pid.sh',
// Sourced by gstack-upgrade: without gstack-config in the fixture it resolves main unless GSTACK_UPDATE_CHANNEL says stable.
'bin/gstack-update-channel.sh',
];
/**
* cloneAt 'A' (default): the clone sits at A (1.0.0), below B's minimum 1.0.1.
* cloneAt 'B': the clone sits at B, AT the minimum, and the remote moves on to
* C (1.0.2, minimum unchanged): behind origin but not below the floor.
* next: the remote moves on to C for a clone at A too. stable: origin's stable
* branch (the release, docs/releases.md) at that commit.
*/
function fixture(opts: { minWrapper?: number; cloneAt?: 'A' | 'B' } = {}) {
function fixture(opts: { minWrapper?: number; cloneAt?: 'A' | 'B'; next?: boolean; stable?: 'A' | 'B' | 'C' } = {}) {
const host = capyHost({ systemBun: '1.4.2' });
hosts.push(host);
const git = (args: string[], cwd: string) => {
@@ -94,13 +98,14 @@ function fixture(opts: { minWrapper?: number; cloneAt?: 'A' | 'B' } = {}) {
const b = git(['rev-parse', 'HEAD'], seed);
git(['push', '-q', bare, 'main'], seed);
let c = '';
if (opts.cloneAt === 'B') {
git(['clone', '-q', '--single-branch', '--depth', '1', `file://${bare}`, root], host.base);
if (opts.cloneAt === 'B' || opts.next) {
if (opts.cloneAt === 'B') git(['clone', '-q', '--single-branch', '--depth', '1', `file://${bare}`, root], host.base);
write(path.join(seed, 'VERSION'), '1.0.2\n');
git(['commit', '-qam', 'C'], seed);
c = git(['rev-parse', 'HEAD'], seed);
git(['push', '-q', bare, 'main'], seed);
}
if (opts.stable) git(['push', '-q', bare, `${{ A: a, B: b, C: c }[opts.stable]}:refs/heads/stable`], seed);
const env = { ...host.env, GSTACK_CAPY_ROOT: root, GSTACK_CAPY_REPO: `file://${bare}`, WRAP_LOG: log, WRAP_FAIL_ONCE: path.join(host.base, 'fail-once') };
const run = (args: string[] = [], extra: Record<string, string> = {}) => {
const r = spawnSync('bash', [BOOT, ...args], { cwd: host.home, encoding: 'utf8', env: { ...env, ...extra }, timeout: 120_000 });
@@ -140,13 +145,13 @@ describe('capy skill bootstrapper (capy/skill/gstack/scripts/install.sh)', () =>
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
expect(f.git(['rev-parse', '--is-shallow-repository'])).toBe('true');
expect(install.last).toBe('gstack: ok 1.0.1 for=autoplan');
expect(f.calls()).toEqual([`installer --root ${f.root} --for autoplan lock=1 rev=3 min=1.0.1 from=1.0.0`]);
expect(f.calls()).toEqual([`installer --root ${f.root} --for autoplan lock=1 rev=4 min=1.0.1 from=1.0.0`]);
const next = f.run(['--check', '--for', 'autoplan']);
expect(next.status, next.stdout + next.stderr).toBe(0);
expect(next.stdout).not.toContain('upgraded');
expect(next.last).toBe('gstack: ok 1.0.1 for=autoplan');
expect(f.calls().at(-1)).toBe(`installer --check --root ${f.root} --for autoplan lock= rev=3 min=1.0.1 from=`);
expect(f.calls().at(-1)).toBe(`installer --check --root ${f.root} --for autoplan lock= rev=4 min=1.0.1 from=`);
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
});
@@ -263,7 +268,7 @@ describe('capy skill: always current when online (the minimum is the floor, not
expect(r.stdout).toMatch(/^SELF_UPGRADE: done 1\.0\.1 -> 1\.0\.2$/m);
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.c);
expect(r.last).toBe('gstack: ok 1.0.2 for=autoplan');
expect(f.calls()).toEqual([`installer --root ${f.root} --for autoplan lock=1 rev=3 min=1.0.1 from=1.0.1`]);
expect(f.calls()).toEqual([`installer --root ${f.root} --for autoplan lock=1 rev=4 min=1.0.1 from=1.0.1`]);
expect(fs.existsSync(path.join(f.root, '.git/gstack-upgrade.pending'))).toBe(false);
const next = f.run(['--for', 'autoplan']);
@@ -271,7 +276,7 @@ describe('capy skill: always current when online (the minimum is the floor, not
expect(next.stdout).toMatch(/^SELF_UPGRADE: none \(current\)$/m);
expect(next.stdout).not.toContain('SELF_UPGRADE: done');
expect(next.last).toBe('gstack: ok 1.0.2 for=autoplan');
expect(f.calls().at(-1)).toBe(`installer --root ${f.root} --for autoplan lock=1 rev=3 min=1.0.1 from=`);
expect(f.calls().at(-1)).toBe(`installer --root ${f.root} --for autoplan lock=1 rev=4 min=1.0.1 from=`);
});
test('--check mode never moves a behind checkout: it names the gap and the fast-forwarding command, then certifies the installed revision', () => {
@@ -282,7 +287,7 @@ describe('capy skill: always current when online (the minimum is the floor, not
expect(r.stdout).not.toContain('SELF_UPGRADE');
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
expect(r.last).toBe('gstack: ok 1.0.1 for=autoplan');
expect(f.calls()).toEqual([`installer --check --root ${f.root} --for autoplan lock= rev=3 min=1.0.1 from=`]);
expect(f.calls()).toEqual([`installer --check --root ${f.root} --for autoplan lock= rev=4 min=1.0.1 from=`]);
});
test('a dirty behind checkout is skipped with gstack-upgrade\'s reason and still ends ok: above the floor, the trailer is the doctor\'s', () => {
@@ -295,7 +300,7 @@ describe('capy skill: always current when online (the minimum is the floor, not
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
expect(fs.readFileSync(path.join(f.root, 'README.md'), 'utf8')).toBe('local edit\n');
expect(r.last).toBe('gstack: ok 1.0.1 for=autoplan');
expect(f.calls()).toEqual([`installer --root ${f.root} --for autoplan lock=1 rev=3 min=1.0.1 from=`]);
expect(f.calls()).toEqual([`installer --root ${f.root} --for autoplan lock=1 rev=4 min=1.0.1 from=`]);
});
test('GSTACK_CAPY_SELF_UPGRADE=off and GSTACK_CAPY_AUTO_UPGRADE=0 each disable the fast-forward by name and leave HEAD where it is', () => {
@@ -315,6 +320,53 @@ describe('capy skill: always current when online (the minimum is the floor, not
// Plan A9: the `latest:` trailer note comes only from origin's VERSION the
// bootstrapper read in this run; a FETCH_HEAD left by an earlier run never
// reaches the installer.
describe('capy skill bootstrapper: the update channel (wrapper 4, docs/releases.md#channels)', () => {
// Value: protects=an install that follows stable (GSTACK_UPDATE_CHANNEL=stable, or team mode through bin/gstack-update-channel.sh) is cloned at
// origin's stable branch, not main, and stays there through the checkout half's self-upgrade; GSTACK_UPDATE_CHANNEL=main and a remote with no stable
// branch (before the first release) clone main; fails_when=a fresh team install lands on unreleased main, the override cannot pick main, or a missing
// stable branch fails the install; why_new=the clone test covers the default branch only; seam=GSTACK_UPDATE_CHANNEL instead of gstack-config's
// team_mode; no_claim=GitHub's real stable branch
test('a fresh clone follows stable when origin has it; GSTACK_UPDATE_CHANNEL=main and a missing stable branch clone main', () => {
const f = fixture({ cloneAt: 'B', stable: 'B' });
fs.rmSync(f.root, { recursive: true, force: true });
const stable = f.run(['--for', 'autoplan'], { GSTACK_UPDATE_CHANNEL: 'stable' });
expect(stable.status, stable.stdout + stable.stderr).toBe(0);
expect(stable.stdout).toContain('gstack-capy-skill: channel stable 1.0.1 (GSTACK_UPDATE_CHANNEL=main follows every merge)');
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
expect(stable.last).toBe('gstack: ok 1.0.1 for=autoplan');
fs.rmSync(f.root, { recursive: true, force: true });
const main = f.run(['--for', 'autoplan'], { GSTACK_UPDATE_CHANNEL: 'main' });
expect(main.status, main.stdout + main.stderr).toBe(0);
expect(main.stdout).not.toContain('channel stable');
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.c);
spawnSync('git', ['--git-dir', f.bare, 'update-ref', '-d', 'refs/heads/stable'], { env: f.host.env, timeout: 30_000 });
fs.rmSync(f.root, { recursive: true, force: true });
const bootstrap = f.run(['--for', 'autoplan'], { GSTACK_UPDATE_CHANNEL: 'stable' });
expect(bootstrap.status, bootstrap.stdout + bootstrap.stderr).toBe(0);
expect(bootstrap.stdout).toContain('channel stable, but origin has no stable branch yet (or the fetch failed); following main');
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.c);
});
// Value: protects=the bootstrapper's floor fast-forward of a checkout below the supported minimum goes to stable on the stable channel when stable
// meets the minimum, and to main when stable is itself below it; fails_when=a stable install is pushed past the release to main, or left below the
// floor because stable is too old; why_new=the floor tests cover main only; seam=GSTACK_UPDATE_CHANNEL; no_claim=GitHub's real stable branch
test('below the floor: the fast-forward goes to stable when stable meets the minimum, else to main', () => {
const atRelease = fixture({ next: true, stable: 'B' });
const r = atRelease.run(['--for', 'autoplan'], { GSTACK_UPDATE_CHANNEL: 'stable' });
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(r.stdout).toContain('gstack-capy-skill: upgraded 1.0.0 -> 1.0.1');
expect(atRelease.git(['rev-parse', 'HEAD'])).toBe(atRelease.b);
const old = fixture({ next: true, stable: 'A' });
const o = old.run(['--for', 'autoplan'], { GSTACK_UPDATE_CHANNEL: 'stable' });
expect(o.status, o.stdout + o.stderr).toBe(0);
expect(o.stdout).toContain('gstack-capy-skill: channel stable is at 1.0.0, below the supported minimum 1.0.1; following main');
expect(old.git(['rev-parse', 'HEAD'])).toBe(old.c);
});
});
describe('capy skill bootstrapper: GSTACK_CAPY_REMOTE_VERSION', () => {
test('a fresh fetch exports origin\'s VERSION; a failed fetch exports nothing even with a stale FETCH_HEAD', () => {
const f = fixture({ cloneAt: 'B' });
@@ -587,9 +639,11 @@ describe('capy skill: factory.md, the auto-loaded "how we work" page', () => {
return [...new Set(problems)];
}
test('it fits the 3,300-byte budget, and every citation, heading and skill it names resolves', () => {
test('it fits the 3,450-byte budget, and every citation, heading and skill it names resolves', () => {
const text = fs.readFileSync(FACTORY, 'utf8');
expect(Buffer.byteLength(text)).toBeLessThanOrEqual(3300);
// 3,300 since #3634; raised to 3,450 (the page's new size, rounded up to the next 25 bytes) for point 12's
// R68 clause (Garry, 2026-11-03: crew threads ask their Captain for new threads). Raise it only by what a rule needs.
expect(Buffer.byteLength(text)).toBeLessThanOrEqual(3450);
expect(text).toMatch(/^# How we work/);
expect(citationProblems(text)).toEqual([]);
});
+86
View File
@@ -0,0 +1,86 @@
import { afterAll, 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 { bunIsolationArgs } from '../lib/bun-isolation';
const SCRIPT = path.resolve(import.meta.dir, '..', 'scripts/check-conflict-markers.ts');
const OPEN = '<'.repeat(7), MID = '='.repeat(7), CLOSE = '>'.repeat(7);
const dirs: string[] = [];
afterAll(() => { for (const d of dirs) fs.rmSync(d, { recursive: true, force: true }); });
function repo(base: Record<string, string> = { 'README.md': 'base\n' }) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-markers-'));
dirs.push(root);
const git = (...args: string[]) => {
const r = spawnSync('git', ['-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', '-c', 'user.email=test@example.com', '-c', 'user.name=test', ...args], { cwd: root, encoding: 'utf8', timeout: 30_000 });
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
return r.stdout;
};
git('init', '-q');
git('symbolic-ref', 'HEAD', 'refs/heads/main');
const write = (files: Record<string, string>) => { for (const [p, c] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(root, p)), { recursive: true }); fs.writeFileSync(path.join(root, p), c); } };
write(base);
git('add', '-A');
git('commit', '-q', '-m', 'base');
git('checkout', '-q', '-b', 'feat');
const change = (files: Record<string, string>) => { write(files); git('add', '-A'); git('commit', '-q', '-m', 'change'); };
const run = (env: Record<string, string> = {}) => spawnSync('bun', [...bunIsolationArgs(), SCRIPT, '--base', 'main'], { cwd: root, encoding: 'utf8', timeout: 60_000, env: { ...process.env, ...env } });
return { root, git, change, write, run };
}
describe('check-conflict-markers (GST-1190: #3927 kept markers inside a template literal and its tests passed)', () => {
// Value: protects=a merge that leaves conflict markers in a changed file is refused before the push, even where the
// markers sit inside a string the parser accepts; fails_when=a marker line in a template literal passes, a markdown
// code block that shows a marker is refused, or a git display setting hides a file; why_new=GSTA-234 #3927;
// seam=a real git repository and the script
test('a marker inside a template literal is refused', () => {
const r = repo();
r.change({ 'lib/prompt.ts': `export const PROMPT = \`\n${OPEN} HEAD\nkeep this\n${MID}\nor this\n${CLOSE} origin/main\n\`;\n` });
const out = r.run();
expect(out.status).toBe(1);
expect(out.stdout).toContain(`CONFLICT_MARKER: lib/prompt.ts:2 ${OPEN}\n`);
expect(out.stdout).toContain(`CONFLICT_MARKER: lib/prompt.ts:4 ${MID}\n`);
expect(out.stdout).toContain(`CONFLICT_MARKER: lib/prompt.ts:6 ${CLOSE}\n`);
});
test('a markdown code block that shows a marker is not refused; the same lines outside a fence, or in a bash fence, are', () => {
const r = repo();
const block = `${OPEN} HEAD\nours\n${MID}\ntheirs\n${CLOSE} main\n`;
r.change({ 'docs/merging.md': `# Merging\n\nGit writes this:\n\n\`\`\`\n${block}\`\`\`\n\nA heading\n${MID}\n` });
const fenced = r.run();
expect([fenced.status, fenced.stdout]).toEqual([0, expect.stringContaining('CONFLICT_MARKERS: none')]);
r.change({ 'docs/merging.md': `# Merging\n\n${block}` });
const bare = r.run();
expect(bare.status).toBe(1);
expect(bare.stdout).toContain(`CONFLICT_MARKER: docs/merging.md:3 ${OPEN}`);
expect(bare.stdout).toContain(`CONFLICT_MARKER: docs/merging.md:5 ${MID}`);
r.change({ 'skill/sections/x.md': `# X\n\n\`\`\`bash\n${OPEN} HEAD\necho a\n${MID}\necho b\n${CLOSE} feat\n\`\`\`\n` });
expect(r.run().stdout).toContain(`CONFLICT_MARKER: skill/sections/x.md:4 ${OPEN}`);
});
test('only changed lines count; a fixture declares itself on a line of its own, a mention elsewhere does not exempt a file', () => {
const r = repo({ 'old.txt': `${OPEN} kept on main\n` });
r.change({ 'old.txt': `${OPEN} kept on main\nnew line\n`, 'test/fixtures/merge.txt': `# gstack-conflict-markers: fixture\n${OPEN} HEAD\n${CLOSE} main\n` });
expect(r.run()).toMatchObject({ status: 0 });
r.change({ 'cfg.json': `{"fix": "declare gstack-conflict-markers: fixture"}\n${OPEN} HEAD\n` });
expect(r.run().stdout).toContain(`CONFLICT_MARKER: cfg.json:2 ${OPEN}`);
});
test('the pushed commit is read, not only the working tree; untracked files are not; git display settings change nothing', () => {
const r = repo();
r.change({ 'src/a b/ünï.ts': `const s = \`\n${CLOSE} theirs\n\`;\n` });
for (const env of <Record<string, string>[]>[{}, { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'diff.noprefix', GIT_CONFIG_VALUE_0: 'true' }, { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'diff.mnemonicPrefix', GIT_CONFIG_VALUE_0: 'true' }]) {
expect(r.run(env).stdout, JSON.stringify(env)).toContain(`CONFLICT_MARKER: src/a b/ünï.ts:2 ${CLOSE}`);
}
r.write({ 'src/a b/ünï.ts': 'const s = 1;\n', 'merge.ts.orig': `${OPEN} HEAD\n` });
const repaired = r.run();
expect([repaired.status, repaired.stdout]).toEqual([1, expect.stringContaining(`CONFLICT_MARKER: src/a b/ünï.ts:2 ${CLOSE}`)]);
expect(repaired.stdout).not.toContain('merge.ts.orig');
r.write({ 'README.md': `base\n${MID}\n` });
r.git('add', '-A'); r.git('commit', '-q', '-m', 'repair');
r.write({ 'lib/wip.ts': `${CLOSE} uncommitted\n` }); r.git('add', 'lib/wip.ts'); r.git('commit', '-q', '-m', 'wip'); r.write({ 'lib/wip.ts': `ok\n${OPEN} x\n` });
expect(r.run().stdout).toContain(`CONFLICT_MARKER: lib/wip.ts:2 ${OPEN} (working tree)`);
});
});
+1 -1
View File
@@ -67,7 +67,7 @@
"spec": 9136,
"sync-gbrain": 9749,
"test-audit": 4622,
"ubicloud": 3594,
"ubicloud": 3971,
"unfreeze": 409,
"verify": 2896,
"write-skill": 2264
+2 -2
View File
@@ -325,8 +325,8 @@ repository-landing asks, including on Apple repos.
## Step 1: Pre-flight
Run `~/.gemini/antigravity-cli/skills/gstack/bin/gstack-ship-preflight --base <base>`: Steps 1–3's mechanics in one call;
act on every `STOP:` and `NEXT:` line.
Run `~/.gemini/antigravity-cli/skills/gstack/bin/gstack-ship-preflight --base <base> --run <SESSION_ID>`: Steps 1–3 at once;
act on each `STOP:` and `NEXT:` line.
1. It saves the current branch as `<branch-name>`. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
2. Uncommitted changes are always included; do not ask (`STATUS:`).
+2 -2
View File
@@ -324,8 +324,8 @@ repository-landing asks, including on Apple repos.
## Step 1: Pre-flight
Run `~/.claude/skills/gstack/bin/gstack-ship-preflight --base <base>`: Steps 1–3's mechanics in one call;
act on every `STOP:` and `NEXT:` line.
Run `~/.claude/skills/gstack/bin/gstack-ship-preflight --base <base> --run <SESSION_ID>`: Steps 1–3 at once;
act on each `STOP:` and `NEXT:` line.
1. It saves the current branch as `<branch-name>`. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
2. Uncommitted changes are always included; do not ask (`STATUS:`).
+2 -2
View File
@@ -323,8 +323,8 @@ repository-landing asks, including on Apple repos.
## Step 1: Pre-flight
Run `$GSTACK_ROOT/bin/gstack-ship-preflight --base <base>`: Steps 1–3's mechanics in one call;
act on every `STOP:` and `NEXT:` line.
Run `$GSTACK_ROOT/bin/gstack-ship-preflight --base <base> --run <SESSION_ID>`: Steps 1–3 at once;
act on each `STOP:` and `NEXT:` line.
1. It saves the current branch as `<branch-name>`. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
2. Uncommitted changes are always included; do not ask (`STATUS:`).
+2 -2
View File
@@ -325,8 +325,8 @@ repository-landing asks, including on Apple repos.
## Step 1: Pre-flight
Run `$GSTACK_ROOT/bin/gstack-ship-preflight --base <base>`: Steps 1–3's mechanics in one call;
act on every `STOP:` and `NEXT:` line.
Run `$GSTACK_ROOT/bin/gstack-ship-preflight --base <base> --run <SESSION_ID>`: Steps 1–3 at once;
act on each `STOP:` and `NEXT:` line.
1. It saves the current branch as `<branch-name>`. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
2. Uncommitted changes are always included; do not ask (`STATUS:`).
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more