Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 221 additions & 0 deletions .github/workflows/winget-token-expiry-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) 2025-2026 SKY, LLC.
#
# ─────────────────────────────────────────────────────────────────────────────
# winget-token-expiry-check — weekly heads-up before WINGET_TOKEN dies.
#
# `winget-publish.yml` needs a classic PAT (`WINGET_TOKEN`, `public_repo`
# scope) to fork + push to microsoft/winget-pkgs. When that PAT expires
# the release-triggered WinGet submission fails — and because it runs on
# the `release: released` event (not the PR path), nobody sees the red X
# until someone notices winget is stuck at an old version weeks later.
#
# This workflow removes that silent-failure window. Once a week it asks
# GitHub how long the token actually has left — classic PATs return their
# expiry in the `github-authentication-token-expiration` response header
# on any authenticated REST call — and:
#
# * < WARN_DAYS left → opens / refreshes a tracking issue so the
# maintainer rotates the token before a release needs it.
# * already expired / invalid (401) → opens / refreshes the issue with
# an "ACT NOW" body — the next release WILL fail.
# * healthy → closes any previously-opened tracking issue
# (so a rotation auto-resolves the reminder).
#
# Why a probe and not a hard-coded date: the probe catches early
# revocation, accidental scope changes, and manual rotations too — the
# reminder always reflects the token's real state, never a stale guess.
#
# Manual `workflow_dispatch` is provided so you can re-check immediately
# after rotating the token (confirms the new one is healthy + closes the
# issue without waiting for Monday).

name: 🔑 WinGet Token Expiry Check

on:
schedule:
# Monday 07:00 UTC — after tier-2 (06:00) so it doesn't contend for
# the scheduled-runner window, and well ahead of any weekday release.
- cron: '0 7 * * 1'
workflow_dispatch:

# Least-privilege: the job needs to read API headers (no special scope —
# the token itself authenticates) and write issues for the reminder.
permissions:
contents: read
issues: write

concurrency:
group: winget-token-expiry-check
cancel-in-progress: false

jobs:
check:
name: Probe WINGET_TOKEN expiry
runs-on: ubuntu-22.04
# Only the canonical repo holds WINGET_TOKEN; never run from forks.
if: github.repository_owner == 'skyllc-ai'
timeout-minutes: 5
env:
# Days-remaining threshold below which we warn. Four weeks gives
# comfortable lead time to mint + set a replacement PAT.
WARN_DAYS: '28'
ISSUE_LABEL: 'winget-token-expiry'
steps:
- name: Probe token expiry via API response header
id: probe
env:
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
shell: bash
run: |
set -euo pipefail

if [[ -z "${WINGET_TOKEN:-}" ]]; then
# Secret missing entirely — treat as "broken now".
{
echo "status=missing"
echo "expiry="
echo "days_left="
} >> "$GITHUB_OUTPUT"
echo "::warning::WINGET_TOKEN secret is not set."
exit 0
fi

# Any authenticated REST call returns the PAT's expiry in a
# response header. /rate_limit is the cheapest (does not count
# against the limit it reports). Capture headers only.
headers="$(curl -sS -D - -o /dev/null \
-H "Authorization: Bearer ${WINGET_TOKEN}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/rate_limit \
-w '%{http_code}' || true)"

http_code="$(printf '%s' "$headers" | tail -n1)"
# Header name is case-insensitive; grep accordingly. Value is
# like: 2027-06-01 02:27:55 UTC
expiry="$(printf '%s' "$headers" \
| grep -i '^github-authentication-token-expiration:' \
| sed -E 's/^[^:]+:[[:space:]]*//; s/\r$//' || true)"

if [[ "$http_code" == "401" ]]; then
{ echo "status=expired"; echo "expiry="; echo "days_left="; } >> "$GITHUB_OUTPUT"
echo "::warning::WINGET_TOKEN rejected with HTTP 401 — expired or revoked."
exit 0
fi

if [[ -z "$expiry" ]]; then
# No expiry header: the token never expires (a no-expiry PAT)
# or is a token class without the header. A no-expiry token
# is a security smell but not an imminent-failure condition;
# report healthy-but-note.
{ echo "status=no-expiry"; echo "expiry="; echo "days_left="; } >> "$GITHUB_OUTPUT"
echo "::notice::WINGET_TOKEN has no expiration header (non-expiring PAT?)."
exit 0
fi

# Compute whole days remaining.
now_epoch="$(date -u +%s)"
exp_epoch="$(date -u -d "$expiry" +%s 2>/dev/null || true)"
if [[ -z "$exp_epoch" ]]; then
{ echo "status=parse-error"; echo "expiry=$expiry"; echo "days_left="; } >> "$GITHUB_OUTPUT"
echo "::warning::Could not parse WINGET_TOKEN expiry: $expiry"
exit 0
fi
days_left=$(( (exp_epoch - now_epoch) / 86400 ))

if (( days_left < 0 )); then
status=expired
elif (( days_left <= WARN_DAYS )); then
status=warn
else
status=healthy
fi
{
echo "status=$status"
echo "expiry=$expiry"
echo "days_left=$days_left"
} >> "$GITHUB_OUTPUT"
echo "::notice::WINGET_TOKEN status=$status, expiry=$expiry, days_left=$days_left"

- name: Open / update / close the expiry tracking issue
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
STATUS: ${{ steps.probe.outputs.status }}
EXPIRY: ${{ steps.probe.outputs.expiry }}
DAYS_LEFT: ${{ steps.probe.outputs.days_left }}
ISSUE_LABEL: ${{ env.ISSUE_LABEL }}
WARN_DAYS: ${{ env.WARN_DAYS }}
with:
script: |
const status = process.env.STATUS;
const expiry = process.env.EXPIRY || '(unknown)';
const daysLeft = process.env.DAYS_LEFT || '?';
const label = process.env.ISSUE_LABEL;
const warnDays = process.env.WARN_DAYS;
const { owner, repo } = context.repo;

// Healthy / non-expiring → close any open reminder (a
// rotation auto-resolves it) and stop.
const needsAttention = ['warn', 'expired', 'missing', 'parse-error'].includes(status);

const existing = await github.rest.issues.listForRepo({
owner, repo, state: 'open', labels: label, per_page: 1,
});

if (!needsAttention) {
if (existing.data.length > 0) {
const num = existing.data[0].number;
await github.rest.issues.createComment({
owner, repo, issue_number: num,
body: `✅ WINGET_TOKEN is healthy again (status: \`${status}\`, expiry: \`${expiry}\`, ~${daysLeft} days left). Closing.`,
});
await github.rest.issues.update({ owner, repo, issue_number: num, state: 'closed' });
core.info(`Closed reminder issue #${num} — token healthy.`);
} else {
core.info(`Token healthy (status=${status}); no reminder needed.`);
}
return;
}

// Needs attention → build the body.
const headline = {
warn: `⏰ \`WINGET_TOKEN\` expires in ~${daysLeft} day(s) (on \`${expiry}\`)`,
expired: `🔴 \`WINGET_TOKEN\` has EXPIRED or been revoked`,
missing: `🔴 \`WINGET_TOKEN\` secret is NOT SET`,
'parse-error': `⚠️ \`WINGET_TOKEN\` expiry could not be parsed (\`${expiry}\`)`,
}[status];

const body = [
`## ${headline}`,
``,
`The WinGet auto-publish workflow (\`winget-publish.yml\`) needs \`WINGET_TOKEN\` to fork + push to \`microsoft/winget-pkgs\` on every release. Once it expires, **releases will silently fail to update WinGet**.`,
``,
`| Field | Value |`,
`|-------|-------|`,
`| Status | \`${status}\` |`,
`| Expiry | \`${expiry}\` |`,
`| Days left | \`${daysLeft}\` |`,
`| Warn threshold | ${warnDays} days |`,
``,
`### How to rotate`,
`1. Create a new **classic** PAT at https://github.com/settings/tokens — scope \`public_repo\` only, ~1-year expiry.`,
`2. \`gh secret set WINGET_TOKEN --repo ${owner}/${repo}\` and paste it.`,
`3. Run the **🔑 WinGet Token Expiry Check** workflow manually (\`workflow_dispatch\`) to confirm healthy — it will auto-close this issue.`,
``,
`_Filed by \`winget-token-expiry-check.yml\` (weekly probe of the token's real expiry header)._`,
].join('\n');

const title = `🔑 WINGET_TOKEN needs rotation (${status})`;

if (existing.data.length > 0) {
const num = existing.data[0].number;
await github.rest.issues.createComment({ owner, repo, issue_number: num, body });
core.info(`Updated existing reminder issue #${num}.`);
} else {
await github.rest.issues.create({
owner, repo, title, body,
labels: [label],
assignees: ['githubrobbi'],
});
core.info('Opened new WINGET_TOKEN expiry reminder issue.');
}
Loading