Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions docs/preflight.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Pre-flight gate for `gx branch finish`

`gx branch finish` runs a **pre-flight verification script** in the
agent's worktree **before** any push happens. If the script fails, the
push is refused and the PR is never created — the broken commit never
reaches CI, the merge funnel, or the review surface.

This is the cheapest gate in the agent workflow:

| Gate | Cost | Catches |
| --- | --- | --- |
| Local pre-flight (this) | free, runs on agent's CPU | most regressions before they reach CI |
| Draft PR (gx finish opens it) | $0 — draft skips CI | nothing extra; the gate is the next step |
| `ready_for_review` flip (auto-promote) | first CI run | regressions pre-flight missed |
| Branch protection on `main` | required CI must be green | merge-time defense in depth |

Pre-flight is enabled by default. Disable per-call with `--no-preflight`,
or globally with `GUARDEX_FINISH_PREFLIGHT=0`.

## Convention

`gx branch finish` looks for `scripts/agent-preflight.sh` inside the
target repo's working tree. If it is executable, it runs from the repo
root. Non-zero exit refuses the push.

For gitguardex-managed projects, `gx setup` scaffolds a default
`scripts/agent-preflight.sh` that auto-detects the project stack and
runs conventional verification:

- **Node + pnpm** (lockfile present): `pnpm typecheck && pnpm lint && pnpm test`, each only if the package.json script exists.
- **Node + npm** (lockfile present): `npm test` if defined.
- **Rust** (`Cargo.toml`): `cargo check --quiet`.
- **Python** (`pyproject.toml`): `ruff check .` if `ruff` is installed.

If none of these match, pre-flight passes with a warn-only message —
the script doesn't refuse pushes for repos it can't classify.

## Override per-project

Replace the symlinked default with a custom script:

```bash
rm scripts/agent-preflight.sh # remove the symlink
# write your own script that exits non-zero on failure
chmod +x scripts/agent-preflight.sh
```

The custom script receives no arguments and runs with the worktree as
its working directory. It MUST return non-zero to block a push.

## Auto-promote on pass

After pre-flight passes, `gx branch finish` creates the PR. If the PR
is in draft state (manually opened earlier, or via a future `--draft`
option here), the finish script automatically marks it
ready-for-review by calling `gh pr ready`. With the budget-friendly
CI defaults (draft PRs skip CI), this is the moment CI is allowed to
fire — once, on a known-passing commit.

Disable per-call with `--no-auto-promote`, or globally with
`GUARDEX_FINISH_AUTO_PROMOTE=0`.

## Flags + env vars

| CLI flag | Env var | Default | Effect |
| --- | --- | --- | --- |
| `--preflight` / `--no-preflight` | `GUARDEX_FINISH_PREFLIGHT` | `true` | Run/skip the pre-flight gate. |
| `--preflight-script <path>` | `GUARDEX_FINISH_PREFLIGHT_SCRIPT` | `scripts/agent-preflight.sh` | Override the script path (relative to worktree, or absolute). |
| `--auto-promote` / `--no-auto-promote` | `GUARDEX_FINISH_AUTO_PROMOTE` | `true` | Promote a draft PR to ready-for-review after pre-flight passes. |

## When to bypass

Only `--no-preflight` if:

- the pre-flight script itself is broken and you need to ship the fix,
- you are landing an emergency rollback and CI/branch protection will
catch any remaining issue, or
- your repo has no `scripts/agent-preflight.sh` and you've decided not
to write one.

For ordinary "the tests are slow" cases, write a faster pre-flight
that only runs the targeted suite for changed paths, instead of
disabling the gate entirely.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-13
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# agent-claude-gx-finish-preflight-and-auto-promote-2026-05-14-01-09 (T1)

Branch: `agent/claude/gx-finish-preflight-and-auto-promote-2026-05-14-01-09`

Shifts agent verification from billable cloud Actions minutes to free local CPU. `gx branch finish` now runs a `scripts/agent-preflight.sh` gate in the worktree BEFORE pushing; non-zero refuses the push. On pass, any draft PR is promoted to ready-for-review so the budget-friendly CI defaults can fire once on a known-passing commit.

## Files

- `templates/scripts/agent-preflight.sh` (new) — auto-detects Node/pnpm, Node/npm, Rust, Python and runs conventional verification.
- `scripts/agent-preflight.sh` (new symlink) — points at the template per the existing paired-script convention.
- `templates/scripts/agent-branch-finish.sh` — adds `--no-preflight` / `--preflight` / `--preflight-script <path>` / `--no-auto-promote` / `--auto-promote` flags + matching `GUARDEX_FINISH_PREFLIGHT*` / `GUARDEX_FINISH_AUTO_PROMOTE` env vars; calls `run_preflight` before any push; calls `maybe_auto_promote_pr` after the PR exists.
- `scripts/check-script-symlinks.sh` — adds `scripts/agent-preflight.sh` to the required-symlinks list.
- `src/context.js` — adds `scripts/agent-preflight.sh` to `TEMPLATE_FILES` so `gx setup` scaffolds it into downstream projects.
- `docs/preflight.md` (new) — documents the convention.

## Acceptance

- Pre-flight runs before push; non-zero refuses push (verified via shell syntax + `--no-preflight` override path exists).
- Symlink check passes (`scripts/check-script-symlinks.sh` now expects 11 paired files).
- gitguardex own `npm test` green after symlink + finish-script edits.

## Cleanup

- [ ] Run: `gx branch finish --branch agent/claude/gx-finish-preflight-and-auto-promote-2026-05-14-01-09 --base main --via-pr --wait-for-merge --cleanup`
- [ ] Record PR URL + `MERGED` state.
- [ ] Confirm sandbox worktree is gone.
1 change: 1 addition & 0 deletions scripts/agent-preflight.sh
1 change: 1 addition & 0 deletions scripts/check-script-symlinks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ required_symlinks=(
scripts/agent-branch-finish.sh
scripts/agent-branch-merge.sh
scripts/agent-file-locks.py
scripts/agent-preflight.sh
scripts/agent-worktree-prune.sh
scripts/codex-agent.sh
scripts/install-agent-git-hooks.sh
Expand Down
1 change: 1 addition & 0 deletions src/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ function toDestinationPath(relativeTemplatePath) {
// safety block (auto-managed by syncManagedGitignoreLines below).
const TEMPLATE_FILES = [
'scripts/agent-session-state.js',
'scripts/agent-preflight.sh',
'scripts/guardex-docker-loader.sh',
'scripts/guardex-env.sh',
'scripts/install-vscode-active-agents-extension.js',
Expand Down
104 changes: 103 additions & 1 deletion templates/scripts/agent-branch-finish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ PARENT_GITLINK_AUTO_COMMIT_RAW="${GUARDEX_FINISH_PARENT_GITLINK_AUTO_COMMIT:-tru
AUTO_RESOLVE_MODE_RAW="${GUARDEX_FINISH_AUTO_RESOLVE:-none}"
AUTO_RESOLVE_SAFE_GLOBS_DEFAULT='.omc/**:.omx/state/**:.dev-ports.json:apps/logs/**:.agents/settings.local.json:.codex/state/**:.claude/state/**'
AUTO_RESOLVE_SAFE_GLOBS_RAW="${GUARDEX_FINISH_AUTO_RESOLVE_SAFE_GLOBS-$AUTO_RESOLVE_SAFE_GLOBS_DEFAULT}"
PREFLIGHT_ENABLED_RAW="${GUARDEX_FINISH_PREFLIGHT:-true}"
PREFLIGHT_SCRIPT_RAW="${GUARDEX_FINISH_PREFLIGHT_SCRIPT:-scripts/agent-preflight.sh}"
AUTO_PROMOTE_DRAFT_RAW="${GUARDEX_FINISH_AUTO_PROMOTE:-true}"

run_guardex_cli() {
if [[ -n "$CLI_ENTRY" ]]; then
Expand Down Expand Up @@ -67,11 +70,83 @@ normalize_int() {
printf '%s' "$value"
}

# Resolve the pre-flight script path against the source worktree. The
# caller passes either the configured path (which may be relative) or
# an empty string; we return the absolute path if it exists and is
# executable, otherwise return empty.
resolve_preflight_script() {
local worktree="$1"
local configured="$2"
if [[ -z "$configured" ]]; then
configured="scripts/agent-preflight.sh"
fi
if [[ "$configured" = /* ]]; then
if [[ -x "$configured" ]]; then
printf '%s' "$configured"
fi
return 0
fi
local candidate="${worktree}/${configured}"
if [[ -x "$candidate" ]]; then
printf '%s' "$candidate"
fi
}

# Run the pre-flight verification gate in the agent worktree before
# any push happens. Returns 0 on success or when no gate is
# configured; returns non-zero (and prints a hint) on failure, which
# the caller propagates so the push is refused.
run_preflight() {
local worktree="$1"
if [[ "$PREFLIGHT_ENABLED" -ne 1 ]]; then
return 0
fi
local script_path
script_path="$(resolve_preflight_script "$worktree" "$PREFLIGHT_SCRIPT_RAW")"
if [[ -z "$script_path" ]]; then
echo "[agent-branch-finish] No executable pre-flight script at ${PREFLIGHT_SCRIPT_RAW} (in ${worktree}); skipping pre-flight." >&2
return 0
fi
echo "[agent-branch-finish] Running pre-flight: ${script_path}" >&2
if ( cd "$worktree" && "$script_path" ); then
echo "[agent-branch-finish] Pre-flight passed." >&2
return 0
fi
echo "[agent-branch-finish] Pre-flight FAILED; refusing push. Override with --no-preflight if you really mean it." >&2
return 1
}

# After a PR exists, if it is in draft and auto-promote is enabled,
# mark it ready-for-review. With the budget-friendly CI defaults
# (draft PRs skip CI), this is the moment when CI is allowed to fire.
maybe_auto_promote_pr() {
local pr_url="$1"
if [[ -z "$pr_url" ]] || [[ "$AUTO_PROMOTE_DRAFT" -ne 1 ]]; then
return 0
fi
if ! command -v "$GH_BIN" >/dev/null 2>&1; then
return 0
fi
local is_draft
is_draft="$("$GH_BIN" pr view "$pr_url" --json isDraft --jq '.isDraft' 2>/dev/null || true)"
if [[ "$is_draft" != "true" ]]; then
return 0
fi
echo "[agent-branch-finish] PR is draft; promoting to ready-for-review (pre-flight passed)." >&2
if "$GH_BIN" pr ready "$pr_url" >/dev/null 2>&1; then
echo "[agent-branch-finish] PR marked ready-for-review." >&2
else
echo "[agent-branch-finish] gh pr ready failed; PR left in draft. Promote manually if intended." >&2
fi
}

CLEANUP_AFTER_MERGE="$(normalize_bool "$CLEANUP_AFTER_MERGE_RAW" "1")"
WAIT_FOR_MERGE="$(normalize_bool "$WAIT_FOR_MERGE_RAW" "1")"
WAIT_TIMEOUT_SECONDS="$(normalize_int "$WAIT_TIMEOUT_SECONDS_RAW" "1800" "30")"
WAIT_POLL_SECONDS="$(normalize_int "$WAIT_POLL_SECONDS_RAW" "10" "0")"
PARENT_GITLINK_AUTO_COMMIT="$(normalize_bool "$PARENT_GITLINK_AUTO_COMMIT_RAW" "1")"
PREFLIGHT_ENABLED="$(normalize_bool "$PREFLIGHT_ENABLED_RAW" "1")"
AUTO_PROMOTE_DRAFT="$(normalize_bool "$AUTO_PROMOTE_DRAFT_RAW" "1")"

while [[ $# -gt 0 ]]; do
case "$1" in
Expand Down Expand Up @@ -159,9 +234,29 @@ while [[ $# -gt 0 ]]; do
AUTO_RESOLVE_MODE_RAW="none"
shift
;;
--no-preflight)
PREFLIGHT_ENABLED_RAW="false"
shift
;;
--preflight)
PREFLIGHT_ENABLED_RAW="true"
shift
;;
--preflight-script)
PREFLIGHT_SCRIPT_RAW="${2:-}"
shift 2
;;
--no-auto-promote)
AUTO_PROMOTE_DRAFT_RAW="false"
shift
;;
--auto-promote)
AUTO_PROMOTE_DRAFT_RAW="true"
shift
;;
*)
echo "[agent-branch-finish] Unknown argument: $1" >&2
echo "Usage: $0 [--base <branch>] [--branch <branch>] [--no-push] [--cleanup|--no-cleanup] [--wait-for-merge|--no-wait-for-merge] [--wait-timeout-seconds <n>] [--wait-poll-seconds <n>] [--parent-gitlink-commit|--no-parent-gitlink-commit] [--keep-remote-branch|--delete-remote-branch] [--mode auto|direct|pr|--via-pr|--direct-only] [--auto-resolve[=none|safe|full]|--no-auto-resolve]" >&2
echo "Usage: $0 [--base <branch>] [--branch <branch>] [--no-push] [--cleanup|--no-cleanup] [--wait-for-merge|--no-wait-for-merge] [--wait-timeout-seconds <n>] [--wait-poll-seconds <n>] [--parent-gitlink-commit|--no-parent-gitlink-commit] [--keep-remote-branch|--delete-remote-branch] [--mode auto|direct|pr|--via-pr|--direct-only] [--auto-resolve[=none|safe|full]|--no-auto-resolve] [--no-preflight|--preflight] [--preflight-script <path>] [--no-auto-promote|--auto-promote]" >&2
exit 1
;;
esac
Expand Down Expand Up @@ -1155,6 +1250,10 @@ run_pr_flow() {
fi
echo "[agent-branch-finish] PR URL: ${pr_url}" >&2

# Pre-flight already passed by the time we reach the PR; promote any
# existing draft so the budget-friendly CI gate fires once.
maybe_auto_promote_pr "$pr_url"

merge_output=""
if merge_output="$("$GH_BIN" pr merge "$SOURCE_BRANCH" --squash --delete-branch 2>&1)"; then
return 0
Expand Down Expand Up @@ -1186,6 +1285,9 @@ run_pr_flow() {
}

if [[ "$PUSH_ENABLED" -eq 1 ]]; then
if ! run_preflight "$source_worktree"; then
exit 1
fi
if [[ "$MERGE_MODE" != "pr" ]]; then
maybe_push_changed_submodule_branches "$start_ref" "$SOURCE_BRANCH"
if ! direct_push_output="$(git -C "$integration_worktree" push origin "HEAD:${BASE_BRANCH}" 2>&1)"; then
Expand Down
89 changes: 89 additions & 0 deletions templates/scripts/agent-preflight.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# Pre-flight verification gate for gitguardex-managed projects.
#
# Runs in the agent's worktree from `gx branch finish` BEFORE the push
# happens. Returns non-zero to refuse the push so a broken commit
# never reaches the PR / CI / merge funnel.
#
# Auto-detects the project's stack and runs conventional verification:
# - Node/pnpm: pnpm typecheck && pnpm lint && pnpm test (each only
# if the script exists in package.json)
# - Node/npm: npm test (only if defined)
# - Rust: cargo check
# - Python: ruff check (only if ruff is installed)
#
# Override per-project by replacing this file (delete the symlink under
# scripts/agent-preflight.sh and write your own).
#
# Skip a single run with `gx branch finish --no-preflight`.

set -euo pipefail

repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cd "$repo_root"

ran=0
fail=0
run_step() {
local label="$1"
shift
echo "[agent-preflight] -> $label"
if "$@"; then
ran=$((ran + 1))
echo "[agent-preflight] ok"
else
echo "[agent-preflight] FAIL: $label" >&2
fail=1
fi
}

has_package_script() {
local script_name="$1"
[[ -f package.json ]] || return 1
grep -E "\"${script_name}\"\\s*:" package.json >/dev/null 2>&1
}

# Node detection
if [[ -f package.json ]]; then
pkg_manager=""
if command -v pnpm >/dev/null 2>&1 && [[ -f pnpm-lock.yaml ]]; then
pkg_manager="pnpm"
elif command -v npm >/dev/null 2>&1 && [[ -f package-lock.json ]]; then
pkg_manager="npm"
fi

case "$pkg_manager" in
pnpm)
has_package_script typecheck && run_step "pnpm typecheck" pnpm typecheck
has_package_script lint && run_step "pnpm lint" pnpm lint
has_package_script test && run_step "pnpm test" pnpm test
;;
npm)
has_package_script test && run_step "npm test" npm test
;;
esac
fi

# Rust detection
if [[ -f Cargo.toml ]] && command -v cargo >/dev/null 2>&1; then
run_step "cargo check" cargo check --quiet
fi

# Python detection (ruff if available; pytest is too project-specific to default)
if [[ -f pyproject.toml ]] && command -v ruff >/dev/null 2>&1; then
run_step "ruff check" ruff check .
fi

if [[ "$ran" -eq 0 ]]; then
echo "[agent-preflight] No recognized project stack detected; skipping checks." >&2
exit 0
fi

if [[ "$fail" -ne 0 ]]; then
echo "[agent-preflight] Verification failed; refusing push." >&2
echo "[agent-preflight] Fix the issues, or re-run with: gx branch finish --no-preflight ..." >&2
exit 1
fi

echo "[agent-preflight] ${ran} step(s) passed."
exit 0
Loading