Skip to content

docs(changelog): expand composite policy specification (#211) #8

docs(changelog): expand composite policy specification (#211)

docs(changelog): expand composite policy specification (#211) #8

name: Notify Base Docs from base-std
on:
push:
branches: [main]
paths:
# Public documentation sources. Keep this list synchronized with the
# `paths=(...)` array in "Compute diff and changed paths" below.
- "src/StdPrecompiles.sol"
- "src/interfaces/**"
- "src/lib/**"
- "test/lib/mocks/**"
- "docs/**"
- "CHANGELOG.md"
- "changelog/**"
workflow_dispatch:
inputs:
base_sha:
description: "Override BASE_SHA for the diff. Leave blank for HEAD^."
required: false
type: string
default: ""
target_sha:
description: "Override AFTER_SHA for the diff. Leave blank for HEAD."
required: false
type: string
default: ""
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
# id-token: write is required so the OIDC mint step below can request a
# GitHub-signed JWT attesting this run's source repo. The token is the
# only server-attested binding from the dispatch credential to a
# specific source repo — without it the docs-side `source_repo` claim
# is just attacker-controllable JSON. See README "OIDC source-repo
# attestation" for the threat model.
id-token: write
env:
# Required repo variable: set DOCS_REPOSITORY to "<owner>/<docs-repo>"
# under Settings → Secrets and variables → Actions → Variables. There is
# no default — the workflow validates this is set before dispatching.
DOCS_REPOSITORY: ${{ vars.DOCS_REPOSITORY }}
# GitHub Actions client_payload has a 64 KiB hard ceiling. We now also
# carry an OIDC attestation JWT (~1.5–2 KiB) plus the usual PR/source
# context fields, so we sit further under the ceiling than before
# (was 64512). Artifact transport picks up anything bigger.
DIFF_SIZE_LIMIT: 60000
# Code-change dispatches use this event_type. Release dispatches use a
# different one and are fired from docs-pr-dispatch-release.yml.
EVENT_TYPE: base-code-changed
# The "effective" source SHA — what the receiver should treat as the
# commit that drove this dispatch. On a real push to main this is just
# github.sha. On a workflow_dispatch replay it's the `target_sha` input,
# so the receiver's branch name + PR title + provenance links all
# reference the diff content. Without keying off target_sha, replays
# would collide on the same `docs/sync-code-change-<HEAD>` branch.
EFFECTIVE_SHA: ${{ github.event.inputs.target_sha || github.sha }}
jobs:
notify:
name: Dispatch base-std change to docs repo
# Fork guard: do not run on forks of the host repo, even when the
# workflow file is copied with it. The dispatch step holds a cross-repo
# PAT (DOCS_REPO_TOKEN) — refusing to run on forks is the cheapest form
# of containment, and forks never inherit the secret anyway so this is
# defense in depth, not the only check.
if: github.event.repository.fork == false
runs-on: ubuntu-latest
steps:
- name: Harden the runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with:
egress-policy: audit
- name: Authorize trigger
# Defense in depth: push-to-main already requires write access via branch
# protection, but we explicitly verify the pusher (github.actor) has at
# least write permission via the REST collaborators endpoint. Fails the
# job before any cross-repo network call happens if the actor doesn't
# qualify. workflow_dispatch is gated the same way.
env:
GITHUB_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
ACTOR: ${{ github.actor }}
run: |
set -euo pipefail
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' has '$perm' permission on $REPO."
;;
*)
echo "Actor '$ACTOR' has '$perm' permission on $REPO — refusing to dispatch." >&2
exit 1
;;
esac
- name: Checkout repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
# 0 = full history. Needed so historical SHAs passed via
# workflow_dispatch inputs (demo replays) are reachable. For normal
# pushes 2 is enough, but the cost of full fetch is a few seconds
# and the simplicity is worth it.
fetch-depth: 0
- name: Compute diff and changed paths
id: diff
env:
# When fired via workflow_dispatch with explicit inputs we honor those
# (used for demo replays where we want to dispatch a historical diff).
# On real pushes both inputs are absent → fall through to the event's
# before/after SHAs.
BEFORE_SHA: ${{ github.event.inputs.base_sha || github.event.before }}
AFTER_SHA: ${{ github.event.inputs.target_sha || github.sha }}
run: |
set -euo pipefail
# workflow_dispatch string inputs preserve pasted whitespace. A
# commit SHA cannot contain whitespace, so trim it before using it
# as a Git revision and fail with a focused message if it is still
# not resolvable in the full checkout.
BEFORE_SHA=$(printf '%s' "$BEFORE_SHA" | xargs)
AFTER_SHA=$(printf '%s' "$AFTER_SHA" | xargs)
paths=(
"src/StdPrecompiles.sol"
"src/interfaces"
"src/lib"
"test/lib/mocks"
"docs"
"CHANGELOG.md"
"changelog"
)
diff_file="$RUNNER_TEMP/base-std-docs.diff"
changed_paths_file="$RUNNER_TEMP/changed-paths.json"
# Three cases for $BEFORE_SHA:
# 1. Normal push: a real parent SHA → diff against it.
# 2. Initial push to a branch: all-zeros sentinel → use HEAD^.
# 3. workflow_dispatch: empty (event has no `before` field) → use HEAD^.
# Cases 2 and 3 collapse to the same fallback.
if [[ -z "$BEFORE_SHA" || "$BEFORE_SHA" =~ ^0+$ ]]; then
base="${AFTER_SHA}^"
else
base="$BEFORE_SHA"
fi
if ! git rev-parse --verify --quiet "${AFTER_SHA}^{commit}" > /dev/null; then
echo "::error title=Invalid target SHA::'$AFTER_SHA' is not a commit available in this checkout." >&2
exit 1
fi
if ! git rev-parse --verify --quiet "${base}^{commit}" > /dev/null; then
echo "::error title=Invalid base SHA::'$base' is not a commit available in this checkout." >&2
exit 1
fi
# Full unified diff for the watched paths.
git diff "$base" "$AFTER_SHA" -- "${paths[@]}" > "$diff_file"
# List of changed file paths (relative to repo root). The docs-side
# router uses these to decide which doc pages to regenerate.
git diff "$base" "$AFTER_SHA" --name-only -- "${paths[@]}" \
| jq -R -s 'split("\n") | map(select(length > 0))' > "$changed_paths_file"
diff_size=$(wc -c < "$diff_file" | tr -d ' ')
echo "diff_size=$diff_size" >> "$GITHUB_OUTPUT"
echo "diff_path=$diff_file" >> "$GITHUB_OUTPUT"
echo "changed_paths_path=$changed_paths_file" >> "$GITHUB_OUTPUT"
echo "Computed diff: $diff_size bytes across ${#paths[@]} watched paths"
echo "Changed files: $(jq 'length' "$changed_paths_file")"
- name: Resolve associated pull request
id: pr
env:
# Use the auto-injected workflow token; no gh CLI, no third-party tooling.
GITHUB_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
# Look up the PR for the SHA the diff actually lands at — on a
# workflow_dispatch replay this is target_sha, not the dispatcher
# run's HEAD. Otherwise the PR title/body in the docs PR come from
# the wrong upstream PR.
SHA: ${{ env.EFFECTIVE_SHA }}
run: |
set -euo pipefail
pr_json=$(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}/commits/${SHA}/pulls" \
| jq '.[0] // {}')
number=$(jq -r '.number // ""' <<< "$pr_json")
title=$(jq -r '.title // ""' <<< "$pr_json")
body=$(jq -r '.body // ""' <<< "$pr_json")
echo "number=$number" >> "$GITHUB_OUTPUT"
printf '%s' "$title" > "$RUNNER_TEMP/pr_title.txt"
printf '%s' "$body" > "$RUNNER_TEMP/pr_body.txt"
if [[ -n "$number" ]]; then
echo "Resolved PR #$number: $title"
else
echo "No PR associated with this commit (direct push to main); dispatching with empty PR context."
fi
- name: Decide diff transport (inline vs artifact)
id: transport
env:
DIFF_SIZE: ${{ steps.diff.outputs.diff_size }}
run: |
set -euo pipefail
# Small diffs travel inline in the repository_dispatch payload
# (fast path, no extra round-trip). Large diffs go via a GitHub
# Actions artifact: the dispatcher uploads, the payload references
# it by run-id + name, and the receiver downloads it via the
# GitHub API. This avoids the ~64 KB client_payload ceiling.
if (( DIFF_SIZE > DIFF_SIZE_LIMIT )); then
echo "Diff is $DIFF_SIZE bytes (> $DIFF_SIZE_LIMIT); will upload as artifact."
echo "use_artifact=true" >> "$GITHUB_OUTPUT"
else
echo "Diff is $DIFF_SIZE bytes (<= $DIFF_SIZE_LIMIT); embedding inline."
echo "use_artifact=false" >> "$GITHUB_OUTPUT"
fi
- name: Upload diff artifact (only for oversized diffs)
if: steps.transport.outputs.use_artifact == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
# Key the artifact off the workflow run id (always unique) rather
# than github.sha — two replays of the same target_sha must not
# collide on artifact storage, and the receiver pulls by
# diff_artifact_run_id + diff_artifact_name so semantic SHAs
# aren't needed here.
name: sync-diff-${{ github.run_id }}
path: ${{ steps.diff.outputs.diff_path }}
retention-days: 7
if-no-files-found: error
- name: Mint OIDC attestation
id: oidc
# Requests a GitHub-signed OIDC token whose `repository` claim is
# server-attested (it cannot be forged by the dispatcher's PAT —
# only by a workflow actually running on this repo with
# `id-token: write`). The receiver verifies this token against
# GitHub's JWKS and requires payload.repository ==
# client_payload.source_repo, closing the source_repo spoofing
# vector described in the Archon finding.
#
# Audience binds the token to a specific docs repo so a leaked
# token can't be replayed against an unrelated receiver. We
# encode the docs repo so audits can map tokens → intended
# receiver without parsing the JWT body.
env:
AUDIENCE: docs-sync:${{ env.DOCS_REPOSITORY }}
run: |
set -euo pipefail
if [[ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" || -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]]; then
echo "::error title=OIDC mint failed::ACTIONS_ID_TOKEN_REQUEST_* env vars are not set. The job-level 'permissions: id-token: write' declaration is required for OIDC minting." >&2
exit 1
fi
response_path="$RUNNER_TEMP/oidc-response.json"
status=$(curl -sS -o "$response_path" -w '%{http_code}' \
-H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
-H "Accept: application/json" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${AUDIENCE}")
if [[ "$status" != "200" ]]; then
echo "::error title=OIDC mint failed::token endpoint returned HTTP ${status}" >&2
cat "$response_path" >&2 || true
exit 1
fi
token=$(jq -r '.value // ""' "$response_path")
if [[ -z "$token" || "$token" == "null" ]]; then
echo "::error title=OIDC mint failed::token endpoint returned 200 but no .value field" >&2
exit 1
fi
# Mask the token immediately so neither this step's trailing
# lines nor any downstream step can leak it via log echo.
echo "::add-mask::$token"
# Write to a file rather than $GITHUB_OUTPUT — JWTs sit close
# to the per-line cap and we want zero risk of truncation.
# The chmod 600 limits exposure on shared self-hosted runners.
token_path="$RUNNER_TEMP/oidc-token.jwt"
printf '%s' "$token" > "$token_path"
chmod 600 "$token_path"
echo "token_path=$token_path" >> "$GITHUB_OUTPUT"
echo "Minted OIDC token (audience=${AUDIENCE}, ${#token} bytes)"
- name: Build dispatch payload
id: payload
env:
DIFF_SIZE: ${{ steps.diff.outputs.diff_size }}
DIFF_PATH: ${{ steps.diff.outputs.diff_path }}
CHANGED_PATHS_PATH: ${{ steps.diff.outputs.changed_paths_path }}
PR_NUMBER: ${{ steps.pr.outputs.number }}
# Effective source SHA — see comment on EFFECTIVE_SHA above. The
# receiver derives branch name, PR title, and provenance from
# this; using github.sha here collapses every replay into one PR.
SHA: ${{ env.EFFECTIVE_SHA }}
USE_ARTIFACT: ${{ steps.transport.outputs.use_artifact }}
RUN_ID: ${{ github.run_id }}
OIDC_TOKEN_PATH: ${{ steps.oidc.outputs.token_path }}
run: |
set -euo pipefail
# Two payload shapes, mutually exclusive:
# * inline: diff carries the full diff and diff_truncated=false;
# empty artifact references are omitted.
# * artifact: diff and diff_truncated are omitted; artifact
# references point at the GitHub Actions artifact on
# THIS run.
#
# GitHub repository_dispatch accepts at most 10 top-level
# client_payload properties. Omitting fields that have no meaning
# for a transport keeps both shapes within that API limit while
# remaining compatible with the receiver's empty-field defaults.
# Today both shapes deliver the full diff to the receiver.
if [[ "$USE_ARTIFACT" == "true" ]]; then
diff_arg=("--arg" "diff" "")
transport="artifact"
artifact_run_id="$RUN_ID"
# Must match the `name:` we used in the Upload step above.
artifact_name="sync-diff-$RUN_ID"
else
diff_arg=("--rawfile" "diff" "$DIFF_PATH")
transport="inline"
artifact_run_id=""
artifact_name=""
fi
# The previous step already enforced that the OIDC token was
# minted and wrote it to OIDC_TOKEN_PATH. jq --rawfile will
# error here if the path is empty, so no extra recheck needed.
payload_file="$RUNNER_TEMP/dispatch.json"
jq -n \
--arg event_type "$EVENT_TYPE" \
--arg transport "$transport" \
--arg source_repo "$GITHUB_REPOSITORY" \
--arg sha "$SHA" \
--arg pr_number "$PR_NUMBER" \
--rawfile pr_title "$RUNNER_TEMP/pr_title.txt" \
--rawfile pr_body "$RUNNER_TEMP/pr_body.txt" \
"${diff_arg[@]}" \
--argjson truncated false \
--arg diff_artifact_run_id "$artifact_run_id" \
--arg diff_artifact_name "$artifact_name" \
--slurpfile changed_paths "$CHANGED_PATHS_PATH" \
--rawfile oidc_token "$OIDC_TOKEN_PATH" \
'{
event_type: $event_type,
client_payload: (
{
kind: "code-change",
source_repo: $source_repo,
sha: $sha,
pr_number: $pr_number,
pr_title: $pr_title,
pr_body: $pr_body,
changed_paths: $changed_paths[0],
oidc_token: $oidc_token
}
+ (if $transport == "artifact" then
{
diff_artifact_run_id: $diff_artifact_run_id,
diff_artifact_name: $diff_artifact_name
}
else
{
diff: $diff,
diff_truncated: $truncated
}
end)
)
}' > "$payload_file"
property_count=$(jq '.client_payload | length' "$payload_file")
if (( property_count > 10 )); then
echo "::error title=Dispatch payload too wide::client_payload has $property_count top-level properties; GitHub permits at most 10." >&2
exit 1
fi
echo "payload_path=$payload_file" >> "$GITHUB_OUTPUT"
echo "Payload size: $(wc -c < "$payload_file") bytes (use_artifact=$USE_ARTIFACT, client_payload_properties=$property_count)"
- name: Validate required secrets and variables
env:
DOCS_PAT: ${{ secrets.DOCS_REPO_TOKEN }}
DOCS_REPO: ${{ env.DOCS_REPOSITORY }}
run: |
set -euo pipefail
if [[ -z "${DOCS_REPO:-}" ]]; then
echo "DOCS_REPOSITORY repo variable is not configured." >&2
echo "Set it under Settings → Secrets and variables → Actions → Variables to '<owner>/<docs-repo>'." >&2
exit 1
fi
if [[ -z "${DOCS_PAT:-}" ]]; then
echo "DOCS_REPO_TOKEN secret is not configured." >&2
echo "Create a fine-grained PAT scoped to ${DOCS_REPO} and save it as the DOCS_REPO_TOKEN secret." >&2
exit 1
fi
- name: Send repository_dispatch to docs repo
env:
DOCS_PAT: ${{ secrets.DOCS_REPO_TOKEN }}
PAYLOAD_PATH: ${{ steps.payload.outputs.payload_path }}
# Notice banner + log line should show the SHA the receiver will
# actually document, not whatever happened to be at base/main HEAD.
SHA: ${{ env.EFFECTIVE_SHA }}
run: |
set -euo pipefail
response_body="$RUNNER_TEMP/response.txt"
status_code=$(curl -sS \
-o "$response_body" \
-w "%{http_code}" \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $DOCS_PAT" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/$DOCS_REPOSITORY/dispatches" \
--data-binary @"$PAYLOAD_PATH")
echo "Dispatch status: $status_code"
if [[ -s "$response_body" ]]; then
echo "Response body:"
cat "$response_body"
fi
if [[ "$status_code" != "204" ]]; then
echo "Dispatch failed (expected 204, got $status_code)." >&2
exit 1
fi
echo "Dispatched '$EVENT_TYPE' to $DOCS_REPOSITORY for ${SHA}"
# `::notice::` surfaces this as a banner in the run summary,
# rather than being buried in the step log. Same primitive
# Mintlify uses via core.notice() in their automate-agent example.
echo "::notice title=Docs dispatch sent::${EVENT_TYPE} → ${DOCS_REPOSITORY} (base-std@${SHA:0:7})"