base-code-changed #4
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }} | |
| TAG: ${{ env.PAYLOAD_TAG }} | |
| KIND: ${{ env.PAYLOAD_KIND }} | |
| run: | | |
| set -euo pipefail | |
| if [[ -z "${BRANCH:-}" ]] || [[ "${TOUCHED_COUNT:-0}" == "0" ]]; then | |
| echo "no_changes=true" >> "$GITHUB_OUTPUT" | |
| echo "No pages touched by sync; skipping PR." | |
| # Surface this as a banner too — otherwise a run that legitimately | |
| # "did nothing" (e.g. Claude returned every page unchanged) produces | |
| # zero annotations and the summary page looks empty. | |
| echo "::notice title=No changes::Pages already match the requested intent — nothing to commit" | |
| exit 0 | |
| fi | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| git checkout -B "$BRANCH" | |
| # Structural guard. The route-table is the routing decision; | |
| # this is the last-mile check that fails closed if the script | |
| # (or a future LLM regression) ever emits a path outside the | |
| # docs tree. No denylist needed — anything not matching the | |
| # allowlist regex is rejected. | |
| # | |
| # Allowed: docs/<anything>/<file>.{md,mdx,txt} | |
| # Rejected: absolute paths, embedded newlines, '..' traversal, | |
| # anything outside docs/, any non-doc extension. | |
| allow_re='^docs/[A-Za-z0-9._/-]+\.(md|mdx|txt)$' | |
| for p in $TOUCHED_PATHS; do | |
| case "$p" in | |
| /*|*..*|*$'\n'*) | |
| echo "::error title=Touched path rejected::'$p' contains an unsafe component (absolute path, '..' traversal, or newline)" >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| if [[ ! "$p" =~ $allow_re ]]; then | |
| echo "::error title=Touched path rejected::'$p' is outside the docs allowlist (${allow_re})" >&2 | |
| exit 1 | |
| fi | |
| git add -- "$p" | |
| done | |
| if git diff --staged --quiet; then | |
| echo "no_changes=true" >> "$GITHUB_OUTPUT" | |
| echo "Staged diff is empty — script returned paths but git sees no change." | |
| echo "::notice title=No changes::Script staged paths but git sees no diff" | |
| exit 0 | |
| fi | |
| short_sha="${SHA:0:7}" | |
| case "$KIND" in | |
| release) | |
| commit_msg="docs: sync ${TAG:-release} from base-std" | |
| ;; | |
| manual-update) | |
| commit_msg="docs: manual sync from maintainer" | |
| ;; | |
| *) | |
| commit_msg="docs: sync from base-std@${short_sha:-unknown}" | |
| ;; | |
| esac | |
| git commit -m "$commit_msg" | |
| git push -f origin "$BRANCH" | |
| echo "no_changes=false" >> "$GITHUB_OUTPUT" | |
| # Annotation surfaces in the run summary — same primitive Mintlify | |
| # uses (core.notice). Keep it terse, the next step's notice covers | |
| # the PR URL. | |
| echo "::notice title=Pages synced::${TOUCHED_COUNT} page(s) committed to ${BRANCH}" | |
| - name: Open or update PR via REST | |
| if: steps.commit.outputs.no_changes != 'true' | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| REPO: ${{ github.repository }} | |
| # Server-attested target branch. base/docs currently uses `master`; | |
| # deriving it avoids another hardcoded main/master mismatch. | |
| DOCS_BASE_BRANCH: ${{ github.event.repository.default_branch }} | |
| BRANCH: ${{ steps.sync.outputs.branch }} | |
| TOUCHED_PATHS: ${{ steps.sync.outputs.touched_paths }} | |
| REJECTED_PAGES: ${{ steps.sync.outputs.rejected_pages }} | |
| REJECTED_COUNT: ${{ steps.sync.outputs.rejected_count }} | |
| PROVENANCE_MD_PATH: ${{ steps.sync.outputs.provenance_md_path }} | |
| REVIEW_MD_PATH: ${{ steps.sync.outputs.review_md_path }} | |
| # All payload fields come from $PAYLOAD_* env vars set by Materialize | |
| # dispatch payload — works for both repository_dispatch and | |
| # workflow_dispatch trigger types. | |
| KIND: ${{ env.PAYLOAD_KIND }} | |
| SOURCE_REPO: ${{ env.PAYLOAD_SOURCE_REPO }} | |
| SHA: ${{ env.PAYLOAD_SHA }} | |
| TAG: ${{ env.PAYLOAD_TAG }} | |
| PR_NUMBER: ${{ env.PAYLOAD_PR_NUMBER }} | |
| PR_TITLE: ${{ env.PAYLOAD_PR_TITLE }} | |
| INTENT: ${{ env.PAYLOAD_INTENT }} | |
| SOURCE_REFS: ${{ env.PAYLOAD_SOURCE_REFS }} | |
| run: | | |
| set -euo pipefail | |
| short_sha="${SHA:0:7}" | |
| case "$KIND" in | |
| release) | |
| title="docs: sync ${TAG:-release} from base-std" | |
| ;; | |
| manual-update) | |
| # Use the first 80 chars of the intent as the title hint. | |
| intent_preview="${INTENT:0:80}" | |
| title="docs: ${intent_preview:-manual sync from maintainer}" | |
| ;; | |
| *) | |
| if [[ -n "$PR_TITLE" ]]; then | |
| # Source PRs often already start with 'docs(...)' or 'docs:'. | |
| # Prepending 'docs:' unconditionally would produce a | |
| # a duplicated 'docs: docs(...): …' prefix. Strip the prefix | |
| # off the upstream title first so we always end up with exactly one. | |
| clean_pr_title="${PR_TITLE#docs: }" | |
| clean_pr_title="${clean_pr_title#docs\(*\): }" | |
| title="docs: ${clean_pr_title} (base-std@${short_sha})" | |
| else | |
| title="docs: sync from base-std@${short_sha}" | |
| fi | |
| ;; | |
| esac | |
| body_file="$RUNNER_TEMP/pr_body.md" | |
| { | |
| if [[ "$KIND" == "manual-update" ]]; then | |
| echo "Maintainer-curated update." | |
| echo | |
| echo "**Intent**: ${INTENT}" | |
| if [[ -n "${SOURCE_REFS:-}" ]]; then | |
| echo | |
| echo "**Source references**:" | |
| # SOURCE_REFS is space-separated; render as a bullet list. | |
| for r in ${SOURCE_REFS}; do | |
| echo "- ${r}" | |
| done | |
| fi | |
| else | |
| # Source PR comes first — reviewers want to click straight to | |
| # the upstream PR (with full source diff + discussion) before | |
| # looking at anything else. Bold + link + title where available. | |
| if [[ -n "${PR_NUMBER:-}" ]]; then | |
| if [[ -n "${PR_TITLE:-}" ]]; then | |
| echo "> **Source PR**: [${SOURCE_REPO}#${PR_NUMBER}](https://github.com/${SOURCE_REPO}/pull/${PR_NUMBER}) — _${PR_TITLE}_" | |
| else | |
| echo "> **Source PR**: [${SOURCE_REPO}#${PR_NUMBER}](https://github.com/${SOURCE_REPO}/pull/${PR_NUMBER})" | |
| fi | |
| echo ">" | |
| if [[ -n "${SHA:-}" ]]; then | |
| echo "> **Merge commit**: [\`${SHA:0:7}\`](https://github.com/${SOURCE_REPO}/commit/${SHA})" | |
| fi | |
| echo | |
| echo "Auto-generated from the source PR above." | |
| else | |
| echo "Auto-generated from \`${SOURCE_REPO}\`." | |
| if [[ -n "${SHA:-}" ]]; then | |
| echo | |
| echo "**Commit**: [\`${SHA:0:7}\`](https://github.com/${SOURCE_REPO}/commit/${SHA})" | |
| fi | |
| fi | |
| if [[ -n "${TAG:-}" ]]; then | |
| echo | |
| echo "**Tag**: \`${TAG}\`" | |
| fi | |
| fi | |
| # Reviewer checklist + newly-introduced external URLs. Placed | |
| # ahead of the file-touched / provenance sections so the | |
| # action-required items are the first thing a reviewer reads | |
| # after the source-PR link. Sync script writes the file at | |
| # $REVIEW_MD_PATH; we splice it in verbatim. | |
| if [[ -n "${REVIEW_MD_PATH:-}" ]] && [[ -f "${REVIEW_MD_PATH}" ]]; then | |
| cat "${REVIEW_MD_PATH}" | |
| fi | |
| echo | |
| echo "## Files touched" | |
| for p in ${TOUCHED_PATHS:-}; do | |
| echo "- \`${p}\`" | |
| done | |
| # Surface pages that Claude tried to write but the validator | |
| # rejected. Reviewer should expect those pages to be missing from | |
| # the diff and act accordingly (manual edit, retry, or accept the | |
| # gap). | |
| if [[ -n "${REJECTED_PAGES:-}" ]]; then | |
| echo | |
| echo "## Skipped pages (validator rejected the model output)" | |
| # REJECTED_PAGES is tab-separated tuples of "path|reason". | |
| # Split on TAB, then on the first | per tuple. | |
| IFS=$'\t' read -ra tuples <<< "$REJECTED_PAGES" | |
| for tup in "${tuples[@]}"; do | |
| page="${tup%%|*}" | |
| reason="${tup#*|}" | |
| echo "- \`${page}\` — ${reason}" | |
| done | |
| fi | |
| # Splice in the source-provenance markdown table the script wrote. | |
| # Lets a reviewer click straight from each doc page to the source | |
| # file(s) in base that drove its edit. | |
| if [[ -n "${PROVENANCE_MD_PATH:-}" ]] && [[ -f "${PROVENANCE_MD_PATH}" ]]; then | |
| cat "${PROVENANCE_MD_PATH}" | |
| fi | |
| echo | |
| echo "_Opened by \`Apply Base Std Update\` workflow._" | |
| } > "$body_file" | |
| owner="${REPO%%/*}" | |
| existing=$(curl -sS \ | |
| -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}/pulls?state=open&head=${owner}:${BRANCH}" \ | |
| | jq -r '.[0].number // ""') | |
| payload_file="$RUNNER_TEMP/pr.json" | |
| if [[ -n "$existing" ]]; then | |
| echo "Updating existing PR #$existing" | |
| jq -n \ | |
| --arg title "$title" \ | |
| --rawfile body "$body_file" \ | |
| '{title: $title, body: $body}' > "$payload_file" | |
| status=$(curl -sS -o "$RUNNER_TEMP/resp.json" -w "%{http_code}" \ | |
| -X PATCH \ | |
| -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}/pulls/${existing}" \ | |
| --data-binary @"$payload_file") | |
| expected="200" | |
| else | |
| echo "Creating PR for $BRANCH" | |
| jq -n \ | |
| --arg title "$title" \ | |
| --arg head "$BRANCH" \ | |
| --arg base "$DOCS_BASE_BRANCH" \ | |
| --rawfile body "$body_file" \ | |
| '{title: $title, head: $head, base: $base, body: $body, maintainer_can_modify: true}' > "$payload_file" | |
| status=$(curl -sS -o "$RUNNER_TEMP/resp.json" -w "%{http_code}" \ | |
| -X POST \ | |
| -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}/pulls" \ | |
| --data-binary @"$payload_file") | |
| expected="201" | |
| fi | |
| if [[ "$status" != "$expected" ]]; then | |
| echo "PR API call failed: expected $expected, got $status" >&2 | |
| cat "$RUNNER_TEMP/resp.json" >&2 || true | |
| exit 1 | |
| fi | |
| pr_url=$(jq -r '.html_url // empty' "$RUNNER_TEMP/resp.json") | |
| # `::notice::` is GitHub Actions' annotation primitive — surfaces a | |
| # green banner in the run summary + the Annotations panel, vs | |
| # being buried in the step log. Same thing Mintlify's example | |
| # workflow uses via core.notice(). Pure visibility, no behavior. | |
| if [[ -n "${pr_url:-}" ]]; then | |
| echo "::notice title=Docs PR opened::${pr_url}" | |
| else | |
| echo "::warning title=Docs PR step::Step succeeded but no PR URL was returned" | |
| fi | |
| echo "PR: ${pr_url:-(no url returned)}" |