hermes-agent/.github/workflows/autofix.yml

331 lines
14 KiB
YAML

name: auto-fix lint issues & formatting
# On push to main (or manual trigger), run every registered auto-fixer —
# `npm run fix` on each workspace package plus the project lint fixers
# (`scripts/lints/run.py --fix`) — and apply any changes via a PR.
#
# Fixable lint issues (import sorting, unused imports, curly braces, stale
# .npmrc entries, etc.) are auto-corrected on merge so PRs aren't blocked by
# them. PR-time checks fail only when un-fixable errors remain — that split
# is the project-wide severity policy (see scripts/lints/__init__.py).
#
# No schedule trigger, on purpose: merges to main are frequent enough to be
# the clock. No paths filter either — lint inputs span the whole tree
# (.npmrc, lockfiles, workflows, ...), the run is cheap, and it exits with
# an empty patch when there is nothing to fix.
#
# NOTE: App token pushes DO trigger further workflow runs (unlike
# secrets.GITHUB_TOKEN). The concurrency group (autofix-${{ github.ref }})
# with cancel-in-progress: true prevents an infinite loop — a re-triggered
# run cancels the in-flight one, and since the second run finds no new fixes
# (the first run already applied them), it exits with an empty patch.
#
# ── Security model: two-job split ───────────────────────────────────────────
#
# The fixers execute repo code (eslint.config.mjs, package.json scripts,
# installed plugins, scripts/lints/*.py). To prevent a malicious PR from
# getting arbitrary code execution on a runner with push access, the work is
# split:
#
# 1. generate-patch (unprivileged, contents: read only)
# Checks out, installs deps, runs the fixers, produces a .patch artifact.
# Worst case: malicious code runs here on an ephemeral runner with zero
# push permissions.
#
# 2. apply-patch (privileged, contents: write + pull-requests: write)
# Checks out, downloads the patch artifact, applies it, pushes to the
# bot/autofix branch, creates/updates a PR, and enables auto-merge.
# This job never runs npm, never installs anything, never executes any
# repo code. The only input it trusts is the patch artifact.
# Skipped entirely when generate-patch reports no fixes (has-fixes != true).
# The PR auto-merges (squash) once CI passes. If CI fails or main moves,
# the PR is auto-closed and the branch deleted — the next run re-applies
# on the current state.
#
# ── Patch guard ─────────────────────────────────────────────────────────────
#
# The patch is rejected unless every touched file matches an allowed
# pattern: the JS/TS/JSON sources `npm run fix` owns, plus each lint's
# declared fix_touches globs (queried via `run.py --print-fix-globs`).
# Per-lint attribution — a fixer writing a file that only ANOTHER lint's
# globs allow — is enforced inside run.py --fix itself, which exits
# non-zero and fails this job before the patch is produced.
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read # default; apply-patch job overrides to write
concurrency:
group: autofix-${{ github.ref }}
cancel-in-progress: true
jobs:
generate-patch:
name: Generate auto-fix patch
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
has-fixes: ${{ steps.produce-patch.outputs.has-fixes }}
# No permissions override → inherits workflow-level contents: read.
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 26
cache: npm
- name: grab npm 12
run: |
npm i -g npm@12
# --ignore-scripts: eslint only needs TS sources + eslint packages.
- uses: ./.github/actions/retry
with:
command: npm ci --ignore-scripts
- name: npm run fix in all workspaces
# continue-on-error: if un-fixable errors exist on main, we still want
# to commit whatever fixes were applied. The PR-time eslint check is
# what blocks un-fixable errors from landing.
continue-on-error: true
run: npm run fix
- name: Install uv + ruff
uses: ./.github/actions/retry
with:
command: curl -LsSf https://astral.sh/uv/0.9.28/install.sh | sh && ~/.local/bin/uv tool install ruff
- name: ruff check --fix
# Same deal as npm run fix: apply what's fixable, let the PR-time
# ruff-blocking job be the gate for what isn't.
continue-on-error: true
run: ~/.local/bin/uv tool run ruff check --fix .
- name: Run project lint fixers
# NOT continue-on-error: a non-zero exit here means a fixer crashed
# or wrote outside its own declared fix_touches globs (per-lint
# attribution lives in run.py). Either way the diff is not
# trustworthy and must not become a patch.
run: python3 scripts/lints/run.py --fix
- name: Produce patch
id: produce-patch
run: |
# Exclude every file that the dep-version-gate ruleset guards
# (package manifests, eslint configs, workflow files). A patch
# that contains one of these files makes the bot PR wait for a
# team review, and auto-merge then stops. The check step in
# typecheck.yml still reports their lint errors.
# The (glob) magic makes "**/" also match files at the repo
# root, which plain pathspec wildcards do not.
EXCLUDES=(
':(exclude,glob)**/package.json'
':(exclude,glob)**/package-lock.json'
':(exclude,glob)**/eslint.config.*'
':(exclude,glob).github/**'
)
if git diff --quiet -- . "${EXCLUDES[@]}"; then
echo "No fixes needed."
echo "has-fixes=false" >> "$GITHUB_OUTPUT"
# Empty patch signals "nothing to do" to apply-patch.
: > autofix.patch
else
git diff -- . "${EXCLUDES[@]}" > autofix.patch
echo "has-fixes=true" >> "$GITHUB_OUTPUT"
echo "Patch size: $(wc -c < autofix.patch) bytes"
# Reject patches that touch anything outside the allowed set:
# JS/TS/JSON sources (npm run fix), .py (ruff check --fix), plus
# the union of registered lint fix_touches globs. Anything else
# means a fixer or plugin went rogue and we refuse to ship it.
# (Per-lint glob attribution already ran inside run.py --fix
# above; the EXCLUDES pathspec keeps review-gated files out of
# the patch entirely.)
git diff --name-only -- . "${EXCLUDES[@]}" | python3 -c '
import fnmatch, os, subprocess, sys
js_exts = (".js", ".cjs", ".mjs", ".ts", ".tsx", ".json")
# .py: ruff check --fix output. The .json allowance is for
# sources npm run fix formats — NOT lockfiles. A bot-authored,
# auto-merged lockfile rewrite is exactly the shape of a
# supply-chain attack, so lockfiles fall through to the default
# rejection like any other unallowed path.
fixer_exts = js_exts + (".py",)
not_js_sources = {"package-lock.json", "npm-shrinkwrap.json"}
lint_globs = subprocess.run(
[sys.executable, "scripts/lints/run.py", "--print-fix-globs"],
capture_output=True, text=True, encoding="utf-8", check=True,
).stdout.split()
bad = []
for path in (p.strip() for p in sys.stdin if p.strip()):
if path.endswith(fixer_exts) and os.path.basename(path) not in not_js_sources:
continue
if any(fnmatch.fnmatch(path, g) for g in lint_globs):
continue
bad.append(path)
if bad:
print("::error::Refusing to upload patch — touches disallowed files:")
print("\n".join(bad))
sys.exit(1)
'
fi
- name: Upload patch artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: autofix-patch
path: autofix.patch
retention-days: 1
include-hidden-files: true
apply-patch:
name: Apply patch
needs: generate-patch
# Skip entirely when generate-patch found no fixes — saves a runner,
# avoids a redundant checkout/download, and keeps the job graph honest.
if: needs.generate-patch.outputs.has-fixes == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
environment: trusted-automation
permissions:
contents: write # needed to push to bot/autofix
pull-requests: write # needed for PR creation + auto-merge
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ vars.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Download patch
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: autofix-patch
# ${{ runner.temp }} expands in with: params (shell-style $VAR does not).
# download-artifact's path is a *directory* — the artifact's autofix.patch
# file lands inside it, so $RUNNER_TEMP/autofix.patch resolves correctly
# in the run step below.
path: ${{ runner.temp }}
- name: Apply patch and push to bot branch
env:
BOT_BRANCH: bot/autofix
run: |
set -euo pipefail
# Empty patch = nothing to do.
if [ ! -s "$RUNNER_TEMP/autofix.patch" ]; then
echo "Patch is empty. No fixes to apply."
exit 0
fi
# Apply the patch produced by the unprivileged job.
git apply --check "$RUNNER_TEMP/autofix.patch" || {
echo "::error::Patch does not apply cleanly. Branch may have moved."
exit 1
}
git apply "$RUNNER_TEMP/autofix.patch"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
git commit -m "fix: auto-fix lints & formatting on merge"
# Push to the dedicated bot branch. Force-push is safe here:
# bot/autofix is a bot-only branch that gets rewritten each run.
# If the branch was deleted after a previous PR merge, this
# recreates it.
git push --force origin HEAD:"$BOT_BRANCH"
- name: Create/update PR and enable auto-merge
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
BOT_BRANCH: bot/autofix
run: |
set -euo pipefail
# Create PR if one doesn't exist. If it already exists, the
# force-push above already updated it with the latest fixes.
PR_NUM=$(gh pr list --head "$BOT_BRANCH" --state open --json number --jq '.[0].number' 2>/dev/null || true)
if [ -z "$PR_NUM" ]; then
# gh pr create prints the PR URL. Extract the number from it
# (https://github.com/<org>/<repo>/pull/<number>).
PR_URL=$(gh pr create \
--head "$BOT_BRANCH" --base main \
--title 'fix: auto-fix lints & formatting' \
--body 'Auto-generated by the `auto-fix lint issues & formatting` workflow (`npm run fix` + `scripts/lints/run.py --fix`). Auto-merges (squash) once CI passes. If CI fails or `main` moves, the PR is auto-closed and the branch deleted — the next run re-applies on the current state.')
PR_NUM=$(echo "$PR_URL" | grep -oE '[0-9]+$')
fi
# Enable auto-merge (squash). If already enabled, this is a no-op.
gh pr merge "$PR_NUM" --auto --squash || true
- name: Wait for merge, auto-close on failure or stale
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
START_SHA: ${{ github.sha }}
run: |
set -euo pipefail
PR_NUM=$(gh pr list --head bot/autofix --state open --json number --jq '.[0].number' 2>/dev/null || true)
if [ -z "$PR_NUM" ]; then
echo "No open PR. Nothing to wait for."
exit 0
fi
echo "Waiting for PR #$PR_NUM to merge..."
# Poll every 15s for up to ~10 minutes. Auto-merge will handle the
# PR even if this job times out — the polling is for cleanup only
# (auto-close on CI failure, conflicts, or main moving).
for i in $(seq 1 40); do
sleep 15
STATE=$(gh pr view "$PR_NUM" --json state --jq '.state')
if [ "$STATE" = "MERGED" ] || [ "$STATE" = "CLOSED" ]; then
echo "PR #$PR_NUM is $STATE."
exit 0
fi
# If main moved, the PR may have already merged (which moves
# main) or another commit landed. Re-check state first.
CURRENT_SHA=$(gh api "repos/${{ github.repository }}/branches/main" --jq '.commit.sha')
if [ "$CURRENT_SHA" != "$START_SHA" ]; then
STATE=$(gh pr view "$PR_NUM" --json state --jq '.state')
if [ "$STATE" = "MERGED" ]; then
echo "PR #$PR_NUM merged (main moved to $CURRENT_SHA)."
exit 0
fi
echo "Main moved ($START_SHA → $CURRENT_SHA). Closing stale PR."
gh pr close "$PR_NUM" --delete-branch || true
exit 0
fi
# If CI checks failed, close + delete the branch.
if gh pr checks "$PR_NUM" 2>/dev/null | grep -qi "fail"; then
echo "CI failed on PR #$PR_NUM. Closing + deleting branch."
gh pr close "$PR_NUM" --delete-branch
exit 0
fi
# If PR is conflicted, close + delete the branch.
MERGEABLE=$(gh pr view "$PR_NUM" --json mergeable --jq '.mergeable')
if [ "$MERGEABLE" = "CONFLICTING" ]; then
echo "PR #$PR_NUM is conflicted. Closing + deleting branch."
gh pr close "$PR_NUM" --delete-branch
exit 0
fi
done
echo "Timeout reached. Auto-merge will handle PR #$PR_NUM if CI passes."