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: