Skip to content

Commit c8eba3e

Browse files
vishesh92claude
andcommitted
Rotate Copilot tokens for the gh-aw agentic workflows
The scheduled agentic workflows ran on a single COPILOT_GITHUB_TOKEN, which burns through one person's Copilot quota. Volunteers can now add their own fine-grained PATs as COPILOT_GITHUB_TOKEN_<alias> secrets and register the alias in the GH_AW_COPILOT_TOKEN_NAMES repo variable. Runs pick a token by day of year, skip dead ones, and fall back to the base secret when nothing in the pool is live. The two workflows start half the pool apart so they don't drain the same volunteer on the same day. Strict mode forbids reading secrets in the agent job, so each workflow defines a pick_copilot_token job that outputs only the chosen alias; the agent job resolves the secret itself. gh aw compile doesn't know about this wiring, so .github/scripts/post-compile.sh re-applies it to the lock files after every compile, including pointing the log redaction step at the rotated token. A manual copilot-token-health workflow reports which pool tokens are still live. See .github/COPILOT_TOKENS.md for how to contribute a token. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5c4bc48 commit c8eba3e

7 files changed

Lines changed: 640 additions & 46 deletions

File tree

.github/COPILOT_TOKENS.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<!--
2+
Licensed to the Apache Software Foundation (ASF) under one
3+
or more contributor license agreements. See the NOTICE file
4+
distributed with this work for additional information
5+
regarding copyright ownership. The ASF licenses this file
6+
to you under the Apache License, Version 2.0 (the
7+
"License"); you may not use this file except in compliance
8+
with the License. You may obtain a copy of the License at
9+
10+
http://www.apache.org/licenses/LICENSE-2.0
11+
12+
Unless required by applicable law or agreed to in writing,
13+
software distributed under the License is distributed on an
14+
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
KIND, either express or implied. See the License for the
16+
specific language governing permissions and limitations
17+
under the License.
18+
-->
19+
20+
# Contributing a Copilot token for the agentic workflows
21+
22+
This repo runs scheduled [GitHub Agentic Workflows](https://github.github.com/gh-aw/) (the
23+
`*.lock.yml` files compiled from `*.md` in `.github/workflows/`) that drive the GitHub Copilot
24+
CLI. Each run needs a GitHub token from an account with an active Copilot license. So that no
25+
single person's Copilot quota gets burned through, runs rotate day by day across a pool of
26+
volunteer tokens.
27+
28+
If you have a Copilot license and want to help share the load, add your token to the pool.
29+
30+
## What kind of token
31+
32+
- A fine-grained personal access token. Classic PATs don't work with the Copilot CLI.
33+
- Resource owner: your own personal account.
34+
- Permission: Account permissions > "Copilot Requests" > Read. That's the only permission it
35+
needs, no repo access.
36+
- Your account must have an active Copilot seat.
37+
38+
Create it at <https://github.com/settings/personal-access-tokens/new>. Give it a sensible
39+
expiration; when it lapses the health check (below) will flag it and you can re-add it.
40+
41+
## How to add it
42+
43+
1. Pick a short alias for yourself, e.g. `t1`, `t2`, `vol3`. The alias shows up in workflow logs,
44+
so keep it non-identifying if you prefer.
45+
2. Add your token as a repository secret named `COPILOT_GITHUB_TOKEN_<alias>`
46+
(e.g. `COPILOT_GITHUB_TOKEN_t1`). Repo admins do this via
47+
*Settings > Secrets and variables > Actions > New repository secret*, or:
48+
```
49+
gh secret set COPILOT_GITHUB_TOKEN_t1 --body "github_pat_xxx"
50+
```
51+
3. Ask a repo admin to register the alias by appending it to the repository variable
52+
`GH_AW_COPILOT_TOKEN_NAMES`, which is a JSON array:
53+
```
54+
gh variable set GH_AW_COPILOT_TOKEN_NAMES --body '["t1","t2","t3"]'
55+
```
56+
The workflows can't enumerate secrets, so this variable is the source of truth for the pool.
57+
A token isn't used until its alias is listed there.
58+
59+
## How rotation works (for maintainers)
60+
61+
Each agent workflow (`daily-repo-status`, `daily-issue-triage`) defines a `pick_copilot_token`
62+
job in its `.md` source. The job has to run outside the agent job because strict mode forbids
63+
reading secrets there. It picks today's alias by day-of-year mod N, checks the token is live
64+
(`GET /user` returns 200, otherwise it moves on to the next candidate) and outputs the chosen
65+
alias. The token value itself never crosses jobs. The two workflows use different
66+
`ROTATION_SLOT`s, which start them half the pool apart so they don't land on the same
67+
volunteer on the same day (with at least two tokens in the pool).
68+
69+
The agent job resolves the secret itself via
70+
`secrets[format('COPILOT_GITHUB_TOKEN_{0}', needs.pick_copilot_token.outputs.name)]` and falls
71+
back to the base `COPILOT_GITHUB_TOKEN` when the pick job outputs an empty name. Keep the base
72+
secret set to one reliable token.
73+
74+
`gh aw compile` doesn't know about this wiring, so after editing the `.md` sources run:
75+
76+
```
77+
gh aw compile && bash .github/scripts/post-compile.sh
78+
```
79+
80+
See the header of `.github/scripts/post-compile.sh` for what it patches.
81+
82+
To check the pool, trigger the "Copilot token health" workflow
83+
(`.github/workflows/copilot-token-health.yml`) from the Actions tab. It prints an HTTP status
84+
code per alias and nothing else, so no account identities end up in logs. Note it can't tell
85+
when a token is live but has used up its monthly Copilot requests.
86+
87+
## Removing a token
88+
89+
Delete the `COPILOT_GITHUB_TOKEN_<alias>` secret and remove `<alias>` from
90+
`GH_AW_COPILOT_TOKEN_NAMES`.

.github/scripts/post-compile.sh

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
#!/usr/bin/env bash
2+
# Licensed to the Apache Software Foundation (ASF) under one
3+
# or more contributor license agreements. See the NOTICE file
4+
# distributed with this work for additional information
5+
# regarding copyright ownership. The ASF licenses this file
6+
# to you under the Apache License, Version 2.0 (the
7+
# "License"); you may not use this file except in compliance
8+
# with the License. You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing,
13+
# software distributed under the License is distributed on an
14+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
# KIND, either express or implied. See the License for the
16+
# specific language governing permissions and limitations
17+
# under the License.
18+
19+
# Re-applies the token round-robin wiring to the gh-aw generated .lock.yml files,
20+
# which `gh aw compile` doesn't know about. Run after every compile:
21+
#
22+
# gh aw compile && bash .github/scripts/post-compile.sh
23+
#
24+
# Three edits per lock file (see .github/COPILOT_TOKENS.md for the design):
25+
# - point the agent execute step's COPILOT_GITHUB_TOKEN at the pick_copilot_token
26+
# job's output, falling back to the base secret
27+
# - point the agent job's "Redact secrets in logs" step at the same rotated token,
28+
# so a volunteer token is scrubbed from uploaded artifacts, not just the base one
29+
# - make the agent job depend on pick_copilot_token, and strip the self-reference
30+
# gh-aw sometimes adds to pick_copilot_token's own needs (that would be a cycle)
31+
#
32+
# Safe to re-run; a second run is a no-op.
33+
34+
set -euo pipefail
35+
36+
cd "$(git rev-parse --show-toplevel)"
37+
38+
# Kept in an env var so perl doesn't try to interpolate the ${{ }} bits.
39+
export NEWVAL='${{ needs.pick_copilot_token.outputs.name != '"'"''"'"' && secrets[format('"'"'COPILOT_GITHUB_TOKEN_{0}'"'"', needs.pick_copilot_token.outputs.name)] || secrets.COPILOT_GITHUB_TOKEN }}'
40+
41+
FILES=(
42+
".github/workflows/daily-repo-status.lock.yml"
43+
".github/workflows/daily-issue-triage.lock.yml"
44+
)
45+
46+
fail() { echo "ERROR: $1" >&2; exit 1; }
47+
48+
# Fixes up `needs:` for the agent and pick_copilot_token jobs only; gh-aw adds
49+
# pick_copilot_token to several other jobs' needs and those must stay as-is.
50+
# Reads stdin, writes stdout. gh-aw emits inline needs (needs: foo) for single
51+
# dependencies and block form for lists; inline is fine unless it needs editing,
52+
# in which case "INLINE_NEEDS:<job>" is printed to stderr and the caller bails.
53+
normalise_needs() {
54+
awk '
55+
function isjob(l){ return (l ~ /^ [A-Za-z0-9_-]+:[ \t]*$/) }
56+
BEGIN { job=""; inneeds=0; agentpick=0 }
57+
{
58+
line=$0
59+
if (isjob(line)) {
60+
if (inneeds && job=="agent" && !agentpick) print " - pick_copilot_token"
61+
inneeds=0; agentpick=0
62+
name=line; sub(/^ /,"",name); sub(/:[ \t]*$/,"",name); job=name
63+
print line; next
64+
}
65+
if (line ~ /^ needs:[ \t]*[^ \t]/) {
66+
if (job=="agent" && line !~ /pick_copilot_token/) print "INLINE_NEEDS:" job > "/dev/stderr"
67+
if (job=="pick_copilot_token" && line ~ /pick_copilot_token/) print "INLINE_NEEDS:" job > "/dev/stderr"
68+
print line; next
69+
}
70+
if (line ~ /^ needs:[ \t]*$/) { inneeds=1; agentpick=0; print line; next }
71+
if (inneeds) {
72+
if (line ~ /^ - /) {
73+
item=line; sub(/^ - /,"",item); gsub(/[ \t\r]/,"",item)
74+
if (job=="pick_copilot_token" && item=="pick_copilot_token") next
75+
if (job=="agent" && item=="pick_copilot_token") agentpick=1
76+
print line; next
77+
} else {
78+
if (job=="agent" && !agentpick) print " - pick_copilot_token"
79+
inneeds=0
80+
print line; next
81+
}
82+
}
83+
print line
84+
}
85+
END { if (inneeds && job=="agent" && !agentpick) print " - pick_copilot_token" }
86+
'
87+
}
88+
89+
for f in "${FILES[@]}"; do
90+
if [ ! -f "$f" ]; then
91+
echo "WARN: $f not found, run 'gh aw compile' first? Skipping" >&2
92+
continue
93+
fi
94+
95+
# Repoint the agent execute step's token. The anchor is the GH_AW_PHASE: agent env
96+
# var further down the same env block; the detection job's block has
97+
# GH_AW_PHASE: detection so it doesn't match and keeps the base token.
98+
before=$(grep -cF 'COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}' "$f" || true)
99+
perl -0pi -e \
100+
's/^([ \t]*)COPILOT_GITHUB_TOKEN:[ \t]*\$\{\{[ \t]*secrets\.COPILOT_GITHUB_TOKEN[ \t]*\}\}[ \t]*\n(?=(?:[ \t]+[A-Z][A-Za-z0-9_]*:[^\n]*\n)*?[ \t]+GH_AW_PHASE:[ \t]*agent[ \t]*\n)/$1."COPILOT_GITHUB_TOKEN: ".$ENV{NEWVAL}."\n"/me' \
101+
"$f"
102+
after=$(grep -cF 'COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}' "$f" || true)
103+
removed=$(( before - after ))
104+
if [ "$removed" -eq 1 ]; then token_edit="applied"
105+
elif [ "$removed" -eq 0 ] && grep -qE '^[ \t]+COPILOT_GITHUB_TOKEN: \$\{\{ needs\.pick_copilot_token' "$f"; then token_edit="already"
106+
else fail "$f: execute-step token line not patched as expected (removed=$removed), anchor drifted?"
107+
fi
108+
109+
# Repoint the redact step's SECRET_COPILOT_GITHUB_TOKEN the same way; the line is
110+
# unique to the agent job's "Redact secrets in logs" step.
111+
before=$(grep -cF 'SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}' "$f" || true)
112+
if [ "$before" -gt 1 ]; then
113+
fail "$f: expected at most one redact-step SECRET_COPILOT_GITHUB_TOKEN line, found $before"
114+
fi
115+
perl -0pi -e \
116+
's/^([ \t]*)SECRET_COPILOT_GITHUB_TOKEN:[ \t]*\$\{\{[ \t]*secrets\.COPILOT_GITHUB_TOKEN[ \t]*\}\}[ \t]*$/$1."SECRET_COPILOT_GITHUB_TOKEN: ".$ENV{NEWVAL}/me' \
117+
"$f"
118+
after=$(grep -cF 'SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}' "$f" || true)
119+
if [ "$before" -eq 1 ] && [ "$after" -eq 0 ]; then redact_edit="applied"
120+
elif [ "$before" -eq 0 ] && grep -qF 'SECRET_COPILOT_GITHUB_TOKEN: ${{ needs.pick_copilot_token.outputs.name' "$f"; then redact_edit="already"
121+
else fail "$f: redact-step SECRET_COPILOT_GITHUB_TOKEN line not patched (before=$before after=$after)"
122+
fi
123+
124+
errf="$(mktemp)"
125+
normalise_needs < "$f" > "$f.tmp" 2>"$errf"
126+
if grep -q '^INLINE_NEEDS:' "$errf"; then
127+
rm -f "$f.tmp"; rm -f "$errf"
128+
fail "$f: agent/pick_copilot_token have inline 'needs:' that would need editing, update normalise_needs"
129+
fi
130+
rm -f "$errf"
131+
mv "$f.tmp" "$f"
132+
133+
# sanity checks
134+
self_refs=$(awk '
135+
/^ pick_copilot_token:[ \t]*$/{p=1;next}
136+
/^ [A-Za-z0-9_-]+:[ \t]*$/{p=0}
137+
p && /^ - pick_copilot_token[ \t]*$/{c++}
138+
END{print c+0}' "$f")
139+
[ "$self_refs" -eq 0 ] || fail "$f: pick_copilot_token still self-references (cycle)"
140+
awk '/^ agent:[ \t]*$/{a=1} /^ [A-Za-z0-9_-]+:[ \t]*$/ && !/agent/{if(a&&!seen)exit 3} a && /^ - pick_copilot_token/{seen=1} END{exit (seen?0:3)}' "$f" \
141+
|| fail "$f: agent job does not depend on pick_copilot_token"
142+
grep -qF 'Validate COPILOT_GITHUB_TOKEN secret' "$f" || echo "WARN: validate-secret step missing in $f" >&2
143+
grep -qE '^ pick_copilot_token:$' "$f" || echo "WARN: pick_copilot_token job missing in $f, did compile include the .md jobs: block?" >&2
144+
145+
echo "$f: token-ref=$token_edit, redact-ref=$redact_edit, needs=normalised (self-refs=0, agent->pick ok)"
146+
done
147+
148+
echo "Done."
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
# Manual health check for the pool of volunteer Copilot tokens (see .github/COPILOT_TOKENS.md).
19+
# Trigger it from the Actions tab to find dead tokens in GH_AW_COPILOT_TOKEN_NAMES so they can be
20+
# pruned. Only HTTP status codes are printed, never account logins. A 200 just means the token is
21+
# live; there is no endpoint to check whether its monthly Copilot requests are used up.
22+
name: Copilot token health
23+
24+
on:
25+
workflow_dispatch: {}
26+
27+
permissions: {}
28+
29+
jobs:
30+
resolve:
31+
runs-on: ubuntu-latest
32+
outputs:
33+
names: ${{ steps.list.outputs.names }}
34+
steps:
35+
- id: list
36+
env:
37+
NAMES: ${{ vars.GH_AW_COPILOT_TOKEN_NAMES || '[]' }}
38+
run: echo "names=$NAMES" >> "$GITHUB_OUTPUT"
39+
40+
check:
41+
needs: resolve
42+
if: ${{ needs.resolve.outputs.names != '[]' && needs.resolve.outputs.names != '' }}
43+
runs-on: ubuntu-latest
44+
strategy:
45+
fail-fast: false
46+
matrix:
47+
name: ${{ fromJson(needs.resolve.outputs.names) }}
48+
steps:
49+
- name: Check token liveness
50+
env:
51+
TOKEN: ${{ secrets[format('COPILOT_GITHUB_TOKEN_{0}', matrix.name)] }}
52+
run: |
53+
set -euo pipefail
54+
if [ -z "${TOKEN:-}" ]; then
55+
echo "::error::no secret COPILOT_GITHUB_TOKEN_${{ matrix.name }} found for registered alias '${{ matrix.name }}'"
56+
exit 1
57+
fi
58+
code=$(curl -s -o /dev/null -w '%{http_code}' \
59+
-H "Authorization: Bearer $TOKEN" https://api.github.com/user || echo 000)
60+
echo "token '${{ matrix.name }}': HTTP $code"
61+
if [ "$code" != "200" ]; then
62+
echo "::error::token '${{ matrix.name }}' is not live (HTTP $code), consider removing it from GH_AW_COPILOT_TOKEN_NAMES"
63+
exit 1
64+
fi

0 commit comments

Comments
 (0)