Files
gstack/land-and-deploy/sections/merge-and-deploy.md
T
Garry Tan 7b534d3e90 v1.90.2.0 perf: halve local free-suite time and preserve coverage (#2972)
* perf: remove repeated test work and preserve AUQ execution budgets

* fix: validate native evaluation fixture evidence at its actual boundaries

* fix: clarify deployment approval and recovery state transitions

* chore: document coverage and release v1.90.2.0

* test: preserve Windows scheduling and native no-change consent

* test: recognize verified reads through fixture symlinks

* test: isolate alias-name installation from runtime assets
2026-09-25 13:27:07 -04:00

15 KiB

Step 4: Merge the PR

Enter only with Step 3.5 approval for this exact PR_HEAD. Record start time; initialize MERGE_ATTEMPT=none, MERGE_EXIT=0, MERGE_ERROR='', WAITED=false. Keep these values across readbacks; never reset them to retry. Resolve MERGE_METHOD from Deploy Configuration and gh api "repos/$REPO" --jq '{squash: .allow_squash_merge, merge: .allow_merge_commit, rebase: .allow_rebase_merge}'. Prefer squash, then merge, then rebase when not configured. Disallowed/unknown methods: STOP and ask. Set MERGE_FLAG to --squash, --merge or --rebase. Run the following readback before the first attempt, after every attempt, and while waiting. It is the only dispatcher; no command falls through to another merge.

4a-postfail: Post-failure PR-state check

Universal invariant: after ANY non-zero exit from gh pr merge, query authoritative PR state before retrying or stopping. Do NOT retry blindly. Related: cli/cli#3442, cli/cli#13380. gh pr view does not expose queue membership; use GraphQL for both autoMergeRequest and mergeQueueEntry. Failed/unsupported/missing fields are unknown, never evidence that a request or queue entry is absent.

READBACK=$(gh api graphql -f query='query($owner:String!,$name:String!,$number:Int!) {
  repository(owner:$owner,name:$name) { pullRequest(number:$number) {
    state headRefOid baseRefName mergedAt mergeCommit { oid }
    autoMergeRequest { enabledAt } mergeQueueEntry { id state }
  } }
}' -f owner="${REPO%/*}" -f name="${REPO#*/}" -F number="$PR_NUMBER") || exit 1
printf '%s' "$READBACK" | jq -e '
  ((.errors // []) | length == 0) and
  (.data.repository.pullRequest | type == "object" and
    has("state") and has("headRefOid") and has("baseRefName") and has("mergeCommit") and
    has("autoMergeRequest") and has("mergeQueueEntry"))' >/dev/null || exit 1
PR_STATE=$(printf '%s' "$READBACK" | jq -er '.data.repository.pullRequest.state') || exit 1
CURRENT_HEAD=$(printf '%s' "$READBACK" | jq -er '.data.repository.pullRequest.headRefOid') || exit 1
CURRENT_BASE=$(printf '%s' "$READBACK" | jq -er '.data.repository.pullRequest.baseRefName') || exit 1
ACTIVE_REQUEST=$(printf '%s' "$READBACK" | jq -r '.data.repository.pullRequest | .autoMergeRequest != null or .mergeQueueEntry != null')
MERGE_ACTION=STOP
case "$PR_STATE" in
  MERGED)
    if [ "$CURRENT_HEAD" != "$PR_HEAD" ] || [ "$CURRENT_BASE" != "$BASE_BRANCH" ]; then
      MERGE_ACTION=MERGED_CHANGED
    else
      MERGE_ACTION=MERGED
    fi ;;
  OPEN)
    if [ "$CURRENT_HEAD" != "$PR_HEAD" ]; then
      MERGE_ACTION=HEAD_CHANGED
    elif [ "$CURRENT_BASE" != "$BASE_BRANCH" ]; then
      MERGE_ACTION=BASE_CHANGED
    elif [ "$ACTIVE_REQUEST" = true ]; then
      MERGE_ACTION=WAIT
    elif [ "$WAITED" = true ]; then
      MERGE_ACTION=STOP
    elif [ "$MERGE_ATTEMPT" = none ]; then
      MERGE_ACTION=START
    elif [ "$MERGE_ATTEMPT" = auto ] && [ "$MERGE_EXIT" -ne 0 ]; then
      case "$MERGE_ERROR" in
        *"Auto-merge is not allowed for this repository"*|*"Pull request is in clean status"*|*"Pull request is in unstable status"*) MERGE_ACTION=DIRECT ;;
      esac
    fi ;;
esac
printf '%s\n' "$MERGE_ACTION"

Readback failure or unknown state: STOP, preserve command errors and do not merge. HEAD_CHANGED/BASE_CHANGED: invalidate the approval, STOP and return through Step 1 and readiness for the new target. MERGED_CHANGED: report the authoritative external merge, but STOP cleanup/deploy/rollback until the changed head/base is reconciled; the old scope/approval is unusable. Never replay it. WAIT goes to §4a. STOP surfaces the original stderr and current state. If state == "CLOSED": STOP, the PR closed without merging.

Only START makes the first attempt. Immediately before either merge command, repeat readback and Step 1's local HEAD/branch/cleanliness check. Retargeting or local changes invalidate readiness; --match-head-commit protects head, not destination.

MERGE_ATTEMPT=auto
MERGE_EXIT=0
MERGE_ERROR=$(gh pr merge "$MERGE_FLAG" --auto --delete-branch "$PR_NUMBER" --repo "$REPO" --match-head-commit "$PR_HEAD" 2>&1) || MERGE_EXIT=$?

Return to readback, even on exit 0. Only DIRECT permits one direct fallback: readback has confirmed OPEN, no auto request and no queue entry, and the auto attempt returned one of the two documented rejection classes: auto-merge disabled, or PR already clean/unstable with nothing required pending. The latter does not mean auto-merge is disabled. Recheck required CI as in Step 2 before the fallback; failures or unknown check results stop, even if GitHub calls the PR mergeable.

MERGE_ATTEMPT=direct
MERGE_EXIT=0
MERGE_ERROR=$(gh pr merge "$MERGE_FLAG" --delete-branch "$PR_NUMBER" --repo "$REPO" --match-head-commit "$PR_HEAD" 2>&1) || MERGE_EXIT=$?

Return to readback. There is no fallback from a direct attempt. Hard rule: never replay a merge after MERGED, or retry an unknown state/error. No --admin bypass.

If state == "MERGED":

The server-side merge succeeded (possibly completed before the local cleanup phase failed, or a concurrent merge landed). Tell the user: "PR is merged on GitHub." (Do NOT say "the merge succeeded" — this handles the concurrent-merge case.)

Capture merge SHA:

MERGE_SHA=$(printf '%s' "$READBACK" | jq -er '.data.repository.pullRequest.mergeCommit.oid') || exit 1

Squash/rebase merge readback guard:

  • Do not prove success by requiring the PR head SHA to be an ancestor of the base branch. GitHub squash and rebase merges deliberately create a new commit, so git merge-base --is-ancestor <head_sha> origin/<base> can fail even when the PR is merged.
  • Once GitHub reports state == "MERGED" with a non-null mergeCommit.oid, treat that as authoritative. Record the merge SHA and continue.
  • If local cleanup or readback is needed, fetch the base branch and compare/sync against the merge commit, not the old PR branch commit:
git fetch "https://github.com/$REPO.git" "$BASE_BRANCH"
git diff --quiet "$MERGE_SHA" FETCH_HEAD || git log --oneline --decorate -1 "$MERGE_SHA" FETCH_HEAD
  • If the worktree is clean and only needs to stop looking diverged after a squash merge, prefer a named local branch at the merge commit, for example git switch -c "codex/post-merge-pr-$PR_NUMBER" "$MERGE_SHA". Avoid detached HEAD in Codex Desktop worktrees because git action workers often expect git symbolic-ref --short HEAD to return a branch. Do not force-push or reset a user's branch unless they explicitly ask.

Worktree cleanup — non-destructive, candidate-based:

git worktree list --porcelain

Identify candidates: a worktree is stale if (a) it is checked out on the base branch, AND (b) it is not the user's current main working tree, AND (c) git status --porcelain inside it is empty (no uncommitted work).

  • For each clean candidate: OFFER to remove it. Say: "There's a stale worktree at <path> checked out on <branch> with no uncommitted work. Remove it?" Remove only if user confirms (git worktree remove <path> && git worktree prune).
  • If any candidate has uncommitted work: list the files, tell the user, and STOP worktree cleanup without removing anything.
  • Do NOT use --force. Do NOT remove the user's primary working tree.

Remote-branch reconciliation: --delete-branch may not have completed. Verify the branch outcome instead of claiming cleanup from a merge exit code:

# NB: gh leaves .headRepository.nameWithOwner EMPTY (verified against gh
# 2.83); compose owner/name from headRepositoryOwner.login + headRepository.name.
gh pr view "$PR_NUMBER" --repo "$REPO" --json headRepositoryOwner,headRepository,headRefName \
  --jq '"\(.headRepositoryOwner.login)/\(.headRepository.name)\t\(.headRefName)"'
git ls-remote --heads "https://github.com/<head-repository>.git" "<head-branch>"

Record the first field as <head-repository> (owner/name) and the second as <head-branch>, then substitute both into git ls-remote. The PR head repository is the authoritative branch location: for same-repository PRs it is the base repository; for fork PRs it is the fork. Do not substitute the checkout's origin. If the metadata lookup fails or either field is empty or contains a bare /, treat the branch state as unknown and do not run the deletion path.

Three outcomes — never read a failed check as a clean branch:

  • Exit 0, empty output — the remote branch is already gone (GitHub's post-merge deletion or a concurrent actor got there). Tell the user: "The remote branch has already been cleaned up." This makes re-runs of the recovery idempotent.
  • Exit 0, one ref line — the branch survived: the failed merge command never reached its --delete-branch half. If <head-repository> is the BASE repository, OFFER deletion, confirm-first (matching the worktree-cleanup posture above): "The remote branch <head-branch> still exists in <head-repository> — the failed merge never ran its --delete-branch half. Delete it?" Only on confirmation: git push "https://github.com/<head-repository>.git" --delete "<head-branch>". If <head-repository> is a FORK, do not offer deletion — the branch belongs to the contributor and the maintainer typically has no push rights there; report instead: "The branch lives on the contributor's fork <head-repository> — leaving it to them." If a local branch of the same name exists, offer git branch -d "<head-branch>" alongside (-d, never -D — a non-fast-forwarded local branch is the user's call).
  • Non-zero exit — the check ITSELF failed (network, auth). Tell the user: "Couldn't verify remote branch state — leaving it alone." and skip the deletion offer entirely; a failed check is unknown state, not a clean branch.

Record the actual path (auto, direct, queue, or external when already merged before our attempt), then continue to §4b (CI auto-deploy detection).

4a: Merge queue detection and messaging

If state == "OPEN" and either request is non-null: auto-merge is enabled or merge queue is in use. Explain which is observed; an auto request alone does not prove a queue. A queue reruns CI against the proposed merge. Record MERGE_PATH=queue only when mergeQueueEntry was observed, otherwise auto.

Set WAITED=true. Repeat the readback every 30 seconds, up to 30 minutes; report progress every 2 minutes. While OPEN with an active auto request or queue entry, keep waiting. Once waiting has begun, never dispatch START or DIRECT: OPEN with confirmed absence of both means removal/cancellation, so STOP and point to GitHub's checks/queue page. MERGED returns to the merge-SHA/cleanup branch above. CLOSED, head change, failed readback or timeout stops without replaying or cancelling the server-side request. Explain that a timed-out active request may still merge later.

4b: CI auto-deploy detection

After the PR is merged, check if a deploy workflow was triggered by the merge:

gh run list --repo "$REPO" --branch "$BASE_BRANCH" --limit 10 --json databaseId,name,status,conclusion,workflowName,headSha

Look for runs matching MERGE_SHA and the deploy workflow identified before approval (read its jobs, not just its name). Distinguish staging from production. If found:

  • Tell the user: "PR merged. I can see a deploy workflow ('{workflow-name}') kicked off automatically. I'll monitor it and let you know when it's done."

If no deploy workflow is found after merge:

  • Tell the user: "PR merged. I don't see a deploy workflow — your project might deploy a different way, or it might be a library/CLI that doesn't have a deploy step. I'll figure out the right verification in the next step."

If MERGE_PATH=queue and a deploy workflow exists:

  • Tell the user: "PR made it through the merge queue and the deploy workflow is running. Monitoring it now."

Record merge timestamp, duration, and merge path for the deploy report.


Step 5: Deploy strategy detection

Use the saved pre-merge scope and deployment facts; do not classify the cleaned-up checkout. This skill observes existing deployment triggers, not invents new ones.

One precedence rule for Steps 5-7: an explicit verification URL or an actually triggered deployment takes precedence over a docs-only shortcut. Evaluate in order:

Select the production route below, then complete Step 5a before executing that route. The docs-only no-deploy route can finish immediately; it needs no staging offer.

  1. Matching deploy run/platform release: monitor it in Step 6, even for docs-only (it may be a docs site). A configured trigger whose run has not appeared remains pending; poll for the matching revision within Step 6's deadline, not another run.
  2. Explicit VERIFY_URL: run Step 7 even for docs-only. Without deployment-revision evidence, report site health separately from whether this change is live.
  3. DOCS_ONLY=true, no explicit URL, and no triggered/expected deployment: record SKIPPED (docs-only), then Step 9 with MERGED — NO DEPLOY NEEDED. Unknown deployment detection is not proof that nothing was triggered; use the question below instead.
  4. Otherwise use configured production URL/status checks in Steps 6-7. If neither a usable URL nor deploy status exists, ask once. Also ask when Step 6 finishes without a production URL needed for canary:
    • Re-ground: "PR #NNN is merged. {Known deploy state}. I need a URL to check health; merge alone does not prove this revision is live."
    • RECOMMENDATION: A for a web app; B only when no deployment is required.
    • A) Provide the production URL → save it, continue to Step 7
    • B) No deploy needed (library/CLI) → Step 9, MERGED — NO DEPLOY NEEDED
    • C) Finish without verification → Step 9, use the evidence-based verdict table Offer B only with no observed or expected deploy; it cannot erase a running/failing deploy.

5a: Optional staging verification, not a deployment gate

For non-doc changes, offer this only when a staging/preview URL and successful deployment record identify PR_HEAD (preview) or MERGE_SHA (post-merge staging). A URL alone is insufficient. If unavailable, record staging N/A and take the production route above. No staging trigger or promotion is executed here.

  • Re-ground: "There is a deployment of this change at {staging URL}. I can check it too, but production may already be live; this does not hold or roll back production."
  • RECOMMENDATION: A adds staging evidence without dropping production verification.
  • A) Verify staging, then production
  • B) Verify production only
  • C) Verify staging only; leave production verification incomplete

A/C run Step 7 against the staging URL with TARGET=staging, preserving separate staging and production evidence. Healthy staging sets STAGING_STATUS=VERIFIED: A returns to the production route above; C goes to Step 9, STAGING VERIFIED — PRODUCTION UNVERIFIED. On staging failures use Step 7's decision paths, never automatically promote. B records SKIPPED and takes the production route.