Skip to content

Commit 66f5aaa

Browse files
authored
Use a scoped Aspire App token for Copilot review requests (#20016)
* Add dry-run organization-funded Copilot review workflow Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 98e48823-4d0d-4cbc-aa9b-eba1c86342b0 * Skip stale PRs in scheduled Copilot review scans Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 98e48823-4d0d-4cbc-aa9b-eba1c86342b0 * Request Copilot reviews with a scoped Aspire App token Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 98e48823-4d0d-4cbc-aa9b-eba1c86342b0 * Defer Dependabot Copilot reviews to scheduled reconciliation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 98e48823-4d0d-4cbc-aa9b-eba1c86342b0 --------- Copilot-Session: 98e48823-4d0d-4cbc-aa9b-eba1c86342b0
1 parent 3fe9adc commit 66f5aaa

4 files changed

Lines changed: 120 additions & 38 deletions

File tree

.github/workflows/organization-funded-copilot-reviews.yml

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Bot-requested reviews are billed to the organization, unlike ruleset reviews.
1+
# Request reviews through the Aspire App; organization billing still needs verification.
22
# See docs/ci/organization-funded-copilot-reviews.md before enabling writes.
33
name: Organization-funded Copilot reviews
44

@@ -28,23 +28,38 @@ concurrency:
2828

2929
jobs:
3030
request-reviews:
31+
# Dependabot PR events can lack Actions secrets. Skip before token creation;
32+
# scheduled/manual runs still reconcile those PRs using their own credentials.
33+
# https://docs.github.com/en/code-security/reference/supply-chain-security/dependabot-on-actions#restrictions-when-dependabot-triggers-events
3134
if: >-
3235
github.repository == 'microsoft/aspire' &&
3336
vars.COPILOT_REVIEW_MODE != 'disabled' &&
37+
(github.event_name != 'pull_request_target' || github.actor != 'dependabot[bot]') &&
3438
(github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main')
3539
runs-on: ubuntu-latest
3640
timeout-minutes: 10
37-
permissions:
38-
pull-requests: write
41+
permissions: {}
3942
steps:
43+
# Explicit scope avoids inheriting the App's broader installation permissions.
44+
# Dry-run gets read access only; the action revokes the token during job cleanup.
45+
- name: Create Aspire App token
46+
id: app-token
47+
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
48+
with:
49+
client-id: ${{ secrets.ASPIRE_BOT_APP_ID }}
50+
private-key: ${{ secrets.ASPIRE_BOT_PRIVATE_KEY }}
51+
owner: microsoft
52+
repositories: aspire
53+
permission-pull-requests: ${{ vars.COPILOT_REVIEW_MODE == 'enabled' && 'write' || 'read' }}
54+
skip-token-revoke: false
4055
# Intentionally inline: this privileged workflow never checks out code.
4156
- name: Reconcile Copilot reviews
4257
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
4358
env:
4459
COPILOT_REVIEW_MODE: ${{ vars.COPILOT_REVIEW_MODE }}
4560
COPILOT_REVIEW_PR_NUMBER: ${{ vars.COPILOT_REVIEW_PR_NUMBER }}
4661
with:
47-
github-token: ${{ github.token }}
62+
github-token: ${{ steps.app-token.outputs.token }}
4863
# A timed-out POST may already have started a billable review.
4964
retries: 0
5065
script: |
@@ -157,7 +172,7 @@ jobs:
157172
return;
158173
}
159174
await github.rest.pulls.requestReviewers({ ...args, reviewers: [reviewer] });
160-
record(number, sha, 'Requested Copilot review as the Actions bot.');
175+
record(number, sha, 'Requested Copilot review as the Aspire App.');
161176
}
162177
163178
if (context.eventName === 'pull_request_target') {

docs/ci/organization-funded-copilot-reviews.md

Lines changed: 70 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,28 @@
11
# Organization-funded Copilot reviews
22

33
The `Organization-funded Copilot reviews` workflow requests Copilot code review
4-
(CCR) using the repository's `GITHUB_TOKEN`, not a contributor's personal token.
4+
(CCR) using an installation token for `aspire-repo-bot`, not the workflow's
5+
`GITHUB_TOKEN` or a contributor's personal token. Organization funding is the
6+
goal, not a verified billing guarantee.
57
It covers open PRs (including drafts) targeting `main` or `release/**`, including forks
68
and bot-authored PRs. There is no author permission or license filter.
79

8-
**The default is dry-run. Merging this workflow does not enable billable review
9-
requests or change any GitHub rulesets.**
10+
**The default is dry-run only when the mode variable is unset. If it is already
11+
`enabled`, merging this change switches subsequent runs to the App immediately.
12+
Set dry-run and a pilot PR before merging to stage the rollout.** No GitHub
13+
rulesets are changed by the workflow.
14+
15+
## App prerequisites
16+
17+
The Aspire App must be installed on `microsoft/aspire` with Pull requests: write.
18+
The workflow uses the existing `ASPIRE_BOT_APP_ID` and `ASPIRE_BOT_PRIVATE_KEY`
19+
Actions secrets. The token action accepts the existing identifier via its
20+
`client-id` input, matching other repository workflows.
21+
22+
Each run mints an installation token scoped explicitly to the `microsoft`
23+
installation and the `aspire` repository. Only Pull requests access is requested:
24+
write when the mode is exactly `enabled`, otherwise read. Token creation or
25+
permission failures stop the job; there is no fallback to `GITHUB_TOKEN`.
1026

1127
## Controls
1228

@@ -16,7 +32,7 @@ Variables**. No follow-up PR is needed to change modes.
1632
| Variable | Value | Effect |
1733
| --- | --- | --- |
1834
| `COPILOT_REVIEW_MODE` | Unset or `dry-run` | Read metadata and report decisions; never request reviews. |
19-
| `COPILOT_REVIEW_MODE` | `enabled` | Request reviews using the Actions bot. |
35+
| `COPILOT_REVIEW_MODE` | `enabled` | Request reviews using the Aspire App. |
2036
| `COPILOT_REVIEW_MODE` | `disabled` | Skip the job (kill switch). |
2137
| `COPILOT_REVIEW_PR_NUMBER` | Unset | Consider all eligible PRs, including PRs opened before rollout. |
2238
| `COPILOT_REVIEW_PR_NUMBER` | A positive PR number | Restrict both event-driven and scheduled processing to that PR for a pilot. |
@@ -33,6 +49,15 @@ including for draft PRs. Reopening a PR or marking a draft ready does not trigge
3349
an additional run. Manual dispatch on `main` and a scheduled
3450
scan every 15 minutes reconcile open PRs. GitHub can delay scheduled runs.
3551

52+
`pull_request_target` runs initiated by `dependabot[bot]` skip the entire job,
53+
before App token creation, because [Dependabot PR events can lack Actions
54+
secrets](https://docs.github.com/en/code-security/reference/supply-chain-security/dependabot-on-actions#restrictions-when-dependabot-triggers-events).
55+
Dependabot-authored PRs remain eligible for scheduled scans and maintainer
56+
manual dispatches, which use their own execution context and credentials.
57+
Their reviews therefore normally wait for the next scan rather than running
58+
immediately on creation or push. Do not copy the App private key into Dependabot
59+
secrets; it is not needed for this recovery path.
60+
3661
Scheduled scans skip PRs with no activity in the last 14 days, using GitHub's
3762
`updated_at` timestamp (not the PR creation date or latest commit date). The
3863
cutoff is inclusive: an update exactly 14 days before the run is stale. The
@@ -76,11 +101,17 @@ removes someone else's review request to force a retry.
76101

77102
- The privileged workflow does not check out source, execute PR code, load local
78103
actions, install packages, or consume artifacts or caches.
79-
- The only action is a full-SHA-pinned `actions/github-script`. All logic is
80-
inline so even loading policy code does not require checkout.
81-
- The only token permission is `pull-requests: write`. The job uses an ephemeral
82-
GitHub-hosted runner with a ten-minute timeout. No App private key or PAT is
83-
used.
104+
- Both actions (`actions/create-github-app-token` and `actions/github-script`)
105+
are full-SHA-pinned. All policy logic is inline, requiring no checkout.
106+
- The workflow's `GITHUB_TOKEN` has no granted permissions. The App installation
107+
token is restricted to this repository and Pull requests read/write as
108+
described above, plus GitHub's mandatory metadata read access. The token action
109+
revokes it during job cleanup; installation tokens also expire after one hour.
110+
- The App private key is passed only to the token action, not to the inline
111+
reconciliation script. Unlike the narrowed token, the private key can mint
112+
tokens with the App's broader installed permissions: protecting the secret
113+
and trusted workflow/runtime remains essential. No PAT is used. The job uses
114+
an ephemeral GitHub-hosted runner with a ten-minute timeout.
84115
- Repository and reviewer identities are fixed. PR-controlled values are API
85116
data, never interpolated into scripts or used as API URLs. Logs and summaries
86117
include only validated PR numbers, SHAs, and fixed decision messages.
@@ -91,45 +122,56 @@ removes someone else's review request to force a retry.
91122
suggestions. CCR's own runner/setup and secret configuration is a separate
92123
security boundary that must be assessed before the pilot.
93124

94-
Dry-run uses the same token permissions but never calls the write endpoint.
125+
Dry-run uses a read-only App token and never calls the write endpoint. It still
126+
requires the App secrets for token creation; disabled mode skips the entire job.
95127
The single-PR scope and kill switch are rollout controls, not a hard spending
96-
limit. Once enabled for everyone, contributors can generate organization-paid
97-
reviews by pushing changes. Configure organization budgets/alerts and monitor
128+
limit. Once enabled for everyone, contributors can generate potentially billable
129+
reviews by pushing changes. Configure appropriate budgets/alerts and monitor
98130
usage; reconsider cadence if this becomes expensive or is abused.
99131

100132
## Billing and rollout
101133

102134
[GitHub's CCR documentation](https://docs.github.com/en/copilot/concepts/agents/code-review#code-review-usage)
103135
attributes built-in automatic reviews to the author and explicitly states that
104136
bot-requested reviews are billed directly to the organization.
105-
[`GITHUB_TOKEN`](https://docs.github.com/en/actions/concepts/security/github_token)
106-
is an installation token, not the identity of the contributor triggering the
107-
workflow. GitHub documents
137+
However, the previous `GITHUB_TOKEN` implementation showed an Actions-bot review
138+
request followed by CCR starting on behalf of the contributor on PR #18530,
139+
and a quota-limit rejection on PR #17949. Neither is a billing receipt, but
140+
they undermine the assumption that a bot requester alone proves organization
141+
funding. `GITHUB_TOKEN` is itself an installation token; using a separate App
142+
changes the requesting identity, not a documented billing-account selector.
143+
144+
GitHub documents
108145
[requesting the Copilot reviewer through REST](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/request-a-code-review/use-code-review).
109-
These rules support this design, but actual execution and billing under
110-
Microsoft's policies must be confirmed before broad rollout.
146+
Actual execution, quota enforcement, and billing under Microsoft's policies must
147+
be confirmed separately before broad rollout.
111148

112-
1. Merge in dry-run mode and inspect workflow decisions. No production billing
113-
settings or automatic review rules are changed by this PR.
149+
1. Before merging, set `COPILOT_REVIEW_MODE=dry-run` and
150+
`COPILOT_REVIEW_PR_NUMBER` to an agreed team-owned pilot PR. Cancel existing
151+
enabled workflow runs if needed; changing variables does not stop them.
152+
After merging, inspect workflow decisions and successful App token creation.
114153
2. Identify applicable repository and organization automatic CCR rules,
115154
including "Review new pushes." Disable those paths before enabling writes,
116155
or they can still create author-attributed or duplicate reviews.
117-
3. Set `COPILOT_REVIEW_PR_NUMBER` to an agreed pilot PR. Assess CCR's downstream
118-
runner permissions and confirm organization funding/budget policies.
119-
4. Set `COPILOT_REVIEW_MODE=enabled`. Confirm that the Actions bot starts a
156+
3. Assess CCR's downstream runner permissions and confirm organization
157+
funding/budget policies. Obtain the pilot participant's consent for possible
158+
personal allowance consumption; do not experiment on unsuspecting customers.
159+
4. Set `COPILOT_REVIEW_MODE=enabled`. Confirm that `aspire-repo-bot[bot]` requests a
120160
review, then push another commit and confirm a re-review, including a push
121-
during an active review. Include an external contributor in the pilot.
161+
during an active review. Record PR/head, timestamps, requesting actor,
162+
execution attribution, and any quota errors for billing correlation.
122163
5. Have a billing administrator confirm organization attribution and no
123164
contributor allowance consumption for these bot-requested reviews. An API
124165
success or a posted review alone is not proof of billing attribution.
125166
6. Clear the pilot variable to cover all eligible open PRs. Keep human approval
126167
requirements unchanged and monitor spend and failed workflow runs.
127168

128-
If `GITHUB_TOKEN` cannot initiate CCR under organization policy, leave writes
129-
disabled until that failure is understood. An Aspire bot App installation token
130-
is a possible follow-up, scoped to this repository with Pull requests: write.
131-
Do not substitute a personal token, silently escalate permissions, or claim
132-
organization billing based only on the human who triggered a workflow run.
169+
If the App cannot initiate CCR or attribution remains unclear, leave writes
170+
disabled and escalate the pilot evidence to GitHub support. Ask which principal
171+
is checked for quota and which account is billed, and whether Actions and
172+
independent App installation tokens are handled differently. Do not substitute
173+
a personal token, silently escalate permissions, or infer billing solely from
174+
the requesting bot or the displayed "on behalf of" identity.
133175

134176
Personal automatic-review settings and manual requests are outside this
135177
workflow's control and retain their own billing attribution. Actions usage for

tests/Infrastructure.Tests/WorkflowScripts/OrganizationFundedCopilotReviewsTests.cs

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,29 @@ public void WorkflowKeepsPrivilegedExecutionMetadataOnly()
2929
var job = Assert.IsType<YamlMappingNode>(Assert.Single(jobs.Children).Value);
3030
Assert.Equal(
3131
"github.repository == 'microsoft/aspire' && vars.COPILOT_REVIEW_MODE != 'disabled' && " +
32+
"(github.event_name != 'pull_request_target' || github.actor != 'dependabot[bot]') && " +
3233
"(github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main')",
3334
Scalar(job, "if"));
3435
Assert.Equal("ubuntu-latest", Scalar(job, "runs-on"));
3536
Assert.Equal("10", Scalar(job, "timeout-minutes"));
36-
var permission = Assert.Single(Mapping(job, "permissions").Children);
37-
Assert.Equal("pull-requests", permission.Key.ToString());
38-
Assert.Equal("write", permission.Value.ToString());
37+
Assert.Empty(Mapping(job, "permissions").Children);
38+
39+
var steps = Assert.IsType<YamlSequenceNode>(job.Children[new YamlScalarNode("steps")]);
40+
Assert.Equal(2, steps.Children.Count);
41+
var tokenStep = Assert.IsType<YamlMappingNode>(steps.Children[0]);
42+
Assert.Equal(["name", "id", "uses", "with"], tokenStep.Children.Keys.Select(key => key.ToString()));
43+
Assert.Equal("app-token", Scalar(tokenStep, "id"));
44+
Assert.Matches("^actions/create-github-app-token@[a-f0-9]{40}$", Scalar(tokenStep, "uses"));
45+
var tokenOptions = Mapping(tokenStep, "with");
46+
Assert.Equal(
47+
["client-id", "private-key", "owner", "repositories", "permission-pull-requests", "skip-token-revoke"],
48+
tokenOptions.Children.Keys.Select(key => key.ToString()));
49+
Assert.Equal("${{ secrets.ASPIRE_BOT_APP_ID }}", Scalar(tokenOptions, "client-id"));
50+
Assert.Equal("${{ secrets.ASPIRE_BOT_PRIVATE_KEY }}", Scalar(tokenOptions, "private-key"));
51+
Assert.Equal("microsoft", Scalar(tokenOptions, "owner"));
52+
Assert.Equal("aspire", Scalar(tokenOptions, "repositories"));
53+
Assert.Equal("${{ vars.COPILOT_REVIEW_MODE == 'enabled' && 'write' || 'read' }}", Scalar(tokenOptions, "permission-pull-requests"));
54+
Assert.Equal("false", Scalar(tokenOptions, "skip-token-revoke"));
3955

4056
var step = ScriptStep(root);
4157
Assert.Equal(["name", "uses", "env", "with"], step.Children.Keys.Select(key => key.ToString()));
@@ -46,7 +62,7 @@ public void WorkflowKeepsPrivilegedExecutionMetadataOnly()
4662
Assert.Equal("${{ vars.COPILOT_REVIEW_PR_NUMBER }}", Scalar(environment, "COPILOT_REVIEW_PR_NUMBER"));
4763
var options = Mapping(step, "with");
4864
Assert.Equal(["github-token", "retries", "script"], options.Children.Keys.Select(key => key.ToString()));
49-
Assert.Equal("${{ github.token }}", Scalar(options, "github-token"));
65+
Assert.Equal("${{ steps.app-token.outputs.token }}", Scalar(options, "github-token"));
5066
Assert.Equal("0", Scalar(options, "retries"));
5167
Assert.Equal(-1, Scalar(options, "script").IndexOf("${{", StringComparison.Ordinal));
5268
}
@@ -71,6 +87,8 @@ public void WorkflowKeepsPrivilegedExecutionMetadataOnly()
7187
[InlineData("stale-manual")]
7288
[InlineData("external-author")]
7389
[InlineData("bot-author")]
90+
[InlineData("dependabot-scheduled")]
91+
[InlineData("dependabot-manual")]
7492
[InlineData("draft")]
7593
[InlineData("draft-scheduled")]
7694
[InlineData("closed")]
@@ -131,7 +149,8 @@ private static YamlMappingNode LoadWorkflow()
131149
private static YamlMappingNode ScriptStep(YamlMappingNode root)
132150
{
133151
var job = Mapping(Mapping(root, "jobs"), "request-reviews");
134-
return Assert.IsType<YamlMappingNode>(Assert.Single(Assert.IsType<YamlSequenceNode>(job.Children[new YamlScalarNode("steps")])));
152+
var steps = Assert.IsType<YamlSequenceNode>(job.Children[new YamlScalarNode("steps")]);
153+
return Assert.IsType<YamlMappingNode>(steps.Children[1]);
135154
}
136155

137156
private static YamlMappingNode Mapping(YamlMappingNode node, string key)

tests/Infrastructure.Tests/WorkflowScripts/organization-funded-copilot-reviews.harness.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,12 @@ switch (scenario) {
131131
}
132132
expectedWrites = 1;
133133
break;
134+
case 'dependabot-scheduled':
135+
case 'dependabot-manual':
136+
pull.user = { login: 'dependabot[bot]', type: 'Bot' };
137+
context.eventName = scenario === 'dependabot-scheduled' ? 'schedule' : 'workflow_dispatch';
138+
expectedWrites = 1;
139+
break;
134140
case 'draft':
135141
case 'draft-scheduled':
136142
pull.draft = true;

0 commit comments

Comments
 (0)