Skip to content

Commit 357a8a0

Browse files
tode-rlclaude
andcommitted
ci(stlc): gate the fork heal on staging CI passing on the merge
A clean text merge can still fail to build when the two sides combine (a rename on one side, a new call to the old name on the other), and the heal pushed that merge to production main before any CI ran on it. The heal now force-pushes the merge commit to a stlc-fork-heal branch on staging, which triggers the existing CI workflow, waits for that CI run on the exact SHA (30 min cap), and pushes to both mains only on success. A failure, cancellation, or timeout pushes nothing and fails with the CI link and the commits on each side; the temporary branch is deleted either way. Promote gains actions: read and passes github.token as GH_TOKEN for the run lookup. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 6c984a3 commit 357a8a0

2 files changed

Lines changed: 59 additions & 7 deletions

File tree

‎.github/scripts/stlc-heal-fork.sh‎

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
#!/usr/bin/env bash
22
# Heals a staging/production trunk fork with a merge commit, only when the
3-
# trunks merge cleanly; otherwise explains the fork and fails.
3+
# trunks merge cleanly and staging CI passes on that merge; otherwise explains
4+
# the fork and fails.
45
# Expects origin/main = staging main and production/main = production main,
5-
# plus GITHUB_REPOSITORY (staging), PRODUCTION_REPO and PRODUCTION_REPO_TOKEN.
6+
# plus GITHUB_REPOSITORY (staging), PRODUCTION_REPO, PRODUCTION_REPO_TOKEN, and
7+
# GH_TOKEN able to read staging's Actions runs.
68
set -euo pipefail
79

810
HINT="$(dirname "$0")/stlc-fork-hint.sh"
11+
HEAL_BRANCH=stlc-fork-heal
12+
CI_WORKFLOW=.github/workflows/ci.yml
13+
CI_TIMEOUT=${STLC_HEAL_CI_TIMEOUT:-1800}
14+
POLL=${STLC_HEAL_POLL:-15}
915

1016
# One trunk ahead of the other is a fast-forward, not a fork.
1117
if git merge-base --is-ancestor origin/main production/main ||
@@ -39,12 +45,56 @@ MERGE=$(
3945

4046
git remote set-url production "https://x-access-token:${PRODUCTION_REPO_TOKEN}@github.com/${PRODUCTION_REPO}.git"
4147
git remote set-url origin "https://x-access-token:${PRODUCTION_REPO_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
48+
push() { git -c "http.https://github.com/.extraheader=" push "$@"; }
49+
50+
# Nothing reaches main until staging CI passes on this exact merge: a clean text
51+
# merge can still fail to build. CI runs on any staging branch push; the branch
52+
# only triggers it.
53+
push --force origin "$MERGE:refs/heads/$HEAL_BRANCH"
54+
trap 'push -q origin --delete "$HEAL_BRANCH" || true' EXIT
55+
56+
blocked() {
57+
{
58+
echo "### Staging and production main have forked; heal blocked by CI"
59+
echo
60+
echo "They merge cleanly as text, but $1, so nothing was pushed to main. Fix the combination on either trunk (or push a fixed merge by hand); the next back-sync poll retries the heal."
61+
echo
62+
echo "CI run: ${CI_URL:-none found}"
63+
echo
64+
echo "**Only on staging:**"
65+
echo '```'
66+
echo "$STAGING_ONLY"
67+
echo '```'
68+
echo "**Only on production:**"
69+
echo '```'
70+
echo "$PRODUCTION_ONLY"
71+
echo '```'
72+
} >> "${GITHUB_STEP_SUMMARY:-/dev/stdout}"
73+
echo "::error title=Fork heal blocked::$1 on clean merge commit ${MERGE}; nothing was pushed to main. CI run: ${CI_URL:-none found}"
74+
exit 1
75+
}
76+
77+
deadline=$((SECONDS + CI_TIMEOUT))
78+
STATUS="" CONCLUSION="" CI_URL=""
79+
while :; do
80+
# A failed lookup reads as "not finished yet" and is retried until the deadline.
81+
read -r STATUS CONCLUSION CI_URL < <(
82+
gh api "repos/${GITHUB_REPOSITORY}/actions/runs?head_sha=${MERGE}&event=push" \
83+
--jq "[.workflow_runs[] | select(.path | startswith(\"${CI_WORKFLOW}\"))][0] | \"\(.status) \(.conclusion) \(.html_url)\""
84+
) || true
85+
[ "$CI_URL" = null ] && CI_URL=""
86+
[ "$STATUS" = completed ] && break
87+
[ "$SECONDS" -ge "$deadline" ] && blocked "staging CI did not finish within ${CI_TIMEOUT}s"
88+
sleep "$POLL"
89+
done
90+
91+
[ "$CONCLUSION" = success ] || blocked "staging CI concluded ${CONCLUSION}"
4292

4393
# Both pushes are fast-forwards (MERGE descends from each tip), so a trunk that
4494
# moved since the fetch rejects the push and the next scheduled run retries.
4595
# Production first: if staging then moves, the next run sees a fresh fork and heals it.
46-
git -c "http.https://github.com/.extraheader=" push production "$MERGE:refs/heads/main"
47-
git -c "http.https://github.com/.extraheader=" push origin "$MERGE:refs/heads/main"
96+
push production "$MERGE:refs/heads/main"
97+
push origin "$MERGE:refs/heads/main"
4898

4999
SHORT=$(git rev-parse --short "$MERGE")
50100
{
@@ -60,6 +110,6 @@ SHORT=$(git rev-parse --short "$MERGE")
60110
echo '```'
61111
echo "$PRODUCTION_ONLY"
62112
echo '```'
63-
echo "The combination was not tested before it reached production; staging CI runs on the push."
113+
echo "Staging CI passed on this exact commit before it was pushed: ${CI_URL}"
64114
} >> "${GITHUB_STEP_SUMMARY:-/dev/stdout}"
65115
echo "::warning title=Trunk fork healed::pushed clean merge commit ${SHORT} to staging and production main. See the job summary for the commits it joined."

‎.github/workflows/stlc-promote.yml‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ name: Promote SDKs
77
# staging main, because any staging-only commit (stlc build or not) forks the trunks
88
# against the next production merge; the daily schedule is a backstop and
99
# workflow_dispatch an eager promote (back-sync dispatches it on a fork). A fork
10-
# that merges cleanly is healed with a merge commit on both trunks (the only
11-
# non-linear history this writes); a conflicting fork fails for a person to resolve.
10+
# that merges cleanly and passes staging CI is healed with a merge commit on both
11+
# trunks (the only non-linear history this writes); anything else fails for a person.
1212
# Every run is a safe no-op when there's nothing to promote.
1313
on:
1414
push:
@@ -20,6 +20,7 @@ on:
2020

2121
permissions:
2222
contents: read
23+
actions: read
2324

2425
jobs:
2526
promote:
@@ -70,6 +71,7 @@ jobs:
7071
if: steps.diff.outputs.synced == 'false'
7172
env:
7273
PRODUCTION_REPO_TOKEN: ${{ secrets.PRODUCTION_REPO_TOKEN }}
74+
GH_TOKEN: ${{ github.token }}
7375
run: |
7476
# Production not an ancestor of staging means the trunks have forked
7577
# (production advanced without a back-sync) and FF is unsafe: heal with a

0 commit comments

Comments
 (0)