Skip to content

Commit a569a31

Browse files
dot-agiclaude
andcommitted
feat(ci): promote staging to main on a pull request comment (#1073)
* feat(ci): promote staging to main on a pull request comment * fix(ci): gate the promote push on a job env value * fix(ci): promote push works and reports its real outcome * docs: hotfix procedure, code owners, promote command * fix(ci): tighten the promote trigger, gate the reply, scope concurrency Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PtexagKz2Si6SVLZoFKrz * docs(ci): header states the auth gate on replies * fix(ci): accept a promote command followed by an explanation Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PtexagKz2Si6SVLZoFKrz --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent dbf6771 commit a569a31

3 files changed

Lines changed: 182 additions & 2 deletions

File tree

.github/CODEOWNERS

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
# Default owners for everything in the repo.
2-
* @pfbyjy @RishiDesai
2+
* @pfbyjy @RishiDesai @dot-agi
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
name: Promote on comment
2+
# Comment `/promote` on the staging -> main pull request.
3+
#
4+
# For a commenter with write access, the job always runs the promotion checks
5+
# and reports them back on the pull request; anyone else gets a failed run
6+
# and no reply. It performs the fast-forward push only when the repository holds a
7+
# PROMOTE_TOKEN secret: a fine-grained personal access token (Contents: Read
8+
# and write) belonging to a user who is a bypass actor on the main ruleset.
9+
# The built-in GITHUB_TOKEN cannot do this — `github-actions[bot]` is not
10+
# selectable as a bypass actor — and deploy keys are disabled for this
11+
# organization, so a user token is the remaining option. Without the secret
12+
# the job still verifies everything and prints the command to run by hand.
13+
on:
14+
issue_comment:
15+
types: [created]
16+
17+
jobs:
18+
promote:
19+
# The command is the first line of the comment, so a maintainer can write
20+
# `/promote` and explain the release underneath. Expression string literals
21+
# have no escape sequences, so `fromJSON` is the only way to write the line
22+
# break; GitHub sends CRLF, and the bare CR clause covers it.
23+
if: >-
24+
github.event.issue.pull_request &&
25+
(github.event.comment.body == '/promote' ||
26+
startsWith(github.event.comment.body, '/promote ') ||
27+
startsWith(github.event.comment.body, fromJSON('"/promote\n"')) ||
28+
startsWith(github.event.comment.body, fromJSON('"/promote\r"')))
29+
runs-on: ubuntu-latest
30+
# Job level, not workflow level: a workflow-level group is joined by every
31+
# comment in the repository, and a newly queued run replaces the pending
32+
# one. A job skipped by the `if` above never queues, so only real
33+
# promotions serialize here.
34+
concurrency:
35+
group: promote-main
36+
cancel-in-progress: false
37+
permissions:
38+
contents: read
39+
pull-requests: write
40+
actions: read
41+
env:
42+
# A step `if:` cannot use the secrets context; expressions in env can,
43+
# so the optional-token gate reads this value instead.
44+
HAS_PROMOTE_TOKEN: ${{ secrets.PROMOTE_TOKEN != '' }}
45+
steps:
46+
- name: Check that the commenter can write to the repository
47+
id: auth
48+
shell: bash
49+
env:
50+
GH_TOKEN: ${{ github.token }}
51+
ACTOR: ${{ github.event.comment.user.login }}
52+
run: |
53+
set -euo pipefail
54+
level=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$ACTOR/permission" --jq '.permission')
55+
case "$level" in
56+
admin|write|maintain) echo "$ACTOR has $level access" ;;
57+
*) echo "::error::$ACTOR has '$level' access and cannot promote"; exit 1 ;;
58+
esac
59+
60+
- uses: actions/checkout@v5
61+
with:
62+
fetch-depth: 0
63+
# The persisted workflow token would override the PROMOTE_TOKEN in
64+
# the remote URL during the push below.
65+
persist-credentials: false
66+
67+
- name: Verify promotion preconditions
68+
id: checks
69+
shell: bash
70+
env:
71+
GH_TOKEN: ${{ github.token }}
72+
PR: ${{ github.event.issue.number }}
73+
run: |
74+
set -euo pipefail
75+
read -r base head state < <(gh pr view "$PR" --repo "$GITHUB_REPOSITORY" \
76+
--json baseRefName,headRefName,state --jq '"\(.baseRefName) \(.headRefName) \(.state)"')
77+
[ "$state" = "OPEN" ] || { echo "::error::pull request #$PR is $state"; exit 1; }
78+
[ "$base" = "main" ] || { echo "::error::#$PR targets '$base'; promotion targets main"; exit 1; }
79+
[ "$head" = "staging" ] || { echo "::error::#$PR comes from '$head'; promotion comes from staging"; exit 1; }
80+
cross=$(gh pr view "$PR" --repo "$GITHUB_REPOSITORY" --json isCrossRepository -q .isCrossRepository)
81+
[ "$cross" = "false" ] || { echo "::error::#$PR comes from a fork; promotion uses the repository's own staging branch"; exit 1; }
82+
83+
git fetch origin main staging
84+
target=$(git rev-parse origin/staging)
85+
git merge-base --is-ancestor origin/main "$target" \
86+
|| { echo "::error::main is not an ancestor of $target — run Sync Preflight for the repair steps"; exit 1; }
87+
88+
[ "$(gh pr view "$PR" --repo "$GITHUB_REPOSITORY" --json reviewDecision -q .reviewDecision)" = "APPROVED" ] \
89+
|| { echo "::error::#$PR is not approved by a code owner"; exit 1; }
90+
91+
gh run list --repo "$GITHUB_REPOSITORY" --workflow "Staging Deploy" \
92+
--branch staging --commit "$target" --json conclusion -q '.[0].conclusion' \
93+
| grep -qx success \
94+
|| { echo "::error::Staging Deploy is not green on $target"; exit 1; }
95+
96+
echo "sha=$target" >> "$GITHUB_OUTPUT"
97+
echo "checks passed for $target"
98+
99+
- name: Fast-forward main
100+
id: push
101+
if: success() && env.HAS_PROMOTE_TOKEN == 'true'
102+
shell: bash
103+
env:
104+
PROMOTE_TOKEN: ${{ secrets.PROMOTE_TOKEN }}
105+
SHA: ${{ steps.checks.outputs.sha }}
106+
run: |
107+
set -euo pipefail
108+
git remote set-url origin \
109+
"https://x-access-token:${PROMOTE_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
110+
# A plain push is fast-forward only; git itself rejects anything else.
111+
# `:?` because an empty source refspec deletes the branch.
112+
git push origin "${SHA:?}:refs/heads/main"
113+
git fetch origin main
114+
test "$(git rev-parse origin/main)" = "$SHA"
115+
echo "pushed=true" >> "$GITHUB_OUTPUT"
116+
echo "origin/main == $SHA"
117+
118+
- name: Report back on the pull request
119+
# Only reply to a commenter who passed the write-access check. Anyone
120+
# can comment on a public pull request, so an ungated reply lets a
121+
# stranger post bot comments. The failed run stays visible to
122+
# maintainers.
123+
if: always() && steps.auth.outcome == 'success'
124+
shell: bash
125+
env:
126+
GH_TOKEN: ${{ github.token }}
127+
PR: ${{ github.event.issue.number }}
128+
SHA: ${{ steps.checks.outputs.sha }}
129+
PUSH_OUTCOME: ${{ steps.push.outcome }}
130+
CHECKS: ${{ steps.checks.outcome }}
131+
run: |
132+
set -euo pipefail
133+
if [ "$CHECKS" != "success" ]; then
134+
body="Promotion checks failed. See the run for the reason: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
135+
elif [ "$PUSH_OUTCOME" = "success" ]; then
136+
body="Promoted. main now points at \`${SHA}\`, the same commit as staging."
137+
elif [ "$PUSH_OUTCOME" = "failure" ]; then
138+
body="Promotion checks passed but the push failed. See the run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
139+
else
140+
body=$(printf '%s\n' \
141+
"Promotion checks passed for \`${SHA}\`." \
142+
"" \
143+
"This repository has no PROMOTE_TOKEN secret, so the push did not run." \
144+
"A maintainer completes the promotion with:" \
145+
"" \
146+
'```bash' \
147+
"git fetch origin staging" \
148+
"git push origin ${SHA}:refs/heads/main" \
149+
'```')
150+
fi
151+
gh pr comment "$PR" --repo "$GITHUB_REPOSITORY" --body "$body"

CLAUDE.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,36 @@ branch, commit there, push that branch, and open a PR for review — PRs target
2323
fast-forward promotion by a maintainer with push access to `main`, who runs
2424
the `Promotion Preflight` workflow (it verifies the approved promotion PR, the staging deploy, and the
2525
fast-forward condition, then prints the push command) and executes that push
26-
themselves; never merge, squash, or push to `main` directly.
26+
themselves; never merge, squash, or push to `main` directly. A maintainer
27+
can instead comment `/promote` on the promotion pull request; the workflow
28+
runs the same checks and, when the promote token is set, does the push.
29+
30+
## Hotfixes
31+
32+
Branch the fix from `main`, not from `staging`. `main` is always an ancestor of
33+
`staging`, so a fix based on it carries no unreleased work and still
34+
fast-forwards cleanly:
35+
36+
```bash
37+
git fetch origin main && git checkout -b fix/<name> origin/main
38+
```
39+
40+
Open it as a normal PR into `staging`, get an expedited review, squash-merge,
41+
then promote immediately. This is the standard path — use it whenever the
42+
pipeline is fast enough for the incident.
43+
44+
Break-glass (landing a fix on `main` directly) is only for two cases: the
45+
pipeline is too slow for the incident, or `staging` holds work that cannot
46+
ship. It breaks the fast-forward invariant on purpose, so it needs an incident
47+
ticket, a second person's approval, and an immediate repair afterwards —
48+
fast-forward `staging` up to `main`, or rebuild `staging` on the new `main` if
49+
it carries unpromoted commits. Never cherry-pick the fix into `staging`: the
50+
copy gets a different commit id, so the branches stay diverged.
51+
52+
Not every change has to be releasable to merge. Land unfinished work behind a
53+
flag that is off by default (as `ODDISH_GKE_ENABLED` and
54+
`ODDISH_PRE_TRIAL_ENABLED` do), or promote only part of `staging` by giving
55+
the promotion workflow the commit to stop at.
2756

2857
## Useful pointers
2958

0 commit comments

Comments
 (0)