diff --git a/.github/workflows/archive-traffic.yml b/.github/workflows/archive-traffic.yml index bf5c11b..fc1c12e 100644 --- a/.github/workflows/archive-traffic.yml +++ b/.github/workflows/archive-traffic.yml @@ -53,9 +53,21 @@ jobs: - name: Collect traffic env: - GH_TRAFFIC_TOKEN: ${{ secrets.TRAFFIC_ARCHIVE_TOKEN || github.token }} + # Traffic endpoints reject the Actions GITHUB_TOKEN ("Resource not + # accessible by integration") — a PAT from a user with push access + # is required: classic with repo scope, or fine-grained with read + # access to Administration on this repository. + GH_TRAFFIC_TOKEN: ${{ secrets.TRAFFIC_ARCHIVE_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} - run: node scripts/archive-github-traffic.mjs --archive-dir "${TRAFFIC_ARCHIVE_DIR}" + run: | + set -euo pipefail + + if [ -z "${GH_TRAFFIC_TOKEN}" ]; then + echo "::error::No traffic-capable token configured. Set the TRAFFIC_ARCHIVE_TOKEN secret to a PAT with push access (classic: repo scope; fine-grained: Administration read)." + exit 1 + fi + + node scripts/archive-github-traffic.mjs --archive-dir "${TRAFFIC_ARCHIVE_DIR}" - name: Commit archive run: | diff --git a/scripts/archive-github-traffic.mjs b/scripts/archive-github-traffic.mjs index d224d0a..d1b6bb6 100644 --- a/scripts/archive-github-traffic.mjs +++ b/scripts/archive-github-traffic.mjs @@ -321,7 +321,14 @@ const fetchJson = async ({ repo, token, pathname, fetchImpl }) => { if (!response.ok) { const body = await response.text().catch(() => ''); const suffix = body ? ` ${body.slice(0, 500)}` : ''; - throw new Error(`GitHub traffic API request failed for ${repo}: ${url.pathname}${url.search} returned ${response.status}.${suffix}`); + const lacksPushAccess = response.status === 403 + && /resource not accessible|must have push access/i.test(body); + const hint = lacksPushAccess + ? ' Traffic endpoints require a token with push access to the repository; the Actions GITHUB_TOKEN is always rejected. Use a classic PAT with the repo scope or a fine-grained PAT with read access to Administration.' + : response.status === 401 + ? ' The token was rejected as invalid — it may be expired or revoked. Rotate the TRAFFIC_ARCHIVE_TOKEN secret.' + : ''; + throw new Error(`GitHub traffic API request failed for ${repo}: ${url.pathname}${url.search} returned ${response.status}.${suffix}${hint}`); } return response.json(); diff --git a/scripts/test-github-traffic-archive.mjs b/scripts/test-github-traffic-archive.mjs index d1c4e1e..84b8f62 100644 --- a/scripts/test-github-traffic-archive.mjs +++ b/scripts/test-github-traffic-archive.mjs @@ -76,6 +76,40 @@ test('fetchGitHubTraffic requests the daily GitHub traffic endpoints with auth', assert.deepEqual(snapshot.clones.clones, responses[`/repos/${TEST_REPOSITORY}/traffic/clones?per=day`].clones); }); +test('fetchGitHubTraffic explains traffic token requirements on 403', async () => { + const fetchImpl = async () => new globalThis.Response( + JSON.stringify({ message: 'Resource not accessible by integration' }), + { status: 403 }, + ); + + await assert.rejects( + fetchGitHubTraffic({ + repo: TEST_REPOSITORY, + token: 'installation-token', + capturedAt, + fetchImpl, + }), + /returned 403\..*push access/, + ); +}); + +test('fetchGitHubTraffic flags invalid tokens on 401', async () => { + const fetchImpl = async () => new globalThis.Response( + JSON.stringify({ message: 'Bad credentials' }), + { status: 401 }, + ); + + await assert.rejects( + fetchGitHubTraffic({ + repo: TEST_REPOSITORY, + token: 'expired-token', + capturedAt, + fetchImpl, + }), + /returned 401\..*expired or revoked/, + ); +}); + test('mergeTrafficArchive upserts daily views and clones without double-counting overlapping windows', () => { const archive = mergeTrafficArchive( { @@ -232,7 +266,8 @@ test('traffic archive workflow uses a daily schedule and a dedicated archive bra assert.match(workflow, /cron:\s+'17 3 \* \* \*'/); assert.match(workflow, /TRAFFIC_ARCHIVE_BRANCH:\s+traffic-archive/); - assert.match(workflow, /TRAFFIC_ARCHIVE_TOKEN/); + assert.match(workflow, /GH_TRAFFIC_TOKEN:\s*\$\{\{\s*secrets\.TRAFFIC_ARCHIVE_TOKEN\b/); + assert.doesNotMatch(workflow, /GH_TRAFFIC_TOKEN:[^\n]*github\.token/); assert.match(workflow, /node scripts\/archive-github-traffic\.mjs/); assert.match(workflow, /git add traffic\/archive\.json traffic\/summary\.json/); assert.match(workflow, /git rm --ignore-unmatch traffic\/README\.md/);