Files
gstack/.github/workflows/windows-pregate-e2e.yml
T
garrytanandcapy-ai[bot] f0748d6157 ci(pregate): gstack-pregate joins the Windows pregate job; pregate tests return to the Windows lane (#3197)
- windows-pregate-e2e runs gstack-pregate run (preflight: regen, secrets
  through the gstack-redact bun bin, literals, guards) in both journeys, and
  asserts a missing GSTACK_GIT_BASH refuses with one WTREE_UNAVAILABLE --json
  envelope.
- test/helpers/pregate-fixture.ts and restamp-fixture.ts runBin launch bins
  through lib/script-invocation.ts (bun bins as Bun argv, bash bins through
  Git Bash on Windows), which removes the "shebang via CreateProcess" reason
  for the pregate tests; they move from KNOWN_WINDOWS_INCOMPATIBLE to
  KNOWN_WINDOWS_SAFE.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-17 00:02:56 +00:00

245 lines
14 KiB
YAML

name: Windows Pregate E2E
# Native proof for #3197 on windows-latest: Bun on Windows cannot execute an
# extensionless shebang bin (bin/gstack-wtree, gstack-slug, gstack-redact),
# so every TypeScript caller goes through lib/script-invocation.ts, which
# hands bash bins to Git Bash and bun bins to Bun. This job drives the real
# CLIs from TypeScript entry points against a fixture repository:
# - the resolver finds Git Bash from `git --exec-path`;
# - gstack-restamp --dry-run fingerprints the tree and stamps;
# - gstack-regen check (scratch worktree from the fingerprint, the entry's
# command through `bash -c`) and write (the no-scratch path);
# - gstack-evidence records a content fingerprint, its command through bash -c;
# - gstack-gate-diff records a candidate with a tree id, not `+nowtree`;
# - gstack-pregate run fingerprints the tree and runs its preflight checks
# (secrets through the gstack-redact bun bin, regen and guards);
# - a hook invocation is timed cold and warm (the cached resolution);
# - a missing GSTACK_GIT_BASH refuses with BASH_UNAVAILABLE, not `()`, and
# gstack-pregate --json refuses with one WTREE_UNAVAILABLE envelope;
# - the same journey under a pinned, checksum-verified PortableGit found
# only through `git --exec-path`.
#
# Runner: GitHub-hosted free windows-latest. ~4-6 min.
on:
pull_request:
branches: [main]
paths:
- 'lib/script-invocation.ts'
- 'lib/regen.ts'
- 'lib/restamp.ts'
- 'lib/gbrain-exec.ts'
- 'lib/gate-diff/**'
- 'lib/pregate/**'
- 'lib/result-codes.ts'
- 'bin/gstack-wtree'
- 'bin/gstack-slug'
- 'bin/gstack-path-spelling.sh'
- 'bin/gstack-restamp'
- 'bin/gstack-regen'
- 'bin/gstack-evidence'
- 'bin/gstack-gate-diff'
- 'bin/gstack-redact'
- 'bin/gstack-pregate'
- 'hosts/claude/hooks/**'
- '.github/workflows/windows-pregate-e2e.yml'
workflow_dispatch:
concurrency:
group: windows-pregate-e2e-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
env:
PORTABLE_GIT_VERSION: 2.56.0.windows.2
PORTABLE_GIT_FILE: PortableGit-2.56.0.2-64-bit.7z.exe
PORTABLE_GIT_SHA256: 075e158ef8e1f0ab80b347e245405d3eca735c2dc88fd8e032e137d0ca61f61b
jobs:
windows-pregate:
runs-on: windows-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.2
- name: Configure git identity
run: |
git config --global user.email "windows-pregate-e2e@gstack.test"
git config --global user.name "Windows Pregate E2E"
git config --global init.defaultBranch main
git config --global commit.gpgsign false
shell: bash
- name: Install dependencies
run: bun install --frozen-lockfile
shell: bash
- name: Build the fixture repository
# A VERSION + CHANGELOG + ship policy + generated-file registry, a
# bare origin and a feature branch with an [Unreleased] entry.
run: |
set -euo pipefail
T="$RUNNER_TEMP/pregate-e2e"
rm -rf "$T"; mkdir -p "$T/home"
git init -q --bare -b main "$T/origin.git"
git init -q -b main "$T/work"
cd "$T/work"
mkdir -p generated .gstack
echo 1.2.3.0 > VERSION
printf '{ "name": "fixture", "version": "1.2.3" }\n' > package.json
printf '%s\n' "const fs=require('fs');const v=fs.readFileSync('VERSION','utf8').trim();fs.writeFileSync('generated/digest.md','# digest v'+v+'\\n');" > gen.js
node gen.js
printf '# Changelog\n\n## [1.2.3.0] - 2026-10-01\n\n- base release\n' > CHANGELOG.md
cat > .gstack/ship-policy.json <<'JSON'
{ "$schema": "https://github.com/garrytan/gstack/docs/ship-policy.md#schema-v1", "bump": "patch", "queue_mode": "stamp-at-merge", "release_tool": "node gen.js", "release_outputs": ["generated/**"], "stamp_paths": [], "changelog": "CHANGELOG.md", "history": "squash-ok", "stamp_sensitive_gates": [] }
JSON
cat > .gstack/generated.json <<'JSON'
{ "$schema": "https://github.com/garrytan/gstack/docs/pregate.md#generated-v1", "entries": [ { "id": "digest", "command": "node gen.js", "outputs": ["generated/**"], "inputs": ["VERSION", "gen.js"] } ] }
JSON
git add -A && git commit -qm base
git remote add origin "$T/origin.git" && git push -q origin main && git remote set-head origin main
git checkout -qb feature
echo feature > feature.txt
printf '# Changelog\n\n## [Unreleased]\n\n- feature change\n\n## [1.2.3.0] - 2026-10-01\n\n- base release\n' > CHANGELOG.md
git add -A && git commit -qm feature
shell: bash
- name: The resolver finds the runner's Git Bash from git --exec-path
run: |
set -euo pipefail
git --version
OUT=$(bun -e 'import { resolveWindowsBash } from "./lib/script-invocation"; const r = resolveWindowsBash(); console.log(JSON.stringify(r)); process.exit(r.ok ? 0 : 1);')
echo "$OUT"
echo "$OUT" | grep -q '"source":"git --exec-path"' || (echo "Git Bash was not found through git --exec-path" && exit 1)
echo "$OUT" | grep -qiE '"bash":"[^"]*/(usr/)?bin/bash\.exe"' || (echo "the resolved bash is not a Git Bash bash.exe" && exit 1)
shell: bash
- name: A hook invocation, timed cold and warm (cached Git Bash resolution)
run: |
set -euo pipefail
T="$RUNNER_TEMP/hook-timing"
rm -rf "$T"; mkdir -p "$T"
PAYLOAD='{"session_id":"e2e","hook_event_name":"PostToolUse","tool_name":"AskUserQuestion","tool_use_id":"TOOL_ID","tool_input":{"questions":[{"question":"Ship it?","options":[{"label":"Ship now (recommended)"},{"label":"Hold"}]}]},"tool_response":{"answers":[{"option_label":"Ship now"}]}}'
run_hook() {
local start end
start=$(date +%s%3N)
printf '%s' "${PAYLOAD/TOOL_ID/$1}" | (cd "$T" && GSTACK_STATE_ROOT="$T" GSTACK_QUESTION_LOG_NO_DERIVE=1 bun "$GITHUB_WORKSPACE/hosts/claude/hooks/question-log-hook.ts")
end=$(date +%s%3N)
echo $((end - start))
}
COLD=$(run_hook toolu_cold)
test -f "$T/cache/windows-bash.json" || (echo "the hook did not cache its Git Bash resolution" && exit 1)
cat "$T/cache/windows-bash.json"
WARM=$(run_hook toolu_warm)
echo "question-log-hook: cold ${COLD} ms, warm ${WARM} ms"
echo "### question-log-hook on windows-latest: cold ${COLD} ms, warm ${WARM} ms" >> "$GITHUB_STEP_SUMMARY"
LOGS=$(find "$T/projects" -name question-log.jsonl | head -n 1)
test -n "$LOGS" || (echo "the hook wrote no question-log.jsonl" && exit 1)
test "$(wc -l < "$LOGS")" -eq 2 || (echo "expected two logged events" && exit 1)
test ! -s "$T/hook-errors.log" || { cat "$T/hook-errors.log"; echo "the hook logged errors"; exit 1; }
shell: bash
- name: A missing GSTACK_GIT_BASH refuses with BASH_UNAVAILABLE and names every candidate
run: |
set -uo pipefail
T="$RUNNER_TEMP/pregate-e2e"
cd "$T/work"
OUT=$(GSTACK_HOME="$T/home" GSTACK_GIT_BASH='C:\no-such-git\bin\bash.exe' bun "$GITHUB_WORKSPACE/bin/gstack-restamp" --base main --allow-repo-commands --next --dry-run 2>&1) && CODE=0 || CODE=$?
echo "$OUT" | tail -n 3
test "$CODE" -ne 0 || (echo "restamp passed without a bash" && exit 1)
echo "$OUT" | grep -q 'cannot fingerprint the working tree (gstack-wtree could not start: gstack: no Git Bash found' || (echo "the refusal does not say why" && exit 1)
echo "$OUT" | grep -q 'GSTACK_GIT_BASH C:/no-such-git/bin/bash.exe: does not exist' || (echo "the refusal does not list the override" && exit 1)
echo "$OUT" | grep -q '(BASH_UNAVAILABLE)' || (echo "the refusal has no result code" && exit 1)
OUT=$(GSTACK_HOME="$T/home" GSTACK_GIT_BASH='C:\no-such-git\bin\bash.exe' bun "$GITHUB_WORKSPACE/bin/gstack-pregate" run --base main --stage preflight --json 2>/dev/null) && CODE=0 || CODE=$?
echo "$OUT"
test "$CODE" -eq 3 || (echo "gstack-pregate exited $CODE, expected 3 (refused)" && exit 1)
echo "$OUT" | bun -e 'const j = JSON.parse(await Bun.stdin.text()); const ok = j.ok === false && j.status === "refused" && j.code === "WTREE_UNAVAILABLE" && j.detail.includes("GSTACK_GIT_BASH C:/no-such-git/bin/bash.exe: does not exist") && j.fix.includes("GSTACK_GIT_BASH"); process.exit(ok ? 0 : 1);' || (echo "gstack-pregate --json did not refuse with one WTREE_UNAVAILABLE envelope" && exit 1)
shell: bash
- name: Download the pinned PortableGit and verify its checksum
run: |
set -euo pipefail
PG="$RUNNER_TEMP/PortableGit"
curl -fsSL -o "$RUNNER_TEMP/$PORTABLE_GIT_FILE" "https://github.com/git-for-windows/git/releases/download/v${PORTABLE_GIT_VERSION}/${PORTABLE_GIT_FILE}"
echo "$PORTABLE_GIT_SHA256 $RUNNER_TEMP/$PORTABLE_GIT_FILE" | sha256sum -c -
"$RUNNER_TEMP/$PORTABLE_GIT_FILE" -o"$(cygpath -w "$PG")" -y
test -f "$PG/bin/bash.exe" || (echo "PortableGit has no bin/bash.exe" && exit 1)
"$PG/cmd/git.exe" --version
shell: bash
- name: The resolver finds the PortableGit bash through git --exec-path alone
run: |
set -euo pipefail
PG=$(cygpath -u "$RUNNER_TEMP/PortableGit")
export PATH="$PG/cmd:$PATH"
git --version
WANT=$(cygpath -m "$PG/bin/bash.exe")
OUT=$(bun -e 'import { resolveWindowsBash } from "./lib/script-invocation"; const r = resolveWindowsBash(); console.log(JSON.stringify(r)); process.exit(r.ok ? 0 : 1);')
echo "$OUT"
echo "$OUT" | grep -qiF "\"bash\":\"$WANT\"" || (echo "expected $WANT" && exit 1)
echo "$OUT" | grep -q '"source":"git --exec-path"' || (echo "PortableGit was not found through git --exec-path" && exit 1)
shell: bash
- name: The fixture journey (restamp, regen, evidence, gate-diff) under the runner's Git and the pinned PortableGit
run: |
set -euo pipefail
journey() {
G="$PWD"
T="$RUNNER_TEMP/pregate-e2e"
export GSTACK_HOME="$T/home" GSTACK_STATE_ROOT="$T/home"
cd "$T/work"
git reset -q --hard && git clean -fdq
rm -rf "$T/home/projects"
OUT=$(bun "$G/bin/gstack-restamp" --base main --allow-repo-commands --next --dry-run 2>&1) || { echo "$OUT"; echo "gstack-restamp --dry-run failed"; exit 1; }
echo "$OUT" | tail -n 8
echo "$OUT" | grep -q '^+1\.2\.4\.0$' || (echo "restamp did not stamp VERSION" && exit 1)
echo "$OUT" | grep -q '^+# digest v1\.2\.4\.0$' || (echo "restamp did not run the release tool" && exit 1)
echo "$OUT" | grep -q 'GSTACK_RESULT: skill=restamp status=complete' || (echo "restamp did not complete" && exit 1)
test -z "$(git status --porcelain)" || (echo "--dry-run wrote to the tree" && exit 1)
OUT=$(bun "$G/bin/gstack-regen" check --allow-repo-commands 2>&1) || { echo "$OUT"; echo "gstack-regen check failed"; exit 1; }
echo "$OUT"
echo "$OUT" | grep -q '^regen digest: fresh' || (echo "the regen command did not run fresh in the scratch worktree" && exit 1)
echo "$OUT" | grep -qE 'GSTACK_RESULT: skill=regen status=complete run=[0-9a-f]{40}$' || (echo "regen check has no tree fingerprint" && exit 1)
OUT=$(bun "$G/bin/gstack-regen" write --allow-repo-commands 2>&1) || { echo "$OUT"; echo "gstack-regen write failed"; exit 1; }
echo "$OUT" | grep -qE 'GSTACK_RESULT: skill=regen status=complete run=[0-9a-f]{40}$' || (echo "regen write has no tree fingerprint" && exit 1)
bun "$G/bin/gstack-evidence" run --label tests -- 'echo evidence-ok && test -f VERSION'
LEDGER=$(find "$T/home/projects" -name '*-evidence.jsonl' | head -n 1)
test -n "$LEDGER" || (echo "no evidence ledger written" && exit 1)
tail -n 1 "$LEDGER"
tail -n 1 "$LEDGER" | grep -qE '"exit":0,' || (echo "the evidence command did not exit 0 through bash -c" && exit 1)
tail -n 1 "$LEDGER" | grep -qE '"wtree":"[0-9a-f]{40}"' || (echo "evidence recorded no content fingerprint" && exit 1)
set +e
OUT=$(bun "$G/bin/gstack-gate-diff" main 2>&1)
CODE=$?
set -e
echo "$OUT" | tail -n 3
test "$CODE" -eq 0 || (echo "gstack-gate-diff exited $CODE" && exit 1)
! echo "$OUT" | grep -q 'fingerprint unavailable' || (echo "gate-diff could not fingerprint the tree" && exit 1)
echo "$OUT" | grep -qE 'candidate=[0-9a-f]{12}\+[0-9a-f]{12} ' || (echo "gate-diff candidate has no tree id" && exit 1)
OUT=$(bun "$G/bin/gstack-pregate" run --base main --stage preflight --only regen,secrets,literals,guards --allow-repo-commands 2>&1) || { echo "$OUT"; echo "gstack-pregate run failed"; exit 1; }
echo "$OUT" | tail -n 10
echo "$OUT" | grep -qE '^preflight secrets pass .* added lines clean' || (echo "pregate secrets did not scan through gstack-redact" && exit 1)
echo "$OUT" | grep -q 'GSTACK_RESULT: skill=pregate status=complete run=' || (echo "pregate did not complete" && exit 1)
grep -qE '"tree": "[0-9a-f]{40}"' .gstack/tmp/pregate.json || (echo "pregate.json has no tree fingerprint" && exit 1)
}
echo "::group::runner Git for Windows ($(git --version))"
(journey)
echo "::endgroup::"
echo "::group::pinned PortableGit $PORTABLE_GIT_VERSION"
PG_CMD=$(cygpath -u "$RUNNER_TEMP/PortableGit/cmd")
(export PATH="$PG_CMD:$PATH"; git --version; journey)
echo "::endgroup::"
shell: bash