ci: add desktop autofix-on-merge with two-job security split

This commit is contained in:
ethernet 2026-07-10 14:21:55 -04:00 committed by kshitij
parent ef7aabd3d1
commit f32a1f6078
2 changed files with 163 additions and 2 deletions

161
.github/workflows/js-autofix.yml vendored Normal file
View file

@ -0,0 +1,161 @@
name: auto-fix lint issues & formatting
# On push to main (or manual trigger), run `npm run fix` on each workspace
# package and commit any changes.
# 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.
#
# Pushes made by GITHUB_TOKEN don't trigger further workflow runs, so there's
# no infinite loop.
#
# ── 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)
# Checks out, downloads the patch artifact, applies it,
# then pushes with --force-with-lease. This job never runs npm, never
# installs anything, never executes any repo code. The only input it
# trusts is the patch artifact.
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
# 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
run: |
if git diff --quiet; then
echo "No fixes needed."
# Empty patch signals "nothing to do" to apply-patch.
: > js-fix.patch
else
git diff > js-fix.patch
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
runs-on: ubuntu-latest
timeout-minutes: 3
permissions:
contents: write # needed to push
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
env:
START_SHA: ${{ github.sha }}
BRANCH: ${{ github.ref_name }}
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"
# --force-with-lease=<ref>:<sha> makes the precondition check atomic
# on the server side: the push only succeeds if origin/<branch> still
# points at START_SHA when the server processes it. If the branch moved
# since checkout (another push landed), the server rejects it and
# the next run will re-apply on the current state.
if git push --force-with-lease="$BRANCH":"$START_SHA" origin HEAD:"$BRANCH"; then
echo "Pushed fixes."
else
echo "::error::$BRANCH moved since checkout ($START_SHA). Push rejected."
echo "Next run will re-apply on the current state."
exit 1
fi

View file

@ -166,7 +166,7 @@ jobs:
ci-review:
# Require explicit maintainer review when CI-sensitive files change:
# eslint config, workflow YAMLs, or composite actions. These files
# influence what code the desktop-autofix job executes and pushes to
# influence what code the js-autofix job executes and pushes to
# main, so a malicious PR could inject arbitrary code via a custom eslint
# rule's `fix` function. The label gate ensures a human reviews before
# merge. Mirrors the mcp-catalog-reviewed pattern in supply-chain-audit.yml.
@ -210,7 +210,7 @@ jobs:
This PR changes CI-sensitive files (eslint config, workflow YAMLs,
or composite actions). These files influence what code the
desktop-autofix job executes and pushes to main.
js-autofix job executes and pushes to main.
A maintainer should verify:
- no new eslint rules with custom \`fix\` functions that write outside linted paths,