From 631baf4300a7d6457c84f3676e6e9278b5258741 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 13:23:24 +0000 Subject: [PATCH 1/2] Upgrade actions/ai-inference from v2 to v3 v3 removes the GitHub Models provider and makes Copilot the only one, so v2's `models: read` + hosted inference API path no longer works at all. The action now shells out to the Copilot CLI, which is not preinstalled on GitHub-hosted runners, so both inference jobs install it first. No PAT is required. Copilot CLI accepts the built-in `GITHUB_TOKEN` when the workflow grants `copilot-requests: write`, which replaces `models: read` in both the reusable workflow and its caller (a caller can only cap a reusable workflow's permissions, so both need it). Usage is metered to the organization, which requires the "Allow use of Copilot CLI billed to the organization" policy to stay enabled. The action passes no `--allow-tool` flags, so Copilot keeps v2's containment: no shell, filesystem or network access, prompt in and text out. That matters most for `triage-new-item`, which runs on `pull_request_target` and feeds it pull request text from anyone who can open a PR. Noted inline so `copilot-allow-tools` is not added casually. The prompt told the model to "use the available tools to gather information", which no tool grant backs, so it now says to work only from the input data. actionlint 1.7.11 does not know the `copilot-requests` scope yet and fails the workflow lint, so `.github/actionlint.yml` filters that one error and is synced alongside `issue-triage.yml` to keep the ~44 target repositories green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RdDrWtq6hWWuDDQDxQx94G --- .github/actionlint.yml | 13 +++++ .github/workflows/issue-triage.yml | 5 +- .github/workflows/reusable-issue-triage.yml | 62 ++++++++++++++++++--- .github/workflows/sync-workflows.yml | 2 + 4 files changed, 72 insertions(+), 10 deletions(-) create mode 100644 .github/actionlint.yml diff --git a/.github/actionlint.yml b/.github/actionlint.yml new file mode 100644 index 0000000..813317a --- /dev/null +++ b/.github/actionlint.yml @@ -0,0 +1,13 @@ +# Configuration for actionlint, run by the `actionlint` job in +# `.github/workflows/reusable-code-quality.yml`. +paths: + .github/workflows/**/*.{yml,yaml}: + ignore: + # `copilot-requests` is a real permission scope - it is what lets a + # workflow authenticate the Copilot CLI with the built-in `GITHUB_TOKEN` + # instead of a personal access token - but actionlint's hard-coded scope + # list has not caught up with it yet, so it reports every use as unknown. + # Drop this entry once actionlint ships the scope, and it will go back to + # catching genuine typos in permission names. + # See https://docs.github.com/en/copilot/how-tos/copilot-cli/use-copilot-cli-in-actions + - 'unknown permission scope "copilot-requests"' diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index bf816ae..0795944 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -17,7 +17,10 @@ permissions: issues: write pull-requests: write contents: read - models: read + # A caller can only cap a reusable workflow's permissions, so the scope the + # Copilot CLI needs has to be granted here too. `models: read` was for the + # GitHub Models provider, which `actions/ai-inference` v3 removed. + copilot-requests: write # A caller can only cap a reusable workflow's permissions, never raise them, # so `actions: write` has to be granted here for the dispatch job downstream # to work at all. The reusable workflow narrows it to that single job, so the diff --git a/.github/workflows/reusable-issue-triage.yml b/.github/workflows/reusable-issue-triage.yml index 02f051d..d5d2f97 100644 --- a/.github/workflows/reusable-issue-triage.yml +++ b/.github/workflows/reusable-issue-triage.yml @@ -13,7 +13,11 @@ permissions: issues: write pull-requests: write contents: read - models: read + # `actions/ai-inference` v3 dropped the GitHub Models provider, so `models: + # read` no longer buys anything. The Copilot CLI it now shells out to + # authenticates as the workflow itself, and this is the scope that lets it + # spend a Copilot request. See the notes on the inference steps below. + copilot-requests: write jobs: triage-new-item: @@ -34,10 +38,34 @@ jobs: const labelNames = labels.data.map(label => label.name); return labelNames.join(', '); + # The Copilot CLI is not preinstalled on GitHub-hosted runners, and + # `actions/ai-inference` v3 only shells out to it - it never installs it. + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: lts/* + + # Deliberately unpinned, unlike everything else here: authenticating with + # `GITHUB_TOKEN` instead of a PAT only works on recent CLI releases, so a + # pin would be a slow trap rather than a safeguard. + - name: Install Copilot CLI + run: npm install -g @github/copilot + - name: Analyze with AI id: ai-triage - uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2 + uses: actions/ai-inference@2c43c91ae16266ca159d311430343c67a5ffa222 # v3 env: + # No PAT secret needed: the CLI authenticates as the workflow, and + # `copilot-requests: write` meters the request to the organization. + # + # This job runs on `pull_request_target`, so the prompt below carries + # pull request text written by anyone who can open one. The action + # passes no `--allow-tool` flags, so Copilot is denied shell, + # filesystem and network access and cannot reach this token - it + # reads the prompt and returns text, exactly as v2 did. Keep it that + # way: setting `copilot-allow-tools` here would hand a fork's prompt + # a runner with a write-scoped token on it. + GITHUB_TOKEN: ${{ github.token }} AVAILABLE_LABELS: ${{ steps.get-labels.outputs.result }} ITEM_TITLE: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.title || github.event.issue.title }} ITEM_BODY: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.body || github.event.issue.body }} @@ -47,9 +75,9 @@ jobs: ## Role You are an issue and pull request triage assistant. Analyze the current GitHub - ${{ env.ITEM_TYPE }} and identify the most appropriate existing labels. Use the - available tools to gather information; do not ask for information - to be provided. + ${{ env.ITEM_TYPE }} and identify the most appropriate existing labels. Work + only from the input data below; you have no tools available and no + further information can be provided. ## Guidelines @@ -271,10 +299,26 @@ jobs: type: itemType }; + # The Copilot CLI is not preinstalled on GitHub-hosted runners, and + # `actions/ai-inference` v3 only shells out to it - it never installs it. + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: lts/* + + # Deliberately unpinned, unlike everything else here: authenticating with + # `GITHUB_TOKEN` instead of a PAT only works on recent CLI releases, so a + # pin would be a slow trap rather than a safeguard. + - name: Install Copilot CLI + run: npm install -g @github/copilot + - name: Analyze with AI id: ai-triage - uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2 + uses: actions/ai-inference@2c43c91ae16266ca159d311430343c67a5ffa222 # v3 env: + # No PAT secret needed: the CLI authenticates as the workflow, and + # `copilot-requests: write` meters the request to the organization. + GITHUB_TOKEN: ${{ github.token }} AVAILABLE_LABELS: ${{ steps.get-labels.outputs.result }} ITEM_TITLE: ${{ fromJSON(steps.get-item.outputs.result).title }} ITEM_BODY: ${{ fromJSON(steps.get-item.outputs.result).body }} @@ -284,9 +328,9 @@ jobs: ## Role You are an issue and pull request triage assistant. Analyze the current GitHub - ${{ env.ITEM_TYPE }} and identify the most appropriate existing labels. Use the - available tools to gather information; do not ask for information - to be provided. + ${{ env.ITEM_TYPE }} and identify the most appropriate existing labels. Work + only from the input data below; you have no tools available and no + further information can be provided. ## Guidelines diff --git a/.github/workflows/sync-workflows.yml b/.github/workflows/sync-workflows.yml index 3382f05..307b49f 100644 --- a/.github/workflows/sync-workflows.yml +++ b/.github/workflows/sync-workflows.yml @@ -10,6 +10,7 @@ on: - '.actrc' - '.editorconfig' - 'AGENTS.md' + - '.github/actionlint.yml' - '.github/dependabot.yml' - '.github/workflows/check-branch-alias.yml' - '.github/workflows/copilot-setup-steps.yml' @@ -54,6 +55,7 @@ jobs: FILE_PATTERNS: | ^\.actrc ^\.editorconfig + ^\.github/actionlint\.yml ^\.github/workflows/copilot-setup-steps\.yml ^\.github/workflows/regenerate-readme\.yml ^\.github/workflows/welcome-new-contributors\.yml From c7b24db7e6e1428bf2bf51b3f6ae1731d84f8f3e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 13:47:27 +0000 Subject: [PATCH 2/2] Split triage inference and label writes into separate jobs The Copilot CLI is installed from npm at run time and then executes with a token in its environment. The `--allow-tool` containment noted in the previous commit stops a prompt injection from steering Copilot, but it does nothing about a compromised CLI release, because there the binary itself is what runs. On `pull_request_target` that token carried `issues: write` and `pull-requests: write`. Both triage paths now run inference in a job scoped to read plus `copilot-requests: write`, and hand the response to a dependent job that holds the write scopes and never installs or runs the CLI. No job holds both, so a compromised release has nothing to write with. The label allowlist check is unchanged and still gates what a model response can turn into. The CLI install stays unpinned: `GITHUB_TOKEN` authentication only works on recent releases, so a pin would eventually break the thing this migration is for. The permissions split addresses the exposure a pin was being asked to cover. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RdDrWtq6hWWuDDQDxQx94G --- .github/workflows/reusable-issue-triage.yml | 90 ++++++++++++++++----- 1 file changed, 69 insertions(+), 21 deletions(-) diff --git a/.github/workflows/reusable-issue-triage.yml b/.github/workflows/reusable-issue-triage.yml index d5d2f97..538b23d 100644 --- a/.github/workflows/reusable-issue-triage.yml +++ b/.github/workflows/reusable-issue-triage.yml @@ -9,21 +9,30 @@ name: Issue and PR Triage required: false type: string +# Every job narrows this further, and no job holds both `copilot-requests: +# write` and a write scope. `actions/ai-inference` v3 dropped the GitHub Models +# provider and now runs the Copilot CLI, installed from npm at run time, with a +# token in its environment - so inference and label writes are split into +# separate jobs and the token that can write never reaches the CLI. permissions: - issues: write - pull-requests: write contents: read - # `actions/ai-inference` v3 dropped the GitHub Models provider, so `models: - # read` no longer buys anything. The Copilot CLI it now shells out to - # authenticates as the workflow itself, and this is the scope that lets it - # spend a Copilot request. See the notes on the inference steps below. - copilot-requests: write jobs: triage-new-item: name: Triage New Issue or PR if: github.event_name == 'issues' || github.event_name == 'pull_request_target' runs-on: ubuntu-latest + # Read-only, plus the scope that pays for the inference. This is the job + # that runs the Copilot CLI, so it is the job that must not be able to + # write anything. + permissions: + contents: read + issues: read + pull-requests: read + copilot-requests: write + outputs: + response: ${{ steps.ai-triage.outputs.response }} + labels: ${{ steps.get-labels.outputs.result }} steps: - name: Get available labels id: get-labels @@ -47,7 +56,10 @@ jobs: # Deliberately unpinned, unlike everything else here: authenticating with # `GITHUB_TOKEN` instead of a PAT only works on recent CLI releases, so a - # pin would be a slow trap rather than a safeguard. + # pin would be a slow trap rather than a safeguard. The exposure that + # would otherwise argue for pinning is handled by this job's permissions + # instead - the CLI runs with a read-only token and cannot write even if + # a release is compromised. - name: Install Copilot CLI run: npm install -g @github/copilot @@ -59,12 +71,13 @@ jobs: # `copilot-requests: write` meters the request to the organization. # # This job runs on `pull_request_target`, so the prompt below carries - # pull request text written by anyone who can open one. The action - # passes no `--allow-tool` flags, so Copilot is denied shell, - # filesystem and network access and cannot reach this token - it - # reads the prompt and returns text, exactly as v2 did. Keep it that - # way: setting `copilot-allow-tools` here would hand a fork's prompt - # a runner with a write-scoped token on it. + # pull request text written by anyone who can open one. Two separate + # things contain that. The action passes no `--allow-tool` flags, so + # Copilot gets no shell, filesystem or network access - that stops a + # prompt injection. And this job's token is read-only, so even a + # malicious CLI release, which no prompt restriction would stop, + # cannot write anything with it. Adding `copilot-allow-tools` here + # would give up the first of those. GITHUB_TOKEN: ${{ github.token }} AVAILABLE_LABELS: ${{ steps.get-labels.outputs.result }} ITEM_TITLE: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.title || github.event.issue.title }} @@ -119,12 +132,24 @@ jobs: label1, label2, label3 ``` + apply-new-item-labels: + name: Apply Labels to New Issue or PR + needs: triage-new-item + if: needs.triage-new-item.outputs.response != '' + runs-on: ubuntu-latest + # Holds the write scopes `triage-new-item` gave up. Nothing here installs or + # runs the Copilot CLI, so this token is only ever exposed to the allowlist + # check below, which is what keeps a model response from becoming a write. + permissions: + contents: read + issues: write + pull-requests: write + steps: - name: Apply labels - if: steps.ai-triage.outputs.response != '' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - AI_RESPONSE: ${{ steps.ai-triage.outputs.response }} - AVAILABLE_LABELS: ${{ steps.get-labels.outputs.result }} + AI_RESPONSE: ${{ needs.triage-new-item.outputs.response }} + AVAILABLE_LABELS: ${{ needs.triage-new-item.outputs.labels }} with: script: | const response = process.env.AI_RESPONSE; @@ -265,6 +290,16 @@ jobs: github.event_name == 'workflow_dispatch' && inputs.issue_number != '' runs-on: ubuntu-latest + # Same split as `triage-new-item`: this job runs the Copilot CLI, so it + # holds no write scope. `apply-single-item-labels` does the writing. + permissions: + contents: read + issues: read + pull-requests: read + copilot-requests: write + outputs: + response: ${{ steps.ai-triage.outputs.response }} + labels: ${{ steps.get-labels.outputs.result }} steps: - name: Get available labels id: get-labels @@ -308,7 +343,10 @@ jobs: # Deliberately unpinned, unlike everything else here: authenticating with # `GITHUB_TOKEN` instead of a PAT only works on recent CLI releases, so a - # pin would be a slow trap rather than a safeguard. + # pin would be a slow trap rather than a safeguard. The exposure that + # would otherwise argue for pinning is handled by this job's permissions + # instead - the CLI runs with a read-only token and cannot write even if + # a release is compromised. - name: Install Copilot CLI run: npm install -g @github/copilot @@ -372,13 +410,23 @@ jobs: label1, label2, label3 ``` + apply-single-item-labels: + name: Apply Labels to Single Issue or PR + needs: triage-single-item + if: needs.triage-single-item.outputs.response != '' + runs-on: ubuntu-latest + # Holds the write scopes `triage-single-item` gave up, and runs no CLI. + permissions: + contents: read + issues: write + pull-requests: write + steps: - name: Apply labels - if: steps.ai-triage.outputs.response != '' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - AI_RESPONSE: ${{ steps.ai-triage.outputs.response }} + AI_RESPONSE: ${{ needs.triage-single-item.outputs.response }} ITEM_NUMBER: ${{ inputs.issue_number }} - AVAILABLE_LABELS: ${{ steps.get-labels.outputs.result }} + AVAILABLE_LABELS: ${{ needs.triage-single-item.outputs.labels }} with: script: | const response = process.env.AI_RESPONSE;