jinwon-int

gh-pr-flow

Use when committing or pushing code, opening or merging a PR, resolving REVIEW_REQUIRED, or landing changes in jinwon-int repos. PR-first — exact-head green checks, cross-account review (jinon86/seoseo-ai via relay) after fresh user approval, squash merge, cleanup. Not for Wiki edits (wiki-record).

jinwon-int 0 Updated 1w ago

Resources

6
GitHub

Install

npx skillscat add jinwon-int/ccc-node/gh-pr-flow

Install via the SkillsCat registry.

SKILL.md

gh-pr-flow — PR-first GitHub flow

Use this for code changes that land in GitHub. Operational repos live under
jinwon-int where possible. Never push directly to main; use a branch and PR.
For Wiki content use wiki-record instead.

The flow enforces: no direct main pushes, exact-head and green-check
validation, an independent cross-account review in either direction (protected
PRs; see the table below) after fresh explicit user approval, secret-safe
credential use, squash merge, and verified cleanup.

Identities and review directions

A node's local gh holds jinon86 (typically the only local account).
seoseo-ai review authority lives on the relay node as an isolated,
root-owned profile config, and the relay node separately holds an authorized
jinon86 session. Check with gh auth status. Whichever account authored
the PR, the OTHER account reviews it — both directions are supported
symmetrically:

PR author Reviewer / merger Mechanism
jinon86 seoseo-ai reviews; jinon86 merges approve-via-relay.sh --review-profile seoseo-ai (relay-held profile, exact-head)
seoseo-ai jinon86 reviews and, if needed, merges approve-via-relay.sh --review-profile jinon86 (exact-head) / merge-via-relay.sh (relay-held session over SSH)

Every cross-account review or merge is a privileged credential use and needs
fresh explicit user approval in the current conversation, in both directions.
The sole exception is the owner-switched standing approval for mechanical
fleet-skills promote PRs at the end of this skill (#2239 L3).

GitHub transport policy

  • Use local git and the authenticated gh CLI for every GitHub read and write.
  • Do not use GitHub App, connector, MCP, or plugin tools unless the user
    explicitly requests that transport in the current task.
  • If gh fails, report the error; do not automatically retry through a GitHub
    connector.
  • Run gh auth status before the first authenticated GitHub operation when the
    current session has not already verified it.

Issue claim before implementing

Before creating a branch (or dispatching an A2A patch lane) for a GitHub issue,
apply the fleet issue-claim rule (Family Wiki DOC-3508,
pages/rules/github-issue-claim). Parallel sessions share one GitHub account,
so the account name is not an identity.

  • Read the issue first. An open PR that references it, or an issue-claim or A2A
    Start comment from the last 3 hours, means the issue is already taken: do
    not start. Coordinate on that PR or issue, pick other work, or ask the owner.
  • Otherwise post a claim comment before any code change, with a machine-readable
    first line and a one-line human summary:
    <!-- issue-claim:v1 node=<node> session=<session-or-task-id> state=active -->
    followed by claim: <node> (<session>) — scope: <one line> — PR ETA: <KST>.
  • Filing an issue is not a claim. Opening the PR supersedes the claim; when
    abandoning, post state=released. A claim with no PR or update for 3 hours
    has expired; take it over with state=takeover prev=<previous session>.
  • To replace a claimed issue or open PR with a broader one, comment on it
    first with the reason. Never silently open and merge a replacement.

Procedure

  1. Sync and branch:

    git switch main
    git pull --ff-only
    git switch -c <type>/<slug>
  2. Stage only the intended files and commit with the required trailer:

    git add <file>...
    git commit -F - <<'EOF'
    <type>(<scope>): <imperative summary>
    
    <optional body — what and why>
    
    Co-Authored-By: <the model running this session> <noreply@anthropic.com>
    EOF
  3. Push and open a PR against the repository's default branch. It is main in
    most jinwon-int repos but master in seoyoon-family-wiki — the review
    and merge helpers verify the base against gh api repos/<owner>/<repo> --jq .default_branch, so a PR opened against the wrong branch is refused:

    git push -u origin <branch>
    base="$(gh api repos/<owner>/<repo> --jq .default_branch)"
    gh pr create --repo <owner/repo> --base "$base" --head <branch> \
      --title "<type>(<scope>): <summary>" --body "<what / why / evidence>"
  4. Inspect identity, review, exact head, checks, and merge state:

    gh pr view <n> --repo <owner/repo> \
      --json author,reviewRequests,reviewDecision,headRefOid,isDraft,mergeable,mergeStateStatus,statusCheckRollup
  5. Resolve REVIEW_REQUIRED with an independent reviewer. Never use --admin
    merely to bypass branch protection.

    • Old approvals, memory, environment state, or approval for another PR do
      not count. If approval is absent or ambiguous, stop and ask.

    • Pushing to someone else's PR can delete the only remaining approver.
      jinwon-int/ccc-node main sets require_last_push_approval: true, so
      the account that pushed last cannot approve that PR. Push a review fix to
      a jinon86-authored PR as seoseo-ai and both paths close: seoseo-ai
      is now the last pusher, jinon86 is the author. Nothing can approve it,
      and enforce_admins: true means no bypass. Read the rule and count who is
      left before pushing:

      gh api repos/<owner>/<repo>/branches/main/protection \
        --jq '.required_pull_request_reviews.require_last_push_approval'

      Prefer a review comment, or carry the fix in your own PR. A one-line push
      is still fine when the PR is already blocked for another reason (failing
      required checks, draft) — there is no approval to lose yet.

    • Force-pushing the offending commit away does not undo it. GitHub keys
      this rule to the pusher, not the commits: rewinding the branch to the
      author's own SHA still records you as the last pusher and the enqueue is
      refused with the same message. Verified 2026-09-19 on #1802.
      The ways out are (a) the original author pushes anything, even an empty
      commit, or (b) reopen the work as a PR you author, so the other account
      can review it. Keep the original commits (and their author metadata)
      when you do (b), and say in both PRs why the number changed.

    • One narrow exception, and it is not this one. Some repos deny
      infrastructure paths (scripts/, .github/) at a required check on every
      branch and deliberately leave enforce_admins false, documenting the admin
      merge as the intended way in. That is a sanctioned hatch, not a bypass —
      see infra-gate-admin-merge-discipline, which also covers the trap that
      such gates exit before running their own tests. It applies only when the
      gate's own source sanctions it; absent that sentence, this rule stands.

    • After approval, set the approval flag only on the one approved command.

    Helper scripts live next to this SKILL.md. Resolve the directory once —
    the installed copy first, the template checkout as fallback (e.g. when an
    installed copy is stale, as happened on one node on 2026-08-07):

    GH_PR_FLOW_DIR="${CCC_CLAUDE_DIR:-$HOME/.claude}/skills/gh-pr-flow"
    if [ ! -d "$GH_PR_FLOW_DIR" ]; then
      for _cand in /opt/ccc-node "$HOME/ccc-node" /root/ccc-node; do
        [ -d "$_cand/claude/skills/gh-pr-flow" ] || continue
        GH_PR_FLOW_DIR="$_cand/claude/skills/gh-pr-flow"; break
      done
    fi

    (The fallback tries the fleet's checkout locations in order — /opt/ccc-node,
    $HOME/ccc-node, /root/ccc-node — instead of hardcoding one; nodes install
    the repo in different places, which previously forked this snippet per node.)

    Direction A — jinon86-authored PR, seoseo-ai reviews (relay-held
    profile):

    Both directions use the same exact-head profile helper. It ships with the
    managed Codex skills that setup.sh installs on every node. Resolve it
    first, with template-checkout fallback:

    RELAY_FLOW_DIR="${CODEX_HOME:-$HOME/.codex}/skills/gh-pr-flow/scripts"
    if [ ! -d "$RELAY_FLOW_DIR" ]; then
      for _cand in /opt/ccc-node "$HOME/ccc-node" /root/ccc-node; do
        [ -d "$_cand/codex/skills/gh-pr-flow/scripts" ] || continue
        RELAY_FLOW_DIR="$_cand/codex/skills/gh-pr-flow/scripts"; break
      done
    fi

    Both relay helpers (approve-via-relay.sh, merge-via-relay.sh) default
    to --ssh-target relay. A node without a relay ssh alias must pass the
    credential-holding node explicitly (or export CCC_RELAY_SSH_TARGET) —
    observed 2026-09-05 on a node whose ssh config names the relay node by its
    own hostname alias, not relay:

    head_sha="$(gh pr view <n> --repo <owner/repo> --json headRefOid --jq .headRefOid)"
    CCC_EXPLICIT_USER_APPROVAL=1 \
      bash "$RELAY_FLOW_DIR/approve-via-relay.sh" \
      --review-profile seoseo-ai --repo <owner/repo> --pr <n> \
      --expected-head "$head_sha" --operator-approved

    The helper runs gh on the relay node with the isolated seoseo-ai profile
    config — credentials never leave the relay node and are never printed,
    copied, or switched.
    It fail-closes unless the remote actor is seoseo-ai, the open default-
    branch PR is authored by jinon86, seoseo-ai is the requested reviewer
    (request it with gh pr edit --add-reviewer seoseo-ai when missing), the
    head matches --expected-head exactly, the PR is mergeable, and every
    reported check on that head is successful. --operator-approved records
    the fresh user authorization for this exact PR and head. After approval,
    jinon86 (the local account) merges normally in step 6.

    Direction B — seoseo-ai-authored PR, jinon86 reviews (via the relay
    node):

    Same helper, opposite profile:

    head_sha="$(gh pr view <n> --repo <owner/repo> --json headRefOid --jq .headRefOid)"
    CCC_EXPLICIT_USER_APPROVAL=1 \
      bash "$RELAY_FLOW_DIR/approve-via-relay.sh" \
      --review-profile jinon86 --repo <owner/repo> --pr <n> \
      --expected-head "$head_sha" --operator-approved

    It runs gh on the relay node with the root-owned jinon86 config and fail-closes
    unless the remote actor is jinon86, the open default-branch PR is authored
    by seoseo-ai with jinon86 requested, the head matches --expected-head
    exactly, the PR is mergeable, and every reported check on that head is
    successful. The approval is commit-bound and the credential never leaves
    the relay node. The helper is idempotent per actor and exact head: a re-run
    reuses an already-recorded exact-head approving review instead of posting a
    duplicate, and success is judged from that recorded review — reviewDecision
    reads null on repos whose branch protection requires zero approving
    reviews (#1714), so only an explicit CHANGES_REQUESTED fails verification.

  6. With required review and checks green, squash-merge normally (on
    jinwon-int/ccc-node and jinwon-int/fleet-skills, main runs a merge
    queue — see the merge-queue subsection at the end of this step; enqueue
    instead of merging):

    gh pr merge <n> --repo <owner/repo> --squash --delete-branch

    If the local identity lacks repository merge permission, use the exact-head
    exact-head relay merge fallback below. Do not weaken branch protection.

    mergeable: UNKNOWN is "not computed yet", not "blocked". GitHub builds
    a throwaway test merge commit in a background job; until it finishes the
    GraphQL enum reads UNKNOWN ("The mergeability of the pull request is still
    being calculated") and REST mergeable reads null, which the REST docs
    tell you to resolve by resubmitting the request. Closed and merged PRs also
    report UNKNOWN, so it is never by itself evidence of a problem. Re-read
    until it settles instead of reaching for a workaround:

    for i in $(seq 1 30); do
      read -r m s <<<"$(gh pr view <n> --repo <owner/repo> \
        --json mergeable,mergeStateStatus --jq '"\(.mergeable) \(.mergeStateStatus)"')"
      [ "$m" = UNKNOWN ] || { echo "mergeable=$m mergeStateStatus=$s"; break; }
      sleep 10
    done

    Then act on mergeStateStatus, not on the merge command's failure text:
    CLEAN merge; UNSTABLE/BLOCKED means checks or required reviews are the
    real gate — fix those; DIRTY is a genuine conflict (see
    github-merge-state-conflict-diagnosis); BEHIND means the base moved.

    Two failures here look like merge problems but are repository settings, and
    neither justifies --admin, weaker protection, or a credential escalation:

    gh pr view --json exposes neither capability flag — it only carries
    autoMergeRequest. Read them over GraphQL before attempting either command:

    gh api graphql -f query='
    query($o:String!,$r:String!,$n:Int!){repository(owner:$o,name:$r){pullRequest(number:$n){
      viewerCanEnableAutoMerge viewerCanUpdateBranch}}}' \
      -F o=<owner> -F r=<repo> -F n=<n> --jq .data.repository.pullRequest
    • gh pr merge --auto fails when the repo has allow_auto_merge: false
      (viewerCanEnableAutoMerge: false; also gh api repos/<owner>/<repo> --jq .allow_auto_merge). The observed message is roughly Auto merge is not allowed for this repository — match it loosely, since GitHub does not
      document the string and a configured merge queue can produce it too.
      Enabling the setting is a repository-settings change and a separate
      approval; without it, watch and merge in the foreground instead — with
      ci-watch.sh (below), not a hand-rolled loop.
    • Watching checks without the bridge wait: when gh-ci-wait
      registration fails (route-unavailable), run
      bash "$HOME/.claude/skills/gh-pr-flow/ci-watch.sh" --repo <owner/repo> --pr <n> --head <sha>
      in the background and branch on its exit code (0 green · 10 failed,
      names the checks · 11 merged · 12 closed · 13 head moved · 20
      timeout). It derives the verdict inside jq; two ad-hoc loops on
      2026-10-02 grepped gh --jq output for a key order gh never produces
      and silently missed "green" twice (#2113, #2120). Green also requires
      every required context of the base branch (branch protection plus
      rulesets) to be present and successful: right after a push or
      update-branch only CodeQL had registered, "nothing pending" held, and
      an older version reported green on a head whose required check later
      failed (#2200). When the required set cannot be read it falls back to
      green on --settle (default 2) consecutive polls; --required-context
      pins the set explicitly. approve-via-relay.sh refuses a head with a
      required check not yet reported, for the same reason.
    • gh pr update-branch only does something when the head is genuinely
      behind. Gate it on viewerCanUpdateBranch, which GitHub documents as
      false when the head is already up to date. The REST endpoint answers
      202 Accepted with "Updating pull request branch." — an async
      acknowledgement, not proof a commit was created — so confirm by comparing
      headRefOid before and after rather than trusting the success line.

    Several PRs under strict up-to-date protection. Once one merges, every
    sibling flips to BEHIND. Refresh each over REST — older gh builds have
    no gh pr update-branch subcommand, the endpoint works on any build:

    gh api -X PUT repos/<owner>/<repo>/pulls/<n>/update-branch

    Then wait for CI on the new head and re-run the relay approval: approvals
    are commit-bound to --expected-head, so the pre-refresh approval no
    longer counts.

Merge queues: jinwon-int/ccc-node main (since 2026-09-13),
jinwon-int/fleet-skills main (since 2026-09-17), and jinwon-int/piri
main (observed 2026-09-21).
Each has a merge_queue ruleset rule, so plain
gh pr merge is refused ("the merge strategy for main is set by the merge
queue") — including by the relay merge helper. Do not treat this list as
closed: it is a per-repo ruleset that anyone can enable, and piri was found
only when gh pr merge --squash --delete-branch failed mid-batch. Confirm
before merging rather than after:

gh api repos/<owner>/<repo>/rulesets --jq '.[].id' | while read -r id; do
  gh api repos/<owner>/<repo>/rulesets/"$id" --jq '[.name,([.rules[].type]|join(","))]|join(" | ")'
done

Land by enqueueing instead; exact-head and independent-review rules
are unchanged, and the queue re-runs every required check on a speculative
gh-readonly-queue/main/... group ref before squash-landing:

pr_id="$(gh pr view <n> --repo <owner/repo> --json id --jq .id)"
gh api graphql \
  -f query='mutation($id:ID!){enqueuePullRequest(input:{pullRequestId:$id}){clientMutationId}}' \
  -f id="$pr_id"
# then watch BOTH the PR state and the queue entry — never "poll until MERGED":
bash "$HOME/.claude/skills/gh-pr-flow/merge-queue-watch.sh" --repo <owner/repo> --pr <n>

Watch for eviction, not just for MERGED. When the speculative group run
fails, GitHub drops the queue entry silently: the PR stays OPEN/CLEAN
with its approval intact and nothing on the PR changes, so a watch that only
polls state waits forever (2026-10-02, ccc-node#2113 — evicted by a flaky
shard test, noticed by the operator, not the watch). merge-queue-watch.sh
polls the PR and the repo's mergeQueue entries together and exits on the
first terminal event: 0 merged, 10 evicted (it names the failed
gh-readonly-queue/<branch>/pr-<n>-… run and its failing jobs), 11 closed
unmerged, 12 never enqueued, 20 timeout. The queue deletes the entry the
moment it squash-lands, so an empty entry is re-confirmed against the PR state
(--settle-tries, default 3 × 5 s) before it becomes 10/12 — a PR that
merged between the two reads reports merged (#2208, pr-shepherd#154). Run it in the background and
act on the exit code; on 10, classify the failing job first — an unrelated
flaky test means re-enqueue the same approved head (and record the flake on
its issue); a real failure means push the fix, get fresh exact-head approval
for the new head, then re-enqueue, as with any other push.

Enqueue needs the head up to date and its checks green; a stale BEHIND head
goes through the update-branch loop above first.

A queue merge does not clean up the head branch, and --delete-branch
cannot be used to make it.
gh pr merge -d is refused outright on a queued
repo, so cleanup falls entirely to the repo's own delete_branch_on_merge
setting — which is off on ccc-node:

ccc-node  delete_branch_on_merge=false     piri  true     fleet-skills  true

So every queue merge on ccc-node leaves its head branch behind (the repo
carries 95 remote heads as of 2026-09-21), while the same flow on piri
self-cleans and looks like the queue handled it. Do not generalise from one
repo. A direct gh pr merge --squash --delete-branch deletes regardless of
the setting, which is why this only bites on queued repos. Verify and delete
explicitly, per step 7:

git ls-remote --heads https://github.com/<owner>/<repo> <branch> | grep -q . \
  && gh api -X DELETE repos/<owner>/<repo>/git/refs/heads/<branch>

Do not assume a repo without a queue is simply slower to merge. main on
both repos also requires strict (up-to-date) status checks, and that pairing
without a queue is what makes a batch land serially: the first merge puts
every sibling PR BEHIND, and each one then needs update-branch plus a
full CI round trip. Promotion batches #232-#235 cost four such cycles by hand
on 2026-09-16 before the fleet-skills queue existed.

Before enabling a queue on another repo, check the workflow triggers. The
queue reports against the merge_group event, so a required check whose
workflow only lists pull_request (and push) never reports inside a group
and every enqueued PR waits forever. fleet-skills needed
#236 to add
merge_group: to validate.yml — which owns the required skills and
merge-boundary contexts — before the queue could be turned on. A check that
is not in the required set (fleet-skills receipts) does not gate the queue
and needs no trigger change.

Enabling is a ruleset operation, not branch protection: GraphQL
updateBranchProtectionRule rejects requiresMergeQueue
(argumentNotAccepted). Add a ruleset carrying only the merge_queue rule
and leave classic protection (required checks, approval count,
enforce_admins) untouched — rollback is deleting that one ruleset.

  1. Verify and clean up:

    gh pr view <n> --repo <owner/repo> --json state,mergedAt,mergeCommit
    git switch main
    git pull --ff-only
    git ls-remote --exit-code --heads origin <branch>

    A nonzero ls-remote result means the remote branch is absent, as expected.
    git branch -d <branch> may reject a squash-merged branch because squash
    does not preserve ancestry. Use git branch -D <branch> only after verifying
    the PR is merged, main contains the change, and the remote branch is gone.
    If the PR links an issue with a closing keyword, verify that issue is closed.

Relay-held jinon86 exact-head merge fallback

Use this only when all of the following are true:

  • The operator explicitly approved merging this specific repository and PR in
    the current task. Approval does not carry to another PR or a changed head.
  • The local identity cannot merge it, while the relay node already has the
    authorized jinon86 GitHub session.
  • The PR is non-draft, targets main, is MERGEABLE/CLEAN, has all required
    reviews, and its exact head has passing GitHub checks or documented equivalent
    validation.

Capture the exact head locally, then call the fail-closed helper:

head_sha="$(gh pr view <n> --repo <owner/repo> --json headRefOid --jq .headRefOid)"
bash "$GH_PR_FLOW_DIR/merge-via-relay.sh" \
  --repo <owner/repo> --pr <n> --expected-head "$head_sha" \
  --operator-approved

The helper verifies actor jinon86, re-reads the PR on the relay node, requires the
same head SHA and clean merge state, rejects pending or failed checks, and uses
GitHub's merge API with the SHA precondition. It never uses an admin bypass. A
PR with no reported checks is allowed only when exact-head equivalent evidence
is already recorded and the operator is told that GitHub reported no checks.

Delete the contributor branch using the identity that owns it. If cleanup
permission is unavailable, report it rather than moving a credential.

Relay-held jinon86 owner-repo hygiene

The merge helper above cannot change repository settings, and running an ad-hoc
gh api as jinon86 on the relay node is a credential use outside any
fail-closed helper. For the one sanctioned settings change — Dependabot
vulnerability alerts plus a minimal default-branch ruleset
(default-branch-no-delete-no-force-push: blocks deletion and force-push, no
review or PR requirement) — use repo-hygiene-via-relay.sh. The target
repositories come from a node-local allowlist (one OWNER/REPO per line,
default ~/.config/ccc/repo-hygiene-allowlist, override with
CCC_REPO_HYGIENE_ALLOWLIST or --allowlist); repository names are fleet
data, so they never live in this canon file.

# dry run (no mutation) — still a relay credential use, so still approved
bash "$GH_PR_FLOW_DIR/repo-hygiene-via-relay.sh" --operator-approved
# apply after the operator approves the dry-run result
bash "$GH_PR_FLOW_DIR/repo-hygiene-via-relay.sh" --operator-approved --apply

--repo OWNER/REPO (repeatable) narrows the run; a name outside the allowlist
is refused. The helper verifies actor jinon86 and per-repo admin, never
overwrites a same-name ruleset whose rules differ (reported as
conflict-mismatch), re-reads every change it makes, and exits non-zero unless
every selected repository ends compliant. Widening the ruleset is a code change
through this skill's PR flow; editing the allowlist is an operator decision
recorded with the run's approval.

Promotion branches (#2292). A branch that nodes deploy from — for example a
stable ref that a scheduled self-update follows while the default branch is
auto-merged — must not be movable by a plain push. Opt a repository in with
protect-branch=NAME tokens on its allowlist line
(OWNER/REPO protect-branch=NAME [protect-branch=NAME2]). For each name the
helper ensures a protected-branch-NAME ruleset on refs/heads/NAME:
deletion and force-push blocked, plus a pull request with one approval from
someone other than the last pusher (stale approvals dismissed, merge commits
only, no bypass actors). Promotion then becomes a PR into that branch,
reviewed cross-account like any other. The helper refuses master/main (any
case) locally and the repository's actual default branch remotely, because a
PR requirement there would break node automation; an unknown default branch
fails closed the same way. An existing ruleset of that name is accepted only
if it is active, targets branches, includes exactly refs/heads/NAME with no
exclude, has an empty bypass list (and current_user_can_bypass never), and
carries exactly those three rules with those pull-request parameters; anything
weaker is a conflict-mismatch and is left untouched. Each repository row
reports protected_branches as NAME:<state> entries. The helper needs bash
4+ locally and validates names in the C locale; repository and branch lists
travel to the relay as shell-inert OWNER/REPO:b1,b2 arguments, because ssh
re-parses the remote command line with the relay's login shell.

Environment deployment approval

A workflow job gated by a GitHub Actions environment with required reviewers
(for example a release environment whose reviewer is jinon86) waits in
pending_deployments until that reviewer approves. Approving it is a
release-gate action: it needs fresh explicit user approval for that exact
repository, run, environment, and head, every time — an earlier release
approval or a PR merge approval does not carry over. Never approve it with an
ad-hoc gh api call on the relay node; use the fail-closed helper, which ships
with the managed Codex skills next to approve-via-relay.sh (resolve
RELAY_FLOW_DIR as in step 5):

CCC_EXPLICIT_USER_APPROVAL=1 \
  bash "$RELAY_FLOW_DIR/approve-deployment-via-relay.sh" \
  --repo jinwon-int/<repo> --run-id <run-id> --environment release \
  --expected-head <full-40-char-sha> --operator-approved
# optional: --expected-workflow .github/workflows/release.yml (default)
#           --expected-branch main (default) --expected-event workflow_dispatch
#           (default) --ssh-target <relay-alias> --dry-run

The helper runs gh on the relay node with the root-owned jinon86 config and
fail-closes unless the actor is jinon86, the run belongs to the repository
(not a fork), its head SHA, branch, event, and workflow path match exactly, and
exactly one environment — the named one — is pending with
current_user_can_approve: true. It records the approval with a comment naming
the operator approval and the source node, then verifies it from the run's
review history. It is idempotent: when the environment is no longer pending (or
the run is no longer waiting) and jinon86 already approved it, it reports
already_approved without posting again; a run that is no longer waiting and
was never approved exits 66 with its state and submits nothing. Approving the
deployment authorizes only that job to proceed — not a re-run, another
environment, or a follow-up release.

Security and merge rules

  • Never push directly to main; always use a branch and PR.
  • The PR author cannot approve their own PR. Keep author and reviewer identities
    independent.
  • Under require_last_push_approval, whoever pushed last cannot approve either.
    Author and last pusher are two separate disqualifications, and on a two-account
    repo pushing to the other account's PR can leave nobody eligible. See step 5.
  • Only merge with green required checks and a mergeable state. Report failed or
    pending checks instead of forcing the merge.
  • Never read, print, copy, persist, export, or re-login with the relay's gh
    token. Run gh on the relay node so the credential stays there. Do not
    enable shell trace, switch the persistent local account, or put credentials
    in arguments, commits, PR text, logs, or memory.
  • The relay-held seoseo-ai profile credential follows the same discipline:
    use it only through the exact-head approve-via-relay.sh profile helper,
    which runs gh on the relay node with the isolated profile config for a
    single approved invocation. Never print it, never copy it off the relay
    node, never
    gh auth switch to it, and never reuse one approval for a second invocation
    or a different head.
  • An approval flag records a fresh user decision; it does not grant standing
    authority (the only standing approval is the #2239 L3 owner switch below).
    Set it for one approved invocation only. Review approval and merge
    approval are separate privileged writes unless the user's current instruction
    explicitly authorizes both for that exact PR.
  • A merge instruction authorizes the merge, not token disclosure, credential
    transfer, release/publish, deploy, restart, migration, or another mutation.

Standing approval for mechanical fleet-skills promote PRs (#2239 L3)

Same policy as the codex copy (codex/skills/gh-pr-flow/SKILL.md, same
heading); keep both in step. It is the single exception to the
fresh-approval rules above and exists only while the owner has switched it on:

  • Scope: jinwon-int/fleet-skills PRs on promote/auto-* branches opened
    by the skill-promotion publisher (ccc-skill-promotion.py, ledger kind
    a2a-promote-pr), approved by that publisher's own collect sweep with the
    seoseo-ai profile (the publisher authors as jinon86). An interactive
    session never uses it.
  • Owner switch: the owner-created 0600 flag file
    the publisher state-dir flag $CCC_STATE_DIR/skill-promotion.auto-approve reading true on the
    publisher (or CCC_SKILL_PROMOTION_AUTO_APPROVE=true in its owner-only
    config file). dry-run approves nothing and only records
    would-approve-promote-pr. Absent/off = no standing approval.
  • Conditions, all on the exact head: no creation-time identity hits; every
    path in the full PR diff under approved/<audience>/<staged skill>/; every
    added line rescanned clean (credentials, home paths, IPs, emails, runtime
    coupling, fleet identities) and approval.json in the generator's exact
    shape; checks green and MERGEABLE; the helper re-verifies with
    --expected-head, and the merge uses --match-head-commit (never
    --admin).
  • Everything else — another repository, any non-promote PR, a blocked
    or changed head, a promote PR a check rejected, a merge outside the queue —
    still needs fresh per-PR approval as above. Each decision is recorded in
    the publisher ledger (a2a-promote-autoapprove) and the owner is notified
    afterwards (PR numbers only).
  • Revoke: delete the flag file or write off; the next sweep stops
    approving. No restart.