From 9fd3059271f47ef014dd52d8f157f75633d43537 Mon Sep 17 00:00:00 2001 From: davida-ps Date: Thu, 11 Jun 2026 08:25:56 +0300 Subject: [PATCH] fix(traffic): require a traffic-capable PAT for the archive workflow (#265) * fix(traffic): use a traffic-capable PAT for the archive workflow The daily Archive GitHub Traffic run has failed since creation: the TRAFFIC_ARCHIVE_TOKEN secret was never provisioned, so the workflow fell back to github.token, which GitHub categorically rejects on traffic endpoints (403 "Resource not accessible by integration"). - Fall back to the existing POLL_NVD_CVES_PAT automation token instead of github.token, keeping TRAFFIC_ARCHIVE_TOKEN as the preferred override once provisioned. - Fail fast with an actionable error when no traffic-capable token is configured. - Explain token requirements in the script's 401/403 errors. Co-Authored-By: Claude Fable 5 * fix(traffic): require dedicated TRAFFIC_ARCHIVE_TOKEN, drop expired PAT fallback A live dispatch confirmed POLL_NVD_CVES_PAT is expired (401 Bad credentials), so falling back to it only trades one daily failure for another. Require the dedicated secret and fail fast with setup instructions instead. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .github/workflows/archive-traffic.yml | 16 +++++++++-- scripts/archive-github-traffic.mjs | 9 +++++- scripts/test-github-traffic-archive.mjs | 37 ++++++++++++++++++++++++- 3 files changed, 58 insertions(+), 4 deletions(-) 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/);