Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 34 additions & 155 deletions .github/workflows/auto-fix.yml
Original file line number Diff line number Diff line change
@@ -1,22 +1,12 @@
---
name: Auto-fix

# Privileged half of the Dependabot auto-fix flow. React to the Validate workflow
# FAILING on a Dependabot PR and push the mechanical fixes that Validate's
# unprivileged `autofix-patch` job already computed. This job consumes that fix
# set as DATA and rebuilds the commit through the Git Data API: it never checks
# out, fetches, or executes the PR's code (no actions/checkout of the untrusted
# ref, no git fetch/checkout of its SHA, no uv/prek), so no untrusted code runs in
# this privileged, write-credentialled context. workflow_run runs in the base-repo
# context, so the App-token secrets are available; the App-token ref update
# re-triggers Validate (GITHUB_TOKEN pushes would not). Human PRs are never
# auto-mutated.
# A Validate failure is only a wake-up signal, NEVER a source of replacement bytes.
# All code, tools and configuration below come from this workflow's immutable base
# commit. PR blobs are data only. See docs/autofix-threat-model.md.
on:
# workflow_run is required by design (react to
# Validate FAILING and re-trigger CI after the App-token ref update); it runs in
# the base-repo context. The PR head is referenced only as a Git Data API
# parameter (base tree + parent) — never checked out, fetched, or executed — so
# no untrusted code runs at elevated privilege.
# Retained intentionally for base-owned code and the existing narrowly scoped App.
# No PR checkout, PR dependency installation, hook execution or artifact download.
workflow_run: # zizmor: ignore[dangerous-triggers]
workflows: [Validate]
types: [completed]
Expand All @@ -32,169 +22,58 @@ jobs:
auto-fix:
name: auto-fix
runs-on: ubuntu-latest
timeout-minutes: 15
if: >-
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'failure' &&
github.event.workflow_run.actor.login == 'dependabot[bot]'
github.event.workflow_run.actor.id == 49699333 &&
github.event.workflow_run.triggering_actor.id == 49699333
permissions:
contents: read
actions: read # download-artifact reads the triggering run's artifacts
actions: read # Authenticate the original run and its latest attempt.
pull-requests: read # Verify the current, open, same-repository bot PR.
steps:
- name: Harden the runner
uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1
with:
egress-policy: audit

- name: Guard against auto-fix loops
id: guard
# Abort if the PR head is already an auto-fix commit — prevents an
# infinite fix -> fail -> fix cycle when fixers cannot green the checks.
# Decided from the event payload; no PR checkout needed.
env:
HEAD_MSG: ${{ github.event.workflow_run.head_commit.message }}
run: |
if printf '%s' "$HEAD_MSG" | head -n1 | grep -q '^ci: 🤖 mechanical auto-fix'; then
echo "Head is already an auto-fix commit; skipping to avoid a loop." >&2
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Check out only the trusted workflow commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.workflow_sha }}
persist-credentials: false

- name: Download the fix set
id: download
if: steps.guard.outputs.skip == 'false'
# Missing artifact (no fixable changes, or a genuinely red PR) is the
# normal no-op case, not an error — fall through and let later steps skip.
continue-on-error: true
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
- name: Install the base-locked uv
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
name: autofix-fixes
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
path: fixes
version-file: uv.lock
enable-cache: false

- name: Read the trusted PR file tree
if: steps.guard.outputs.skip == 'false' && steps.download.outcome == 'success'
- name: Install only trusted base-locked tools
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
REPO: ${{ github.repository }}
run: gh api "repos/$REPO/git/trees/$HEAD_SHA?recursive=1" > trusted-tree.json
UV_MALWARE_CHECK: "1"
run: uv sync --locked --only-group dev --no-install-project --no-cache

- name: Validate mechanical fix scope
id: scope
if: steps.guard.outputs.skip == 'false' && steps.download.outcome == 'success'
# This code comes from the trusted base workflow. Never load a checker
# from the PR artifact or checkout before minting a write credential.
shell: python
run: |
# BEGIN AUTOFIX SCOPE GUARD
import json
import os
from pathlib import Path

fixes = json.loads(Path("fixes/fixes.json").read_text(encoding="utf-8"))
tree = json.loads(Path("trusted-tree.json").read_text(encoding="utf-8"))
if tree.get("truncated") is not False:
raise ValueError("Cannot verify a partial PR file tree")
tracked = {
item["path"]: item["mode"]
for item in tree["tree"]
if item["type"] == "blob"
}
protected_files = {
".github/advisory-exceptions.json",
".github/osv-scanner-empty.toml",
"pyproject.toml",
}
protected_prefixes = (
".github/workflows/",
".github/security-scanner/",
"ci/security-scanner/",
"scripts/",
)
if not isinstance(fixes, list):
raise ValueError("Mechanical fixes must be a JSON array")
for entry in fixes:
if not isinstance(entry, dict):
raise ValueError("Malformed mechanical fix entry")
path = entry.get("path")
if (
not isinstance(path, str)
or "\\" in path
or any(part in {"", ".", ".."} for part in path.split("/"))
or path not in tracked
or entry.get("mode") != tracked[path]
or entry.get("status") != "M"
or entry.get("mode") not in {"100644", "100755"}
or path in protected_files
or path.startswith(protected_prefixes)
):
raise ValueError(
f"Refusing auto-fix of protected, new, or noncanonical path: {path!r}. "
"A maintainer must review policy and automation changes."
)
with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as output:
output.write("safe=true\n")
# END AUTOFIX SCOPE GUARD
- name: Independently derive bounded fixes
id: fixes
env:
GH_READ_TOKEN: ${{ github.token }}
TRUSTED_SHA: ${{ github.workflow_sha }}
run: .venv/bin/python -I scripts/autofix.py prepare

- name: Mint a GitHub App token
id: app-token
# Only mint a write-capable token once we actually have fixes to push.
if: steps.scope.outputs.safe == 'true'
if: steps.fixes.outputs.has_fixes == 'true'
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
with:
client-id: ${{ vars.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
# Least privilege: only Contents write (push mechanical fixes).
permission-contents: write

- name: Rebuild the fix commit via the Git Data API and update the branch
if: steps.scope.outputs.safe == 'true'
- name: Revalidate and atomically commit the locally derived fixes
if: steps.fixes.outputs.has_fixes == 'true'
env:
# gh api authenticates as the App installation (Contents: write).
GH_TOKEN: ${{ steps.app-token.outputs.token }}
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
REPO: ${{ github.repository }}
run: |
f=fixes/fixes.json
if [ ! -s "$f" ] || [ "$(jq 'length' "$f")" = "0" ]; then
echo "No mechanical fixes to apply; leaving the PR unchanged." >&2
exit 0
fi
# Refuse to push a partial commit if the fix set includes deletions —
# rebuilding those safely is out of scope; leave the PR red for a human.
if [ "$(jq '[.[] | select(.status == "D")] | length' "$f")" != "0" ]; then
echo "Fix set includes deletions; refusing a partial auto-fix." >&2
exit 0
fi
# Rebuild the fix commit entirely through the Git Data API. The PR head
# is referenced only as an API parameter (base tree + parent) — its code
# is never checked out, fetched, or executed in this privileged job.
head_tree=$(gh api "repos/$REPO/git/commits/$HEAD_SHA" -q '.tree.sha')
tree='[]'
len=$(jq 'length' "$f")
i=0
while [ "$i" -lt "$len" ]; do
path=$(jq -r ".[$i].path" "$f")
mode=$(jq -r ".[$i].mode" "$f")
blob=$(jq -c ".[$i] | {content, encoding: \"base64\"}" "$f" \
| gh api "repos/$REPO/git/blobs" --input - -q '.sha')
tree=$(jq -c --arg p "$path" --arg m "$mode" --arg s "$blob" \
'. + [{path: $p, mode: $m, type: "blob", sha: $s}]' <<<"$tree")
i=$((i + 1))
done
new_tree=$(jq -nc --arg base "$head_tree" --argjson t "$tree" \
'{base_tree: $base, tree: $t}' \
| gh api "repos/$REPO/git/trees" --input - -q '.sha')
new_commit=$(jq -nc --arg t "$new_tree" --arg p "$HEAD_SHA" \
'{message: "ci: 🤖 mechanical auto-fix", tree: $t, parents: [$p],
author: {name: "optimizer-release-bot[bot]",
email: "optimizer-release-bot[bot]@users.noreply.github.com"}}' \
| gh api "repos/$REPO/git/commits" --input - -q '.sha')
# force=false => a branch that moved since Validate ran is a non-fast-
# forward the API rejects; we never clobber newer work with a stale fix.
jq -nc --arg s "$new_commit" '{sha: $s, force: false}' \
| gh api -X PATCH "repos/$REPO/git/refs/heads/$HEAD_BRANCH" --input -
echo "Pushed mechanical auto-fix $new_commit to $HEAD_BRANCH." >&2
GH_READ_TOKEN: ${{ github.token }}
GH_WRITE_TOKEN: ${{ steps.app-token.outputs.token }}
run: .venv/bin/python -I scripts/autofix.py apply
33 changes: 24 additions & 9 deletions .github/workflows/dependabot-auto-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,20 +42,35 @@ jobs:
with:
github-token: ${{ secrets.GITHUB_TOKEN }}

- name: Approve the current Dependabot revision
- name: Approve and enable auto-merge for the current revision
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_URL: ${{ github.event.pull_request.html_url }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
REPO: ${{ github.repository }}
run: |
jq -nc --arg commit_id "$HEAD_SHA" \
set -euo pipefail
is_current() {
gh api "repos/$REPO/pulls/$PR_NUMBER" | jq -e \
--arg head "$HEAD_SHA" --arg repo "$REPO" --arg base "$DEFAULT_BRANCH" '
.state == "open" and .draft == false and .merged == false and
.user.id == 49699333 and .head.repo.full_name == $repo and
.base.repo.full_name == $repo and .base.ref == $base and
.head.sha == $head' >/dev/null
}
if ! is_current; then
echo "The bot event is stale; leaving review and merge unchanged."
exit 0
fi
review_id=$(jq -nc --arg commit_id "$HEAD_SHA" \
'{event: "APPROVE", commit_id: $commit_id,
body: "Approved automatically for a trusted Dependabot revision."}' \
| gh api --method POST "repos/$REPO/pulls/$PR_NUMBER/reviews" --input -

- name: Enable auto-merge (squash)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_URL: ${{ github.event.pull_request.html_url }}
run: gh pr merge --auto --squash "$PR_URL"
| gh api --method POST "repos/$REPO/pulls/$PR_NUMBER/reviews" --input - -q '.id')
if ! is_current; then
jq -nc '{message: "Head changed during automated review; refusing stale approval."}' \
| gh api --method PUT "repos/$REPO/pulls/$PR_NUMBER/reviews/$review_id/dismissals" --input -
exit 0
fi
gh pr merge --auto --squash --match-head-commit "$HEAD_SHA" "$PR_URL"
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ on:
branches: [main]
# Operator recovery when GitHub accepts a main push but drops its workflow dispatch.
workflow_dispatch:
# Reconcile bot merges whose GITHUB_TOKEN push cannot trigger another workflow.
# This is eventual recovery, not an ordering dependency between workflows.
schedule:
- cron: "47 3 * * *"

# Read-only by default; the release job elevates to contents: write, the publish
# job to id-token/attestations for OIDC trusted publishing.
Expand Down
93 changes: 4 additions & 89 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ on:
branches: [main]
# Operator recovery when GitHub accepts a main push but drops its workflow dispatch.
workflow_dispatch:
# Reconcile bot merges whose GITHUB_TOKEN push cannot trigger another workflow.
# This is eventual recovery, not an ordering dependency between workflows.
schedule:
- cron: "17 3 * * *"

# Read-only by default; jobs opt into the least privilege they need.
permissions:
Expand Down Expand Up @@ -144,92 +148,3 @@ jobs:
file=sys.stderr,
)
raise SystemExit(1)

# Unprivileged half of the Dependabot auto-fix flow. Runs the SAME mechanical
# fixers as `gate`, but here in the untrusted pull_request context (no secrets,
# read-only token) and captures the result as a patch artifact. The privileged
# Auto-fix workflow later APPLIES this patch without ever executing PR code, so
# untrusted code never runs in a context that holds a write credential. Gated to
# Dependabot PRs; skipped (and never required) on human PRs. continue-on-error
# keeps a compute hiccup from flipping Validate's conclusion — the failure signal
# that drives Auto-fix stays owned by gate/deps-security/pr-title.
autofix-patch:
name: autofix-patch
runs-on: ubuntu-latest
if: >-
github.event_name == 'pull_request' &&
github.event.pull_request.user.login == 'dependabot[bot]'
continue-on-error: true
permissions:
contents: read
steps:
- name: Harden the runner
uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1
with:
egress-policy: audit

- name: Check out the PR head commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Pin to the head SHA (not the merge ref) so the patch applies cleanly
# onto the same commit the privileged job checks out.
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false

- name: Install uv
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
version-file: uv.lock

- name: Compute mechanical fixes as self-contained data
id: patch
# Mirror the auto-fixers exactly (lockfile refresh + every autofix hook).
# prek's exit code is ignored: non-fixable failures (real pytest/pyright
# errors) leave the tree unchanged, so the fix set is empty and nothing is
# committed downstream — the PR stays red for a human.
#
# Emit each changed file as {path, mode, status, base64 content} rather
# than a diff, so the privileged Auto-fix job can rebuild the commit via
# the Git Data API WITHOUT ever checking out, fetching, or executing this
# PR's code. (Assumes paths without embedded tabs/newlines — true for this
# repo's tracked files.)
env:
UV_MALWARE_CHECK: "1"
run: |
uv lock
uv sync
uv run prek run --all-files || true
git add -A
git diff --cached --no-renames --raw > raw.txt
entries='[]'
while IFS="$(printf '\t')" read -r meta path; do
[ -n "$path" ] || continue
status=${meta##* } # last token of the metadata field
# shellcheck disable=SC2086 # intentional split: meta is git-controlled
set -- $meta
mode=$2 # new file mode (000000 for deletions)
if [ "$status" = "D" ]; then
entry=$(jq -nc --arg p "$path" '{path:$p, status:"D"}')
else
entry=$(jq -nc --arg p "$path" --arg m "$mode" --arg s "$status" \
--arg c "$(base64 -w0 "$path")" \
'{path:$p, mode:$m, status:$s, content:$c}')
fi
entries=$(jq -c --argjson e "$entry" '. + [$e]' <<<"$entries")
done < raw.txt
printf '%s\n' "$entries" > fixes.json
if [ "$entries" != "[]" ]; then
echo "has_patch=true" >> "$GITHUB_OUTPUT"
else
echo "has_patch=false" >> "$GITHUB_OUTPUT"
echo "No mechanical fixes to apply." >&2
fi

- name: Upload the fix set
if: steps.patch.outputs.has_patch == 'true'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: autofix-fixes
path: fixes.json
retention-days: 1
if-no-files-found: error
Loading
Loading