Repository navigation
docs(changelog): expand composite policy specification (#211) #8
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: 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})" |