mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
generate-patch now emits a has-fixes job output (true/false). apply-patch is gated on it via `if: needs.generate-patch.outputs.has-fixes == 'true'`, so when `npm run fix` produces no diff, the privileged job is skipped entirely — no runner allocation, no redundant checkout/download/push/PR.
251 lines
10 KiB
YAML
251 lines
10 KiB
YAML
name: auto-fix lint issues & formatting
|
|
|
|
# On push to main (or manual trigger), run `npm run fix` on each workspace
|
|
# package and apply any changes via a PR.
|
|
#
|
|
# Fixable lint issues (import sorting, unused imports, curly braces, etc.) are
|
|
# auto-corrected on merge so PRs aren't blocked by them. The PR-time eslint
|
|
# check in typecheck.yml fails only when un-fixable errors remain.
|
|
#
|
|
# NOTE: AUTOFIX_BOT_PAT pushes DO trigger further workflow runs (unlike
|
|
# secrets.GITHUB_TOKEN). The concurrency group (ts-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 eslint process executes repo code (eslint.config.mjs, package.json
|
|
# scripts, installed plugins). 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 eslint --fix, 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/js-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.
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
paths:
|
|
- '**/*.js'
|
|
- '**/*.cjs'
|
|
- '**/*.mjs'
|
|
- '**/*.ts'
|
|
- '**/*.tsx'
|
|
- 'package.json'
|
|
- 'package-lock.json'
|
|
workflow_dispatch:
|
|
|
|
permissions:
|
|
contents: read # default; apply-patch job overrides to write
|
|
|
|
concurrency:
|
|
group: ts-autofix-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
jobs:
|
|
generate-patch:
|
|
name: Generate eslint --fix patch
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
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: 22
|
|
cache: npm
|
|
|
|
# --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 check in
|
|
# typecheck.yml is what blocks un-fixable errors from landing.
|
|
continue-on-error: true
|
|
run: npm run fix
|
|
|
|
- name: Produce patch
|
|
id: produce-patch
|
|
run: |
|
|
if git diff --quiet; then
|
|
echo "No fixes needed."
|
|
echo "has-fixes=false" >> "$GITHUB_OUTPUT"
|
|
# Empty patch signals "nothing to do" to apply-patch.
|
|
: > js-fix.patch
|
|
else
|
|
git diff > js-fix.patch
|
|
echo "has-fixes=true" >> "$GITHUB_OUTPUT"
|
|
echo "Patch size: $(wc -c < js-fix.patch) bytes"
|
|
|
|
# Reject patches that touch anything outside JS/TS/JSON sources.
|
|
# `npm run fix` should only ever modify those; anything else means
|
|
# eslint/prettier or a plugin went rogue and we refuse to ship it.
|
|
BAD=$(git diff --name-only | grep -vE '\.(js|cjs|mjs|ts|tsx|json)$' || true)
|
|
if [ -n "$BAD" ]; then
|
|
echo "::error::Refusing to upload patch — touches disallowed files:"
|
|
echo "$BAD"
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
- name: Upload patch artifact
|
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
|
with:
|
|
name: js-fix-patch
|
|
path: js-fix.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
|
|
permissions:
|
|
contents: write # needed to push to bot/js-autofix
|
|
pull-requests: write # needed for PR creation + auto-merge
|
|
steps:
|
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
|
|
- name: Download patch
|
|
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
|
with:
|
|
name: js-fix-patch
|
|
# ${{ runner.temp }} expands in with: params (shell-style $VAR does not).
|
|
# download-artifact's path is a *directory* — the artifact's js-fix.patch
|
|
# file lands inside it, so $RUNNER_TEMP/js-fix.patch resolves correctly
|
|
# in the run step below.
|
|
path: ${{ runner.temp }}
|
|
|
|
- name: Apply patch and push to bot branch
|
|
env:
|
|
BOT_BRANCH: bot/js-autofix
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
# Empty patch = nothing to do.
|
|
if [ ! -s "$RUNNER_TEMP/js-fix.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/js-fix.patch" || {
|
|
echo "::error::Patch does not apply cleanly. Branch may have moved."
|
|
exit 1
|
|
}
|
|
git apply "$RUNNER_TEMP/js-fix.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 "fmt(js): \`npm run fix\` on merge"
|
|
|
|
# Push to the dedicated bot branch. Force-push is safe here:
|
|
# bot/js-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: ${{ secrets.AUTOFIX_BOT_PAT }}
|
|
BOT_BRANCH: bot/js-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 'fmt(js): `npm run fix` auto-fix' \
|
|
--body 'Auto-generated by the `auto-fix lint issues & formatting` workflow. 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: ${{ secrets.AUTOFIX_BOT_PAT }}
|
|
START_SHA: ${{ github.sha }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
PR_NUM=$(gh pr list --head bot/js-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."
|