Skip to content

Commit cef0317

Browse files
committed
chore: add promote, back-sync, and trunk-lock workflows
1 parent 701f641 commit cef0317

3 files changed

Lines changed: 213 additions & 0 deletions

File tree

.github/workflows/stlc-promote.yml

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
name: Promote SDKs
2+
3+
# Promote staging to production by fast-forwarding production `main` up to the
4+
# staging trunk, preserving commit SHAs. Because the SHAs are preserved,
5+
# production `main` and staging `main` stay identical and linear, the sealed
6+
# custom-code commit stays an ancestor of both, and there is no divergence to
7+
# heal on the next `stlc build`.
8+
#
9+
# Manual dispatch (workflow_dispatch): spec/codegen changes accumulate on
10+
# staging `main`, and a maintainer promotes the batch in one fast-forward when
11+
# ready to cut a release. Optionally gate each run behind a required-reviewer
12+
# approval — see the `environment` note on the job below. release-please then
13+
# opens its version/changelog PR on production on top.
14+
on:
15+
workflow_dispatch: {}
16+
17+
permissions:
18+
contents: read
19+
20+
jobs:
21+
promote:
22+
runs-on: ubuntu-latest
23+
if: github.repository == 'sentdm/sent-dm-python-staging'
24+
# Optional: add required reviewers on each promote run before the fast-forward push.
25+
# With no reviewers it only scopes secrets/variables and adds no gate, so the manual dispatch
26+
# above is the only checkpoint. Remove this line if you don't use it.
27+
environment: production
28+
env:
29+
PRODUCTION_REPO: sentdm/sent-dm-python
30+
GH_TOKEN: ${{ secrets.PRODUCTION_REPO_TOKEN }}
31+
steps:
32+
- name: Check out staging
33+
uses: actions/checkout@v6
34+
with:
35+
fetch-depth: 0
36+
persist-credentials: false
37+
38+
- name: Fetch production main
39+
run: |
40+
git remote add production \
41+
"https://x-access-token:${GH_TOKEN}@github.com/${PRODUCTION_REPO}.git"
42+
git fetch production main
43+
44+
- name: Check whether production already has staging's content
45+
id: diff
46+
run: |
47+
# After a release, production carries release-please's version/changelog
48+
# commits that staging lacks. Ask whether merging staging into production
49+
# would change production's tree: if not, production already has staging's content.
50+
MERGED=$(git merge-tree --write-tree production/main origin/main) || MERGED=conflict
51+
PRODUCTION_TREE=$(git rev-parse 'production/main^{tree}')
52+
if [ "$MERGED" = "$PRODUCTION_TREE" ]; then
53+
echo "Production already contains staging's content. Nothing to promote."
54+
echo "synced=true" >> "$GITHUB_OUTPUT"
55+
else
56+
echo "synced=false" >> "$GITHUB_OUTPUT"
57+
fi
58+
59+
- name: Promote staging to production (fast-forward)
60+
if: steps.diff.outputs.synced == 'false'
61+
run: |
62+
# Refuse unless production/main is an ancestor of staging/main. If it
63+
# isn't, the trunks have forked (someone advanced production out of
64+
# band without back-syncing first) and a fast-forward is unsafe.
65+
if ! git merge-base --is-ancestor production/main origin/main; then
66+
echo "::error title=Promote blocked::production/main is not an ancestor of staging main. Back-sync production into staging first."
67+
exit 1
68+
fi
69+
git push production origin/main:refs/heads/main
70+
echo "Fast-forwarded production/main to staging/main."
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
name: Sync from production
2+
3+
# Bring production's commits back onto staging `main` by fast-forwarding.
4+
# This keeps staging and production identical and linear so the next `stlc build`
5+
# reseals against the released state.
6+
#
7+
# Triggered by the production-side release dispatch and by a periodic poll.
8+
on:
9+
schedule:
10+
# Polls for non-release production changes. If you take frequent
11+
# community PRs, tighten this or add an eager `on: push: [main]`
12+
# dispatch on production alongside the release one.
13+
- cron: '17 */6 * * *'
14+
workflow_dispatch: {}
15+
repository_dispatch:
16+
types: [prod-released]
17+
18+
permissions:
19+
contents: write
20+
21+
concurrency:
22+
group: stlc-sync-from-production
23+
cancel-in-progress: true
24+
25+
jobs:
26+
sync:
27+
runs-on: ubuntu-latest
28+
if: github.repository == 'sentdm/sent-dm-python-staging'
29+
env:
30+
PRODUCTION_REPO: sentdm/sent-dm-python
31+
steps:
32+
- name: Check out staging
33+
uses: actions/checkout@v6
34+
with:
35+
fetch-depth: 0
36+
37+
- name: Fetch production main
38+
run: |
39+
# Production is public, so the built-in token reads it with no
40+
# credential. If your production repo is private, add a remote with
41+
# PRODUCTION_REPO_TOKEN instead (the same token the promote uses).
42+
git remote add production "https://github.com/${PRODUCTION_REPO}.git"
43+
git fetch production main
44+
45+
- name: Check whether production has content staging lacks
46+
id: diff
47+
run: |
48+
# Inverse of the promote guard: would merging production into
49+
# staging change staging's tree? If not, staging already has
50+
# production's content.
51+
MERGED=$(git merge-tree --write-tree origin/main production/main) || MERGED=conflict
52+
STAGING_TREE=$(git rev-parse 'origin/main^{tree}')
53+
if [ "$MERGED" = "$STAGING_TREE" ]; then
54+
echo "Staging already has production's content. Nothing to pull back."
55+
echo "behind=false" >> "$GITHUB_OUTPUT"
56+
else
57+
echo "behind=true" >> "$GITHUB_OUTPUT"
58+
fi
59+
60+
- name: Sync production to staging (fast-forward)
61+
if: steps.diff.outputs.behind == 'true'
62+
run: |
63+
# Refuse unless staging/main is an ancestor of production/main. If it
64+
# isn't, the trunks have forked (staging advanced out of band while a
65+
# production change was unsynced) and a fast-forward is unsafe.
66+
#
67+
# The trunk-sync lock (trunk-sync-lock.yml) is what normally
68+
# prevents this by freezing staging merges until the back-sync lands.
69+
if ! git merge-base --is-ancestor origin/main production/main; then
70+
echo "::error title=Back-sync blocked::staging main is not an ancestor of production/main."
71+
exit 1
72+
fi
73+
git push origin production/main:refs/heads/main
74+
echo "Fast-forwarded staging/main to production/main."
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
name: Trunk sync lock
2+
3+
# Posts a `trunk-synced` commit status to open PRs targeting staging `main`:
4+
# red while production is ahead of staging (a release hasn't been synced back
5+
# yet), green once they are in sync.
6+
#
7+
# You should make `trunk-synced` a required status check on staging `main`
8+
# to block merges onto a stale trunk during the release
9+
# window.
10+
#
11+
# This only gates PRs, not direct pushes. The back-sync bypasses this check
12+
# by pushing to staging `main` directly. If staging `main` also requires a pull
13+
# request, give the back-sync's identity ruleset-bypass so it can push. Never
14+
# route the back-sync through a PR gated by this check, or it deadlocks.
15+
#
16+
# First-run note: GitHub treats a status check as "required" only once it has
17+
# been reported at least once. At setup time staging and production are
18+
# identical, so trigger this workflow once (push a PR or use the Run workflow
19+
# button) BEFORE adding `trunk-synced` to the required checks, so the first
20+
# status is green rather than missing.
21+
on:
22+
pull_request:
23+
branches: [main]
24+
types: [opened, synchronize, reopened]
25+
workflow_run:
26+
workflows: ["Sync from production"]
27+
types: [completed]
28+
repository_dispatch:
29+
types: [prod-released]
30+
workflow_dispatch: {}
31+
schedule:
32+
- cron: '*/30 * * * *'
33+
34+
permissions:
35+
contents: read
36+
statuses: write
37+
pull-requests: read
38+
39+
jobs:
40+
lock:
41+
runs-on: ubuntu-latest
42+
if: github.repository == 'sentdm/sent-dm-python-staging'
43+
env:
44+
PRODUCTION_REPO: sentdm/sent-dm-python
45+
GH_TOKEN: ${{ github.token }}
46+
steps:
47+
- uses: actions/checkout@v6
48+
with:
49+
fetch-depth: 0
50+
persist-credentials: false
51+
- name: Evaluate sync state and post status to open main PRs
52+
run: |
53+
set -euo pipefail
54+
# Production is public; the built-in token reads it with no credential.
55+
git remote add production "https://github.com/${PRODUCTION_REPO}.git"
56+
git fetch --no-tags production main
57+
if git merge-base --is-ancestor production/main HEAD; then
58+
state=success; desc="staging main is in sync with production"
59+
else
60+
state=failure; desc="production is ahead — wait for the back-sync before merging"
61+
fi
62+
echo "trunk-synced => $state ($desc)"
63+
shas=$(gh pr list --repo "$GITHUB_REPOSITORY" --base main --state open --json headRefOid --jq '.[].headRefOid')
64+
if [ -z "$shas" ]; then echo "no open PRs targeting main"; exit 0; fi
65+
for sha in $shas; do
66+
gh api -X POST "repos/$GITHUB_REPOSITORY/statuses/$sha" \
67+
-f state="$state" -f context="trunk-synced" -f description="$desc" >/dev/null
68+
echo "posted trunk-synced=$state to $sha"
69+
done

0 commit comments

Comments
 (0)