-
Notifications
You must be signed in to change notification settings - Fork 757
1268 lines (1190 loc) · 64.4 KB
/
Copy pathbase-std-docs-sync.yml
File metadata and controls
1268 lines (1190 loc) · 64.4 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
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
name: Apply Base Std Update
on:
repository_dispatch:
types:
- base-code-changed
- base-release-published
workflow_dispatch:
inputs:
payload_json:
description: "Raw client_payload JSON (for manual runs)"
required: false
default: "{}"
# Default minimum; the privileged job below raises only what it needs.
permissions:
contents: read
concurrency:
# Key by source_repo + sha (not sha alone) so two different source repos
# dispatching the same SHA can't starve each other's queue. On
# workflow_dispatch both client_payload fields are empty and we fall
# back to run_id, which is always unique per run.
group: ${{ github.workflow }}-${{ github.event.client_payload.source_repo || 'manual' }}-${{ github.event.client_payload.sha || github.run_id }}
cancel-in-progress: false
jobs:
# --------------------------------------------------------------------------
# 1) authorize: fork guard + write-permission check on the actor.
# Runs with read-only token. If this job fails, `apply` never starts.
# --------------------------------------------------------------------------
authorize:
name: Authorize trigger
if: github.event.repository.fork == false
# Keep every job in this workflow on BaseRunnerGroup. Besides providing a
# consistent trusted execution environment, the downstream apply job uses
# Base's internal LLM Gateway.
runs-on:
group: BaseRunnerGroup
permissions:
contents: read
steps:
- name: Validate actor and event
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
ACTOR: ${{ github.actor }}
# github.event.sender.login is the ONLY server-attested identity
# on a repository_dispatch — it's the user whose PAT/App token
# called the dispatches API. We capture it explicitly here even
# though it equals github.actor for these event types, so the
# downstream audit log (and ALLOWED_SENDER_BINDINGS check in the
# apply job) has a stable, documented field to key on.
SENDER_LOGIN: ${{ github.event.sender.login }}
EVENT: ${{ github.event_name }}
run: |
set -euo pipefail
# Both event types we accept (repository_dispatch from the source
# repo's dispatcher, and workflow_dispatch from a maintainer) are
# authorized the same way: the actor must have write-equivalent
# permission on THIS repo, queried live from the GitHub
# collaborators API.
#
# github.actor on a repository_dispatch is the user whose PAT was
# used to call the dispatches API (e.g. the dispatcher's
# DOCS_REPO_TOKEN owner). So this check answers: "does the PAT
# owner who fired this dispatch have write access to this docs
# repo?" — exactly the policy we want.
case "$EVENT" in
repository_dispatch|workflow_dispatch) ;;
*)
echo "::error title=Unsupported event::event '$EVENT' is not in the allowlist (repository_dispatch|workflow_dispatch)" >&2
exit 1
;;
esac
# Defense-in-depth invariant: for repository_dispatch and
# workflow_dispatch, github.actor and github.event.sender.login
# are documented to be the same value. If they ever diverge
# (event-shape regression, or a future event type slipping
# through the case above), fail closed rather than guess which
# one to trust.
if [[ -n "${SENDER_LOGIN:-}" && "$SENDER_LOGIN" != "$ACTOR" ]]; then
echo "::error title=Sender identity mismatch::github.actor='${ACTOR}' but github.event.sender.login='${SENDER_LOGIN}' — refusing to run" >&2
exit 1
fi
perm=$(curl -sS --fail-with-body \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/${REPO}/collaborators/${ACTOR}/permission" \
| jq -r '.permission // "none"')
case "$perm" in
admin|maintain|write)
echo "Actor '$ACTOR' (sender='${SENDER_LOGIN:-unknown}') has '$perm' permission on $REPO (event: $EVENT)."
echo "::notice title=Authorization passed::actor=${ACTOR} sender=${SENDER_LOGIN:-unknown} permission=${perm} event=${EVENT}"
;;
*)
echo "::error title=Actor not authorized::actor='${ACTOR}' sender='${SENDER_LOGIN:-unknown}' has '${perm}' permission on ${REPO} (event=${EVENT}) — refusing to run" >&2
exit 1
;;
esac
- name: Emit kill-switch notice (if set)
# Repo variable: vars.DISABLE_BASE_SYNC. When set to "true", the
# `apply` job is skipped via its job-level `if:` and we surface a
# banner here so the run summary explains why nothing happened.
# Lets a maintainer pause docs syncs during an incident without
# revoking the dispatcher PAT or deleting the workflow.
if: vars.DISABLE_BASE_SYNC == 'true'
run: |
echo "::notice title=Base sync disabled::vars.DISABLE_BASE_SYNC is 'true' — authorization passed but the apply job is skipped."
# --------------------------------------------------------------------------
# 2) apply: privileged job. Delegates the actual content transformation to
# scripts/sync-from-base-std, then commits the result and opens a PR.
# --------------------------------------------------------------------------
apply:
name: Open docs PR from base-std dispatch
needs: authorize
# Kill switch: see "Emit kill-switch notice" step in the authorize job
# above. Flipping vars.DISABLE_BASE_SYNC to 'true' skips this job
# without affecting authorize (so the banner still fires).
if: github.event.repository.fork == false && vars.DISABLE_BASE_SYNC != 'true'
# Run where Base's internal LLM Gateway is available. The corresponding
# Base gateway integration documents BaseRunnerGroup as a requirement.
runs-on:
group: BaseRunnerGroup
permissions:
contents: write
pull-requests: write
steps:
- name: Harden the runner
# Audit mode logs every outbound connection without blocking. After a
# few real runs, review the egress report on the run summary and
# switch to:
# egress-policy: block
# allowed-endpoints: >
# api.github.com:443
# llm-gateway.coinbase-corp.com:443
# registry.npmjs.org:443
# objects.githubusercontent.com:443
# plus anything else the audit log surfaces (e.g. npm CDN hosts).
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
cache: "npm"
cache-dependency-path: scripts/package-lock.json
- name: Install dependencies
run: npm ci --prefix scripts --no-audit --no-fund
- name: Materialize dispatch payload
id: payload_file
env:
# Routed through env — never inlined into the shell, so a hostile
# PR title/body in the payload cannot escape into a command.
PAYLOAD: ${{ toJSON(github.event.client_payload) }}
MANUAL_PAYLOAD: ${{ github.event.inputs.payload_json }}
EVENT_NAME: ${{ github.event_name }}
# Captured once here and re-exported to $GITHUB_ENV below so
# every downstream step (including the shared workflow_fail
# helper) sees the same sender identity under one name.
# github.event.sender.login is the only server-attested identity
# on a repository_dispatch — it's the user/bot whose token
# called the API.
SENDER_LOGIN: ${{ github.event.sender.login }}
run: |
set -euo pipefail
raw_path="$RUNNER_TEMP/payload.raw"
payload_path="$RUNNER_TEMP/payload.json"
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
printf '%s' "$MANUAL_PAYLOAD" > "$raw_path"
else
printf '%s' "$PAYLOAD" > "$raw_path"
fi
# Normalize: a workflow_dispatch textarea sometimes ends up containing
# the default '{}' alongside the user's paste, which produces a file
# with two stacked JSON values. jq -s slurps them all into an array
# and we keep the last non-empty object. Single-value inputs pass
# through unchanged.
jq -s '
map(select(type == "object" and (. != {} or length == 0))) as $objs
| if ($objs | length) == 0 then
(.[-1] // {})
else
($objs | .[-1])
end
' "$raw_path" > "$payload_path"
# Final invariant: must be a single JSON object.
jq -e 'type == "object"' "$payload_path" > /dev/null
# Export payload fields into $GITHUB_ENV so downstream steps work
# the same way on `repository_dispatch` and `workflow_dispatch`.
# On workflow_dispatch, `github.event.client_payload` is null and
# all the field expressions return empty strings — so without this,
# the PR title/commit/PR-body construction sees blanks and
# produces titles with no source SHA.
#
# Multi-line values (intent, pr_body, etc.) use the heredoc form
# GitHub Actions requires for $GITHUB_ENV.
set_env_multiline() {
local key="$1"; local value="$2"
{
printf '%s<<EOF_PAYLOAD_VAL\n' "$key"
printf '%s' "$value"
printf '\nEOF_PAYLOAD_VAL\n'
} >> "$GITHUB_ENV"
}
set_env_single() {
local key="$1"; local value="$2"
printf '%s=%s\n' "$key" "$value" >> "$GITHUB_ENV"
}
set_env_single PAYLOAD_KIND "$(jq -r '.kind // ""' "$payload_path")"
# Required field. If absent, the allowlist step below will reject
# the dispatch with a clear error.
set_env_single PAYLOAD_SOURCE_REPO "$(jq -r '.source_repo // ""' "$payload_path")"
set_env_single PAYLOAD_SHA "$(jq -r '.sha // ""' "$payload_path")"
# Do not export tag before schema validation. Unlike SHA, it is a
# newly introduced free-form payload field and a newline here would
# create an attacker-controlled second GITHUB_ENV assignment.
set_env_single PAYLOAD_PR_NUMBER "$(jq -r '.pr_number // ""' "$payload_path")"
set_env_multiline PAYLOAD_PR_TITLE "$(jq -r '.pr_title // ""' "$payload_path")"
set_env_multiline PAYLOAD_INTENT "$(jq -r '.intent // ""' "$payload_path")"
# source_refs is an array; flatten to space-separated for shell use.
set_env_single PAYLOAD_SOURCE_REFS "$(jq -r '(.source_refs // []) | join(" ")' "$payload_path")"
# For large diffs the dispatcher uploads the diff as an artifact
# on the SOURCE repo and the payload only carries a reference.
# We pick the two fields up here; the next step does the fetch.
set_env_single PAYLOAD_DIFF_ARTIFACT_RUN_ID "$(jq -r '.diff_artifact_run_id // ""' "$payload_path")"
set_env_single PAYLOAD_DIFF_ARTIFACT_NAME "$(jq -r '.diff_artifact_name // ""' "$payload_path")"
# Propagate the sender identity to every downstream step so
# rejection messages and the workflow_fail helper see the same
# field. The value originates in the step `env:` block above.
set_env_single SENDER_LOGIN "${SENDER_LOGIN:-}"
# OIDC attestation token — Phase 1 is verify-when-present (Phase 2
# will make it required). Write the JWT to a private file at
# $RUNNER_TEMP and pass only the PATH downstream so the token
# itself never sits in $GITHUB_ENV. We mask the value too as
# belt-and-suspenders against accidental log echo. PAYLOAD_OIDC_PRESENT
# ("true"/"false") lets downstream steps decide between fail-closed
# verification and a soft warning.
oidc_token_value=$(jq -r '.oidc_token // ""' "$payload_path")
if [[ -n "$oidc_token_value" ]]; then
echo "::add-mask::$oidc_token_value"
oidc_token_path="$RUNNER_TEMP/oidc-token.jwt"
printf '%s' "$oidc_token_value" > "$oidc_token_path"
chmod 600 "$oidc_token_path"
set_env_single PAYLOAD_OIDC_TOKEN_PATH "$oidc_token_path"
set_env_single PAYLOAD_OIDC_PRESENT "true"
else
set_env_single PAYLOAD_OIDC_TOKEN_PATH ""
set_env_single PAYLOAD_OIDC_PRESENT "false"
fi
echo "path=$payload_path" >> "$GITHUB_OUTPUT"
echo "Payload size: $(wc -c < "$payload_path") bytes"
echo "First 200 bytes:"
head -c 200 "$payload_path"
echo
- name: Validate payload schema
# Defense in depth — even with a valid DOCS_REPO_TOKEN, a holder of
# that PAT could craft a payload with a malformed `sha`, a
# gigantic `changed_paths` array, or a multi-megabyte `pr_body`.
# We validate field shapes and apply hard caps BEFORE any
# privileged action (artifact fetch, LLM call, git push). Caps
# are declared here in one block so they're easy to tune.
env:
PAYLOAD_PATH: ${{ steps.payload_file.outputs.path }}
MAX_PR_TITLE_BYTES: 1024
MAX_PR_BODY_BYTES: 16384
MAX_INTENT_BYTES: 4096
MAX_RELEASE_NOTES_BYTES: 16384
# code-change dispatches touch a handful of watched files; release
# dispatches diff a whole tag-to-tag tree and can legitimately list
# many more. Both stay bounded (each entry is also capped at
# MAX_CHANGED_PATH_BYTES) so the payload array can't grow without
# limit. The release cap matches the dispatcher's MAX_CHANGED_PATHS.
MAX_CHANGED_PATHS: 200
MAX_CHANGED_PATHS_RELEASE: 2000
MAX_CHANGED_PATH_BYTES: 512
# Inline diffs only — the dispatcher already enforces a 60000
# byte ceiling on this side. Artifact-delivered diffs are capped
# separately in the artifact-fetch step.
MAX_INLINE_DIFF_BYTES: 65536
STEP_NAME: "Validate payload schema"
run: |
set -euo pipefail
source "${GITHUB_WORKSPACE}/scripts/lib/workflow-fail.sh"
kind=$(jq -r '.kind // ""' "$PAYLOAD_PATH")
case "$kind" in
code-change|release|manual-update) ;;
*) workflow_fail "$STEP_NAME" "kind '${kind}' is not in the allowlist (code-change|release|manual-update)" ;;
esac
sha=$(jq -r '.sha // ""' "$PAYLOAD_PATH")
if [[ -n "$sha" && ! "$sha" =~ ^[0-9a-f]{7,40}$ ]]; then
workflow_fail "$STEP_NAME" "sha '${sha}' is not a 7-40 char lowercase hex string"
fi
# Release provenance is meaningful only with BOTH fields. Keep tags
# deliberately narrow so they are safe in GitHub API paths and can
# never inject a line into GITHUB_ENV.
tag=$(jq -r '.tag // ""' "$PAYLOAD_PATH")
previous_tag=$(jq -r '.previous_tag // ""' "$PAYLOAD_PATH")
if [[ "$kind" == "release" ]]; then
if [[ -z "$sha" || ! "$sha" =~ ^[0-9a-f]{7,40}$ ]]; then
workflow_fail "$STEP_NAME" "release payload requires a lowercase 7-40 character sha"
fi
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
workflow_fail "$STEP_NAME" "release payload requires a final tag in vX.Y.Z form"
fi
if [[ -n "$previous_tag" && ! "$previous_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
workflow_fail "$STEP_NAME" "previous_tag must be empty or a final vX.Y.Z tag"
fi
if ! jq -e '(.release_notes // "") | type == "string"' "$PAYLOAD_PATH" >/dev/null; then
workflow_fail "$STEP_NAME" "release_notes is not a string"
fi
fi
pr_number=$(jq -r '.pr_number // ""' "$PAYLOAD_PATH")
if [[ -n "$pr_number" && ! "$pr_number" =~ ^[0-9]+$ ]]; then
workflow_fail "$STEP_NAME" "pr_number '${pr_number}' is not numeric"
fi
# changed_paths: when present, must be an array of strings, each
# within size cap. Empty/absent is fine for release + manual-update.
# The count cap is kind-aware: release diffs span the whole tree.
changed_paths_cap="$MAX_CHANGED_PATHS"
if [[ "$kind" == "release" ]]; then
changed_paths_cap="$MAX_CHANGED_PATHS_RELEASE"
fi
if jq -e '.changed_paths != null' "$PAYLOAD_PATH" >/dev/null; then
if ! jq -e '.changed_paths | type == "array"' "$PAYLOAD_PATH" >/dev/null; then
workflow_fail "$STEP_NAME" "changed_paths is not an array"
fi
count=$(jq '.changed_paths | length' "$PAYLOAD_PATH")
if (( count > changed_paths_cap )); then
workflow_fail "$STEP_NAME" "changed_paths has $count entries (cap: $changed_paths_cap for kind '$kind')"
fi
bad=$(jq -r --argjson cap "$MAX_CHANGED_PATH_BYTES" '
.changed_paths
| map(select(type != "string" or (length > $cap)))
| length
' "$PAYLOAD_PATH")
if (( bad > 0 )); then
workflow_fail "$STEP_NAME" "$bad changed_paths entr(y/ies) are not strings or exceed $MAX_CHANGED_PATH_BYTES bytes"
fi
fi
diff_bytes=$(jq -r '.diff // "" | length' "$PAYLOAD_PATH")
if (( diff_bytes > MAX_INLINE_DIFF_BYTES )); then
workflow_fail "$STEP_NAME" "inline diff is $diff_bytes bytes (cap: $MAX_INLINE_DIFF_BYTES)"
fi
# Truncate large freeform strings in-place. Rejecting would take
# the whole sync down for a cosmetic overrun; truncating fails
# safe and emits a warning the reviewer can act on. PR_TITLE and
# INTENT also flow through $GITHUB_ENV (set in the previous
# step), so we refresh those when we shorten them. PR_BODY only
# flows through the payload file (sync script reads it from
# there), so no env refresh needed.
truncate_field() {
local field="$1"; local cap="$2"; local env_key="${3:-}"
local current
current=$(jq -r --arg f "$field" '.[$f] // ""' "$PAYLOAD_PATH")
local len=${#current}
if (( len > cap )); then
echo "::warning title=Payload field truncated::${field} was ${len} bytes; truncated to ${cap}"
local truncated="${current:0:$cap}"
jq --arg f "$field" --arg v "$truncated" \
'.[$f] = $v' "$PAYLOAD_PATH" > "$PAYLOAD_PATH.tmp"
mv "$PAYLOAD_PATH.tmp" "$PAYLOAD_PATH"
if [[ -n "$env_key" ]]; then
{
printf '%s<<EOF_PAYLOAD_VAL\n' "$env_key"
printf '%s' "$truncated"
printf '\nEOF_PAYLOAD_VAL\n'
} >> "$GITHUB_ENV"
fi
fi
}
truncate_field pr_title "$MAX_PR_TITLE_BYTES" PAYLOAD_PR_TITLE
truncate_field pr_body "$MAX_PR_BODY_BYTES"
truncate_field intent "$MAX_INTENT_BYTES" PAYLOAD_INTENT
if [[ "$kind" == "release" ]]; then
truncate_field release_notes "$MAX_RELEASE_NOTES_BYTES"
# Safe only after strict vX.Y.Z validation above.
printf 'PAYLOAD_TAG=%s\n' "$tag" >> "$GITHUB_ENV"
fi
echo "::notice title=Payload schema validated::kind=${kind} sha=${sha:0:7} pr_number=${pr_number:-none} changed_paths=$(jq -r '.changed_paths // [] | length' "$PAYLOAD_PATH") inline_diff_bytes=${diff_bytes}"
- name: Validate source_repo against allowlist
# The dispatcher's PAT (DOCS_REPO_TOKEN) authenticates *which dispatcher*
# can call this receiver, but it does NOT bind the dispatcher to any
# particular source_repo value — a holder of the PAT could spoof the
# field. So we validate the claimed source_repo against an explicit
# allowlist before using it to fetch artifacts or build PR URLs.
#
# Required repo variable: ALLOWED_SOURCE_REPOS, space-separated list
# of "<owner>/<repo>" entries. No default — an unset or empty value
# rejects every dispatch with a clear error.
#
# This is the COARSE check (is the claimed source repo in the
# allowlist at all?). The FINE check that source_repo is the same
# repo the dispatch credential is actually bound to lives in the
# next step (Verify OIDC source-repo attestation).
env:
SOURCE_REPO: ${{ env.PAYLOAD_SOURCE_REPO }}
ALLOWED: ${{ vars.ALLOWED_SOURCE_REPOS }}
STEP_NAME: "Validate source_repo against allowlist"
run: |
set -euo pipefail
source "${GITHUB_WORKSPACE}/scripts/lib/workflow-fail.sh"
if [[ -z "${ALLOWED:-}" ]]; then
workflow_fail "$STEP_NAME" "ALLOWED_SOURCE_REPOS repo variable is not configured. Set it under Settings -> Secrets and variables -> Actions -> Variables to a space-separated list of '<owner>/<repo>' entries."
fi
if [[ -z "${SOURCE_REPO:-}" ]]; then
workflow_fail "$STEP_NAME" "Payload is missing required 'source_repo' field — dispatcher is likely on an older payload shape."
fi
for allowed in $ALLOWED; do
if [[ "$SOURCE_REPO" == "$allowed" ]]; then
echo "source_repo '$SOURCE_REPO' is in the allowlist."
echo "::notice title=Allowlist passed::source_repo=${SOURCE_REPO} sender=${SENDER_LOGIN:-unknown}"
exit 0
fi
done
workflow_fail "$STEP_NAME" "source_repo '${SOURCE_REPO}' is NOT in the allowlist '${ALLOWED}'. If this is a legitimate new source repo, add it to the ALLOWED_SOURCE_REPOS repo variable."
- name: Verify OIDC source-repo attestation
# Archon security finding closure: source_repo in client_payload is
# attacker-controllable JSON. A GitHub-signed OIDC token with
# payload.repository == source_repo is the only way to *prove*
# which repo the dispatch actually came from, without trusting
# the JSON.
#
# Phase 1 behavior (this file): verify-when-present, fail closed
# on signature/claim mismatch, soft-warn when absent so existing
# dispatchers continue to work during migration.
#
# Phase 2 (when REQUIRE_OIDC = "true" in repo vars): missing
# token is a hard reject too. Flip the variable once every
# source repo's dispatcher has been updated.
#
# Required scopes: none beyond GITHUB_TOKEN read — verification
# only needs the public JWKS at token.actions.githubusercontent.com.
env:
PAYLOAD_PATH: ${{ steps.payload_file.outputs.path }}
REQUIRE_OIDC: ${{ vars.REQUIRE_OIDC }}
OIDC_REQUIRE_MAIN_WORKFLOW: ${{ vars.OIDC_REQUIRE_MAIN_WORKFLOW }}
STEP_NAME: "Verify OIDC source-repo attestation"
run: |
set -euo pipefail
source "${GITHUB_WORKSPACE}/scripts/lib/workflow-fail.sh"
# Phase 1 default is opt-in; Phase 2 will document flipping
# vars.REQUIRE_OIDC = "true" as the breaking cutover.
require_oidc="${REQUIRE_OIDC:-false}"
if [[ "${PAYLOAD_OIDC_PRESENT:-false}" != "true" ]]; then
if [[ "$require_oidc" == "true" ]]; then
workflow_fail "$STEP_NAME" "OIDC token is missing from client_payload and vars.REQUIRE_OIDC='true'. The dispatcher must be updated to mint an OIDC token before dispatching."
fi
echo "::warning title=OIDC attestation missing::client_payload.oidc_token is empty. Phase 1 backward-compat: dispatch proceeds, but provenance is not cryptographically bound. Migrate the dispatcher (see README 'OIDC source-repo attestation') before flipping vars.REQUIRE_OIDC to 'true'."
exit 0
fi
if [[ ! -s "${PAYLOAD_OIDC_TOKEN_PATH:-}" ]]; then
workflow_fail "$STEP_NAME" "PAYLOAD_OIDC_PRESENT='true' but the token file at '${PAYLOAD_OIDC_TOKEN_PATH:-}' is missing or empty — internal materialize step regression."
fi
# The verify script reads OIDC_TOKEN from a file path via shell
# substitution rather than process env so the JWT never appears
# in any logged env dump.
claims_path="$RUNNER_TEMP/oidc-claims.json"
if ! OIDC_TOKEN="$(cat "${PAYLOAD_OIDC_TOKEN_PATH}")" \
OIDC_EXPECTED_AUDIENCE="docs-sync:${GITHUB_REPOSITORY}" \
OIDC_EXPECTED_REPOSITORY="${PAYLOAD_SOURCE_REPO}" \
OIDC_REQUIRE_MAIN_WORKFLOW="${OIDC_REQUIRE_MAIN_WORKFLOW:-false}" \
node "${GITHUB_WORKSPACE}/scripts/verify-oidc.mjs" > "$claims_path"; then
workflow_fail "$STEP_NAME" "OIDC verification failed for source_repo='${PAYLOAD_SOURCE_REPO}'. See the preceding structured stderr line (verification_code) for the precise reason."
fi
# Cache claims for downstream steps + audit log. The file is
# not sensitive (claims are public metadata once verified).
attested_repo=$(jq -r '.repository // ""' "$claims_path")
attested_workflow_ref=$(jq -r '.workflow_ref // ""' "$claims_path")
attested_sha=$(jq -r '.sha // ""' "$claims_path")
attested_actor=$(jq -r '.actor // ""' "$claims_path")
echo "OIDC verified: repository=${attested_repo} workflow_ref=${attested_workflow_ref} sha=${attested_sha:0:7} actor=${attested_actor}"
echo "::notice title=OIDC attestation verified::source_repo=${attested_repo} workflow_ref=${attested_workflow_ref} sender=${SENDER_LOGIN:-unknown}"
- name: Enforce ALLOWED_SENDER_BINDINGS
# Optional defense-in-depth: a space-separated map of
# "<sender_login>=<owner>/<repo>" pairs. When set, the dispatch
# is rejected unless github.event.sender.login is listed AND
# maps to the claimed source_repo. Useful for deployments that
# issue a distinct PAT (or App installation) per source repo.
# No-op when unset — the OIDC step is the primary binding.
env:
BINDINGS: ${{ vars.ALLOWED_SENDER_BINDINGS }}
STEP_NAME: "Enforce ALLOWED_SENDER_BINDINGS"
run: |
set -euo pipefail
source "${GITHUB_WORKSPACE}/scripts/lib/workflow-fail.sh"
if [[ -z "${BINDINGS:-}" ]]; then
echo "ALLOWED_SENDER_BINDINGS not set — skipping per-sender enforcement (OIDC step is the primary binding)."
exit 0
fi
if [[ -z "${SENDER_LOGIN:-}" ]]; then
workflow_fail "$STEP_NAME" "ALLOWED_SENDER_BINDINGS is set but github.event.sender.login is empty — cannot enforce binding."
fi
if [[ -z "${PAYLOAD_SOURCE_REPO:-}" ]]; then
workflow_fail "$STEP_NAME" "ALLOWED_SENDER_BINDINGS is set but client_payload.source_repo is empty — cannot enforce binding."
fi
matched_repo=""
for entry in $BINDINGS; do
entry_login="${entry%%=*}"
entry_repo="${entry#*=}"
if [[ -z "$entry_login" || -z "$entry_repo" || "$entry_login" == "$entry" ]]; then
workflow_fail "$STEP_NAME" "ALLOWED_SENDER_BINDINGS entry '${entry}' is malformed — expected '<sender_login>=<owner>/<repo>'."
fi
if [[ "$entry_login" == "$SENDER_LOGIN" ]]; then
matched_repo="$entry_repo"
break
fi
done
if [[ -z "$matched_repo" ]]; then
workflow_fail "$STEP_NAME" "Sender '${SENDER_LOGIN}' is not present in ALLOWED_SENDER_BINDINGS. If this sender is legitimate, add '${SENDER_LOGIN}=<owner>/<repo>' to the variable."
fi
if [[ "$matched_repo" != "$PAYLOAD_SOURCE_REPO" ]]; then
workflow_fail "$STEP_NAME" "Sender '${SENDER_LOGIN}' is bound to '${matched_repo}' but client_payload.source_repo is '${PAYLOAD_SOURCE_REPO}'. Spoofing attempt or stale binding."
fi
echo "Sender binding satisfied: ${SENDER_LOGIN} -> ${matched_repo}"
echo "::notice title=Sender binding verified::sender=${SENDER_LOGIN} source_repo=${matched_repo}"
- name: Verify payload provenance against source_repo
# Archon security finding: source_repo is allowlisted, but every other
# client_payload field (sha, tag, pr_number, diff_artifact_run_id) is
# attacker-controllable. A holder of DOCS_REPO_TOKEN — or a compromised
# contributor on an allowlisted source repo — could forge those fields
# to point at unmerged branches, malicious forks, or attacker-uploaded
# artifacts. The allowlist authenticates the sender repo, not the
# claimed content.
#
# For each non-empty provenance field we independently re-verify it
# against the source repo via the GitHub API. ANY failed check rejects
# the dispatch outright — we never fall back to the client_payload
# value, per the Archon recommendation.
#
# The sha anchor depends on the dispatch kind:
# * code-change / manual-update — anchor to the source repo's `main`
# branch (sha must be identical to, or an ancestor of, main HEAD).
# This is base/base's default branch, so a merged change is on it.
# * release — anchor to the published release TAG instead. Release
# commits are cut on releases/* branches and tagged; they are NOT
# on main, so a main-anchored check would wrongly reject every
# release. We require compare {tag}...{sha} == `identical`, which
# proves (a) the tag actually exists on the source repo and (b) the
# dispatched sha is exactly that tag's commit. This is an
# equivalent-or-stronger binding than the main-ancestry check (an
# exact ref+commit match rather than mere reachability), not a
# downgrade — a forger without push access to the source repo
# cannot make an arbitrary commit resolve to a real release tag.
#
# The artifact-run check stays anchored to `main`: this dispatcher is
# invoked via workflow_run and therefore always executes from the
# source repo's default branch (main), so its uploaded artifact run's
# head_branch is main for both code-change and release dispatches.
#
# Required DOCS_REPO_TOKEN scopes on every ALLOWED_SOURCE_REPOS entry:
# - Contents: read (compare endpoint)
# - Pull requests: read (pulls endpoint)
# - Actions: read (workflow-runs endpoint; already needed for artifact fetch)
env:
KIND: ${{ env.PAYLOAD_KIND }}
SOURCE_REPO: ${{ env.PAYLOAD_SOURCE_REPO }}
SHA: ${{ env.PAYLOAD_SHA }}
TAG: ${{ env.PAYLOAD_TAG }}
PR_NUMBER: ${{ env.PAYLOAD_PR_NUMBER }}
ARTIFACT_RUN_ID: ${{ env.PAYLOAD_DIFF_ARTIFACT_RUN_ID }}
SOURCE_TOKEN: ${{ secrets.DOCS_REPO_TOKEN }}
STEP_NAME: "Verify payload provenance against source_repo"
run: |
set -euo pipefail
source "${GITHUB_WORKSPACE}/scripts/lib/workflow-fail.sh"
if [[ -z "${SOURCE_TOKEN:-}" ]]; then
workflow_fail "$STEP_NAME" "DOCS_REPO_TOKEN secret is not configured — required for cross-repo provenance verification"
fi
# Thin wrapper: writes body to $2, echoes status code on stdout.
# Keeps the per-check blocks short and uniform.
api_get() {
local path="$1"; local out="$2"
local code
code=$(curl -sS -o "$out" -w '%{http_code}' \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $SOURCE_TOKEN" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com${path}") || code="000"
echo "$code"
}
# 1) sha provenance — anchored to the release tag for release
# dispatches, otherwise to main (see step header).
if [[ -n "${SHA:-}" ]]; then
cmp_path="$RUNNER_TEMP/provenance_compare.json"
if [[ "$KIND" == "release" ]]; then
if [[ -z "${TAG:-}" ]]; then
workflow_fail "$STEP_NAME" "release payload is missing required 'tag' field — cannot anchor sha provenance"
fi
# GET /repos/{source_repo}/compare/{tag}...{sha}
# Require status `identical`: sha must be exactly the commit
# the release tag points to.
code=$(api_get "/repos/${SOURCE_REPO}/compare/${TAG}...${SHA}" "$cmp_path")
case "$code" in
403|404)
echo "Response body:" >&2
cat "$cmp_path" >&2 || true
workflow_fail "$STEP_NAME" "compare ${TAG}...${SHA} on ${SOURCE_REPO} returned HTTP ${code} — DOCS_REPO_TOKEN likely missing 'Contents: read' on ${SOURCE_REPO}, or the tag/sha does not exist"
;;
200) ;;
*)
cat "$cmp_path" >&2 || true
workflow_fail "$STEP_NAME" "compare ${TAG}...${SHA} on ${SOURCE_REPO} failed (HTTP ${code})"
;;
esac
status=$(jq -r '.status // ""' "$cmp_path")
if [[ "$status" != "identical" ]]; then
workflow_fail "$STEP_NAME" "release sha ${SHA} does not match tag ${TAG} on ${SOURCE_REPO} (compare status='${status}', expected 'identical')"
fi
echo "release sha ${SHA:0:7} matches tag ${TAG} of ${SOURCE_REPO} (compare status=identical)"
else
# GET /repos/{source_repo}/compare/main...{sha}
# Accept when status is `identical` (sha == main HEAD) or
# `behind` (sha is an ancestor of main HEAD). `ahead` or
# `diverged` means sha is not on main's history → reject.
code=$(api_get "/repos/${SOURCE_REPO}/compare/main...${SHA}" "$cmp_path")
case "$code" in
403|404)
echo "Response body:" >&2
cat "$cmp_path" >&2 || true
workflow_fail "$STEP_NAME" "compare main...${SHA} on ${SOURCE_REPO} returned HTTP ${code} — DOCS_REPO_TOKEN likely missing 'Contents: read' on ${SOURCE_REPO}, or sha does not exist"
;;
200) ;;
*)
cat "$cmp_path" >&2 || true
workflow_fail "$STEP_NAME" "compare main...${SHA} on ${SOURCE_REPO} failed (HTTP ${code})"
;;
esac
status=$(jq -r '.status // ""' "$cmp_path")
case "$status" in
identical|behind)
echo "sha ${SHA:0:7} reachable from main of ${SOURCE_REPO} (compare status=${status})"
;;
*)
workflow_fail "$STEP_NAME" "sha ${SHA} is not reachable from main of ${SOURCE_REPO} (compare status='${status}')"
;;
esac
fi
fi
# 2) pr_number (if present) merged into main
# GET /repos/{source_repo}/pulls/{pr_number}
# Require merged === true AND base.ref === "main".
if [[ -n "${PR_NUMBER:-}" ]]; then
pr_path="$RUNNER_TEMP/provenance_pr.json"
code=$(api_get "/repos/${SOURCE_REPO}/pulls/${PR_NUMBER}" "$pr_path")
case "$code" in
403|404)
echo "Response body:" >&2
cat "$pr_path" >&2 || true
workflow_fail "$STEP_NAME" "pulls/${PR_NUMBER} on ${SOURCE_REPO} returned HTTP ${code} — DOCS_REPO_TOKEN likely missing 'Pull requests: read' on ${SOURCE_REPO}, or PR does not exist"
;;
200) ;;
*)
cat "$pr_path" >&2 || true
workflow_fail "$STEP_NAME" "pulls/${PR_NUMBER} on ${SOURCE_REPO} failed (HTTP ${code})"
;;
esac
merged=$(jq -r '.merged // false' "$pr_path")
base_ref=$(jq -r '.base.ref // ""' "$pr_path")
if [[ "$merged" != "true" ]]; then
workflow_fail "$STEP_NAME" "pr #${PR_NUMBER} on ${SOURCE_REPO} is not merged"
fi
if [[ "$base_ref" != "main" ]]; then
workflow_fail "$STEP_NAME" "pr #${PR_NUMBER} on ${SOURCE_REPO} base.ref is '${base_ref}', not 'main'"
fi
echo "pr #${PR_NUMBER} on ${SOURCE_REPO} is merged into main"
fi
# 3) artifact run_id belongs to a workflow run on main
# GET /repos/{source_repo}/actions/runs/{run_id}
# Require head_branch === "main".
if [[ -n "${ARTIFACT_RUN_ID:-}" ]]; then
run_path="$RUNNER_TEMP/provenance_run.json"
code=$(api_get "/repos/${SOURCE_REPO}/actions/runs/${ARTIFACT_RUN_ID}" "$run_path")
case "$code" in
403|404)
echo "Response body:" >&2
cat "$run_path" >&2 || true
workflow_fail "$STEP_NAME" "actions/runs/${ARTIFACT_RUN_ID} on ${SOURCE_REPO} returned HTTP ${code} — DOCS_REPO_TOKEN likely missing 'Actions: read' on ${SOURCE_REPO}, or run does not exist"
;;
200) ;;
*)
cat "$run_path" >&2 || true
workflow_fail "$STEP_NAME" "actions/runs/${ARTIFACT_RUN_ID} on ${SOURCE_REPO} failed (HTTP ${code})"
;;
esac
head_branch=$(jq -r '.head_branch // ""' "$run_path")
if [[ "$head_branch" != "main" ]]; then
workflow_fail "$STEP_NAME" "artifact run ${ARTIFACT_RUN_ID} on ${SOURCE_REPO} head_branch is '${head_branch}', not 'main'"
fi
echo "artifact run ${ARTIFACT_RUN_ID} on ${SOURCE_REPO} was on main"
fi
echo "::notice title=Payload provenance verified::kind=${KIND:-code-change} sha=${SHA:0:7} tag=${TAG:-none} pr_number=${PR_NUMBER:-none} artifact_run_id=${ARTIFACT_RUN_ID:-none} (all checks passed against ${SOURCE_REPO})"
- name: Derive trusted release routing inputs
# client_payload.changed_paths is attacker-controlled JSON. For a
# release, replace it with the paths returned by GitHub for the
# independently verified tag range before it can influence LLM page
# discovery. previous_tag is also recomputed rather than trusted.
if: env.PAYLOAD_KIND == 'release'
env:
SOURCE_REPO: ${{ env.PAYLOAD_SOURCE_REPO }}
SHA: ${{ env.PAYLOAD_SHA }}
TAG: ${{ env.PAYLOAD_TAG }}
SOURCE_TOKEN: ${{ secrets.DOCS_REPO_TOKEN }}
PAYLOAD_PATH: ${{ steps.payload_file.outputs.path }}
STEP_NAME: "Derive trusted release routing inputs"
run: |
set -euo pipefail
source "${GITHUB_WORKSPACE}/scripts/lib/workflow-fail.sh"
api_get() {
local path="$1" out="$2" code
code=$(curl -sS -o "$out" -w '%{http_code}' \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $SOURCE_TOKEN" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com${path}") || code="000"
echo "$code"
}
refs="$RUNNER_TEMP/release-tag-refs.json"
code=$(api_get "/repos/${SOURCE_REPO}/git/matching-refs/tags/" "$refs")
if [[ "$code" != "200" ]]; then
workflow_fail "$STEP_NAME" "listing release tags on ${SOURCE_REPO} failed (HTTP ${code})"
fi
mapfile -t finals < <(
jq -r '.[].ref | select(test("^refs/tags/v[0-9]+\\.[0-9]+\\.[0-9]+$")) | sub("^refs/tags/"; "")' "$refs" \
| sort -Vr
)
previous=""
found=false
for candidate in "${finals[@]}"; do
if [[ "$found" == true ]]; then previous="$candidate"; break; fi
if [[ "$candidate" == "$TAG" ]]; then found=true; fi
done
if [[ "$found" != true ]]; then
workflow_fail "$STEP_NAME" "verified tag ${TAG} is not a final release ref on ${SOURCE_REPO}"
fi
if [[ -n "$previous" ]]; then
base="$previous"
else
commit="$RUNNER_TEMP/release-commit.json"
code=$(api_get "/repos/${SOURCE_REPO}/commits/${SHA}" "$commit")
if [[ "$code" != "200" ]]; then
workflow_fail "$STEP_NAME" "reading tagged commit ${SHA} failed (HTTP ${code})"
fi
base=$(jq -r '.parents[0].sha // ""' "$commit")
if [[ ! "$base" =~ ^[0-9a-f]{40}$ ]]; then
workflow_fail "$STEP_NAME" "tagged commit ${SHA} has no first parent for initial-release comparison"
fi
fi
comparison="$RUNNER_TEMP/release-compare.json"
code=$(api_get "/repos/${SOURCE_REPO}/compare/${base}...${TAG}" "$comparison")
if [[ "$code" != "200" ]]; then
workflow_fail "$STEP_NAME" "comparing trusted release range ${base}...${TAG} failed (HTTP ${code})"
fi
jq -e '(.files // []) | all(.[]; (.filename | type == "string") and (length <= 512))' "$comparison" >/dev/null \
|| workflow_fail "$STEP_NAME" "GitHub comparison returned malformed changed-file metadata"
jq '[.files[]?.filename] | .[0:2000]' "$comparison" > "$RUNNER_TEMP/trusted-changed-paths.json"
jq --arg previous_tag "$previous" --slurpfile paths "$RUNNER_TEMP/trusted-changed-paths.json" \
'.previous_tag = $previous_tag | .changed_paths = $paths[0]' "$PAYLOAD_PATH" > "$PAYLOAD_PATH.tmp"
mv "$PAYLOAD_PATH.tmp" "$PAYLOAD_PATH"
echo "::notice title=Trusted release routing inputs derived::tag=${TAG} previous=${previous:-<initial>} changed_paths=$(jq length "$RUNNER_TEMP/trusted-changed-paths.json")"
- name: Fetch diff artifact from source repo (if payload references one)
# Dispatcher uploads oversized diffs as a workflow artifact on the
# source repo (env.PAYLOAD_SOURCE_REPO, validated against the
# allowlist in the previous step) and the payload only carries
# `diff_artifact_run_id` + `diff_artifact_name`. We resolve those
# here, download the zip via the GitHub Actions API, unpack it, and
# splice the diff content back into the payload file so the script
# sees it like any inline diff.
#
# Requires the DOCS_REPO_TOKEN secret on this repo to have
# `actions:read` on every allowlisted source repo. Inline-diff
# dispatches skip this step entirely (both env vars are empty).
env:
ARTIFACT_RUN_ID: ${{ env.PAYLOAD_DIFF_ARTIFACT_RUN_ID }}
ARTIFACT_NAME: ${{ env.PAYLOAD_DIFF_ARTIFACT_NAME }}
SOURCE_REPO: ${{ env.PAYLOAD_SOURCE_REPO }}
SOURCE_TOKEN: ${{ secrets.DOCS_REPO_TOKEN }}
PAYLOAD_PATH: ${{ steps.payload_file.outputs.path }}
# Hard caps — tune here, document in README. Sized for release
# dispatches: the dispatcher caps the raw diff at 12 MiB
# (MAX_RELEASE_DIFF_BYTES), which zips far smaller, so MAX_DIFF_BYTES
# matches that ceiling and the zip cap leaves headroom. Both stay
# bounded so a tampered artifact can't exhaust the runner.
MAX_ARTIFACT_ZIP_BYTES: 16777216 # 16 MiB zip on the wire
MAX_DIFF_BYTES: 12582912 # 12 MiB unpacked diff (matches dispatcher cap)
STEP_NAME: "Fetch diff artifact from source repo"
run: |
set -euo pipefail
source "${GITHUB_WORKSPACE}/scripts/lib/workflow-fail.sh"
# Both-or-none. An asymmetric pair is malformed (or tampered) —
# fail closed rather than silently fall back to "no artifact".
if [[ -z "${ARTIFACT_RUN_ID:-}" && -z "${ARTIFACT_NAME:-}" ]]; then
echo "No diff artifact reference — diff is inline (or empty)."
exit 0
fi
if [[ -z "${ARTIFACT_RUN_ID:-}" || -z "${ARTIFACT_NAME:-}" ]]; then
workflow_fail "$STEP_NAME" "diff_artifact_run_id and diff_artifact_name must be both set or both empty"
fi
# Validate artifact-reference shape before we let either value
# flow into a URL or a filename. run_id is a uint64; name is
# the exact pattern the dispatcher uses (sync-diff-<run_id>).
if [[ ! "$ARTIFACT_RUN_ID" =~ ^[0-9]+$ ]]; then
workflow_fail "$STEP_NAME" "diff_artifact_run_id '$ARTIFACT_RUN_ID' is not numeric"
fi
if [[ ! "$ARTIFACT_NAME" =~ ^sync-diff-[0-9]+$ ]]; then
workflow_fail "$STEP_NAME" "diff_artifact_name '$ARTIFACT_NAME' does not match sync-diff-<run_id>"
fi
if [[ -z "${SOURCE_TOKEN:-}" ]]; then
workflow_fail "$STEP_NAME" "DOCS_REPO_TOKEN secret is not configured but the dispatch references a diff artifact. Add a PAT with 'actions:read' on ${SOURCE_REPO} as the DOCS_REPO_TOKEN secret."
fi
echo "Fetching diff artifact '${ARTIFACT_NAME}' from ${SOURCE_REPO} run ${ARTIFACT_RUN_ID}"
# 1. List artifacts and pick ours by EXACT name. Require
# exactly one match — multiple matches means the dispatcher
# uploaded a name collision and we shouldn't guess.
listing="$RUNNER_TEMP/artifacts.json"
status=$(curl -sS -o "$listing" -w '%{http_code}' \
-H "Authorization: Bearer $SOURCE_TOKEN" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/${SOURCE_REPO}/actions/runs/${ARTIFACT_RUN_ID}/artifacts?per_page=100")
if [[ "$status" != "200" ]]; then
cat "$listing" >&2 || true
workflow_fail "$STEP_NAME" "Listing artifacts failed (HTTP ${status})"
fi
matches=$(jq --arg name "$ARTIFACT_NAME" \
'[.artifacts[] | select(.name == $name)] | length' "$listing")
if [[ "$matches" -ne 1 ]]; then
echo "Available artifacts:" >&2
jq -r '.artifacts[].name' "$listing" >&2 || true
workflow_fail "$STEP_NAME" "expected exactly 1 artifact named '$ARTIFACT_NAME' on run ${ARTIFACT_RUN_ID}, found $matches"
fi
archive_url=$(jq -r --arg name "$ARTIFACT_NAME" \
'.artifacts[] | select(.name == $name) | .archive_download_url' "$listing")
declared_size=$(jq -r --arg name "$ARTIFACT_NAME" \
'.artifacts[] | select(.name == $name) | .size_in_bytes' "$listing")
if [[ -z "$declared_size" || "$declared_size" == "null" ]]; then
workflow_fail "$STEP_NAME" "artifact listing did not include size_in_bytes"
fi
if (( declared_size > MAX_ARTIFACT_ZIP_BYTES )); then
workflow_fail "$STEP_NAME" "artifact declared size $declared_size exceeds cap $MAX_ARTIFACT_ZIP_BYTES"
fi
# 2. Download the zip.
# --fail-with-body → non-zero exit on any 4xx/5xx
# --max-filesize → abort mid-stream if the server lies
# about size and tries to feed us more
# -L → follow the archive endpoint's 302 to
# the actual blob
zip_path="$RUNNER_TEMP/diff-artifact.zip"
if ! curl -sSL --fail-with-body \
--max-filesize "$MAX_ARTIFACT_ZIP_BYTES" \
-o "$zip_path" \
-H "Authorization: Bearer $SOURCE_TOKEN" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"$archive_url"; then
workflow_fail "$STEP_NAME" "artifact download failed (curl non-zero — see step log for HTTP body)"
fi
actual_size=$(wc -c < "$zip_path" | tr -d ' ')
if (( actual_size > MAX_ARTIFACT_ZIP_BYTES )); then
workflow_fail "$STEP_NAME" "downloaded zip is $actual_size bytes (cap: $MAX_ARTIFACT_ZIP_BYTES)"
fi
# 3. Unzip into a FRESH directory (rm -rf in case of retry on
# the same runner) and enforce zip-slip protection: every
# extracted file's resolved path must stay inside the
# unpack dir. realpath + prefix compare with a trailing
# '/' on the boundary avoids the
# /tmp/diff-unpack-evil/ -> /tmp/diff-unpack/ false-pass.
unpack_dir="$RUNNER_TEMP/diff-unpack"
rm -rf "$unpack_dir"
mkdir -p "$unpack_dir"
unzip -q "$zip_path" -d "$unpack_dir"
unpack_abs="$(cd "$unpack_dir" && pwd -P)"
while IFS= read -r -d '' f; do
resolved="$(realpath "$f")"
case "$resolved" in
"$unpack_abs"/*) ;;
*) workflow_fail "$STEP_NAME" "zip-slip detected: '$f' resolves to '$resolved' which escapes '$unpack_abs'" ;;
esac
done < <(find "$unpack_dir" -mindepth 1 -print0)
# 4. Require EXACTLY one *.diff file. The dispatcher uploads
# one *.diff file and nothing else; anything else here is
# either a misconfigured dispatcher or a tampered artifact.
mapfile -d '' diff_candidates < <(find "$unpack_dir" -type f -name '*.diff' -print0)
if (( ${#diff_candidates[@]} != 1 )); then
echo "Unpack contents:" >&2
find "$unpack_dir" -type f >&2 || true
workflow_fail "$STEP_NAME" "expected exactly 1 *.diff file in artifact, found ${#diff_candidates[@]}"
fi
diff_path="${diff_candidates[0]}"
diff_size=$(wc -c < "$diff_path" | tr -d ' ')
if (( diff_size > MAX_DIFF_BYTES )); then
workflow_fail "$STEP_NAME" "unpacked diff is $diff_size bytes (cap: $MAX_DIFF_BYTES)"
fi
echo "Downloaded diff: $diff_size bytes at $diff_path"
# 5. Splice the diff back into the payload JSON in-place. The
# sync-from-base script reads payload.diff as a string — once
# we inject the content here, the script can't tell the
# difference between an inline and an artifact-delivered diff.
jq --rawfile diff "$diff_path" '.diff = $diff' "$PAYLOAD_PATH" > "$PAYLOAD_PATH.new"
mv "$PAYLOAD_PATH.new" "$PAYLOAD_PATH"
echo "Diff injected into payload. New payload size: $(wc -c < "$PAYLOAD_PATH") bytes."
echo "::notice title=Artifact accepted::${ARTIFACT_NAME} from ${SOURCE_REPO} run ${ARTIFACT_RUN_ID} (zip=${actual_size}B diff=${diff_size}B)"
- name: Run sync-from-base-std
id: sync
env:
LLM_GATEWAY_API_KEY: ${{ secrets.LLM_GATEWAY_API_KEY }}
PAYLOAD_PATH: ${{ steps.payload_file.outputs.path }}
CODE_CHANGE_PAGE_CONCURRENCY: ${{ vars.CODE_CHANGE_PAGE_CONCURRENCY }}
RELEASE_PAGE_CONCURRENCY: ${{ vars.RELEASE_PAGE_CONCURRENCY }}
CLAUDE_MODEL: ${{ vars.CLAUDE_MODEL }}
CLAUDE_MAX_TOKENS: ${{ vars.CLAUDE_MAX_TOKENS }}
run: |
node scripts/sync-from-base-std/index.mjs --payload "$PAYLOAD_PATH"
- name: Upload sync benchmark log
# `if: always()` so we still capture the partial bench file even when
# sync-from-base failed (e.g. on a non-retryable error mid-batch).
if: always() && hashFiles('.sync-bench/*.jsonl') != ''
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: sync-bench-${{ github.run_id }}
path: .sync-bench/*.jsonl
if-no-files-found: ignore
retention-days: 14
# The bench dir starts with a dot. upload-artifact filters hidden
# files by default, so without this the artifact silently uploads
# zero files (the `if:` above sees the file via hashFiles, but the
# action's own glob excludes it).
include-hidden-files: true
- name: Commit branch
id: commit
env:
BRANCH: ${{ steps.sync.outputs.branch }}
TOUCHED_PATHS: ${{ steps.sync.outputs.touched_paths }}
TOUCHED_COUNT: ${{ steps.sync.outputs.touched_count }}
# Use the env vars populated by Materialize dispatch payload — they
# work for both repository_dispatch (where client_payload is set)
# and workflow_dispatch (where it isn't).
SHA: ${{ env.PAYLOAD_SHA }}