Skip to content

Commit 669a0af

Browse files
committed
ci: add stlc promote and back-sync workflows
Two-repo fast-forward core from the stlc sdk-repo example, with the placeholders pointed at this pair: staging runloopai/api-client-python-staging production runloopai/api-client-python config runloopai/runloop Both files self-route with if: github.repository == ... guards, so the same two files serve staging and production and only the right jobs run in each. They reach production via the first promote. Sealed as custom code so they survive regeneration.
1 parent 0996271 commit 669a0af

2 files changed

Lines changed: 295 additions & 0 deletions

File tree

.github/workflows/stlc-promote.yml

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
name: Promote SDKs
2+
3+
# Promote staging to production by fast-forwarding production main up to staging,
4+
# preserving SHAs so the trunks stay identical and linear (nothing to heal on the
5+
# next stlc build). Manual dispatch by design: a maintainer promotes the accumulated
6+
# batch to cut a release; release-please then opens its version PR on production.
7+
on:
8+
workflow_dispatch: {}
9+
10+
permissions:
11+
contents: read
12+
13+
jobs:
14+
promote:
15+
# Runner comes from the STLC_RUNNER repo/org variable when set; defaults to GitHub-hosted.
16+
runs-on: ${{ vars.STLC_RUNNER || 'ubuntu-latest' }}
17+
if: github.repository == 'runloopai/api-client-python-staging'
18+
# Optional gate: add required reviewers to this environment to approve each
19+
# promote. With none it only scopes secrets and adds no gate. Remove if unused.
20+
environment: production
21+
env:
22+
PRODUCTION_REPO: runloopai/api-client-python
23+
GH_TOKEN: ${{ secrets.PRODUCTION_REPO_TOKEN }}
24+
steps:
25+
- name: Check out staging
26+
uses: actions/checkout@v6
27+
with:
28+
fetch-depth: 0
29+
persist-credentials: false
30+
31+
- name: Fetch production main
32+
run: |
33+
git remote add production \
34+
"https://x-access-token:${GH_TOKEN}@github.com/${PRODUCTION_REPO}.git"
35+
git fetch production main
36+
37+
- name: Check whether production already has staging's content
38+
id: diff
39+
run: |
40+
# After a release, production has release-please commits staging lacks, so compare trees.
41+
MERGED=$(git merge-tree --write-tree production/main origin/main) || MERGED=conflict
42+
PRODUCTION_TREE=$(git rev-parse 'production/main^{tree}')
43+
if [ "$MERGED" = "$PRODUCTION_TREE" ]; then
44+
echo "Production already contains staging's content. Nothing to promote."
45+
echo "synced=true" >> "$GITHUB_OUTPUT"
46+
else
47+
echo "synced=false" >> "$GITHUB_OUTPUT"
48+
fi
49+
50+
- name: Promote staging to production (fast-forward)
51+
if: steps.diff.outputs.synced == 'false'
52+
run: |
53+
# Refuse unless production is an ancestor of staging: otherwise the trunks
54+
# have forked (production advanced without a back-sync) and FF is unsafe.
55+
if ! git merge-base --is-ancestor production/main origin/main; then
56+
echo "::error title=Promote blocked::production/main is not an ancestor of staging main. Back-sync production into staging first."
57+
exit 1
58+
fi
59+
git push production origin/main:refs/heads/main
60+
echo "Fast-forwarded production/main to staging/main."
61+
62+
- name: Alert on failure
63+
if: failure()
64+
env:
65+
ALERT_WEBHOOK_URL: ${{ secrets.STLC_ALERT_WEBHOOK_URL }}
66+
run: |
67+
run_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
68+
msg="stlc promote failed in ${{ github.repository }}. A stalled promote or back-sync lets custom-code tracking drift, which later builds refuse on — investigate before the next build. Run: $run_url"
69+
echo "::error title=stlc workflow failed::$msg"
70+
{ echo "### ⚠️ stlc workflow failed"; echo ""; echo "$msg"; } >> "$GITHUB_STEP_SUMMARY"
71+
if [ -n "${ALERT_WEBHOOK_URL:-}" ]; then
72+
curl -sS -X POST -H 'Content-Type: application/json' \
73+
-d "$(jq -n --arg text "$msg" '{text:$text}')" "$ALERT_WEBHOOK_URL" \
74+
|| echo "::warning::Alert webhook POST failed"
75+
fi

.github/workflows/stlc-sync.yml

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
name: Sync SDK repos
2+
3+
# Keeps the staging and production trunks in sync and the config repo's
4+
# tracking files fresh. Each job self-routes by repo + event, so this one file
5+
# can live in both repos and only the right job runs. The dispatch jobs are
6+
# eager-only (the polls cover them) and no-op when their token isn't set.
7+
on:
8+
schedule:
9+
# back-sync poll: a cheap pure-git check, twice hourly so an unsynced production
10+
# change (e.g. a community PR between releases) can't hold codegen for long.
11+
- cron: '7,37 * * * *'
12+
workflow_dispatch: {}
13+
repository_dispatch:
14+
types: [prod-released]
15+
release:
16+
types: [published]
17+
push:
18+
# main only. stlc preview/integrated/codegen branches never push to main.
19+
branches: [main]
20+
21+
jobs:
22+
back-sync:
23+
# Fast-forward production main back onto staging so the trunks stay identical.
24+
# Runner comes from the STLC_RUNNER repo/org variable when set; defaults to GitHub-hosted.
25+
runs-on: ${{ vars.STLC_RUNNER || 'ubuntu-latest' }}
26+
if: >-
27+
github.repository == 'runloopai/api-client-python-staging' &&
28+
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch')
29+
permissions:
30+
contents: write
31+
concurrency:
32+
group: stlc-back-sync
33+
cancel-in-progress: true
34+
env:
35+
PRODUCTION_REPO: runloopai/api-client-python
36+
steps:
37+
- name: Check out staging
38+
uses: actions/checkout@v6
39+
with:
40+
fetch-depth: 0
41+
42+
- name: Fetch production main
43+
env:
44+
PRODUCTION_REPO_TOKEN: ${{ secrets.PRODUCTION_REPO_TOKEN }}
45+
run: |
46+
# Public production reads with no credential; a private production
47+
# repo needs PRODUCTION_REPO_TOKEN (the same token the promote uses).
48+
if [ -n "${PRODUCTION_REPO_TOKEN:-}" ]; then
49+
git remote add production "https://x-access-token:${PRODUCTION_REPO_TOKEN}@github.com/${PRODUCTION_REPO}.git"
50+
else
51+
git remote add production "https://github.com/${PRODUCTION_REPO}.git"
52+
fi
53+
# actions/checkout persists this repo's token as an auth header, which
54+
# outranks the remote URL credential; blank it for this fetch only.
55+
git -c "http.https://github.com/.extraheader=" fetch production main
56+
57+
- name: Check whether production has content staging lacks
58+
id: diff
59+
run: |
60+
# Content compare: would merging production into staging change its tree?
61+
# If not, staging already has production's content (release-please commits).
62+
MERGED=$(git merge-tree --write-tree origin/main production/main) || MERGED=conflict
63+
STAGING_TREE=$(git rev-parse 'origin/main^{tree}')
64+
if [ "$MERGED" = "$STAGING_TREE" ]; then
65+
echo "Staging already has production's content. Nothing to pull back."
66+
echo "behind=false" >> "$GITHUB_OUTPUT"
67+
else
68+
echo "behind=true" >> "$GITHUB_OUTPUT"
69+
fi
70+
71+
- name: Sync production to staging (fast-forward)
72+
if: steps.diff.outputs.behind == 'true'
73+
run: |
74+
# Refuse unless staging is an ancestor of production: otherwise the
75+
# trunks have forked and a fast-forward would be unsafe.
76+
if ! git merge-base --is-ancestor origin/main production/main; then
77+
echo "::error title=Back-sync blocked::staging main is not an ancestor of production/main."
78+
exit 1
79+
fi
80+
git push origin production/main:refs/heads/main
81+
echo "Fast-forwarded staging/main to production/main."
82+
83+
- name: Alert on failure
84+
if: failure()
85+
env:
86+
ALERT_WEBHOOK_URL: ${{ secrets.STLC_ALERT_WEBHOOK_URL }}
87+
run: |
88+
run_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
89+
msg="stlc back-sync (sync from production) failed in ${{ github.repository }}. A stalled back-sync lets custom-code tracking drift, which later builds refuse on — investigate before the next build. Run: $run_url"
90+
echo "::error title=stlc workflow failed::$msg"
91+
{ echo "### ⚠️ stlc workflow failed"; echo ""; echo "$msg"; } >> "$GITHUB_STEP_SUMMARY"
92+
if [ -n "${ALERT_WEBHOOK_URL:-}" ]; then
93+
curl -sS -X POST -H 'Content-Type: application/json' \
94+
-d "$(jq -n --arg text "$msg" '{text:$text}')" "$ALERT_WEBHOOK_URL" \
95+
|| echo "::warning::Alert webhook POST failed"
96+
fi
97+
98+
notify-back-sync:
99+
# On a published release, tell staging to back-sync now instead of waiting for
100+
# the poll. Dispatch-only: it cannot write production or staging contents.
101+
runs-on: ${{ vars.STLC_RUNNER || 'ubuntu-latest' }}
102+
if: >-
103+
github.repository == 'runloopai/api-client-python' &&
104+
(github.event_name == 'release' || github.event_name == 'workflow_dispatch')
105+
permissions:
106+
contents: read
107+
env:
108+
STAGING_REPO: runloopai/api-client-python-staging
109+
steps:
110+
- name: Dispatch back-sync to staging
111+
env:
112+
DISPATCH_TOKEN: ${{ secrets.STAGING_DISPATCH_TOKEN }}
113+
REF_NAME: ${{ github.ref_name }}
114+
run: |
115+
set -euo pipefail
116+
117+
if [ -z "${DISPATCH_TOKEN:-}" ]; then
118+
echo "::notice::STAGING_DISPATCH_TOKEN not configured — skipping the eager back-sync notify. The staging repo's twice-hourly poll covers this."
119+
exit 0
120+
fi
121+
122+
payload=$(jq -n --arg ref "$REF_NAME" '{event_type:"prod-released",client_payload:{ref:$ref}}')
123+
code=$(curl -sS -o /tmp/dispatch.txt -w '%{http_code}' -X POST \
124+
-H "Authorization: Bearer ${DISPATCH_TOKEN}" \
125+
-H "Accept: application/vnd.github+json" \
126+
-H "X-GitHub-Api-Version: 2022-11-28" \
127+
"https://api.github.com/repos/${STAGING_REPO}/dispatches" \
128+
-d "$payload")
129+
if [ "$code" = "204" ]; then
130+
echo "Back-sync dispatched to ${STAGING_REPO}."
131+
else
132+
echo "Dispatch failed (HTTP $code)" >&2; cat /tmp/dispatch.txt >&2; exit 1
133+
fi
134+
135+
- name: Alert on failure
136+
if: failure()
137+
env:
138+
ALERT_WEBHOOK_URL: ${{ secrets.STLC_ALERT_WEBHOOK_URL }}
139+
run: |
140+
run_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
141+
msg="stlc release back-sync trigger failed in ${{ github.repository }} — the staging repo was NOT notified to back-sync this release (likely an expired STAGING_DISPATCH_TOKEN). Staging catches up on its next poll, but verify the token. Run: $run_url"
142+
echo "::error title=stlc workflow failed::$msg"
143+
{ echo "### ⚠️ stlc workflow failed"; echo ""; echo "$msg"; } >> "$GITHUB_STEP_SUMMARY"
144+
if [ -n "${ALERT_WEBHOOK_URL:-}" ]; then
145+
curl -sS -X POST -H 'Content-Type: application/json' \
146+
-d "$(jq -n --arg text "$msg" '{text:$text}')" "$ALERT_WEBHOOK_URL" \
147+
|| echo "::warning::Alert webhook POST failed"
148+
fi
149+
150+
seal-dispatch:
151+
# When out-of-band custom code lands on staging main, tell the config repo to
152+
# re-seal now instead of waiting for its scheduled sync. The loop guards skip
153+
# stlc's own pushes, so the bot's commits can't trigger a re-seal loop.
154+
runs-on: ${{ vars.STLC_RUNNER || 'ubuntu-latest' }}
155+
if: >-
156+
github.repository == 'runloopai/api-client-python-staging' &&
157+
github.event_name == 'push'
158+
permissions:
159+
contents: read
160+
concurrency:
161+
group: seal-dispatch-${{ github.ref }}
162+
cancel-in-progress: false
163+
env:
164+
CONFIG_REPO: runloopai/runloop
165+
steps:
166+
- name: Loop-guard and send re-seal dispatch
167+
env:
168+
DISPATCH_TOKEN: ${{ secrets.CONFIG_DISPATCH_TOKEN }}
169+
HEAD_MSG: ${{ github.event.head_commit.message }}
170+
HEAD_AUTHOR_NAME: ${{ github.event.head_commit.author.name }}
171+
SHA: ${{ github.sha }}
172+
REPO: ${{ github.repository }}
173+
run: |
174+
set -euo pipefail
175+
176+
# Loop guard 1: skip the stlc "Build SDK" squash commit (Stainless-Generated-From trailer).
177+
if printf '%s' "$HEAD_MSG" | grep -q 'Stainless-Generated-From'; then
178+
echo "Head commit is an stlc build — skipping re-seal dispatch."
179+
exit 0
180+
fi
181+
182+
# Loop guard 2: skip stlc-bot commits (e.g. a regeneration commit).
183+
if [ "$HEAD_AUTHOR_NAME" = "stlc-bot" ]; then
184+
echo "Head commit authored by stlc-bot — skipping re-seal dispatch."
185+
exit 0
186+
fi
187+
188+
if [ -z "${DISPATCH_TOKEN:-}" ]; then
189+
echo "::notice::CONFIG_DISPATCH_TOKEN not configured — skipping the eager re-seal. The config repo's scheduled sync covers this."
190+
exit 0
191+
fi
192+
193+
payload=$(jq -n --arg sha "$SHA" --arg repo "$REPO" \
194+
'{event_type:"seal-custom-code",client_payload:{target:"all",sha:$sha,repo:$repo}}')
195+
code=$(curl -sS -o /tmp/dispatch.txt -w '%{http_code}' -X POST \
196+
-H "Authorization: Bearer ${DISPATCH_TOKEN}" \
197+
-H "Accept: application/vnd.github+json" \
198+
-H "X-GitHub-Api-Version: 2022-11-28" \
199+
"https://api.github.com/repos/${CONFIG_REPO}/dispatches" \
200+
-d "$payload")
201+
if [ "$code" = "204" ]; then
202+
echo "Re-seal dispatched to ${CONFIG_REPO}."
203+
else
204+
echo "Dispatch failed (HTTP $code)" >&2; cat /tmp/dispatch.txt >&2; exit 1
205+
fi
206+
207+
- name: Alert on failure
208+
if: failure()
209+
env:
210+
ALERT_WEBHOOK_URL: ${{ secrets.STLC_ALERT_WEBHOOK_URL }}
211+
run: |
212+
run_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
213+
msg="stlc seal-dispatch failed in ${{ github.repository }} — the config repo was NOT notified to re-seal (likely an expired CONFIG_DISPATCH_TOKEN). The config repo's scheduled sync is the backstop, but verify the token. Run: $run_url"
214+
echo "::error title=stlc workflow failed::$msg"
215+
{ echo "### ⚠️ stlc workflow failed"; echo ""; echo "$msg"; } >> "$GITHUB_STEP_SUMMARY"
216+
if [ -n "${ALERT_WEBHOOK_URL:-}" ]; then
217+
curl -sS -X POST -H 'Content-Type: application/json' \
218+
-d "$(jq -n --arg text "$msg" '{text:$text}')" "$ALERT_WEBHOOK_URL" \
219+
|| echo "::warning::Alert webhook POST failed"
220+
fi

0 commit comments

Comments
 (0)