diff --git a/docs/preflight.md b/docs/preflight.md new file mode 100644 index 0000000..ee012f5 --- /dev/null +++ b/docs/preflight.md @@ -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 ` | `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. diff --git a/openspec/changes/agent-claude-gx-finish-preflight-and-auto-promote-2026-05-14-01-09/.openspec.yaml b/openspec/changes/agent-claude-gx-finish-preflight-and-auto-promote-2026-05-14-01-09/.openspec.yaml new file mode 100644 index 0000000..93831bd --- /dev/null +++ b/openspec/changes/agent-claude-gx-finish-preflight-and-auto-promote-2026-05-14-01-09/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-13 diff --git a/openspec/changes/agent-claude-gx-finish-preflight-and-auto-promote-2026-05-14-01-09/notes.md b/openspec/changes/agent-claude-gx-finish-preflight-and-auto-promote-2026-05-14-01-09/notes.md new file mode 100644 index 0000000..3970b8c --- /dev/null +++ b/openspec/changes/agent-claude-gx-finish-preflight-and-auto-promote-2026-05-14-01-09/notes.md @@ -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 ` / `--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. diff --git a/scripts/agent-preflight.sh b/scripts/agent-preflight.sh new file mode 120000 index 0000000..b611e44 --- /dev/null +++ b/scripts/agent-preflight.sh @@ -0,0 +1 @@ +../templates/scripts/agent-preflight.sh \ No newline at end of file diff --git a/scripts/check-script-symlinks.sh b/scripts/check-script-symlinks.sh index a2e9fc2..33a736f 100755 --- a/scripts/check-script-symlinks.sh +++ b/scripts/check-script-symlinks.sh @@ -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 diff --git a/src/context.js b/src/context.js index b9d0d38..6a83402 100644 --- a/src/context.js +++ b/src/context.js @@ -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', diff --git a/templates/scripts/agent-branch-finish.sh b/templates/scripts/agent-branch-finish.sh index 0af6475..8f5325f 100755 --- a/templates/scripts/agent-branch-finish.sh +++ b/templates/scripts/agent-branch-finish.sh @@ -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 @@ -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 @@ -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 ] [--no-push] [--cleanup|--no-cleanup] [--wait-for-merge|--no-wait-for-merge] [--wait-timeout-seconds ] [--wait-poll-seconds ] [--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 ] [--no-push] [--cleanup|--no-cleanup] [--wait-for-merge|--no-wait-for-merge] [--wait-timeout-seconds ] [--wait-poll-seconds ] [--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 ] [--no-auto-promote|--auto-promote]" >&2 exit 1 ;; esac @@ -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 @@ -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 diff --git a/templates/scripts/agent-preflight.sh b/templates/scripts/agent-preflight.sh new file mode 100755 index 0000000..dee5c3f --- /dev/null +++ b/templates/scripts/agent-preflight.sh @@ -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