Repository navigation
557 lines (520 loc) · 29.1 KB
/
Copy pathupstream-sync.yml
File metadata and controls
557 lines (520 loc) · 29.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# upstream-sync: one daily PR merging NVIDIA develop into our develop, updated in place.
# A PR whose range carries a new upstream tag is labelled `upstream-release`.
# Spec: PLAN-pair-upstream-sync.md section 4. Ported from the codex fork's upstream-sync.yml.
#
# Secret UPSTREAM_SYNC_TOKEN (required): fine-grained PAT or App token scoped to this repo with
# contents, workflows, pull-requests and issues. Never GITHUB_TOKEN: PRs it opens do not trigger
# local-ci, and it cannot push workflow files.
name: upstream-sync
on:
schedule:
- cron: "23 6 * * *"
workflow_dispatch:
inputs:
ref:
description: "Upstream ref or tag to sync (default: upstream/develop)"
required: false
type: string
force:
description: "Run the merge path even if upstream has not moved"
required: false
type: boolean
default: false
demo_ref:
description: "Demo mode: origin test/upstream-sim-<n> branch standing in for upstream"
required: false
type: string
# Least privilege by default; the two privileged jobs raise it. Candidate (merged) code is only ever
# executed in the `check` job, which has read-only permissions and no PAT. The privileged jobs run
# sync_upstream.py copied from `develop` before the candidate is checked out, never the candidate's copy.
permissions:
contents: read
concurrency:
group: upstream-sync
cancel-in-progress: false
# Residual risk accepted: upstream ci.yml (untouched here) runs on sync PRs and may write caches; no path leads
# from a cache to the PAT or to a push.
env:
BASE: develop
DEMO_REF: ${{ inputs.demo_ref }}
jobs:
merge:
# While the kill switch is in demo mode the schedule is inert; only a manual (dispatch) demo run proceeds.
if: github.event_name != 'schedule' || vars.SYNC_AUTO_MERGE != 'demo'
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions: { contents: write, pull-requests: write, issues: write }
env:
GH_TOKEN: ${{ secrets.UPSTREAM_SYNC_TOKEN }}
# Pin gh to this fork: the `upstream` remote added below would otherwise
# make gh target NVIDIA for pr/issue commands (codex fork PR #24).
GH_REPO: ${{ github.repository }}
outputs:
status_code: ${{ steps.status.outputs.code }}
mode: ${{ steps.status.outputs.mode }}
code: ${{ steps.merge.outputs.code }}
candidate: ${{ steps.bundle.outputs.candidate }}
pr_labels: ${{ steps.pr.outputs.labels }}
# The original open sync PR (validated), carried to publish: publish never re-selects a PR.
pr_number: ${{ steps.pr.outputs.number }}
pr_branch: ${{ steps.pr.outputs.branch }}
# Identity of what publish may push comes from this privileged job's outputs, never from artifacts.
branch: ${{ steps.bundle.outputs.branch }}
cand_sha: ${{ steps.bundle.outputs.cand_sha }}
bundle_sha256: ${{ steps.bundle.outputs.bundle_sha256 }}
# Digests of every metadata file publish parses; "absent" means the file must not exist.
status_sha256: ${{ steps.digests.outputs.status_sha256 }}
merge_sha256: ${{ steps.digests.outputs.merge_sha256 }}
guard_sha256: ${{ steps.digests.outputs.guard_sha256 }}
steps:
- name: Require UPSTREAM_SYNC_TOKEN
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::error title=UPSTREAM_SYNC_TOKEN missing::Create the secret (fine-grained PAT, this repo only: contents, workflows, pull-requests, issues)."
exit 1
fi
- uses: actions/checkout@v4
with:
ref: develop
fetch-depth: 0
# No persisted GITHUB_TOKEN header: pushes use UPSTREAM_SYNC_TOKEN in the publish job.
persist-credentials: false
- name: Trust develop's scripts, configure git
run: |
# Copied before any candidate branch is checked out: this is the only code that runs with the PAT.
cp -R local/scripts "$RUNNER_TEMP/trusted"
mkdir -p "$RUNNER_TEMP/out"
git config user.name "pair upstream-sync"
git config user.email "upstream-sync@users.noreply.github.com"
git remote add upstream https://github.com/NVIDIA/Personal-AI-Router.git
git fetch upstream --tags --quiet
git fetch origin --quiet
for l in "upstream-sync 0e8a16 Upstream sync" "upstream-release 1d76db Sync carries a new upstream tag" "sync-failing d93f0b Sync needs repair" "workflow-review fbca04 Upstream workflow changes held for manual porting" "ci-failing b60205 Sync PR checks failing" "sync-demo c5def5 Simulated-upstream demo sync" "surface-budget-raise 5319e7 Raises the local surface budget" "sync-conflict e99695 Merge conflicts escalated; a human removes this after repairing"; do
set -- $l; gh label create "$1" --color "$2" --description "${*:3}" 2>/dev/null || true
done
- name: Find the open sync PR
id: pr
run: |
# Only a same-repo PR from an upstream-sync/* (demo: upstream-sync-demo/*) branch is ever treated as the sync PR.
find_args=(); [ -n "$DEMO_REF" ] && find_args+=(--demo)
json=$(python3 "$RUNNER_TEMP/trusted/sync_gh.py" find-pr "${find_args[@]}")
n=$(jq -r '.number // empty' <<<"$json")
[ -z "$n" ] && exit 0
b=$(jq -r .branch <<<"$json")
# Validated before it becomes a job output: publish trusts these two values.
[[ "$n" =~ ^[0-9]+$ ]] || { echo "::error::bad PR number from find-pr"; exit 1; }
[[ "$b" =~ ^upstream-sync(-demo)?/[A-Za-z0-9._-]+$ ]] || { echo "::error::bad PR branch from find-pr"; exit 1; }
echo "number=$n" >> "$GITHUB_OUTPUT"
echo "branch=$b" >> "$GITHUB_OUTPUT"
echo "draft=$(jq -r .draft <<<"$json")" >> "$GITHUB_OUTPUT"
echo "labels=$(jq -r '.labels | join(",")' <<<"$json")" >> "$GITHUB_OUTPUT"
# Snapshot of the enrollment time, taken BEFORE the disarm below, for the stalled check.
echo "enabled_at=$(jq -r '.auto_merge_enabled_at // empty' <<<"$json")" >> "$GITHUB_OUTPUT"
# Disarm at once (same UPSTREAM_SYNC_TOKEN as the rest of this job: pull-requests write), before any checkout
# or stalled reporting: a later tool error in this job must not leave the PR armed. Idempotent with publish.
python3 "$RUNNER_TEMP/trusted/sync_gh.py" disable-auto --pr "$n"
# Merge into the open PR's branch so human commits are preserved.
git checkout -q -B "$b" "origin/$b"
- name: Stalled enrollment
if: steps.pr.outputs.number != ''
env:
PR_NUMBER: ${{ steps.pr.outputs.number }}
ENABLED_AT: ${{ steps.pr.outputs.enabled_at }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: python3 "$RUNNER_TEMP/trusted/sync_gh.py" stalled --pr "$PR_NUMBER" --run-url "$RUN_URL" --enabled-at "$ENABLED_AT"
- name: Status
id: status
env:
REF_INPUT: ${{ inputs.ref }}
FORCE_INPUT: ${{ inputs.force }}
PR_BRANCH: ${{ steps.pr.outputs.branch }}
PR_NUMBER: ${{ steps.pr.outputs.number }}
run: |
# Measured from the pending PR branch when there is one, so later tags update it in place.
args=(--root "$GITHUB_WORKSPACE" status --base HEAD --tags-base "origin/$BASE")
[ -n "$REF_INPUT" ] && args+=(--ref "$REF_INPUT")
[ "$FORCE_INPUT" = "true" ] && args+=(--force)
[ -n "$DEMO_REF" ] && args+=(--demo-ref "$DEMO_REF")
[ -n "$PR_NUMBER" ] && args+=(--pending) # nothing new + open PR => mode recheck (re-run checks)
set +e
python3 "$RUNNER_TEMP/trusted/sync_upstream.py" "${args[@]}" > "$RUNNER_TEMP/out/status.json"
code=$?
cat "$RUNNER_TEMP/out/status.json"
echo "code=$code" >> "$GITHUB_OUTPUT"
echo "mode=$(jq -r '.mode // "none"' "$RUNNER_TEMP/out/status.json")" >> "$GITHUB_OUTPUT"
exit 0 # a failed status is reported by the publish job, never silently dropped
- name: Merge
id: merge
if: steps.status.outputs.mode == 'sync' || steps.status.outputs.mode == 'bump'
env:
REF_INPUT: ${{ inputs.ref }}
FORCE_INPUT: ${{ inputs.force }}
MODE: ${{ steps.status.outputs.mode }}
PR_BRANCH: ${{ steps.pr.outputs.branch }}
run: |
args=(--root "$GITHUB_WORKSPACE" merge --base-ref "origin/$BASE")
[ -n "$REF_INPUT" ] && args+=(--ref "$REF_INPUT")
[ "$FORCE_INPUT" = "true" ] && args+=(--force)
[ -n "$DEMO_REF" ] && args+=(--demo-ref "$DEMO_REF")
[ "$MODE" = "bump" ] && args+=(--bump-only)
[ -n "$PR_BRANCH" ] && args+=(--reuse --branch "$PR_BRANCH")
set +e
python3 "$RUNNER_TEMP/trusted/sync_upstream.py" "${args[@]}" > "$RUNNER_TEMP/out/merge.json"
code=$?
cat "$RUNNER_TEMP/out/merge.json"
echo "code=$code" >> "$GITHUB_OUTPUT"
exit 0
- name: Bundle the candidate
id: bundle
env:
MODE: ${{ steps.status.outputs.mode }}
MERGE_CODE: ${{ steps.merge.outputs.code }}
run: |
committed=$(jq -r '.committed // false' "$RUNNER_TEMP/out/merge.json" 2>/dev/null || echo false)
if { [ "$committed" = "true" ] || [ "$MERGE_CODE" = "0" ] || [ "$MODE" = "recheck" ]; } \
&& [ "$(git rev-parse HEAD)" != "$(git rev-parse "origin/$BASE")" ] \
&& [ "$MODE" != "none" ]; then
git merge --abort 2>/dev/null || true
# A PAT push triggers workflows, so .github/workflows/** must be byte-identical to the base (held).
# Purely structural check: a non-empty diff means no bundle and no push.
set +e
python3 "$RUNNER_TEMP/trusted/sync_upstream.py" --root "$GITHUB_WORKSPACE" workflow-guard \
--base "origin/$BASE" --cand HEAD > "$RUNNER_TEMP/out/guard.json"
guard=$?
set -e
cat "$RUNNER_TEMP/out/guard.json"
[ "$guard" = "0" ] || exit 0 # guard failed: no bundle, no push; report escalates
git bundle create "$RUNNER_TEMP/out/cand.bundle" HEAD --not "origin/$BASE"
echo "candidate=true" >> "$GITHUB_OUTPUT"
echo "cand_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "bundle_sha256=$(sha256sum "$RUNNER_TEMP/out/cand.bundle" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
echo "branch=$(git branch --show-current)" >> "$GITHUB_OUTPUT"
fi
- name: Digest the metadata
id: digests
run: |
# Computed here, in the privileged job, before the artifact leaves it: the check job runs candidate code
# and could replace the sync-merge artifact, so publish authenticates each parsed file against these.
for k in status merge guard; do
f="$RUNNER_TEMP/out/$k.json"
if [ -f "$f" ]; then h=$(sha256sum "$f" | cut -d' ' -f1); else h=absent; fi
echo "${k}_sha256=$h" >> "$GITHUB_OUTPUT"
done
- uses: actions/upload-artifact@v4
with:
name: sync-merge
path: ${{ runner.temp }}/out
check:
needs: merge
if: needs.merge.outputs.candidate == 'true' && (needs.merge.outputs.code == '0' || needs.merge.outputs.code == '1' || needs.merge.outputs.mode == 'recheck')
runs-on: ubuntu-24.04
timeout-minutes: 90
cache-mode: none # no cache restore or save: nothing here may poison what privileged jobs read
# Runs candidate code: read-only token, no PAT, no persisted credentials, no caches.
permissions:
contents: read
outputs:
code: ${{ steps.check.outputs.code }}
steps:
- uses: actions/checkout@v4
with:
ref: develop
fetch-depth: 0
persist-credentials: false
- uses: actions/download-artifact@v4
with:
name: sync-merge
path: ${{ runner.temp }}/in
- name: Check out the candidate
run: |
git fetch "$RUNNER_TEMP/in/cand.bundle" HEAD:refs/heads/candidate
git checkout -q candidate
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: actions/setup-go@v5
with:
go-version-file: services/nvpair-proxy/go.mod
cache: false
- name: Check
id: check
env:
PR_LABELS: ${{ needs.merge.outputs.pr_labels }}
run: |
# Labels on the open sync PR (e.g. surface-budget-raise) reach the budget_raised rule.
args=(); IFS=, read -ra labels <<<"$PR_LABELS"; for l in "${labels[@]}"; do [ -n "$l" ] && args+=(--label "$l"); done
[ -n "$DEMO_REF" ] && args+=(--demo-ref "$DEMO_REF")
mkdir -p "$RUNNER_TEMP/check"
set +e
python3 local/scripts/sync_upstream.py check --base "origin/$BASE" "${args[@]}" > "$RUNNER_TEMP/check/check.json"
code=$?
cat "$RUNNER_TEMP/check/check.json"
echo "code=$code" >> "$GITHUB_OUTPUT" # passed through to report: exit 2/3 must escalate
exit 0
- uses: actions/upload-artifact@v4
if: always()
with:
name: sync-check-result # only check.json: it is untrusted data in publish
path: ${{ runner.temp }}/check/check.json
publish:
needs: [merge, check]
if: >-
always() && needs.merge.result == 'success' &&
(needs.merge.outputs.mode == 'sync' || needs.merge.outputs.mode == 'bump' || needs.merge.outputs.mode == 'recheck' || needs.merge.outputs.status_code != '0')
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions: { contents: write, pull-requests: write, issues: write }
env:
GH_TOKEN: ${{ secrets.UPSTREAM_SYNC_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/checkout@v4
with:
ref: develop
fetch-depth: 0
persist-credentials: false
- name: Trust develop's scripts
run: cp -R local/scripts "$RUNNER_TEMP/trusted"
# First, before any artifact is downloaded or verified: a tool/tamper failure below must not leave a carried
# PR armed. Re-arming happens only through the normal clean Enroll path.
- name: Disarm carried PR
if: always() && needs.merge.outputs.pr_number != ''
env:
PR_NUMBER: ${{ needs.merge.outputs.pr_number }}
run: |
[[ "$PR_NUMBER" =~ ^[0-9]+$ ]] || { echo "::error::bad carried PR number"; exit 1; }
python3 "$RUNNER_TEMP/trusted/sync_gh.py" disable-auto --pr "$PR_NUMBER"
- uses: actions/download-artifact@v4
with:
name: sync-merge
path: ${{ runner.temp }}/in
- uses: actions/download-artifact@v4
continue-on-error: true # absent when the merge failed or the check job did not run: report then escalates
with:
name: sync-check-result
path: ${{ runner.temp }}/chk # separate directory: nothing from the check job can overwrite sync-merge files
- name: Verify merge metadata
env:
STATUS_SHA256: ${{ needs.merge.outputs.status_sha256 }}
MERGE_SHA256: ${{ needs.merge.outputs.merge_sha256 }}
GUARD_SHA256: ${{ needs.merge.outputs.guard_sha256 }}
run: |
# Same pattern as the bundle check: nothing downloaded is parsed before it matches the privileged job's output.
in="$RUNNER_TEMP/in"
verify() {
if [ "$2" = "absent" ]; then
[ ! -e "$in/$1" ] || { echo "::error::$1 present but the merge job emitted none"; exit 1; }
else
[ -n "$2" ] && echo "$2 $in/$1" | sha256sum -c - || { echo "::error::$1 hash mismatch"; exit 1; }
fi
}
verify status.json "$STATUS_SHA256"
verify merge.json "$MERGE_SHA256"
verify guard.json "$GUARD_SHA256"
- name: Head veto
id: veto
if: needs.merge.outputs.pr_number != ''
env:
PR_NUMBER: ${{ needs.merge.outputs.pr_number }}
CAND_SHA: ${{ needs.merge.outputs.cand_sha }}
run: |
# An escalate/failing marker pinned to the PR head vetoes `clean` while that head is unchanged.
[[ "$PR_NUMBER" =~ ^[0-9]+$ ]] || { echo "::error::bad carried PR number"; exit 1; }
json=$(python3 "$RUNNER_TEMP/trusted/sync_gh.py" veto --pr "$PR_NUMBER" --cand "$CAND_SHA")
echo "outcome=$(jq -r .outcome <<<"$json")" >> "$GITHUB_OUTPUT"
echo "head_sha=$(jq -r .head_sha <<<"$json")" >> "$GITHUB_OUTPUT"
- name: Report
id: report
env:
VETO_OUTCOME: ${{ steps.veto.outputs.outcome }}
PR_HEAD_SHA: ${{ steps.veto.outputs.head_sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
CHECK_CODE: ${{ needs.check.outputs.code }}
CHECK_RESULT: ${{ needs.check.result }}
MERGE_BRANCH: ${{ needs.merge.outputs.branch }}
CAND_SHA: ${{ needs.merge.outputs.cand_sha }}
ORIG_PR_BRANCH: ${{ needs.merge.outputs.pr_branch }}
PR_NUMBER: ${{ needs.merge.outputs.pr_number }}
run: |
in="$RUNNER_TEMP/in"
# Anything but a successful check job is never a pass: an empty exit code derives as failing.
[ "$CHECK_RESULT" = "success" ] || CHECK_CODE=""
args=(--root "$GITHUB_WORKSPACE" report --status "$in/status.json" --merge "$in/merge.json" --guard "$in/guard.json"
--check "$RUNNER_TEMP/chk/check.json" --check-code "$CHECK_CODE" --run-url "$RUN_URL" --out "$RUNNER_TEMP/report.md")
[ -n "$VETO_OUTCOME" ] && args+=(--veto-outcome "$VETO_OUTCOME")
# A recheck has no merge result: the open PR's body supplies the last known conflict counts (parsed as integers only).
[ -n "$PR_NUMBER" ] && gh pr view "$PR_NUMBER" --json body -q .body > "$RUNNER_TEMP/prev.md" 2>/dev/null \
&& args+=(--prev-body "$RUNNER_TEMP/prev.md")
head="${CAND_SHA:-$PR_HEAD_SHA}"; [ -n "$head" ] && args+=(--head-sha "$head")
[ -n "$MERGE_BRANCH" ] && args+=(--branch "$MERGE_BRANCH")
[ -n "$ORIG_PR_BRANCH" ] && args+=(--pr-branch "$ORIG_PR_BRANCH")
[ -n "$DEMO_REF" ] && args+=(--demo)
python3 "$RUNNER_TEMP/trusted/sync_upstream.py" "${args[@]}" > "$RUNNER_TEMP/report.json"
cat "$RUNNER_TEMP/report.json"
for k in outcome title draft issue push issue_title issue_key; do
echo "$k=$(jq -r ".$k" "$RUNNER_TEMP/report.json")" >> "$GITHUB_OUTPUT"
done
echo "labels=$(jq -r '.labels | join(",")' "$RUNNER_TEMP/report.json")" >> "$GITHUB_OUTPUT"
- name: Publish PR
id: publish
env:
OUTCOME: ${{ steps.report.outputs.outcome }}
PUSH: ${{ steps.report.outputs.push }}
TITLE: ${{ steps.report.outputs.title }}
DRAFT: ${{ steps.report.outputs.draft }}
LABELS: ${{ steps.report.outputs.labels }}
BRANCH: ${{ needs.merge.outputs.branch }}
PR_NUMBER: ${{ needs.merge.outputs.pr_number }}
PR_FOUND_BRANCH: ${{ needs.merge.outputs.pr_branch }}
CAND_SHA: ${{ needs.merge.outputs.cand_sha }}
BUNDLE_SHA256: ${{ needs.merge.outputs.bundle_sha256 }}
run: |
in="$RUNNER_TEMP/in"; body="$RUNNER_TEMP/report.md"
gh_sync() { python3 "$RUNNER_TEMP/trusted/sync_gh.py" "$@"; }
find_args=(); [ -n "$DEMO_REF" ] && find_args+=(--demo)
# The existing PR is the one the merge job carried (number + branch, validated there); it is never
# re-selected here. Its identity is re-verified by number before every mutation. A mismatch fails the
# run without touching any other PR.
[[ -z "$PR_NUMBER" || "$PR_NUMBER" =~ ^[0-9]+$ ]] || { echo "::error::bad carried PR number"; exit 1; }
verify_pr() { gh_sync verify-pr --pr "$PR_NUMBER" --branch "${PR_FOUND_BRANCH:-$BRANCH}" || { echo "::error::PR #$PR_NUMBER identity mismatch (branch/base/repo)"; exit 1; }; }
# An armed auto-merge must never survive a push to its PR (disarm first: the PAT push would otherwise re-run
# checks under it), nor any non-clean outcome, pushed or not (e.g. a workflow-guard escalate with push=false).
# Disarm is by the carried number and comes before any verification, so a failing verify cannot skip it.
if [ -n "$PR_NUMBER" ] && { [ "$PUSH" = "true" ] || [ "$OUTCOME" != "clean" ]; }; then
gh_sync disable-auto --pr "$PR_NUMBER"
fi
[ -z "$PR_NUMBER" ] || verify_pr
[ -z "$PR_NUMBER" ] || verify_pr
if [ "$PUSH" = "true" ] && [ -f "$in/cand.bundle" ] && [ -n "$BRANCH" ]; then
# Everything downloaded is data. Verify it against the privileged job's outputs before using it.
echo "$BUNDLE_SHA256 $in/cand.bundle" | sha256sum -c - || { echo "::error::bundle hash mismatch"; exit 1; }
git fetch "$in/cand.bundle" HEAD:refs/heads/candidate
[ "$(git rev-parse refs/heads/candidate)" = "$CAND_SHA" ] || { echo "::error::candidate sha mismatch"; exit 1; }
# Re-derive touched workflow paths from the verified bundle with trusted code (NUL-delimited).
python3 "$RUNNER_TEMP/trusted/sync_upstream.py" --root "$GITHUB_WORKSPACE" workflow-guard \
--base "origin/$BASE" --cand "$CAND_SHA" || { echo "::error::candidate differs from develop under .github/workflows; refusing to push"; exit 1; }
# Never push anywhere but a sync branch (a conflicted merge is committed with markers; the draft PR blocks it).
dest="refs/heads/$BRANCH"
case "$dest" in refs/heads/upstream-sync/*|refs/heads/upstream-sync-demo/*) ;; *) echo "::error::refusing to push to $dest"; exit 1;; esac
[ -z "$PR_NUMBER" ] || verify_pr
# A conflict escalation on a carried PR must hold the sync-conflict label BEFORE the conflicted head is
# pushed, so a failed post-push edit cannot leave an unlabelled conflicted head. Abort without pushing.
case ",$LABELS," in *,sync-conflict,*) [ -z "$PR_NUMBER" ] || gh_sync mark-conflict --pr "$PR_NUMBER" || { echo "::error::could not set/verify sync-conflict on PR #$PR_NUMBER; not pushing"; exit 1; };; esac
git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/heads/candidate:$dest"
fi
title=(); [ -n "$TITLE" ] && title=(--title "$TITLE")
if [ -n "$PR_NUMBER" ] && [ "$PUSH" = "true" ]; then
verify_pr
gh pr edit "$PR_NUMBER" "${title[@]}" --body-file "$body" --add-label "$LABELS"
if [ "$OUTCOME" != "clean" ] && [ "$DRAFT" = "true" ]; then
gh pr ready "$PR_NUMBER" --undo || true
fi
elif [ "$PUSH" = "true" ] && [ -f "$in/cand.bundle" ] && [ -n "$BRANCH" ]; then
extra=(); [ "$DRAFT" = "true" ] && extra+=(--draft)
gh pr create --base "$BASE" --head "$BRANCH" --title "$TITLE" --body-file "$body" --label "$LABELS" "${extra[@]}"
fi
# A clean existing PR is always made ready (the helper un-drafts only if needed and drops sync-failing).
if [ -n "$PR_NUMBER" ] && [ "$OUTCOME" = "clean" ]; then
verify_pr
gh_sync ready --pr "$PR_NUMBER"
fi
# A PR just created is looked up once, by the branch we pushed only, then verified; the carried one is kept.
if [ -z "$PR_NUMBER" ] && [ "$PUSH" = "true" ] && [ -n "$BRANCH" ]; then
found=$(gh_sync find-pr "${find_args[@]}" --branch "$BRANCH")
PR_NUMBER=$(jq -r '.number // empty' <<<"$found")
PR_FOUND_BRANCH=$(jq -r '.branch // empty' <<<"$found")
[ -z "$PR_NUMBER" ] || { [ "$PR_FOUND_BRANCH" = "$BRANCH" ] && verify_pr; } || { echo "::error::created PR does not match $BRANCH"; exit 1; }
# A new conflict PR: verify the label landed at creation; add it if missing, else fail the run.
case ",$LABELS," in *,sync-conflict,*) [ -z "$PR_NUMBER" ] || gh_sync mark-conflict --pr "$PR_NUMBER" || { echo "::error::sync-conflict missing on new PR #$PR_NUMBER"; exit 1; };; esac
fi
echo "number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
echo "branch=${PR_FOUND_BRANCH:-$BRANCH}" >> "$GITHUB_OUTPUT"
- name: Enroll
id: enroll
if: steps.report.outputs.outcome == 'clean' && needs.check.result == 'success' && steps.publish.outputs.number != ''
env:
PR_NUMBER: ${{ steps.publish.outputs.number }}
PR_BRANCH: ${{ steps.publish.outputs.branch }}
CAND_SHA: ${{ needs.merge.outputs.cand_sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
# Kill switch: enroll_refused (kill_switch_only) unless this is 'on' (regular) or 'demo' (demo ref).
SYNC_AUTO_MERGE: ${{ vars.SYNC_AUTO_MERGE }}
run: |
echo "enrolled=false" >> "$GITHUB_OUTPUT"
[ -n "$CAND_SHA" ] || { echo "::error::no candidate sha to enroll"; exit 0; }
python3 "$RUNNER_TEMP/trusted/sync_gh.py" verify-pr --pr "$PR_NUMBER" --branch "$PR_BRANCH" \
|| { echo "::error::PR #$PR_NUMBER identity mismatch (branch/base/repo); not enrolling"; exit 1; }
set +e
python3 "$RUNNER_TEMP/trusted/sync_gh.py" enroll --pr "$PR_NUMBER" --head "$CAND_SHA" --demo-ref "$DEMO_REF" \
--config "$GITHUB_WORKSPACE/local/upstream-sync.toml" --body "$RUNNER_TEMP/report.md" > "$RUNNER_TEMP/enroll.json"
code=$?
set -e
cat "$RUNNER_TEMP/enroll.json"
if [ "$code" = "0" ]; then
echo "enrolled=true" >> "$GITHUB_OUTPUT"
elif [ "$code" = "1" ]; then
# Refused. A kill-switch-only refusal is expected while the switch is off: no issue, but the run is still red.
if [ "$(jq -r '.kill_switch_only' "$RUNNER_TEMP/enroll.json")" != "true" ]; then
python3 "$RUNNER_TEMP/trusted/sync_gh.py" issue --branch "$PR_BRANCH" --outcome enroll_refused \
--title "Upstream sync needs attention: $PR_BRANCH [enroll_refused]" --body-file "$RUNNER_TEMP/report.md" \
--sha "$CAND_SHA" --run-url "$RUN_URL"
fi
else
echo "::error::sync_gh.py enroll failed (exit $code)"
fi
- name: File issue
if: steps.report.outputs.issue == 'true'
env:
ISSUE_KEY: ${{ steps.report.outputs.issue_key }}
ISSUE_TITLE: ${{ steps.report.outputs.issue_title }}
LABELS: ${{ steps.report.outputs.labels }}
CAND_SHA: ${{ needs.merge.outputs.cand_sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
# Every non-clean outcome. issue_key is "<branch> <outcome>"; sync_gh adds the upstream-sync label itself.
key_branch="${ISSUE_KEY% *}"; key_outcome="${ISSUE_KEY##* }"
labels=(); IFS=, read -ra all <<<"$LABELS"; for l in "${all[@]}"; do [ -n "$l" ] && [ "$l" != "upstream-sync" ] && labels+=("$l"); done
args=(issue --branch "$key_branch" --outcome "$key_outcome" --title "$ISSUE_TITLE" --body-file "$RUNNER_TEMP/report.md"
--sha "${CAND_SHA:-unknown}" --run-url "$RUN_URL")
[ "${#labels[@]}" -gt 0 ] && args+=(--labels "${labels[@]}")
python3 "$RUNNER_TEMP/trusted/sync_gh.py" "${args[@]}"
- name: Sweep stale issues
if: always() && steps.report.outcome == 'success'
env:
OUTCOME: ${{ steps.report.outputs.outcome }}
LABELS: ${{ steps.report.outputs.labels }}
run: |
# Held-workflow drift issue closes only when this run saw none (no workflow-review label) and ran to a verdict.
args=(sweep)
case "$OUTCOME" in clean|failing) case ",$LABELS," in *,workflow-review,*) ;; *) args+=(--no-held-drift);; esac;; esac
python3 "$RUNNER_TEMP/trusted/sync_gh.py" "${args[@]}"
- name: Final exit
if: always()
env:
ENROLLED: ${{ steps.enroll.outputs.enrolled }}
OUTCOME: ${{ steps.report.outputs.outcome }}
run: |
# Green only when the PR ended enrolled (gap 6): every other run, including a kill-switch-only refusal, is red.
[ "$ENROLLED" = "true" ] && exit 0
echo "::error::sync outcome '$OUTCOME' did not end enrolled"
exit 1
# No-change runs (mode=none) skip publish; they still sweep stale issues and end green. The "red unless
# enrolled" rule above applies only to runs that produced or updated a sync PR.
sweep:
needs: [merge, publish]
if: always() && needs.merge.result == 'success' && needs.publish.result == 'skipped'
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions: { contents: read, pull-requests: read, issues: write }
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/checkout@v4
with:
ref: develop
persist-credentials: false
- name: Sweep stale issues
run: python3 local/scripts/sync_gh.py sweep # no held-drift flag: nothing was evaluated in a no-change run