on main help sign in
Add the Sync with Forkgram workflow

Runs every 6 hours and by hand (inputs force, use_next and allow_drop). The sync job runs scripts/sync-forkgram.sh and pushes the new snapshot to forkgram and the candidate to multigram-next; compile calls build.yml on the candidate; promote, only after a successful compile, tags the old multigram tip multigram-before-<version>, moves multigram with force-with-lease and deletes multigram-next. The report job (scripts/sync-report.py) opens or comments on a forkgram-sync issue on a conflict, a failed compile or promote, a sync error, or a hand-edited multigram-next the sync left alone, with the local commands to fix it, and closes it once multigram is in step again, naming any commits the new multigram leaves out. It only starts when there is something to report, so the usual no-op runs cost one job. Each job asks only for the token permissions it needs, one concurrency group keeps syncs from overlapping, and commit subjects and file names reach scripts only through env. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mVy1fu8zBn38wHgJqmLNz

commitd2516175a9c09b741f09cec6731a6843436d3d11 authorClaude <noreply@anthropic.com> dateSun 27 Sep 2026, 01:44 (2 weeks ago) parent7f35cec
2 files changed+449
.github/workflows/sync-forkgram.yml+177 scripts/sync-report.py+272
.github/workflows/sync-forkgram.ymlnew file+177
@@ -0,0 +1,177 @@
1+name: Sync with Forkgram
2+run-name: Sync with Forkgram${{ inputs.use_next && ' (use multigram-next)' || '' }}${{ inputs.allow_drop && ' (allow drop)' || '' }}${{ inputs.force && ' (force)' || '' }}
3+
4+# Every 6 hours: snapshot a new Forkgram release onto "forkgram", replay the MultiGram
5+# stack onto it as "multigram-next", compile that, and only if it compiles move "multigram"
6+# to it. Conflicts and failures go to an issue labelled forkgram-sync; multigram stays as
7+# it was. A multigram-next changed by hand is never replaced. See README.md and
8+# scripts/sync-forkgram.sh.
9+on:
10+ schedule:
11+ - cron: '23 */6 * * *'
12+ workflow_dispatch:
13+ inputs:
14+ force:
15+ description: Rebuild multigram-next even without a new Forkgram release (tests the pipeline, or retries after a failed compile)
16+ type: boolean
17+ default: false
18+ use_next:
19+ description: Skip the rebase; compile and promote the multigram-next branch you fixed by hand
20+ type: boolean
21+ default: false
22+ allow_drop:
23+ description: Only with use_next. Accept a multigram-next that leaves out some of multigram's commits (Forkgram has them now, or you dropped them on purpose)
24+ type: boolean
25+ default: false
26+
27+permissions: {}
28+
29+concurrency:
30+ group: forkgram-sync
31+ cancel-in-progress: false
32+
33+jobs:
34+ sync:
35+ name: Snapshot Forkgram and replay the stack
36+ runs-on: ubuntu-latest
37+ timeout-minutes: 60
38+ permissions:
39+ contents: write # push forkgram, multigram-next and refs/multigram-sync/next
40+ issues: read # see whether the report job has anything to do
41+ outputs:
42+ status: ${{ steps.sync.outputs.status }}
43+ message: ${{ steps.sync.outputs.message }}
44+ error: ${{ steps.sync.outputs.error }}
45+ new_snapshot: ${{ steps.sync.outputs.new_snapshot }}
46+ forkgram_commit: ${{ steps.sync.outputs.forkgram_commit }}
47+ forkgram_version: ${{ steps.sync.outputs.forkgram_version }}
48+ snapshot: ${{ steps.sync.outputs.snapshot }}
49+ previous_snapshot: ${{ steps.sync.outputs.previous_snapshot }}
50+ multigram: ${{ steps.sync.outputs.multigram }}
51+ multigram_base: ${{ steps.sync.outputs.multigram_base }}
52+ candidate: ${{ steps.sync.outputs.candidate }}
53+ tag_version: ${{ steps.sync.outputs.tag_version }}
54+ conflict_commit: ${{ steps.sync.outputs.conflict_commit }}
55+ conflict_subject: ${{ steps.sync.outputs.conflict_subject }}
56+ conflict_position: ${{ steps.sync.outputs.conflict_position }}
57+ conflict_files: ${{ steps.sync.outputs.conflict_files }}
58+ conflict_details: ${{ steps.sync.outputs.conflict_details }}
59+ dropped: ${{ steps.sync.outputs.dropped }}
60+ existing_next: ${{ steps.sync.outputs.existing_next }}
61+ forkgram_tip: ${{ steps.sync.outputs.forkgram_tip }}
62+ report: ${{ steps.need.outputs.report }}
63+ steps:
64+ # Blobless: the history and trees of forkgram and multigram come cheap, and file
65+ # contents are fetched only when the rebase needs them.
66+ - uses: actions/checkout@v4
67+ with:
68+ fetch-depth: 1
69+ filter: blob:none
70+ - name: Sync
71+ id: sync
72+ env:
73+ FORCE: ${{ inputs.force }}
74+ USE_NEXT: ${{ inputs.use_next }}
75+ ALLOW_DROP: ${{ inputs.allow_drop }}
76+ run: |
77+ git config user.name 'github-actions[bot]'
78+ git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
79+ args=(--remote origin --push)
80+ if [ "$FORCE" = true ]; then args+=(--force); fi
81+ if [ "$USE_NEXT" = true ]; then args+=(--use-next); fi
82+ if [ "$ALLOW_DROP" = true ]; then args+=(--allow-drop); fi
83+ bash scripts/sync-forkgram.sh "${args[@]}"
84+ # Most scheduled runs find nothing new. Skip the report job then, since every job
85+ # costs at least a billed minute: it only has work when an issue is open to close
86+ # (up-to-date), or none is open yet (behind).
87+ - name: Check whether the report job has anything to do
88+ id: need
89+ if: steps.sync.outputs.status == 'up-to-date' || steps.sync.outputs.status == 'behind'
90+ env:
91+ GH_TOKEN: ${{ github.token }}
92+ GH_REPO: ${{ github.repository }}
93+ STATUS: ${{ steps.sync.outputs.status }}
94+ run: |
95+ open=$(gh issue list --label forkgram-sync --state open --json number --jq length) || open=unknown
96+ echo "Open forkgram-sync issues: $open"
97+ case "$STATUS:$open" in
98+ up-to-date:0 | behind:[1-9]*) echo report=no >>"$GITHUB_OUTPUT" ;;
99+ *) echo report=yes >>"$GITHUB_OUTPUT" ;;
100+ esac
101+
102+ compile:
103+ name: Compile multigram-next
104+ needs: sync
105+ if: needs.sync.outputs.candidate != ''
106+ permissions:
107+ contents: read
108+ uses: ./.github/workflows/build.yml
109+ with:
110+ ref: ${{ needs.sync.outputs.candidate }}
111+
112+ promote:
113+ name: Move multigram to multigram-next
114+ needs: [sync, compile]
115+ if: needs.compile.result == 'success'
116+ runs-on: ubuntu-latest
117+ timeout-minutes: 15
118+ permissions:
119+ contents: write # move multigram, tag its old tip, delete multigram-next
120+ outputs:
121+ status: ${{ steps.promote.outputs.status }}
122+ tag: ${{ steps.promote.outputs.tag }}
123+ steps:
124+ - uses: actions/checkout@v4
125+ with:
126+ fetch-depth: 1
127+ filter: blob:none
128+ - name: Promote
129+ id: promote
130+ env:
131+ CANDIDATE: ${{ needs.sync.outputs.candidate }}
132+ PREVIOUS: ${{ needs.sync.outputs.multigram }}
133+ VERSION: ${{ needs.sync.outputs.tag_version }}
134+ run: |
135+ bash scripts/sync-forkgram.sh promote --remote origin --shallow \
136+ --candidate "$CANDIDATE" --previous "$PREVIOUS" --version "$VERSION"
137+
138+ report:
139+ name: Report on the forkgram-sync issue
140+ needs: [sync, compile, promote]
141+ if: ${{ !cancelled() && needs.sync.outputs.report != 'no' }}
142+ runs-on: ubuntu-latest
143+ timeout-minutes: 10
144+ permissions:
145+ contents: read # the report script
146+ issues: write # open, comment on and close the forkgram-sync issue
147+ steps:
148+ - uses: actions/checkout@v4
149+ with:
150+ sparse-checkout: scripts
151+ - name: Open, update or close the issue
152+ env:
153+ GH_TOKEN: ${{ github.token }}
154+ GH_REPO: ${{ github.repository }}
155+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
156+ SYNC_RESULT: ${{ needs.sync.result }}
157+ COMPILE_RESULT: ${{ needs.compile.result }}
158+ PROMOTE_RESULT: ${{ needs.promote.result }}
159+ PROMOTE_TAG: ${{ needs.promote.outputs.tag }}
160+ SYNC_STATUS: ${{ needs.sync.outputs.status }}
161+ SYNC_MESSAGE: ${{ needs.sync.outputs.message }}
162+ SYNC_ERROR: ${{ needs.sync.outputs.error }}
163+ SYNC_FORKGRAM_COMMIT: ${{ needs.sync.outputs.forkgram_commit }}
164+ SYNC_FORKGRAM_VERSION: ${{ needs.sync.outputs.forkgram_version }}
165+ SYNC_SNAPSHOT: ${{ needs.sync.outputs.snapshot }}
166+ SYNC_PREVIOUS_SNAPSHOT: ${{ needs.sync.outputs.previous_snapshot }}
167+ SYNC_MULTIGRAM_BASE: ${{ needs.sync.outputs.multigram_base }}
168+ SYNC_CANDIDATE: ${{ needs.sync.outputs.candidate }}
169+ SYNC_CONFLICT_COMMIT: ${{ needs.sync.outputs.conflict_commit }}
170+ SYNC_CONFLICT_SUBJECT: ${{ needs.sync.outputs.conflict_subject }}
171+ SYNC_CONFLICT_POSITION: ${{ needs.sync.outputs.conflict_position }}
172+ SYNC_CONFLICT_FILES: ${{ needs.sync.outputs.conflict_files }}
173+ SYNC_CONFLICT_DETAILS: ${{ needs.sync.outputs.conflict_details }}
174+ SYNC_DROPPED: ${{ needs.sync.outputs.dropped }}
175+ SYNC_EXISTING_NEXT: ${{ needs.sync.outputs.existing_next }}
176+ SYNC_FORKGRAM_TIP: ${{ needs.sync.outputs.forkgram_tip }}
177+ run: python3 scripts/sync-report.py
scripts/sync-report.pynew file+272
@@ -0,0 +1,272 @@
1+#!/usr/bin/env python3
2+"""Report a "Sync with Forkgram" run on the GitHub issue labelled forkgram-sync.
3+
4+Runs in the report job of .github/workflows/sync-forkgram.yml, after the sync, compile and
5+promote jobs. It reads their results and the sync outputs from the environment and:
6+
7+ - conflict, compile failure, promote failure, a sync error, or a multigram-next changed by
8+ hand that the sync would have replaced: opens an issue, or comments on the open one, with
9+ what happened and the local commands to fix it (the same event is reported once, however
10+ often the schedule runs);
11+ - multigram still behind with no new release: opens an issue only if none is open;
12+ - promoted, or multigram in step with Forkgram: closes the open issue with a note (naming
13+ any MultiGram commits the new multigram leaves out).
14+
15+Environment: SYNC_RESULT, COMPILE_RESULT, PROMOTE_RESULT (job results); SYNC_* (the sync
16+job's outputs, e.g. SYNC_STATUS, SYNC_FORKGRAM_VERSION); PROMOTE_TAG; RUN_URL; GH_TOKEN
17+and GH_REPO for the gh CLI. REPORT_GH names another gh binary (the self-test uses a fake).
18+Standard library only.
19+"""
20+import hashlib
21+import json
22+import os
23+import re
24+import subprocess
25+import sys
26+import tempfile
27+
28+LABEL = 'forkgram-sync'
29+GH = os.environ.get('REPORT_GH', 'gh')
30+SHA = re.compile(r'^[0-9a-f]{40}$')
31+VERSION = re.compile(r'^[0-9A-Za-z][0-9A-Za-z._+-]{0,39}$')
32+
33+
34+def env(name):
35+ return os.environ.get(name, '').strip()
36+
37+
38+def sha(name):
39+ value = env(name)
40+ return value if SHA.match(value) else ''
41+
42+
43+def short(value):
44+ return value[:10] if value else '?'
45+
46+
47+def version():
48+ value = env('SYNC_FORKGRAM_VERSION')
49+ return value if VERSION.match(value) else 'unknown'
50+
51+
52+def block(text):
53+ """Untrusted text (commit subjects, file names, errors) as a Markdown code block."""
54+ text = text.replace('`', "'").strip() or '(none)'
55+ return '```text\n' + text + '\n```'
56+
57+
58+def gh(*args):
59+ run = subprocess.run([GH, *args], capture_output=True, text=True)
60+ if run.returncode != 0:
61+ sys.stderr.write(run.stderr)
62+ raise SystemExit(f'gh {args[0]} {args[1] if len(args) > 1 else ""} failed')
63+ return run.stdout
64+
65+
66+def gh_body(*args, body):
67+ with tempfile.NamedTemporaryFile('w', suffix='.md', delete=False) as f:
68+ f.write(body)
69+ path = f.name
70+ try:
71+ return gh(*args, '--body-file', path)
72+ finally:
73+ os.unlink(path)
74+
75+
76+def open_issues():
77+ issues = json.loads(gh('issue', 'list', '--label', LABEL, '--state', 'open',
78+ '--json', 'number,title', '--limit', '20') or '[]')
79+ return sorted(i['number'] for i in issues)
80+
81+
82+def open_issue():
83+ numbers = open_issues()
84+ return numbers[0] if numbers else None
85+
86+
87+def already_reported(number, key):
88+ data = json.loads(gh('issue', 'view', str(number), '--json', 'body,comments'))
89+ texts = [data.get('body') or ''] + [c.get('body') or '' for c in data.get('comments', [])]
90+ return any(key in t for t in texts)
91+
92+
93+def marker(key):
94+ return f'<!-- forkgram-sync-key: {key} -->'
95+
96+
97+def title():
98+ return f'Forkgram sync needs a hand (Forkgram {version()})'
99+
100+
101+def run_line():
102+ url = env('RUN_URL')
103+ return f'Run: {url}' if url.startswith('https://') else ''
104+
105+
106+def use_next_step():
107+ return ('Then open **Actions > Sync with Forkgram > Run workflow**, tick **use_next** and run '
108+ 'it. It compiles `multigram-next` and, if that passes, moves `multigram` to it (the '
109+ 'old tip is kept as the tag `multigram-before-<version>`) and closes this issue.')
110+
111+
112+def body_conflict():
113+ snap, prev = sha('SYNC_SNAPSHOT') or '<new snapshot>', sha('SYNC_PREVIOUS_SNAPSHOT') or '<old snapshot>'
114+ commit = sha('SYNC_CONFLICT_COMMIT')
115+ return f"""**Forkgram {version()}** is out and is now on `forkgram` (snapshot `{short(snap)}`), but the MultiGram stack does not apply to it. `multigram` is unchanged.
116+
117+MultiGram commit `{short(commit)}` ({env('SYNC_CONFLICT_POSITION') or '?'}) stops the rebase:
118+{block(env('SYNC_CONFLICT_SUBJECT'))}
119+Files:
120+{block(env('SYNC_CONFLICT_DETAILS') or env('SYNC_CONFLICT_FILES'))}
121+{run_line()}
122+
123+### Fix it locally
124+
125+```sh
126+git fetch origin
127+git switch -C multigram-next origin/multigram
128+git config rerere.enabled true # reuses your fixes if you redo this rebase in this clone
129+git rebase --onto {snap} {prev}
130+# fix the files git names, then:
131+git add <files>
132+git rebase --continue # repeat until the rebase is done
133+git push --force origin multigram-next
134+```
135+
136+To leave a MultiGram commit out instead (Forkgram now does the same thing, say), run `git rebase --skip` at that commit.
137+
138+{use_next_step()}
139+"""
140+
141+
142+def body_compile():
143+ snap, cand = sha('SYNC_SNAPSHOT') or '<snapshot>', sha('SYNC_CANDIDATE')
144+ return f"""The MultiGram stack applied cleanly to **Forkgram {version()}**, but the compile check failed. `multigram` is unchanged; the candidate is on `multigram-next` (`{short(cand)}`).
145+
146+{run_line()}
147+
148+If the log shows a runner or network hiccup, use **Re-run failed jobs** on the run page. Otherwise:
149+
150+### Fix it locally
151+
152+```sh
153+git fetch origin
154+git switch -C multigram-next origin/multigram-next
155+# fix the build, then fold the fix into the MultiGram commit it belongs to:
156+git commit -a --fixup=<that commit>
157+GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash {snap}
158+git push --force origin multigram-next
159+```
160+
161+{use_next_step()}
162+"""
163+
164+
165+def body_promote():
166+ cand = sha('SYNC_CANDIDATE')
167+ return f"""The candidate for **Forkgram {version()}** compiled, but moving `multigram` to it failed, usually because someone pushed to `multigram` or `multigram-next` while the sync ran. `multigram` is unchanged; the candidate is still on `multigram-next` (`{short(cand)}`).
168+
169+{run_line()}
170+
171+Check that `multigram-next` holds everything `multigram` has now (rebase any new commits onto it and push it), then run **Actions > Sync with Forkgram** with **use_next**.
172+"""
173+
174+
175+def body_error():
176+ return f"""The sync stopped with an error. Nothing was pushed after the error; `multigram` is unchanged.
177+
178+{block(env('SYNC_ERROR') or env('SYNC_MESSAGE') or 'The sync job failed before it could say why; see the run log.')}
179+{run_line()}
180+
181+Fix the cause (the message says what it is), then run **Actions > Sync with Forkgram** again.
182+"""
183+
184+
185+def body_next_edited():
186+ nxt, tip, fc = sha('SYNC_EXISTING_NEXT'), sha('SYNC_FORKGRAM_TIP'), sha('SYNC_FORKGRAM_COMMIT')
187+ if tip and tip != fc:
188+ what = f"Forkgram's `dev` has moved on (`{short(tip)}`), but the sync did not take it"
189+ else:
190+ what = 'The sync was asked to rebuild `multigram-next` (force), but did not'
191+ return f"""{what}: `multigram-next` (`{short(nxt)}`) has commits the sync did not make, most likely a fix by hand, and the sync never replaces those. Nothing was pushed; `forkgram` and `multigram` are unchanged.
192+
193+{run_line()}
194+
195+- If `multigram-next` is ready, run **Actions > Sync with Forkgram > Run workflow** with **use_next**: it compiles `multigram-next` and, if that passes, moves `multigram` to it. The next scheduled sync then takes the new Forkgram commit.
196+- If you do not need it any more, delete it (`git push origin --delete multigram-next`); the next sync rebuilds it from `multigram`.
197+"""
198+
199+
200+def body_behind():
201+ return f"""`forkgram` has **Forkgram {version()}** (snapshot `{short(sha('SYNC_SNAPSHOT'))}`), but `multigram` is still based on the older snapshot `{short(sha('SYNC_MULTIGRAM_BASE'))}`: an earlier sync could not move it and no issue is open about it.
202+
203+{run_line()}
204+
205+Run **Actions > Sync with Forkgram** with **force** to try again; if the stack still does not apply, this issue gets the details and the commands to fix it.
206+"""
207+
208+
209+def report(key, body):
210+ number = open_issue()
211+ text = marker(key) + '\n' + body
212+ if number is None:
213+ gh('label', 'create', LABEL, '--color', 'D93F0B', '--force',
214+ '--description', 'The automatic sync with Forkgram needs a hand')
215+ print(gh_body('issue', 'create', '--title', title(), '--label', LABEL, body=text).strip())
216+ return
217+ if key and already_reported(number, marker(key)):
218+ print(f'issue #{number} already reports this; nothing to add')
219+ return
220+ gh('issue', 'edit', str(number), '--title', title())
221+ gh_body('issue', 'comment', str(number), body=text)
222+ print(f'commented on issue #{number}')
223+
224+
225+def close(note):
226+ for number in open_issues():
227+ gh('issue', 'close', str(number), '--reason', 'completed', '--comment', note)
228+ print(f'closed issue #{number}')
229+
230+
231+def main():
232+ results = [env('SYNC_RESULT'), env('COMPILE_RESULT'), env('PROMOTE_RESULT')]
233+ status = env('SYNC_STATUS')
234+ print(f'sync={results[0]} compile={results[1]} promote={results[2]} status={status or "-"}')
235+ if 'cancelled' in results:
236+ print('a job was cancelled; nothing to report')
237+ return
238+ fc = sha('SYNC_FORKGRAM_COMMIT')
239+ cand = sha('SYNC_CANDIDATE')
240+ if status == 'conflict':
241+ report(f'conflict {fc} {sha("SYNC_CONFLICT_COMMIT")}', body_conflict())
242+ elif results[0] != 'success':
243+ err = env('SYNC_ERROR') or 'unknown'
244+ report('error ' + hashlib.sha1(err.encode()).hexdigest()[:12], body_error())
245+ elif status == 'next-edited':
246+ report(f'next-edited {sha("SYNC_EXISTING_NEXT")} {sha("SYNC_FORKGRAM_TIP")}', body_next_edited())
247+ elif status == 'candidate' and results[1] == 'failure':
248+ report(f'compile {cand}', body_compile())
249+ elif status == 'candidate' and results[1] == 'success' and results[2] == 'failure':
250+ report(f'promote {cand}', body_promote())
251+ elif status == 'candidate' and results[2] == 'success':
252+ tag = env('PROMOTE_TAG')
253+ moved = f'the old tip is tagged `{tag}`' if re.match(r'^[0-9A-Za-z._-]+$', tag) else 'it already was the candidate'
254+ note = f'Done: `multigram` now carries Forkgram {version()} (`{short(cand)}`); {moved}. {run_line()}'.strip()
255+ if env('SYNC_DROPPED'):
256+ note += ('\n\nThese MultiGram commits are not on the new `multigram` (Forkgram already has '
257+ 'them, or `multigram-next` left them out with allow_drop):\n' + block(env('SYNC_DROPPED')))
258+ close(note)
259+ elif status == 'behind':
260+ if open_issue() is None:
261+ report('', body_behind())
262+ else:
263+ print('multigram is behind and an issue is already open; nothing to add')
264+ elif status == 'up-to-date':
265+ if open_issue() is not None:
266+ close(f'`multigram` is in step with Forkgram {version()} again. {run_line()}'.strip())
267+ else:
268+ print('nothing to report')
269+
270+
271+if __name__ == '__main__':
272+ main()