diff --git a/.github/workflows/js-autofix.yml b/.github/workflows/js-autofix.yml new file mode 100644 index 000000000000..a3326ff324d0 --- /dev/null +++ b/.github/workflows/js-autofix.yml @@ -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=: makes the precondition check atomic + # on the server side: the push only succeeds if origin/ 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 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7f5a87f3b8dd..e88294dd94ed 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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,