diff --git a/.github/workflows/pr-autofix-apply.yml b/.github/workflows/pr-autofix-apply.yml
new file mode 100644
index 0000000000..b2ec8495f9
--- /dev/null
+++ b/.github/workflows/pr-autofix-apply.yml
@@ -0,0 +1,595 @@
+name: PR Autofix (apply)
+
+# CHATOPS HALF of the autofix pipeline.
+#
+# Triggered when a contributor comments `/autofix` on a PR. Validates
+# permission, locates the most recent successful `pr-autofix.yml`
+# artifact for the PR's current head SHA, applies the patch to the PR
+# head, and pushes a commit back to the PR branch.
+#
+# This workflow runs from the default branch's copy of the file
+# regardless of where the comment originates -- that's the trust
+# anchor. Comment body and author login are untrusted; both flow
+# through env vars and pattern-matched, never interpolated into shell.
+#
+# Fork PR support: `git push` with the GITHUB_TOKEN succeeds against
+# fork branches only when the contributor enabled "Allow edits by
+# maintainers" on the PR (the default). When they disabled it, we
+# fail loud with a ๐ reaction and an explanation comment.
+
+on:
+ issue_comment:
+ types: [created]
+
+concurrency:
+ # Per-PR scope. issue_comment events expose `github.event.issue.number`
+ # for both PR and Issue comments; the `pull_request != null` guard on
+ # the job ensures we only run on PRs, so this number is the PR number.
+ # cancel-in-progress: false โ a second `/autofix` should wait for the
+ # first to finish (idempotency check on the second invocation handles
+ # the no-op case).
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }}
+ cancel-in-progress: false
+
+permissions: {}
+
+jobs:
+ apply:
+ name: apply-autofix
+ # Pre-filter at the workflow level so non-PR comments and unrelated
+ # comments don't even spawn a runner. The job-level body re-check
+ # below (Step 1) is the strict gate.
+ if: >-
+ github.event.issue.pull_request != null
+ && startsWith(github.event.comment.body, '/autofix')
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ # React on the triggering comment + post reply comments.
+ pull-requests: write
+ # Push the apply commit to the PR head branch.
+ contents: write
+ # Required by actions/download-artifact to fetch artifacts produced
+ # by a different workflow run.
+ actions: read
+ steps:
+ - name: Validate comment body precisely
+ id: body
+ env:
+ BODY: ${{ github.event.comment.body }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ # Whole-line, case-sensitive match: `^/autofix\s*$`. The
+ # workflow-level startsWith guard is coarse โ `please don't
+ # /autofix this code` would pass that filter but fail this one.
+ # We exit silently (no reaction) on body mismatch so quoted
+ # text in unrelated discussions doesn't get a visible response.
+ if [[ ! "${BODY}" =~ ^/autofix[[:space:]]*$ ]]; then
+ echo "Body did not match strict /autofix regex โ exiting silently."
+ echo "match=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ echo "match=true" >> "$GITHUB_OUTPUT"
+
+ - name: Validate commenter permission
+ id: perm
+ if: steps.body.outputs.match == 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ COMMENTER: ${{ github.event.comment.user.login }}
+ PR_AUTHOR: ${{ github.event.issue.user.login }}
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ # Retry wrapper for transient 5xx / 429 / network blips.
+ # Mirrors the helper in pr-autofix-publish.yml. Used on
+ # idempotent GETs only; reactions/comment-POSTs are NOT
+ # wrapped (retrying a POST would dupe the resource).
+ gh_retry() {
+ local n=0 max=3
+ while true; do
+ if gh "$@"; then return 0; fi
+ n=$((n+1))
+ if [ "$n" -ge "$max" ]; then return 1; fi
+ sleep $((n * 2))
+ done
+ }
+
+ # Allowlist the commenter login before it flows into a URL.
+ # GitHub usernames: alphanumeric + dashes, max 39 chars.
+ if ! [[ "${COMMENTER}" =~ ^[A-Za-z0-9-]{1,39}$ ]]; then
+ echo "::error::Invalid commenter login format: $(printf '%q' "${COMMENTER}")"
+ echo "allowed=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ # Self-comparison: PR author can always /autofix their own PR.
+ if [ "${COMMENTER}" = "${PR_AUTHOR}" ]; then
+ echo "Commenter is PR author โ granting access."
+ echo "allowed=true" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ # Repo permission lookup. admin/write/maintain are sufficient.
+ # Distinguish API failure (5xx, 429, network) from genuine
+ # permission denial (404 = not a collaborator). Conflating them
+ # would silently refuse a legitimate maintainer with a public
+ # ๐ every time GitHub blips. gh_retry handles transient blips;
+ # the stderr-grep distinguishes 404 from persistent failure.
+ perm_stderr=$(mktemp)
+ if permission=$(gh_retry api "repos/${GH_REPO}/collaborators/${COMMENTER}/permission" \
+ --jq '.permission' 2>"$perm_stderr"); then
+ echo "Commenter permission: ${permission}"
+ case "${permission}" in
+ admin|write|maintain)
+ echo "allowed=true" >> "$GITHUB_OUTPUT"
+ ;;
+ *)
+ echo "allowed=false" >> "$GITHUB_OUTPUT"
+ ;;
+ esac
+ else
+ err=$(cat "$perm_stderr")
+ echo "Permission lookup stderr: ${err}" >&2
+ # 404 (not a collaborator) is a genuine deny.
+ # Anything else is a transient API/network failure.
+ if grep -qE "HTTP 404|Not Found" "$perm_stderr"; then
+ echo "allowed=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "::error::Permission lookup failed transiently โ refusing to act."
+ echo "allowed=api-failed" >> "$GITHUB_OUTPUT"
+ fi
+ fi
+
+ - name: React ๐ on transient permission-API failure
+ if: steps.body.outputs.match == 'true' && steps.perm.outputs.allowed == 'api-failed'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ COMMENT_ID: ${{ github.event.comment.id }}
+ PR: ${{ github.event.issue.number }}
+ RUN_ID: ${{ github.run_id }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="confused" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="โ ๏ธ Couldn't verify your repo permission (transient GitHub API failure). Please comment \`/autofix\` again. ([apply run](https://github.com/${GH_REPO}/actions/runs/${RUN_ID}))" \
+ >/dev/null
+ exit 1
+
+ - name: React ๐ on permission denial
+ if: steps.body.outputs.match == 'true' && steps.perm.outputs.allowed == 'false'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ COMMENT_ID: ${{ github.event.comment.id }}
+ PR: ${{ github.event.issue.number }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="-1" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="๐ซ \`/autofix\` is restricted to users with write access or the PR author. Comment ignored." \
+ >/dev/null
+ # Hard exit so the rest of the job is skipped.
+ exit 1
+
+ - name: React ๐ to acknowledge
+ if: steps.perm.outputs.allowed == 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ COMMENT_ID: ${{ github.event.comment.id }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="eyes" >/dev/null
+
+ - name: Resolve PR head and locate autofix run
+ id: locate
+ if: steps.perm.outputs.allowed == 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ PR: ${{ github.event.issue.number }}
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ # Same retry wrapper used in the permission step, repeated
+ # because each YAML `run:` block is a fresh bash session.
+ gh_retry() {
+ local n=0 max=3
+ while true; do
+ if gh "$@"; then return 0; fi
+ n=$((n+1))
+ if [ "$n" -ge "$max" ]; then return 1; fi
+ sleep $((n * 2))
+ done
+ }
+
+ # Fetch PR metadata. All fields here are server-controlled API
+ # output, but we still allowlist before exporting so anything
+ # weird short-circuits before $GITHUB_OUTPUT. Wrapped in
+ # gh_retry so transient blips don't surface as "no autofix run
+ # found" with a wrong remediation.
+ if ! pr_json=$(gh_retry api "repos/${GH_REPO}/pulls/${PR}"); then
+ echo "::error::PR metadata fetch failed after retries."
+ echo "found_status=api-failed" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ head_sha=$(jq -r '.head.sha' <<< "${pr_json}")
+ head_ref=$(jq -r '.head.ref' <<< "${pr_json}")
+ head_repo=$(jq -r '.head.repo.full_name' <<< "${pr_json}")
+
+ [[ "${head_sha}" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::Bad head_sha"; exit 1; }
+ [[ "${head_ref}" =~ ^[A-Za-z0-9._/-]+$ ]] || { echo "::error::Bad head_ref"; exit 1; }
+ [[ "${head_repo}" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || { echo "::error::Bad head_repo"; exit 1; }
+
+ # Find the latest successful pr-autofix.yml run for this head SHA.
+ if ! runs_json=$(gh_retry api "repos/${GH_REPO}/actions/workflows/pr-autofix.yml/runs?head_sha=${head_sha}&per_page=10"); then
+ echo "::error::Workflow run lookup failed after retries."
+ echo "found_status=api-failed" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ run_id=$(jq -r '[.workflow_runs[] | select(.conclusion == "success")] | .[0].id // empty' <<< "${runs_json}")
+
+ if [ -n "${run_id}" ] && [[ "${run_id}" =~ ^[0-9]+$ ]]; then
+ echo "found_status=success" >> "$GITHUB_OUTPUT"
+ {
+ echo "found=true"
+ echo "head_sha=${head_sha}"
+ echo "head_ref=${head_ref}"
+ echo "head_repo=${head_repo}"
+ echo "run_id=${run_id}"
+ } >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ # No successful run. Distinguish "still running" (producer in
+ # flight after a recent push) from "never ran / all failed".
+ # in_progress / queued / pending / waiting cover the GitHub
+ # workflow-run lifecycle states that precede success/failure.
+ in_progress=$(jq -r '[.workflow_runs[] | select(.status == "in_progress" or .status == "queued" or .status == "pending" or .status == "waiting")] | length' <<< "${runs_json}")
+ if [ "${in_progress:-0}" -gt 0 ]; then
+ echo "::warning::pr-autofix run is still in progress for head ${head_sha}."
+ echo "found_status=in-progress" >> "$GITHUB_OUTPUT"
+ else
+ echo "::warning::No successful pr-autofix run found for head ${head_sha}."
+ echo "found_status=not-found" >> "$GITHUB_OUTPUT"
+ fi
+ # Existing `found` boolean is preserved so downstream gates
+ # (`steps.locate.outputs.found == 'true'`) still work.
+ echo "found=false" >> "$GITHUB_OUTPUT"
+
+ - name: Reply when locate did not yield a usable run
+ if: steps.perm.outputs.allowed == 'true' && steps.locate.outputs.found != 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ COMMENT_ID: ${{ github.event.comment.id }}
+ PR: ${{ github.event.issue.number }}
+ FOUND_STATUS: ${{ steps.locate.outputs.found_status }}
+ RUN_ID: ${{ github.run_id }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ run_url="https://github.com/${GH_REPO}/actions/runs/${RUN_ID}"
+ case "${FOUND_STATUS}" in
+ in-progress)
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="confused" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="โณ A pr-autofix run is still in progress for this PR's current head SHA. Wait for it to finish, then comment \`/autofix\` again. ([apply run](${run_url}))" \
+ >/dev/null
+ ;;
+ api-failed)
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="confused" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="โ ๏ธ Couldn't reach the GitHub API to look up the autofix run (transient failure after retries). Please comment \`/autofix\` again. ([apply run](${run_url}))" \
+ >/dev/null
+ ;;
+ *)
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="-1" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="๐ค No successful autofix run found for this PR's current head SHA. Push a new commit to trigger one, then comment \`/autofix\` again." \
+ >/dev/null
+ ;;
+ esac
+ exit 1
+
+ # Pinned to v8.0.1. Same SHA as pr-autofix-publish.yml.
+ # `continue-on-error: true` lets the workflow proceed when the
+ # artifact is expired or pruned (1-day retention). The apply
+ # step distinguishes "patch file missing entirely" (artifact-
+ # expired) from "patch file zero bytes" (genuinely empty patch).
+ - name: Download autofix artifact
+ if: steps.locate.outputs.found == 'true'
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ continue-on-error: true
+ with:
+ name: autofix
+ run-id: ${{ steps.locate.outputs.run_id }}
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ path: autofix-in
+
+ # Pinned to v5.0.4. Verify SHA via:
+ # gh api repos/actions/checkout/git/refs/tags/v5.0.4
+ #
+ # `persist-credentials: false` disables the default behavior where
+ # actions/checkout writes the GITHUB_TOKEN into `.git/config` as an
+ # extraheader. That default is convenient (subsequent git commands
+ # auth automatically) but it means the token is sitting on disk in
+ # the checkout directory โ an `actions/upload-artifact` step on
+ # this directory would leak the token. We don't upload, but
+ # zizmor's `credential-persistence` lint flags it defensively.
+ # Push auth is provided inline at push time via the URL.
+ - name: Checkout PR head
+ if: steps.locate.outputs.found == 'true'
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.4
+ with:
+ repository: ${{ steps.locate.outputs.head_repo }}
+ ref: ${{ steps.locate.outputs.head_sha }}
+ token: ${{ secrets.GITHUB_TOKEN }}
+ persist-credentials: false
+ # Fetch full history so the push doesn't hit shallow-clone errors.
+ fetch-depth: 0
+ path: pr-checkout
+
+ - name: Apply patch and push
+ id: apply
+ if: steps.locate.outputs.found == 'true'
+ env:
+ HEAD_REF: ${{ steps.locate.outputs.head_ref }}
+ HEAD_REPO: ${{ steps.locate.outputs.head_repo }}
+ # The SHA we resolved earlier in `locate` โ this is what the
+ # remote ref MUST still equal at push time. If the contributor
+ # force-pushed between resolve and now, the lease fails and
+ # we surface that distinctly from a fork-without-maintainer
+ # -edit push failure.
+ HEAD_SHA: ${{ steps.locate.outputs.head_sha }}
+ # Auth for the push only โ never persisted to disk. Provided
+ # via env to avoid interpolating into the shell command line.
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ shell: bash
+ working-directory: pr-checkout
+ run: |
+ set -euo pipefail
+ patch="../autofix-in/autofix.patch"
+
+ # Distinguish artifact-expired (file missing entirely, because
+ # actions/download-artifact ran with continue-on-error and the
+ # 1-day retention had elapsed) from genuinely empty patch
+ # (file present, zero bytes, formatter found nothing).
+ if [ ! -e "$patch" ]; then
+ echo "::warning::Patch file does not exist โ autofix artifact likely expired."
+ echo "result=artifact-expired" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ if [ ! -s "$patch" ]; then
+ echo "::warning::Empty patch โ nothing to apply."
+ echo "result=empty-patch" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ # Sensitive-paths guard: refuse to apply patches that touch
+ # `.github/` โ workflow files, action definitions, CODEOWNERS,
+ # dependabot config, etc. A malicious PR could ship a custom
+ # prettier/ESLint config that reformats workflow YAML; the
+ # producer would then capture those edits in autofix.patch,
+ # and a maintainer running `/autofix` would push them under
+ # `contents: write`. The default GITHUB_TOKEN lacks `workflows`
+ # scope so the platform would reject workflow-file pushes
+ # anyway, but that surfaces as a generic `push-failed` and
+ # misleads users into enabling maintainer-edit. Reject early
+ # with a specific reason. CODEOWNERS and dependabot.yml live
+ # under .github/ but outside .github/workflows/ โ the broader
+ # match is intentional (they all govern trust boundaries).
+ if grep -qE '^(diff --git|---|\+\+\+) [ab]?/?\.github/' "$patch"; then
+ echo "::warning::Patch touches .github/ โ refusing to apply (sensitive paths)."
+ echo "result=sensitive-paths" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ # Re-entrancy guard: if HEAD itself is an autofix bot commit,
+ # refuse to apply again. Without this, lint/formatter config
+ # drift between runs could pump arbitrary apply commits into
+ # the same PR if an automated agent watches the sticky and
+ # re-fires `/autofix` on each new "fixes-available" surface.
+ # The contributor can still get out by force-pushing a
+ # human-authored commit to revert the autofix and re-trigger.
+ head_author=$(git log -1 --format='%ae' HEAD)
+ head_subject=$(git log -1 --format='%s' HEAD)
+ if [ "${head_author}" = "41898282+github-actions[bot]@users.noreply.github.com" ] \
+ && [[ "${head_subject}" =~ ^chore\(autofix\) ]]; then
+ echo "::warning::HEAD is an autofix bot commit โ refusing to re-apply (loop guard)."
+ echo "result=loop-prevented" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ # Idempotency probe: does the forward apply work?
+ if git apply --check "$patch" 2>/dev/null; then
+ echo "Patch applies cleanly โ proceeding."
+ elif git apply --check --reverse "$patch" 2>/dev/null; then
+ # Reverse-check passes => the patch is already applied to
+ # the current tree. Treat as success no-op.
+ echo "Patch is already applied (reverse-check passed) โ no-op."
+ echo "result=already-applied" >> "$GITHUB_OUTPUT"
+ exit 0
+ else
+ echo "::error::Patch does not apply (stale or conflicting)."
+ echo "result=stale" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ # Wrap the apply/commit phase so any non-zero exit sets a
+ # meaningful `result=` instead of leaving it unset (which would
+ # send the user to the `*` "unexpected state" arm with a
+ # non-actionable confused-emoji reply).
+ if ! {
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com" &&
+ git config user.name "github-actions[bot]" &&
+ git apply "$patch" &&
+ git add -A &&
+ git commit -m "chore(autofix): apply prettier + eslint fixes via /autofix command"
+ }; then
+ echo "::error::git apply / config / commit failed after idempotency probe passed."
+ echo "result=apply-failed" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ # Push to the PR head branch with a lease against the resolved
+ # SHA. The lease ensures the remote ref still points at HEAD_SHA
+ # when the push lands โ if the contributor force-pushed in the
+ # window between resolve and now, the lease fails and we return
+ # `lease-failed` (NOT `push-failed`, which would mislead users
+ # into enabling maintainer-edit). For fork PRs, the push still
+ # requires "Allow edits by maintainers" to be enabled.
+ #
+ # Auth is supplied inline via `-c http..extraheader` (NOT
+ # via a `https://x-access-token:TOKEN@โฆ` URL โ those leak into
+ # process listings and `git remote -v` output). The header is
+ # set per-invocation; it never lands in `.git/config` on disk.
+ # The token is base64-encoded for the Basic auth header per
+ # GitHub's documented pattern for this scope.
+ push_url="https://github.com/${HEAD_REPO}.git"
+ auth_header="Authorization: Basic $(printf 'x-access-token:%s' "${GITHUB_TOKEN}" | base64 -w0)"
+ # GitHub's secret-masker only masks the raw token, not its
+ # base64-encoded form. Mask the encoded value so any subsequent
+ # log line (set -x, GIT_TRACE, error spew) gets ***-redacted.
+ echo "::add-mask::${auth_header}"
+ push_stderr=$(mktemp)
+ if git -c http.extraheader="${auth_header}" \
+ push --force-with-lease="refs/heads/${HEAD_REF}:${HEAD_SHA}" \
+ "${push_url}" "HEAD:${HEAD_REF}" 2>"$push_stderr"; then
+ echo "result=applied" >> "$GITHUB_OUTPUT"
+ else
+ cat "$push_stderr" >&2
+ # `--force-with-lease` reports "stale info" when the remote
+ # ref has moved past the expected SHA. Other lease-failure
+ # phrases git emits include "remote rejected" (server-side
+ # reject), "non-fast-forward", and the literal flag name. Match
+ # any of those to distinguish from auth/network/maintainer-
+ # edit failures.
+ if grep -qE "stale info|force-with-lease|rejected.*non-fast-forward|remote rejected|! \[rejected\]" "$push_stderr"; then
+ echo "::error::git push lease failed โ branch moved during apply."
+ echo "result=lease-failed" >> "$GITHUB_OUTPUT"
+ else
+ echo "::error::git push failed โ likely fork without maintainer-edit enabled."
+ echo "result=push-failed" >> "$GITHUB_OUTPUT"
+ fi
+ exit 0
+ fi
+
+ - name: React and reply on outcome
+ if: always() && steps.locate.outputs.found == 'true' && steps.apply.outcome != 'skipped'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ COMMENT_ID: ${{ github.event.comment.id }}
+ PR: ${{ github.event.issue.number }}
+ RESULT: ${{ steps.apply.outputs.result }}
+ RUN_ID: ${{ github.run_id }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ run_url="https://github.com/${GH_REPO}/actions/runs/${RUN_ID}"
+
+ case "${RESULT}" in
+ applied)
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="+1" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="โ
Applied autofix and pushed a commit. ([apply run](${run_url}))" \
+ >/dev/null
+ ;;
+ already-applied)
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="+1" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="โ
Autofix is already applied โ no changes needed." \
+ >/dev/null
+ ;;
+ empty-patch)
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="+1" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="โ
No autofix to apply โ formatter found nothing." \
+ >/dev/null
+ ;;
+ artifact-expired)
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="confused" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="โณ The autofix artifact for this PR's head SHA has expired (1-day retention). Push a new commit to regenerate it, then comment \`/autofix\` again. ([apply run](${run_url}))" \
+ >/dev/null
+ exit 1
+ ;;
+ loop-prevented)
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="confused" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="๐ Refusing to re-apply autofix on top of an existing autofix commit. If formatter rules drifted and you genuinely need another pass, push a human-authored commit (or revert the existing autofix commit) before commenting \`/autofix\` again. ([apply run](${run_url}))" \
+ >/dev/null
+ exit 1
+ ;;
+ sensitive-paths)
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="-1" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="๐ Refusing to apply: the autofix patch touches files under \`.github/\` (workflow / CODEOWNERS / dependabot config). Apply formatter changes to those files manually in a regular commit so they get human review. ([apply run](${run_url}))" \
+ >/dev/null
+ exit 1
+ ;;
+ stale)
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="-1" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="โ ๏ธ The autofix patch is stale or conflicts with the current head โ push a new commit to regenerate, then comment \`/autofix\` again. ([apply run](${run_url}))" \
+ >/dev/null
+ exit 1
+ ;;
+ apply-failed)
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="-1" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="โ ๏ธ Autofix applied cleanly in the dry run, but \`git apply\` / \`git commit\` failed when actually landing the patch. This usually means a race with concurrent edits or a corrupt patch. See logs: ${run_url}" \
+ >/dev/null
+ exit 1
+ ;;
+ push-failed)
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="-1" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="โ ๏ธ Couldn't push the autofix commit. If this is a fork PR, please tick **Allow edits by maintainers** in the PR sidebar, then comment \`/autofix\` again. ([apply run](${run_url}))" \
+ >/dev/null
+ exit 1
+ ;;
+ lease-failed)
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="-1" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="โ ๏ธ The PR head moved while autofix was applying โ a new commit landed in the window between resolve and push. Comment \`/autofix\` again to retry against the latest head. ([apply run](${run_url}))" \
+ >/dev/null
+ exit 1
+ ;;
+ *)
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="confused" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="โ Autofix run finished in an unexpected state (\`${RESULT:-unknown}\`). See logs: ${run_url}" \
+ >/dev/null
+ exit 1
+ ;;
+ esac
diff --git a/.github/workflows/pr-autofix-publish.yml b/.github/workflows/pr-autofix-publish.yml
index a22f2cc7a9..08ad1d60f1 100644
--- a/.github/workflows/pr-autofix-publish.yml
+++ b/.github/workflows/pr-autofix-publish.yml
@@ -3,20 +3,19 @@ name: PR Autofix (publish)
# TRUSTED HALF of the autofix pipeline.
#
# Triggered by `pr-autofix.yml` completing on a PR (including fork PRs).
-# Downloads the diff artifact produced by the untrusted job and posts
-# inline review-comment suggestions to the PR using `reviewdog`. This
-# job NEVER checks out fork code โ it only consumes the diff (data) and
-# calls the GitHub API. That isolation is what makes it safe to run
-# under `pull-requests: write` on fork-triggered events.
+# Downloads the diff artifact produced by the untrusted job, verifies
+# its claimed PR identity against the workflow_run authority, then
+# posts (or edits) a single sticky summary comment plus a
+# `gitnexus/autofix` Check Run. This job NEVER checks out fork code โ
+# it only consumes the diff (data) and calls the GitHub API. That
+# isolation is what makes it safe to run under `pull-requests: write`
+# on fork-triggered events.
#
-# Also posts (or edits) a single sticky summary comment so contributors
-# and AI agents have one stable, machine-readable signal that says
-# whether autofix had anything to suggest. Look for the heading
-# "## :sparkles: PR Autofix" in the PR's top-level comments.
-#
-# Reviewdog reporter: `github-pr-review` reads $REVIEWDOG_GITHUB_API_TOKEN
-# and posts via the GraphQL/REST PR-review API. It does not need a
-# checkout because the diff itself encodes file paths + line numbers.
+# The sticky comment is the contributor signal: heading
+# "## :sparkles: PR Autofix" in the PR's top-level comments, with a
+# fenced `gitnexus-autofix` JSON block carrying machine-readable state
+# for AI agents. Contributors apply the patch by commenting `/autofix`
+# on the PR โ handled by the separate `pr-autofix-apply.yml` workflow.
on:
workflow_run:
@@ -49,9 +48,9 @@ jobs:
# by a different workflow run.
actions: read
# Required to create the `gitnexus/autofix` Check Run that reports
- # the outcome (clean / suggestions-posted / skipped-too-large) to
- # the PR's Checks tab. Branch protection or agents can grep the
- # conclusion + output title without parsing the sticky comment.
+ # the outcome (clean / fixes-available) to the PR's Checks tab.
+ # Branch protection or agents can grep the conclusion + output
+ # title without parsing the sticky comment.
checks: write
steps:
# Pinned to v8.0.1. Verify SHA via:
@@ -114,71 +113,78 @@ jobs:
echo "changed_lines=${CHANGED}"
} >> "$GITHUB_OUTPUT"
- # Pinned to v1.5.0. Verify SHA via:
- # gh api repos/reviewdog/action-setup/git/refs/tags/v1.5.0
- # (annotated tag โ resolve via .../git/tags/ --jq .object)
- - name: Install reviewdog
- if: steps.meta.outputs.changed_lines != '0'
- uses: reviewdog/action-setup@d8a7baabd7f3e8544ee4dbde3ee41d0011c3a93f # v1.5.0
- with:
- # Pin the binary, not just the action SHA โ a bad reviewdog
- # release otherwise breaks every PR with no rollback. Bump
- # this knob deliberately when validating a new release.
- reviewdog_version: v0.21.0
-
- - name: Post inline suggestions
- id: suggest
+ # Cross-verify the artifact's claimed identity against the
+ # GitHub-controlled workflow_run event. The previous step's
+ # allowlist only proves the fields are well-formed โ not that
+ # they refer to the PR/SHA that actually triggered this run.
+ # A fork-controlled `npm run lint:fix` could plausibly mutate
+ # metadata.json to reference another PR or SHA, redirecting our
+ # write-scoped sticky/check-run onto an attacker-chosen target.
+ #
+ # Authority sources are all server-controlled GitHub event fields:
+ # - workflow_run.head_sha
+ # - workflow_run.head_repository.full_name
+ # - workflow_run.pull_requests[].number (within-repo PRs only;
+ # empty array on fork PRs โ fall back to commits/{sha}/pulls)
+ #
+ # Mismatch => fail loud BEFORE any sticky/check-run side effect.
+ - name: Verify metadata against workflow_run authority
+ id: verify
if: steps.meta.outputs.changed_lines != '0'
env:
- REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- CI_REPO_OWNER: ${{ github.repository_owner }}
- CI_REPO_NAME: ${{ github.event.repository.name }}
- CI_PULL_REQUEST: ${{ steps.meta.outputs.pr_number }}
- CI_COMMIT: ${{ steps.meta.outputs.head_sha }}
- # Pull `changed_lines` through env so bash gets a real
- # variable (and shellcheck SC2170 doesn't fire on `-gt` against
- # a `${{ }}`-interpolated literal).
- CHANGED_LINES: ${{ steps.meta.outputs.changed_lines }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ META_PR_NUMBER: ${{ steps.meta.outputs.pr_number }}
+ META_HEAD_SHA: ${{ steps.meta.outputs.head_sha }}
+ META_HEAD_REPO: ${{ steps.meta.outputs.head_repo }}
+ WF_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
+ WF_HEAD_REPO: ${{ github.event.workflow_run.head_repository.full_name }}
+ WF_PR_NUMBERS: ${{ toJSON(github.event.workflow_run.pull_requests.*.number) }}
shell: bash
run: |
set -euo pipefail
- patch=autofix-in/autofix.patch
- if [ ! -s "$patch" ]; then
- echo "Empty patch โ nothing to suggest."
- echo "posted=false" >> "$GITHUB_OUTPUT"
- exit 0
+
+ # 1) head_sha must match exactly. workflow_run.head_sha is the
+ # commit GitHub actually ran the producer against โ definitive.
+ if [ "${META_HEAD_SHA}" != "${WF_HEAD_SHA}" ]; then
+ echo "::error::Artifact head_sha (${META_HEAD_SHA}) does not match workflow_run.head_sha (${WF_HEAD_SHA}) โ refusing to publish."
+ exit 1
fi
- # GitHub's review-comment API returns 406 on diffs above ~3k
- # changed lines. Bail out gracefully and let the summary
- # comment carry the signal instead.
- if [ "$CHANGED_LINES" -gt 3000 ]; then
- echo "Diff too large ($CHANGED_LINES lines) โ skipping inline suggestions."
- echo "posted=skipped-too-large" >> "$GITHUB_OUTPUT"
- exit 0
+ # 2) head_repo must match exactly. Same authority anchor.
+ if [ "${META_HEAD_REPO}" != "${WF_HEAD_REPO}" ]; then
+ echo "::error::Artifact head_repo (${META_HEAD_REPO}) does not match workflow_run.head_repository (${WF_HEAD_REPO}) โ refusing to publish."
+ exit 1
fi
- # `-f.diff.strip=1` matches `git diff` output (a/foo b/foo).
- # `-filter-mode=added` only suggests on lines the PR added,
- # which avoids re-suggesting on already-resolved threads when
- # the contributor re-adds the autoformat label.
- reviewdog \
- -f=diff -f.diff.strip=1 \
- -name="prettier+eslint" \
- -reporter=github-pr-review \
- -filter-mode=added \
- -level=warning \
- -fail-on-error=false < "$patch"
+ # 3) pr_number must reference an open PR with this head SHA.
+ # Within-repo PRs: workflow_run.pull_requests[] is populated.
+ # Fork PRs: that array is empty by GitHub design โ fall back
+ # to the REST commit-to-PRs lookup. Fail closed if the lookup
+ # finds no matching open PR (avoids attacker-forged PR ids).
+ allowed_numbers=$(jq -c '.' <<< "${WF_PR_NUMBERS}")
+ if [ "${allowed_numbers}" = "[]" ]; then
+ echo "workflow_run.pull_requests is empty (fork PR) โ falling back to commits/{sha}/pulls."
+ allowed_numbers=$(gh api "repos/${GH_REPO}/commits/${WF_HEAD_SHA}/pulls" \
+ --jq '[.[] | select(.state == "open") | .number]' 2>/dev/null || echo "[]")
+ if [ "${allowed_numbers}" = "[]" ]; then
+ echo "::error::No open PR found for head ${WF_HEAD_SHA} via commits/{sha}/pulls โ refusing to publish."
+ exit 1
+ fi
+ fi
- echo "posted=true" >> "$GITHUB_OUTPUT"
+ if ! jq -e --argjson n "${META_PR_NUMBER}" 'index($n) != null' <<< "${allowed_numbers}" >/dev/null; then
+ echo "::error::Artifact pr_number (${META_PR_NUMBER}) is not in the authoritative PR list (${allowed_numbers}) โ refusing to publish."
+ exit 1
+ fi
+
+ echo "Verified: metadata identity matches workflow_run authority (PR=${META_PR_NUMBER}, head_sha=${META_HEAD_SHA}, head_repo=${META_HEAD_REPO})."
- name: Upsert sticky summary comment
# Only post when ci-quality found something fixable (= the
# autofix patch is non-empty). When prettier/eslint are clean
# the patch is zero bytes and the sticky comment is pure noise,
- # so we skip it. When the diff was too large for inline
- # suggestions, the sticky is the only signal the contributor
- # gets, so we still post in that case.
+ # so we skip it.
if: >-
always()
&& steps.meta.outputs.pr_number != ''
@@ -189,8 +195,6 @@ jobs:
PR: ${{ steps.meta.outputs.pr_number }}
CHANGED: ${{ steps.meta.outputs.changed_lines }}
HEAD_SHA: ${{ steps.meta.outputs.head_sha }}
- SCHEMA: ${{ steps.meta.outputs.schema }}
- POSTED: ${{ steps.suggest.outputs.posted }}
RUN_ID: ${{ github.run_id }}
shell: bash
run: |
@@ -200,25 +204,29 @@ jobs:
marker=""
heading="## :sparkles: PR Autofix"
- if [ "${POSTED}" = "skipped-too-large" ]; then
- ui_state="skipped-too-large"
- prose="Diff is **${CHANGED}** lines โ too large for inline suggestions (GitHub caps the review-comment API at ~3000). Run locally: \`npm run lint:fix && npm run format\`."
- else
- ui_state="suggestions-posted"
- prose="Posted formatting / unused-import suggestions inline. Click **Apply suggestion** on each, or run locally: \`npm run lint:fix && npm run format\`."
- fi
+ # Single state. The /autofix slash command works for any diff
+ # size โ there's no 3K cap and no no-overlap dead-end because
+ # the apply workflow uses `git apply` + push, not the GitHub
+ # review-comment API.
+ ui_state="fixes-available"
+ prose="Found fixable formatting / unused-import issues across **${CHANGED}** changed lines. **Comment \`/autofix\` on this PR to apply them**, or run \`npm run lint:fix && npm run format\` locally."
# Machine-readable JSON block โ agents parse this instead of
# regexing English. Fenced code-block info string is
# `gitnexus-autofix` so agents can locate it without ambiguity.
+ # Schema bumped from v1 -> v2: adds `apply_command`. The v1
+ # field set is preserved as a superset, but the `state` enum
+ # is redefined (v1: suggestions-posted | skipped-too-large |
+ # diff-no-overlap; v2: fixes-available). v1 readers checking
+ # `schema == 'gitnexus.pr-autofix/v1'` see an unfamiliar version
+ # and fall back to prose, which is the intended migration path.
json=$(jq -n -c \
- --arg schema "${SCHEMA}" \
--arg state "${ui_state}" \
--argjson pr_number "${PR}" \
--argjson changed_lines "${CHANGED}" \
--arg head_sha "${HEAD_SHA}" \
--arg run_id "${RUN_ID}" \
- '{schema:$schema, state:$state, pr_number:$pr_number, changed_lines:$changed_lines, head_sha:$head_sha, run_id:$run_id}')
+ '{schema:"gitnexus.pr-autofix/v2", state:$state, pr_number:$pr_number, changed_lines:$changed_lines, head_sha:$head_sha, run_id:$run_id, apply_command:"/autofix"}')
# Multi-line quoted string instead of a column-0 heredoc โ YAML's
# `run: |` block ends as soon as a content line dedents below the
@@ -272,10 +280,9 @@ jobs:
- name: Emit gitnexus/autofix Check Run
# Stable check name `gitnexus/autofix` so PR-watching agents can
# `gh pr checks ` and read the conclusion + title without
- # parsing the sticky comment. Three outcomes:
- # clean โ conclusion: success
- # suggestions-posted โ conclusion: neutral (review suggestions)
- # skipped-too-large โ conclusion: neutral (diff > 3000 lines)
+ # parsing the sticky comment. Two outcomes:
+ # clean โ conclusion: success
+ # fixes-available โ conclusion: neutral
# `neutral` does not block branch-protection required-checks but
# is visually distinct from a green pass.
if: always() && steps.meta.outputs.head_sha != ''
@@ -284,7 +291,6 @@ jobs:
GH_REPO: ${{ github.repository }}
HEAD_SHA: ${{ steps.meta.outputs.head_sha }}
CHANGED: ${{ steps.meta.outputs.changed_lines }}
- POSTED: ${{ steps.suggest.outputs.posted }}
shell: bash
run: |
set -euo pipefail
@@ -293,14 +299,10 @@ jobs:
conclusion="success"
title="Formatting clean"
summary="Prettier and ESLint --fix produced no changes."
- elif [ "${POSTED}" = "skipped-too-large" ]; then
- conclusion="neutral"
- title="Diff too large for inline suggestions (${CHANGED} lines)"
- summary="GitHub caps the review-comment API at ~3000 lines. Run \`npm run lint:fix && npm run format\` locally."
else
conclusion="neutral"
- title="Suggestions posted"
- summary="Inline review-comment suggestions posted. Click **Apply suggestion** on each, or run \`npm run lint:fix && npm run format\` locally."
+ title="Autofix available โ comment /autofix to apply"
+ summary="Comment \`/autofix\` on this PR to apply formatter + unused-import fixes (works at any diff size). Or run \`npm run lint:fix && npm run format\` locally."
fi
gh api -X POST "repos/${GH_REPO}/check-runs" \
diff --git a/.github/workflows/pr-autofix.yml b/.github/workflows/pr-autofix.yml
index f15e8c0e65..dd4a3849ab 100644
--- a/.github/workflows/pr-autofix.yml
+++ b/.github/workflows/pr-autofix.yml
@@ -6,7 +6,9 @@ name: PR Autofix
# (including fork heads) and uploads the resulting diff as an artifact.
# This job has NO privileged token and CANNOT post to the PR. The trusted
# `pr-autofix-publish.yml` workflow downloads the artifact via
-# `workflow_run` and posts the inline review-comment suggestions.
+# `workflow_run` and posts a sticky summary comment + Check Run.
+# Contributors apply the patch by commenting `/autofix` on the PR โ
+# handled by the separate `pr-autofix-apply.yml` ChatOps workflow.
#
# Why the split:
# ESLint loads plugins from fork-controlled `node_modules`, so running
@@ -14,8 +16,8 @@ name: PR Autofix
# ship a poisoned eslint plugin and execute arbitrary code under that
# token. By keeping fork code execution in this job (token: read-only)
# and posting from a separate trusted job that never touches fork
-# code, we get the inline-suggestion UX for fork PRs without the
-# supply-chain hole. (See autofix.ci for the same pattern.)
+# code, we get the autofix UX for fork PRs without the supply-chain
+# hole. (See autofix.ci for the same pattern.)
#
# Removes unused imports via `eslint-plugin-unused-imports`, already in
# devDependencies and wired into the `lint` config.
@@ -105,11 +107,9 @@ jobs:
git diff --no-color > autofix-out/autofix.patch
# NOTE: `changed_lines` is the line-count of the patch file,
- # which includes hunk headers and context lines โ NOT the
- # added/removed source-line count. The 3000-line cap in
- # pr-autofix-publish.yml is therefore conservative (fires
- # before reviewdog hits GitHub's ~3k review-comment API
- # ceiling). That bias is intentional.
+ # (hunk headers + context lines + added/removed). Surfaced in
+ # the sticky comment so contributors and AI agents have a
+ # quick size hint before invoking `/autofix`.
changed_lines=$(wc -l < autofix-out/autofix.patch | tr -d ' ')
echo "changed_lines=${changed_lines}" >> "$GITHUB_OUTPUT"
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 99930cf0ab..d4f6b12b2c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -30,17 +30,17 @@ Format: `[(scope)][!]: `
Allowed types and the release-notes section each one lands in (defined in `.github/release.yml`):
-| Type | Label applied | Release-notes section |
-|------|---------------|-----------------------|
-| `feat` | `enhancement` | ๐ Features |
-| `fix` | `bug` | ๐ Bug Fixes |
-| `perf` | `performance` | ๐๏ธ Performance |
-| `refactor` | `refactor` | ๐ Refactoring |
-| `test` | `test` | ๐งช Tests |
-| `ci` | `ci` | ๐ท CI/CD |
-| `build` / `deps` | `dependencies` | ๐ฆ Dependencies |
-| `docs` | `documentation` | (grouped under Other Changes unless a Docs section is added) |
-| `chore` / `revert` | `chore` | (excluded from release notes) |
+| Type | Label applied | Release-notes section |
+| ------------------ | --------------- | ------------------------------------------------------------ |
+| `feat` | `enhancement` | ๐ Features |
+| `fix` | `bug` | ๐ Bug Fixes |
+| `perf` | `performance` | ๐๏ธ Performance |
+| `refactor` | `refactor` | ๐ Refactoring |
+| `test` | `test` | ๐งช Tests |
+| `ci` | `ci` | ๐ท CI/CD |
+| `build` / `deps` | `dependencies` | ๐ฆ Dependencies |
+| `docs` | `documentation` | (grouped under Other Changes unless a Docs section is added) |
+| `chore` / `revert` | `chore` | (excluded from release notes) |
Append `!` to the type (e.g. `feat(api)!: drop /v1 endpoint`) or include `BREAKING CHANGE:` in the PR body to flag a breaking change โ the labeler then adds the `breaking` label and the ๐ฅ Breaking Changes section is rendered first.
@@ -81,17 +81,17 @@ Every workflow under `.github/workflows/` MUST declare a top-level `concurrency:
- **Merge queue (`merge_group`)**: when this event is added, use `${{ github.workflow }}-${{ github.event.merge_group.head_ref }}` with `cancel-in-progress: false` (every queue entry is a distinct ref; never cancel).
- **`cancel-in-progress` policy:**
- | Event | `cancel-in-progress` | Why |
- |-------|----------------------|-----|
- | `pull_request` CI run | `true` | New push supersedes old run |
- | `push` to `main` | `false` | Every main commit gets validated |
- | Tag push (`v*` publish) | `false` | Never cancel mid-publish |
- | `push` to `main` for release-candidate | `false` | Never cancel mid-RC publish |
- | `workflow_dispatch` (release/publish) | `false` | Manual runs are intentional |
- | `workflow_run` (sticky-comment reports) | `false` | Serialize, don't race |
- | Per-PR bot workflows (`@claude`, review) | `false` | Serialize comments per PR |
- | PR-meta re-checks (pr-description-check) | `true` | Cheap, latest wins |
- | Single-slot utilities (triage sweep) | `true` | Latest dispatch supersedes |
+ | Event | `cancel-in-progress` | Why |
+ | ---------------------------------------- | -------------------- | -------------------------------- |
+ | `pull_request` CI run | `true` | New push supersedes old run |
+ | `push` to `main` | `false` | Every main commit gets validated |
+ | Tag push (`v*` publish) | `false` | Never cancel mid-publish |
+ | `push` to `main` for release-candidate | `false` | Never cancel mid-RC publish |
+ | `workflow_dispatch` (release/publish) | `false` | Manual runs are intentional |
+ | `workflow_run` (sticky-comment reports) | `false` | Serialize, don't race |
+ | Per-PR bot workflows (`@claude`, review) | `false` | Serialize comments per PR |
+ | PR-meta re-checks (pr-description-check) | `true` | Cheap, latest wins |
+ | Single-slot utilities (triage sweep) | `true` | Latest dispatch supersedes |
- For workflows that serve multiple events at once (e.g. `ci.yml` handles `pull_request`, `push`, and `workflow_call`), make `cancel-in-progress` event-aware:
@@ -109,18 +109,35 @@ Two workflows produce machine-readable signals on every PR. Coding agents and hu
### `gitnexus/autofix`
-`pr-autofix.yml` (untrusted) + `pr-autofix-publish.yml` (trusted) run `prettier --write` and `eslint --fix` against the PR head and surface the diff as inline review-comment suggestions. Three signals are emitted:
+`pr-autofix.yml` (untrusted) + `pr-autofix-publish.yml` (trusted) run `prettier --write` and `eslint --fix` against the PR head and surface a single ChatOps button on the PR. Three signals are emitted:
-| Surface | Where | Notes |
-|---|---|---|
-| Sticky PR comment | Top-level comment with the HTML marker `` and heading `## :sparkles: PR Autofix`. Only posted when there is something to fix; clean PRs stay silent. | Edit-in-place via marker; one comment per PR. |
-| Fenced JSON block | Inside the sticky, fenced as `gitnexus-autofix`. Schema `gitnexus.pr-autofix/v1` with fields `state` (`suggestions-posted` \| `skipped-too-large`), `pr_number`, `head_sha`, `changed_lines`, `run_id`. | Parseable signal โ preferred over regexing prose. |
-| Check Run | Stable name `gitnexus/autofix` on the PR head SHA. Conclusion: `success` (clean) or `neutral` (suggestions-posted / skipped-too-large). The output title disambiguates the two `neutral` cases. | Surfaced under PR Checks; readable via `gh pr checks `. |
+| Surface | Where | Notes |
+| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
+| Sticky PR comment | Top-level comment with the HTML marker `` and heading `## :sparkles: PR Autofix`. Only posted when there is something to fix; clean PRs stay silent. | Edit-in-place via marker; one comment per PR. |
+| Fenced JSON block | Inside the sticky, fenced as `gitnexus-autofix`. Schema `gitnexus.pr-autofix/v2` with fields `state` (`fixes-available`), `pr_number`, `head_sha`, `changed_lines`, `run_id`, and `apply_command` (literal `/autofix`). | Parseable signal โ preferred over regexing prose. v1 fields preserved as a superset. |
+| Check Run | Stable name `gitnexus/autofix` on the PR head SHA. Conclusion: `success` (clean) or `neutral` (`fixes-available`). The neutral title is `Autofix available โ comment /autofix to apply`. | Surfaced under PR Checks; readable via `gh pr checks `. |
To detect outcome from an agent: `gh pr checks --json name,conclusion,output | jq '.[] | select(.name == "gitnexus/autofix")'`.
Forks are supported. The untrusted half runs fork code with `permissions: {}` and ships the diff as an artifact; the trusted publish job consumes only the diff (data, not code) and posts the comment + check run.
+#### Applying autofix
+
+Comment `/autofix` on the PR (whole-line, no arguments). The `pr-autofix-apply.yml` workflow:
+
+1. Validates the comment body matches `^/autofix\s*$` exactly. Quoted or inline mentions are silently ignored.
+2. Validates the commenter has `admin`, `write`, or `maintain` permission on the repo, OR is the PR author. Other commenters get a ๐ reaction and a refusal reply.
+3. Locates the most recent successful `pr-autofix.yml` run for the PR's current head SHA, downloads its `autofix` artifact, applies the patch, and pushes a `chore(autofix): ...` commit back to the PR head branch.
+4. Reacts โ
on success, ๐ on stale-patch / push-failure, and posts a short reply with the apply-run URL in either case.
+
+The apply workflow runs from the default branch's copy of the file regardless of where the comment originates โ that's the trust anchor. There is no diff-size cap (the apply workflow uses `git apply` + push, not the GitHub review-comment API).
+
+For fork PRs, the push succeeds only when the contributor has **Allow edits by maintainers** enabled on the PR (the default). When they have disabled it, the workflow fails loud with a ๐ reaction and an explanation comment.
+
+Re-invoking `/autofix` after a successful apply is a safe no-op โ the workflow detects the already-applied state via `git apply --check --reverse` and reacts โ
without pushing.
+
+**Sensitive paths.** The apply workflow refuses any patch that touches `.github/` (workflow files, CODEOWNERS, dependabot config). A malicious PR could ship a custom prettier or ESLint config that reformats workflow YAML; if accepted, those edits would be pushed under `contents: write` without human review. Apply formatter changes to files under `.github/` manually in a normal commit so they get the same review every other workflow change gets.
+
## AI-assisted contributions
If you use coding agents, follow project context files (e.g. `AGENTS.md`, `CLAUDE.md`) and avoid drive-by refactors unrelated to the issue. Prefer incremental, test-backed changes.
@@ -182,8 +199,7 @@ Two publish workflows ship `gitnexus` to npm:
the Docker build.
- Manually run `docker build` + `docker push` locally and sign with Cosign
against the same digest.
- - Delete `rc/` and `v` tags, then redispatch with `force:
- true` to re-run the full RC pipeline (cuts a new RC number).
+ - Delete `rc/` and `v` tags, then redispatch with `force: true` to re-run the full RC pipeline (cuts a new RC number).
The rc workflow never moves `latest`. To verify after a change, inspect dist-tags: