Merge pull request #76562 from NousResearch/bb/npm-engine-outage #4
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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: 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 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/<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: ${{ 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." |