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: App token 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 environment: trusted-automation 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: 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: 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: ${{ steps.app-token.outputs.token }} 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///pull/). 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: ${{ steps.app-token.outputs.token }} 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."