Skip to content

PAT rotation reminder #72

PAT rotation reminder

PAT rotation reminder #72

name: PAT rotation reminder
on:
schedule:
# 05:00 UTC daily — after the traffic snapshot has finished.
- cron: "0 5 * * *"
workflow_dispatch: {}
permissions:
issues: write
contents: write
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Read the secret's real updated_at from the API rather than trusting a
# hand-maintained date. The ledger drifted by 16 days in Aug 2026 because
# a rotation was done but the file was never bumped, which made the
# reminder fire against a stale date and left an issue open for 10 days.
- name: Resolve last rotation date
id: resolve
env:
GH_TOKEN: ${{ secrets.TRAFFIC_PAT }}
GH_REPO: ${{ github.repository }}
run: |
set -uo pipefail
LEDGER=".github/data/pat-rotation.json"
LEDGER_DATE=$(python -c "import json;print(json.load(open('$LEDGER'))['traffic_pat_last_rotated'])")
# TRAFFIC_PAT carries Administration:Read, so it can read its own
# secret metadata. If the call fails (token expired or scope changed)
# fall back to the ledger — a stale date is better than no check.
API_DATE=$(gh api "repos/$GH_REPO/actions/secrets/TRAFFIC_PAT" --jq '.updated_at[0:10]' 2>/dev/null || true)
# Validate the shape, do not just test for non-empty. On a 401 the
# CLI writes its error body to stdout, so API_DATE ends up holding a
# multi-line JSON blob rather than a date. That is non-empty, so the
# old check took the "api" branch with garbage, and writing a
# multi-line value to GITHUB_OUTPUT then failed the whole step. The
# net effect was that this reminder died exactly when the token had
# expired, which is the one moment it needs to fire.
if ! printf '%s' "$API_DATE" | grep -Eq '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'; then
API_DATE=""
fi
if [ -n "$API_DATE" ]; then
SOURCE="api"
LAST_ROTATED="$API_DATE"
else
SOURCE="ledger"
LAST_ROTATED="$LEDGER_DATE"
echo "::warning::Could not read secret metadata — TRAFFIC_PAT may be expired. Falling back to the ledger date."
fi
echo "last_rotated=$LAST_ROTATED" >> "$GITHUB_OUTPUT"
echo "ledger_date=$LEDGER_DATE" >> "$GITHUB_OUTPUT"
echo "source=$SOURCE" >> "$GITHUB_OUTPUT"
echo "Last rotated: $LAST_ROTATED (source: $SOURCE)"
# Keep the human-readable ledger in sync automatically.
- name: Sync ledger if it drifted
if: steps.resolve.outputs.source == 'api' && steps.resolve.outputs.ledger_date != steps.resolve.outputs.last_rotated
env:
NEW_DATE: ${{ steps.resolve.outputs.last_rotated }}
run: |
set -euo pipefail
python - <<'PY'
import json, os
p = ".github/data/pat-rotation.json"
d = json.load(open(p))
d["traffic_pat_last_rotated"] = os.environ["NEW_DATE"]
with open(p, "w") as f:
json.dump(d, f, indent=2)
f.write("\n")
PY
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add .github/data/pat-rotation.json
if ! git diff --cached --quiet; then
git commit -m "chore: sync PAT rotation ledger to ${NEW_DATE}"
git push
fi
- name: Open issue if approaching expiry
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
LAST_ROTATED: ${{ steps.resolve.outputs.last_rotated }}
run: |
set -euo pipefail
LEDGER=".github/data/pat-rotation.json"
EXPIRY_DAYS=$(python -c "import json;print(json.load(open('$LEDGER'))['traffic_pat_expiry_days'])")
WARN_DAYS=$(python -c "import json;print(json.load(open('$LEDGER'))['warn_days_before_expiry'])")
AGE=$(python -c "
from datetime import date
import os
last = date.fromisoformat(os.environ['LAST_ROTATED'])
print((date.today() - last).days)
")
REMAINING=$((EXPIRY_DAYS - AGE))
echo "TRAFFIC_PAT age ${AGE}d — ${REMAINING}d until expiry."
if [ "$AGE" -lt $((EXPIRY_DAYS - WARN_DAYS)) ]; then
echo "Still fresh. No action needed."
exit 0
fi
# Close any stale reminder that predates the current rotation —
# otherwise an old issue suppresses the new one indefinitely.
STALE=$(gh issue list --repo "$GH_REPO" --state open --label pat-rotation-due \
--json number,createdAt \
--jq ".[] | select(.createdAt[0:10] < \"$LAST_ROTATED\") | .number" || true)
for N in $STALE; do
echo "Closing stale reminder #$N (predates rotation on $LAST_ROTATED)"
gh issue close "$N" --repo "$GH_REPO" \
--comment "Superseded — TRAFFIC_PAT was rotated on ${LAST_ROTATED}."
done
EXISTING=$(gh issue list --repo "$GH_REPO" --state open --label pat-rotation-due \
--json number --jq '.[0].number // empty' || true)
if [ -n "$EXISTING" ]; then
echo "Open reminder already exists: #$EXISTING. Skipping."
exit 0
fi
BODY="TRAFFIC_PAT was last rotated on **${LAST_ROTATED}** (${AGE} days ago). It expires in **${REMAINING} day(s)**.
## Rotate it
1. Go to https://github.com/settings/tokens?type=beta and regenerate \`TRAFFIC_PAT (8-day rotating)\`. Keep all repo access plus **Administration: Read** and **Metadata: Read**. Set expiry to 8 days.
2. **Authorize SSO on the token.** Skipping this is the most common cause of a silent failure.
3. \`gh secret set TRAFFIC_PAT --repo $GH_REPO\`
4. \`gh workflow run traffic-snapshot.yml --repo $GH_REPO\` to confirm it works.
The ledger now syncs itself from the secret's \`updated_at\`, so there is no file to bump by hand.
## If this lapses
The nightly \`traffic-snapshot\` workflow fails with HTTP 403 across every microsoft/* repo. Data captured before the failure is still committed (fixed Aug 2026), but **repo traffic for the missed days is lost** — GitHub serves only a rolling 14-day window, so a gap longer than that is unrecoverable.
_Auto-opened by \`.github/workflows/pat-rotation-reminder.yml\`._"
gh issue create --repo "$GH_REPO" \
--title "🔑 Rotate TRAFFIC_PAT — ${REMAINING} day(s) until expiry" \
--label pat-rotation-due \
--body "$BODY"