Push & CI Monitor
SkillMonitoring & opsPush to remote and monitor CI. Validates branch safety, executes git push WITH explicit user approval, then monitors CI run status via gh CLI. Use when: user says 'push', 'push and watch CI', 'ship it', 'push-ci'. Not for: committing (use /smart-commit), creating PRs (use /create-pr), merging (use /merge-prep).
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Push & CI Monitor skill
What this skill tells your AI
The instructions your AI receives, as published by sd0xdev/sd0x-harness in skills/push-ci/SKILL.md and read by ahel’s review.
Push to remote with user approval, then monitor CI run until completion.
Authorization
⚠️ This skill is one of two authorized paths for Claude to execute `git push`.
⚠️ The other is /epic-merge (--force-with-lease for stacked PR chains, per-iteration AskUserQuestion gate).
⚠️ This skill may also use --force-with-lease, but only when the caller passes the flag — and NEVER onto a protected branch; bare --force is forbidden everywhere.
⚠️ All other skills and rules MUST output push commands only (not execute).
⚠️ Push REQUIRES explicit user approval via AskUserQuestion — no exceptions.
| Rule | This Skill | /epic-merge | All Other Skills |
|---|---|---|---|
git push | Execute (after user approval) | Forbidden (uses --force-with-lease only) | Forbidden (output only) |
git push --force | Forbidden | Forbidden | Forbidden |
git push --force-with-lease | Execute — only when --force-with-lease is explicitly passed, after user approval naming the force form; never onto a protected branch (Phase 0 hard-aborts, Phase 2 re-asserts) | Execute (after per-iteration AskUserQuestion) | Forbidden |
| Push to protected branches (main/master/develop/release/*) | Warn + pre-approval via AskUserQuestion (final gate is the terminal hook when installed, otherwise this approval); with --force-with-lease → hard abort, no question asked | Protected PR heads rejected — Phase 0 validation, re-asserted before Step 5 and Rollback (a PR head is not inherently unprotected) | Forbidden |
Defense in Depth: Push Safety
| Layer | Mechanism | Scope | Reliability |
|---|---|---|---|
| L1: git pre-push hook (opt-in) | pre-push-gate.sh reads /dev/tty for terminal confirmation | Two classes reach the prompt (since 2026-08-21): a protected branch with ALLOW_PUSH_PROTECTED unset, and a history-rewriting push whose rewritten refs are not already covered by that first prompt, with ALLOW_FORCE_UNSHARED unset. Non-fast-forward is an earlier, orthogonal refusal — this hook's own exit 1 only when git hands it the ref (the force form); otherwise git refuses first and this hook never sees it (§ below). Skipped by ALLOW_FORCE_WITH_LEASE=1, after which the push falls through to the unshared attestation first and the protected check second — not straight to the protected check (scripts/pre-push-gate.sh: the refusal at the force-form check, then the rewrite gate, then the protected gate) | Immune to Claude Code permission caching — when installed, and only for the classes it prompts on |
| L2: AskUserQuestion | In-session prompt before push | All pushes | May be auto-approved by session caching |
| L3: git-workflow rules | Claude forbidden from raw git push | All contexts | Behavioral enforcement |
Which layer authorizes depends on whether L1 is installed. The pre-push hook is opt-in (/codex-setup init --with-push-gate, or sync --with-push-gate on an existing project). /install-scripts copies pre-push-gate.sh into .claude/scripts/ and never wires up a hook, so having run it is not evidence the gate exists.
L1 prompts for two classes of push, and they are different questions. The protected prompt asks may this branch be pushed to at all; it fires when the ref set includes a protected branch and ALLOW_PUSH_PROTECTED is unset. The unshared attestation asks is anybody else working on the refs this push rewrites; it fires when the push rewrites a ref and ALLOW_FORCE_UNSHARED is unset, over the rewritten refs the protected prompt will not already cover. Rewrite is read per ref class, because ancestry is the branch rule: a branch update is a rewrite when the remote tip is not provably an ancestor of what replaces it, while every update to an existing tag is one — git requires force semantics for any change to an existing refs/tags/* ref, forward moves included, because a tag names one commit rather than a line of history (scripts/pre-push-gate.sh § is_tag_ref, and the hook prints a separate line for tags precisely because a forced tag update can be a textbook fast-forward). A tag creation has no history to overwrite and is not asked about, and neither is a deletion of any ref class — the gate's rewrite test needs a non-null OID on both sides, so removing an existing tag or branch reaches no prompt (rules/git-workflow.md § Push safety states the boundary; the maintainer decided on 2026-08-22 to leave it there). Reading the class as "non-fast-forward" alone would wave through exactly the moves git itself classifies as forced — which is every rewritten ref when ALLOW_PUSH_PROTECTED=1 has silenced that prompt. Neither variable clears the other. Every push the hook permits without meeting either condition exits 0 (scripts/pre-push-gate.sh). A push the hook refuses is a third outcome and not a row below: it never happens, so nothing authorized it. So the gate is chosen by both axes, and an installed hook never demotes L2 for a push the hook does not prompt on:
| Push (one the hook permits) | L1 installed | L1 not installed |
|---|---|---|
Protected branch, ALLOW_PUSH_PROTECTED unset | L1 authorizes — /dev/tty confirmation, immune to permission caching. L2 stays required but advisory | L2 authorizes — the AskUserQuestion in Phase 0/1 |
History-rewriting push — a branch update that is not provably a fast-forward, read fail-closed (the gate negates merge-base --is-ancestor, so an ancestry test that errors lands here exactly as one that answers no), or any update to an existing tag (forward moves included) — with ALLOW_FORCE_UNSHARED unset | L1 authorizes — the /dev/tty unshared attestation. L2 stays required but advisory | L2 authorizes — and Phase 1 must put the unshared question to the user itself (below), because without the hook nothing else will |
| Every other permitted push, an ordinary fast-forward included | L2 authorizes — L1 exits without prompting, so there is no terminal confirmation to defer to | L2 authorizes |
The tag half of that second row is a hook-level fact, not a /push-ci one. This skill only ever
builds a branch refspec, so a tag update cannot arise through it. The row states it anyway because
this table describes pre-push-gate.sh for whoever invokes it — a developer pushing a tag by hand
meets the same gate — and because a class stated as "non-fast-forward" would be read as a complete
definition by exactly the reader who then pushes a tag from the shell.
Non-fast-forward is an orthogonal refusal, not a third row. It is decided before the protected check and never produces a confirmation of its own: without ALLOW_FORCE_WITH_LEASE=1 the push is refused outright (exit 1) — by the hook when it is given the ref, by git before that when no force flag was passed, and a refusal either way is not an authorization: nothing was approved, the push simply did not happen. With the variable set the hook's refusal is skipped entirely and the push falls through to the same protected-branch decision as any other. So it lands in whichever row its branch puts it in. Measured, all five shapes — these are hook-level facts, describing pre-push-gate.sh for whoever invokes it:
| Push | ALLOW_FORCE_WITH_LEASE | Result | Reachable via /push-ci? |
|---|---|---|---|
| Unprotected, non-fast-forward, no force flag | unset or empty | git rejects the ref client-side; the hook runs with an empty ref list and exits 0 having never seen the branch | Yes — a flagless /push-ci on a diverged branch. The gate refused nothing here |
Unprotected, non-fast-forward, --force-with-lease | unset | exit 1 — the hook's own refusal, before the protected check, no prompt | No — Phase 2 sets the variable in the same branch that passes the flag. Only a manual force push reaches this cell |
Unprotected, non-fast-forward, --force-with-lease | 1 | Reaches /dev/tty for the unshared attestation — the force row above, L1 authorizes. exit 0 only once attested (or with ALLOW_FORCE_UNSHARED=1); no terminal ⇒ exit 1 | Yes — this is the one force path this skill has |
Protected, non-fast-forward, --force-with-lease | 1 | Reaches /dev/tty once — the protected prompt; the attestation excludes refs that prompt covers | No — Phase 0 hard-aborts protected × --force-with-lease before any question, and Phase 2 re-asserts it. Only a manual git push --force-with-lease reaches this cell |
Protected, non-fast-forward, --force-with-lease, ALLOW_PUSH_PROTECTED=1 | 1 | Reaches /dev/tty for the unshared attestation — the protected prompt is silenced, so the rewritten ref is no longer excluded from it | No — this skill never sets ALLOW_PUSH_PROTECTED. Only a manual push reaches this cell, and it is the combination that used to pass in total silence |
Row 1 exists because git does not hand the hook a ref it has already rejected. Measured on git 2.55.0 with a hook that dumps its stdin: a flagless push of a diverged branch runs the hook with zero ref lines — so pre-push-gate.sh finds no branches, detects no non-fast-forward, prompts on nothing and exits 0 — and the ! [rejected] … (non-fast-forward) that stops the push is git's. Add --force-with-lease to the same push and the hook receives the ref line and its own refusal fires. Two consequences: the hook's non-fast-forward exit 1 is reachable only on a push carrying a force flag, and a flagless /push-ci that fails on a diverged branch was stopped by git, not by the gate. Reading row 1 as a gate refusal would credit a credential to an operation no gate ever saw — the same error in the same direction as reading an absent hook as "no approval needed".
The last column is the part a reader of this skill needs, and it is why the table is not just the hook's. Three of the five shapes cannot happen through /push-ci at all: describing them as this skill's paths would promise a terminal confirmation on a route the skill refuses to take. The hook-level facts stay documented because the developer who pushes by hand still meets them.
The trap in both directions: grouping non-fast-forward with protected pushes claims a terminal credential for a push that is merely refused, and grouping it against them denies one for the third shape, where the terminal prompt genuinely happens — for a manual caller.
Reading "the hook is installed" as "something stronger will always ask" is the error this table exists to prevent — and the table cannot supply its own counter-example, because every cell in it either refuses the push or reaches /dev/tty. Rows 1 and 2 are refusals: the push does not happen, so nothing authorized anything. Rows 3–5 all prompt, so with the hook installed L1 is the credential there. The pushes whose only credential is the in-session approval are precisely the ones this table does not contain: every permitted push in neither prompting class — the row above it — and every push at all when the hook is not installed. Generalizing "installed ⇒ prompted" from these five rows is getting the wrong rule from the one table where it happens to hold.
And the "L1 installed" column is a state of the world, not something Phase 0 can establish. What Phase 0 reads is whether an executable hook references the gate — PUSH_GATE=referenced, never installed — and reference cannot prove invocation: a script that merely names the gate in a live command satisfies the same test. So the demotion in the top-left cell is earned by the operator seeing the /dev/tty prompt, never by the detection predicting one: if the approval is given and no prompt appears, that in-session approval was the only approval, whatever PUSH_GATE reported. This is why the approval is unconditionally required rather than skipped when L1 looks present — the check is allowed to be wrong in the unsafe direction precisely because nothing is skipped on its word.
Why AskUserQuestion is the weaker of the two: session permission caching can auto-approve AskUserQuestion calls in long-running sessions, especially with -c continue mode (GitHub Issue #15400). That weakness is the reason L1 is worth installing — it is not a licence to push without approval when L1 is absent. An uninstalled gate lowers the strength of the authorization; it never removes the requirement for one.
Workflow
sequenceDiagram
participant C as Claude
participant U as User
participant GH as GitHub
C->>C: Phase 0: Preflight
alt Protected branch + --force-with-lease
C->>U: ⛔ Hard abort — prohibited combination, no question asked, no push
else Permitted combination
opt Protected branch (plain push)
C->>U: ⚠️ Warning + ask pre-approval
U->>C: Continue / Abort
end
C->>U: Phase 1: Show push plan + ask approval
U->>C: Approve / Reject
alt Approved
C->>C: Phase 2: Protected×lease guard + command assembly
C->>GH: Execute git push
C->>GH: Phase 3: Delegate to /watch-ci
GH-->>C: CI verdict
else Rejected
C->>U: Abort (no push)
end
end
Phase 0: Preflight
Run all checks. Hard-abort on infrastructure failures; warn-and-confirm on protected branches — except protected branch × --force-with-lease, which hard-aborts (prohibited combination, see step 0 below).
# 0. Bind the two flag variables from THIS invocation, before anything reads them.
# Write the literal value on each line: `true` only when that flag appeared in the
# /push-ci invocation, `false` otherwise. Both lines are always written, always in
# this order, and never guarded by `:=`, `-z` or `[ -n … ]` — a guard would let an
# inherited environment value survive, which is the entire defect this step closes.
# Measured: with these two unbound and an ambient `FORCE_WITH_LEASE=true` exported,
# the Phase 2 assembly below runs `git push --force-with-lease=refs/heads/<b>:<tip>`
# for an invocation that passed no flag and a plan that showed a plain push. The
# approval must name the force form (`rules/git-workflow.md` § Exception), so the
# form has to come from the arguments and from nothing else.
FORCE_WITH_LEASE=false # `true` only if the invocation contained --force-with-lease
SET_UPSTREAM=false # `true` only if the invocation contained --set-upstream
# ── Step 0a: the interpreter, before anything else ────────────────────────────
# First, because every check below is only as good as the shell running it. A non-interactive bash
# SOURCES `$BASH_ENV` before line 1 of this fence; zsh does the same with `$ENV` under sh
# emulation. A sourced file may define a function whose name contains a slash — bash refuses to
# IMPORT such a name from the environment, which is why the prefix is spelled absolutely, but it
# does not refuse to DEFINE one. Measured 2026-08-22, bash 3.2.57 and zsh 5.9: with
# `function /usr/bin/env { …; }` defined, the word `/usr/bin/env` resolved to the function and the
# child never ran. Every reading this phase prints, and the destination this phase digests for
# the approval, would then be whatever that function chose to say.
#
# **This block contains no command word, and that is the design.** Two `[[ ]]` tests (a keyword the
# parser resolves — a function cannot outrank it), three assignments (syntax, not commands), one
# expansion. Round 65 rewrote it after measuring the two ways the first version failed:
# * it read its sentinel without resetting it, so an exported `SD0X_PUSH_CI_REFUSED=1` satisfied
# the expansion and the fence continued with status 0 — the refusal printed and nothing stopped;
# * it used `${!name+set}`, bash indirect expansion, which zsh rejects as `bad substitution`
# even under `--emulate sh` — so on macOS's default shell it aborted at the first iteration
# whether or not anything was set, and the `ENV` refusal it documents never ran.
# Assign, THEN expand: `:?` fires on null **or** unset, so assigning empty one line above makes it
# fire unconditionally. Set-ness, not emptiness, for what is DETECTED (`${BASH_ENV+set}` — an
# exported empty value is still a file the parent named); names never values (Anchor Register #2).
#
# What this does NOT close, stated because the comment that used to stand here over-claimed: a
# startup file that defines the function and then unsets the variable leaves nothing to detect. That
# residue has no owner downstream — the `pre-push` hook is opt-in, so where it is absent the
# in-session approval is the whole credential (`rules/git-workflow.md` § Push safety).
SHELL_STARTUP_INHERITED=
[[ -n "${BASH_ENV+set}" ]] && SHELL_STARTUP_INHERITED=BASH_ENV
[[ -n "${ENV+set}" ]] && SHELL_STARTUP_INHERITED="${SHELL_STARTUP_INHERITED:+${SHELL_STARTUP_INHERITED}, }ENV"
if [[ -n "$SHELL_STARTUP_INHERITED" ]]; then
# No apostrophe anywhere in the word: inside `${var:?word}` bash reads one as an opening quote
# even within double quotes, and that is a PARSE error — it would take the whole fence down on
# every run, refusing and ordinary alike. Measured 2026-08-22.
SD0X_PUSH_CI_REFUSED=
: "${SD0X_PUSH_CI_REFUSED:?refusing — ${SHELL_STARTUP_INHERITED} is set in this environment.
That startup file is sourced before line 1 of this fence and can redefine the commands below,
including the absolute /usr/bin/env prefix (measured). Nothing this phase reports could then be
relied on, and the in-session approval is the only credential where the opt-in pre-push hook is
not installed. Unset it and re-run. Nothing is planned and nothing is pushed.}"
fi
# 0b. Transport variables decide WHERE git’s traffic goes — which repository is read from and
# written to — so nothing is planned while any of them is set. Four names, each measured 2026-08-22 on git 2.55.0 / OpenSSH 10.3p1: `GIT_SSH_COMMAND`,
# `GIT_SSH` and `GIT_PROXY_COMMAND` are run BY git AS the connection, handed the host and the
# remote command as arguments they are free to ignore; `GIT_SSH_VARIANT` names no executable at
# all but changes the argv git BUILDS — under `=plink` a URL's `:2222` is emitted as OpenSSH's
# `-P`, which takes a *tag* rather than a port (`ssh` usage: `[-P tag]`), so the connection
# silently falls back to 22.
#
# Refusing here, rather than relying on the `-u` clearing every command below carries, is this
# step's whole point. Clearing is not a neutral act: an operator's own
# `GIT_SSH_COMMAND='ssh -p 2222'` encodes part of the destination, and dropping it moves the push
# to port 22 — which SUCCEEDS silently wherever that host serves the same path there too. Set or
# cleared, the URL and digests this phase prints would then describe a destination the push does
# not reach, which is the one thing this phase exists to prevent. The `-u` list stays as defence
# in depth, for any caller that arrives at a later phase without passing through here.
#
# Set-ness, not emptiness, is the test — measured: an exported-empty `GIT_SSH_COMMAND` is not
# treated as unset, git runs `''` as the command (`run_command: GIT_PROTOCOL=version=2 '' -G …`).
# `${VAR+set}` — the direct form, one literal test per name — is what delivers it below; the
# indirect `${!_n+set}` a loop would need is bash-only and is why the loop is gone (next
# paragraph). Names are printed and values never are: a transport
# command line routinely carries a key path (Anchor Register #2).
# Four literal tests rather than a loop over `${!_n+set}`. That is **bash** indirect expansion and
# zsh 5.9 rejects it outright — `bad substitution`, rc=1, even under `--emulate sh` — so on the
# platform's default shell the loop aborted at its FIRST iteration whether or not anything was set:
# this refusal never ran, and neither did anything below it. Measured 2026-08-22. Round 65 took the
# same construction out of step 0a and left this copy, one block away, standing.
TRANSPORT_PRESENT=
[[ -n "${GIT_SSH_COMMAND+set}" ]] && TRANSPORT_PRESENT=GIT_SSH_COMMAND
[[ -n "${GIT_SSH+set}" ]] && TRANSPORT_PRESENT="${TRANSPORT_PRESENT:+${TRANSPORT_PRESENT}, }GIT_SSH"
[[ -n "${GIT_PROXY_COMMAND+set}" ]] && TRANSPORT_PRESENT="${TRANSPORT_PRESENT:+${TRANSPORT_PRESENT}, }GIT_PROXY_COMMAND"
[[ -n "${GIT_SSH_VARIANT+set}" ]] && TRANSPORT_PRESENT="${TRANSPORT_PRESENT:+${TRANSPORT_PRESENT}, }GIT_SSH_VARIANT"
if [[ -n "$TRANSPORT_PRESENT" ]]; then
echo "⛔ transport variables set in this environment: ${TRANSPORT_PRESENT}" >&2
echo " Each one decides where a push lands, so neither honouring nor clearing them lets this" >&2
echo " phase describe the destination that would be reached." >&2
echo " Move the setting to ~/.ssh/config or 'git config core.sshCommand' — per-host, durable," >&2
echo " and visible to 'git config' — then re-run. Nothing is planned or pushed until then." >&2
# Terminated the way step 0a is, and for the same measured reason: `exit` is a builtin, and an
# imported `BASH_FUNC_exit%%` that returns leaves the refusal printed and the phase running.
SD0X_PUSH_CI_REFUSED=
: "${SD0X_PUSH_CI_REFUSED:?refusing — transport variables set in this environment}"
fi
# 1. Current branch
BRANCH=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git rev-parse --abbrev-ref HEAD)
# 1b. Detached HEAD, **or no branch name at all** — hard-abort. On a detached HEAD the command
# above returns the literal string `HEAD`, which is not a branch name; when it *fails* it returns
# nothing, which is not one either, and the empty case was the one this check could not see.
# Every later step treats $BRANCH as a branch name: the protected-branch match below compares it
# against main/master/develop, the upstream probe builds `origin/$BRANCH`, and the push builds a
# branch refspec — so an empty one silently compares against nothing and builds `origin/`. The
# same refusal is already carried in Phase 1; Phase 0 is where the plan is built, so it belongs
# here first.
if [[ -z "$BRANCH" ]] || [[ "$BRANCH" = "HEAD" ]]; then
echo "⛔ Phase 0: no branch name here — detached HEAD, or rev-parse failed. /push-ci pushes a" >&2
echo " named branch, and every check below names it. Nothing is planned and nothing is pushed." >&2
# Not `exit`: a builtin is outranked by an imported `BASH_FUNC_exit%%`, and this arm sets no
# flag a later step reads, so a shadowed `exit` would leave the whole phase running on a
# branch name that is not one.
SD0X_PUSH_CI_REFUSED=
: "${SD0X_PUSH_CI_REFUSED:?refusing — no branch name could be derived in Phase 0}"
fi
# 2. Protected branch detection
# If main, master, develop, or release/* → warn + AskUserQuestion pre-approval
# (do NOT hard-abort; let user decide) — EXCEPT when --force-with-lease was passed:
# that combination hard-aborts, because force push to shared branches is prohibited
# (rules/git-workflow.md § Prohibited) and no approval can authorize it
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 188
- Forks
- 24
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
push-ci- Source
- github.com/sd0xdev/sd0x-harness