From 5d1e0d4cf672cfb8fef764faefe799ca873cf7ca Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sun, 31 May 2026 19:36:04 -0700 Subject: [PATCH] ci(winget): add weekly WINGET_TOKEN expiry reminder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit winget-publish.yml's classic PAT (WINGET_TOKEN, public_repo) silently breaks WinGet auto-publish when it expires — and since that workflow fires on `release: released` (not the PR path), the failure goes unnoticed until WinGet is stuck at an old version. Add a Monday 07:00 UTC (+ workflow_dispatch) probe that reads the token's REAL expiry from the `github-authentication-token-expiration` API response header and: - < 28 days left -> opens/updates a tracking issue (labelled winget-token-expiry, assigned to githubrobbi) with rotation steps. - expired / 401 / missing secret -> same issue with an ACT-NOW body. - healthy -> auto-closes any open reminder, so a rotation resolves it. Probing the real header (not a hard-coded date) also catches early revocation, scope changes, and manual rotations. Least-privilege (contents: read, issues: write); never runs from forks. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workflows/winget-token-expiry-check.yml | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 .github/workflows/winget-token-expiry-check.yml diff --git a/.github/workflows/winget-token-expiry-check.yml b/.github/workflows/winget-token-expiry-check.yml new file mode 100644 index 000000000..db4ec60e8 --- /dev/null +++ b/.github/workflows/winget-token-expiry-check.yml @@ -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.'); + }