From 1c659514edf4ddd580e8fa8ca3ad0a81c80caf38 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:49:54 +0000 Subject: [PATCH 1/2] chore: rebuild catalog [skip ci] --- resource-stats.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resource-stats.json b/resource-stats.json index 9cc7c168..cf2d9c66 100644 --- a/resource-stats.json +++ b/resource-stats.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-07-27T19:00:31.864Z", + "generatedAt": "2026-07-30T21:49:54.356Z", "sources": { "views": "clarity", "upvotes": "github-discussions" @@ -12,7 +12,7 @@ "ai-council": { "views": 78, "viewsUniques": 61, - "upvotes": 0, + "upvotes": 1, "discussion": { "number": 501, "url": "https://github.com/microsoft/FastTrack/discussions/501" From d7a974fabe43251c0164a401b6b4a1069f1b38bc Mon Sep 17 00:00:00 2001 From: soyalejolopez <88358406+soyalejolopez@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:59:40 -0500 Subject: [PATCH 2/2] Fix independent traffic collection and surface stats publishing failures Preserve healthy GitHub and Clarity outputs independently, report unavailable sources, and make stranded catalog updates fail visibly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build-catalog.yml | 4 +- .github/workflows/traffic-stats.yml | 129 ++++++++++++++++--- tools/catalog-build/build-stats.js | 32 ++++- tools/catalog-build/package.json | 3 +- tools/catalog-build/test-traffic-workflow.js | 67 ++++++++++ 5 files changed, 205 insertions(+), 30 deletions(-) create mode 100644 tools/catalog-build/test-traffic-workflow.js diff --git a/.github/workflows/build-catalog.yml b/.github/workflows/build-catalog.yml index e71d81f3..408fedf3 100644 --- a/.github/workflows/build-catalog.yml +++ b/.github/workflows/build-catalog.yml @@ -127,7 +127,7 @@ jobs: else echo "$create_output" if printf '%s' "$create_output" | grep -qi "not permitted to create or approve pull requests"; then - echo "::warning::Catalog rebuild is on branch $BRANCH, but this repo blocks GitHub Actions from opening pull requests. Open it manually: $COMPARE_URL" + echo "::error::Catalog rebuild is on branch $BRANCH, but this repo blocks GitHub Actions from opening pull requests. Open it manually: $COMPARE_URL" { echo "### ⚠️ Catalog rebuild needs a manual PR" echo "" @@ -135,7 +135,7 @@ jobs: echo "" echo "**Open the PR:** $COMPARE_URL" } >> "$GITHUB_STEP_SUMMARY" - exit 0 + exit 1 fi echo "::error::Failed to open the catalog rebuild PR for $BRANCH." exit 1 diff --git a/.github/workflows/traffic-stats.yml b/.github/workflows/traffic-stats.yml index 74ce9d95..fee7a967 100644 --- a/.github/workflows/traffic-stats.yml +++ b/.github/workflows/traffic-stats.yml @@ -25,6 +25,10 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v7 + with: + # An approved fine-grained PAT enables automatic downstream workflow runs. + # The built-in token fallback may require manual PR creation and approval. + token: ${{ secrets.AUTOMATION_PAT || github.token }} - name: Set up Node.js uses: actions/setup-node@v7 @@ -37,7 +41,9 @@ jobs: run: npm ci working-directory: tools/catalog-build - - name: Collect and persist traffic data + - name: Collect and persist GitHub traffic data + id: github-traffic + continue-on-error: true env: GH_TOKEN: ${{ secrets.TRAFFIC_TOKEN }} run: | @@ -55,6 +61,12 @@ jobs: exit 1 fi + is_non_retryable_api_error() { + grep -Eq \ + 'Bad credentials \(HTTP 401\)|SAML enforcement|Resource not accessible by personal access token|Must have (admin rights|push access) to [Rr]epository|HTTP 404|HTTP 422' \ + "$1" + } + gh_api_retry() { local max_attempts=3 local delay=5 @@ -71,6 +83,11 @@ jobs: # Keep gh's stderr: without it every failure looks identical and the # real cause (401 / 403 / 404 / rate limit) is invisible in the log. echo "::warning::gh api attempt $attempt/$max_attempts failed: $(tr '\n' ' ' <"$err_file" | cut -c1-400)" >&2 + # Authentication, SSO and permission failures cannot recover during + # this run. Do not hide the actionable error behind needless retries. + if is_non_retryable_api_error "$err_file"; then + break + fi [ "$attempt" -lt "$max_attempts" ] && sleep "$delay" delay=$((delay * 2)) attempt=$((attempt + 1)) @@ -88,7 +105,7 @@ jobs: # substitution, so anything on stdout is captured into the caller's # variable instead of being printed to the workflow log. if ! response=$(gh_api_retry "$endpoint"); then - echo "::error::Failed to fetch $label from $endpoint after retries. Most likely the TRAFFIC_TOKEN secret has expired or lost administration:read on $REPO." >&2 + echo "::error::Failed to fetch $label from $endpoint. Verify that TRAFFIC_TOKEN is current, authorized for the microsoft organization (including SAML SSO when required), approved for $REPO, and has Administration read access." >&2 return 1 fi @@ -206,28 +223,56 @@ jobs: echo "✅ Saved $DATA_DIR/$DATE.json" - name: Build resource stats + id: stats + if: always() + continue-on-error: true env: CLARITY_API_TOKEN: ${{ secrets.CLARITY_API_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: node tools/catalog-build/build-stats.js + run: node tools/catalog-build/build-stats.js --require-clarity - name: Commit and open PR + id: publish + if: always() && (steps.github-traffic.outcome == 'success' || steps.stats.outputs.clarity_available == 'true') + continue-on-error: true env: - GH_TOKEN: ${{ secrets.TRAFFIC_TOKEN }} + # Publishing must not depend on the separate PAT used only for GitHub's + # traffic API. Prefer an approved fine-grained PAT. + GH_TOKEN: ${{ secrets.AUTOMATION_PAT || github.token }} + AUTOMATION_PAT_CONFIGURED: ${{ secrets.AUTOMATION_PAT != '' }} run: | + set -euo pipefail + + if [ "$AUTOMATION_PAT_CONFIGURED" != "true" ]; then + echo "::warning::AUTOMATION_PAT is not configured. The built-in token may be blocked from creating pull requests; if it creates one, its pull_request runs may require a writer to approve them. Push-triggered workflows remain suppressed." + { + echo "### ⚠️ Publishing is using the built-in token" + echo "" + echo "Publishing may require manual PR creation and approval of downstream workflow runs. Configure an organization-approved fine-grained PAT as \`AUTOMATION_PAT\` for automatic runs. A GitHub App migration is an alternative design, but is not implemented here." + } >> "$GITHUB_STEP_SUMMARY" + fi + git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add traffic-data/ resource-stats.json resource-discussions.json - if [ -f design-concepts/resource-stats.json ]; then - git add design-concepts/resource-stats.json + + DATE=$(date -u +%Y-%m-%d) + if [ "${{ steps.github-traffic.outcome }}" = "success" ]; then + git add "traffic-data/$DATE.json" + fi + if [ "${{ steps.stats.outputs.generated }}" = "true" ]; then + git add resource-stats.json resource-discussions.json traffic-data/clarity-views.json + if [ -f design-concepts/resource-stats.json ]; then + git add design-concepts/resource-stats.json + fi fi + if git diff --cached --quiet; then echo "No changes to commit" exit 0 fi - DATE=$(date -u +%Y-%m-%d) BRANCH="traffic-data/$DATE" + COMPARE_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/master...${BRANCH}?expand=1" # Create a fresh branch and push git checkout -b "$BRANCH" @@ -239,24 +284,37 @@ jobs: if [ -n "$existing_pr" ]; then echo "PR #$existing_pr already exists for $BRANCH" else - gh pr create \ + if create_output=$(gh pr create \ --title "📊 Traffic data for $DATE" \ --body "Automated daily traffic data collection." \ --head "$BRANCH" \ - --base master - echo "✅ PR created" + --base master 2>&1); then + echo "✅ PR created: $create_output" + existing_pr=$(printf '%s' "$create_output" | grep -oE 'pull/[0-9]+' | tail -n1 | cut -d/ -f2) + else + echo "$create_output" + echo "::error::The data branch was pushed, but automation could not open its pull request. Configure an approved fine-grained PAT as AUTOMATION_PAT, or open the PR manually: $COMPARE_URL" + { + echo "### ⚠️ Traffic data needs a manual PR" + echo "" + echo "The safe generated outputs were pushed to \`$BRANCH\`, but the workflow could not create a PR." + echo "" + echo "**Open the PR:** $COMPARE_URL" + } >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi fi # Enable auto-merge (requires repo setting "Allow auto-merge" to be on) - pr_number=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number') + pr_number="${existing_pr:-}" if [ -n "$pr_number" ]; then gh pr merge "$pr_number" --auto --squash \ && echo "✅ Auto-merge enabled for PR #$pr_number" \ || echo "::warning::Could not enable auto-merge — check repo settings" fi - - name: Alert on failure - if: failure() + - name: Alert on GitHub traffic collection failure + if: always() && steps.github-traffic.outcome == 'failure' env: # Use the built-in token here: TRAFFIC_TOKEN may itself be the failure # (e.g. expired), so the alert must not depend on it. @@ -271,14 +329,19 @@ jobs: Traffic data has **stopped being captured**. Because GitHub only retains a rolling 14-day traffic window, every day this stays broken is data lost permanently. - **Most likely cause:** the \`TRAFFIC_TOKEN\` secret has **expired or lost access**. - The traffic API requires a PAT with push / \`administration:read\` on this repo — the - built-in \`GITHUB_TOKEN\` cannot read traffic. + **Most likely cause:** the \`TRAFFIC_TOKEN\` secret is expired, revoked, not + authorized for the Microsoft organization's SAML SSO, or no longer approved for + this repository. The traffic API requires \`administration:read\`; the built-in + \`GITHUB_TOKEN\` cannot read repository traffic. **Fix:** - 1. Create a new PAT (classic \`repo\` scope, or fine-grained: Administration read + Contents R/W + Pull requests R/W). - 2. \`gh secret set TRAFFIC_TOKEN --repo ${GITHUB_REPOSITORY} --body ""\` - 3. Re-run: \`gh workflow run traffic-stats.yml --repo ${GITHUB_REPOSITORY}\` + 1. Create or renew an organization-approved fine-grained PAT owned by a user + with write access to the repository. Select \`${GITHUB_REPOSITORY}\` and + grant Administration read access. + 2. Replace the \`TRAFFIC_TOKEN\` Actions secret with the PAT. + 3. Re-run this workflow and confirm the traffic endpoints return successfully. + + A GitHub App migration is another option, but it is not implemented by this workflow. Failed run: ${RUN_URL} @@ -305,3 +368,29 @@ jobs: --label "traffic-collection-failure" echo "🚨 Opened new alert issue" fi + + - name: Report Clarity collection failure + if: always() && steps.stats.outputs.generated == 'true' && steps.stats.outputs.clarity_available != 'true' + run: | + echo "::error::Clarity export was unavailable. Existing accumulated values were preserved; no missing resource was written as zero." + { + echo "### ⚠️ Clarity stats were not refreshed" + echo "" + echo "Check \`CLARITY_API_TOKEN\` and the Clarity Data Export API. Its maximum three-day lookback makes prompt recovery important." + } >> "$GITHUB_STEP_SUMMARY" + + - name: Report stats generation failure + if: always() && steps.stats.outcome == 'failure' && steps.stats.outputs.generated != 'true' + run: | + echo "::error::Resource stats generation failed before safe outputs were produced; no generated stats files were staged." + + - name: Report publishing failure + if: always() && steps.publish.outcome == 'failure' + run: | + echo "::error::Safe collected outputs could not be published. Review the Commit and open PR step and its manual PR link, if available." + + - name: Fail when collection or publishing was incomplete + if: always() && (steps.github-traffic.outcome == 'failure' || steps.stats.outcome == 'failure' || steps.publish.outcome == 'failure') + run: | + echo "::error::One or more independent traffic sources or the publishing step failed. Successful source outputs were preserved and published when possible." + exit 1 diff --git a/tools/catalog-build/build-stats.js b/tools/catalog-build/build-stats.js index 4bd5113e..09cafaaa 100644 --- a/tools/catalog-build/build-stats.js +++ b/tools/catalog-build/build-stats.js @@ -1,10 +1,11 @@ -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const toolDirectory = dirname(fileURLToPath(import.meta.url)); const repositoryRoot = join(toolDirectory, '..', '..'); const checkOnly = process.argv.includes('--check'); +const requireClarity = process.argv.includes('--require-clarity'); const trafficDirectory = join(repositoryRoot, 'traffic-data'); const clarityStatePath = join(trafficDirectory, 'clarity-views.json'); const catalogPath = join(repositoryRoot, 'catalog.json'); @@ -139,10 +140,13 @@ function parseClarityTraffic(data, knownSlugs) { const traffic = Array.isArray(data) ? data.find(metric => metric?.metricName === 'Traffic') : undefined; + if (!traffic || !Array.isArray(traffic.information)) { + throw new Error('Clarity response did not include the Traffic metric'); + } const totals = new Map(); const dated = new Map(); - for (const row of Array.isArray(traffic?.information) ? traffic.information : []) { + for (const row of traffic.information) { const slug = slugFromClarityUrl(row.URL ?? row.Url ?? row.url, knownSlugs); if (!slug) continue; const metrics = metricsFromClarityRow(row); @@ -199,10 +203,16 @@ function mapToObject(map) { return Object.fromEntries([...map].sort(([left], [right]) => left.localeCompare(right))); } +function setActionOutput(name, value) { + if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`); + } +} + async function updateClarityState(state, knownSlugs) { if (!clarityToken) { console.log('CLARITY_API_TOKEN is not set; preserving accumulated Clarity views.'); - return false; + return { available: false, changed: false }; } try { @@ -225,10 +235,10 @@ async function updateClarityState(state, knownSlugs) { state.days[dates[2]] = mapToObject(subtractClarityTotals(threeDays.totals, twoDays.totals)); } state.lastRun = runDate; - return true; + return { available: true, changed: true }; } catch (error) { console.warn(`Warning: could not collect Clarity views: ${error.message}`); - return false; + return { available: false, changed: false }; } } @@ -358,7 +368,7 @@ const discussionConfig = readOptionalJson(discussionsPath, { const clarityState = readOptionalJson(clarityStatePath, { lastRun: '', days: {} }); if (!clarityState.days || typeof clarityState.days !== 'object') clarityState.days = {}; -const clarityChanged = await updateClarityState(clarityState, knownSlugs); +const clarityResult = await updateClarityState(clarityState, knownSlugs); const clarityViews = collectClarityViews(clarityState, knownSlugs); const fallbackViews = collectFallbackViews(resourceFolders); const discussions = await collectDiscussions(); @@ -404,9 +414,17 @@ if (!checkOnly) { if (!existsSync(discussionsPath)) { writeFileSync(discussionsPath, `${JSON.stringify(discussionConfig, null, 2)}\n`); } - if (clarityChanged || !existsSync(clarityStatePath)) { + if (clarityResult.changed || !existsSync(clarityStatePath)) { writeFileSync(clarityStatePath, `${JSON.stringify(clarityState, null, 2)}\n`); } } console.log(`${checkOnly ? 'Checked' : 'Wrote'} real stats for ${viewCount} resource${viewCount === 1 ? '' : 's'} with views and ${upvoteCount} resource${upvoteCount === 1 ? '' : 's'} with upvotes.`); + +setActionOutput('generated', 'true'); +setActionOutput('clarity_available', String(clarityResult.available)); + +if (requireClarity && !clarityResult.available) { + console.error('Clarity collection is required but unavailable; accumulated values were preserved and no missing metrics were replaced with zero.'); + process.exitCode = 1; +} diff --git a/tools/catalog-build/package.json b/tools/catalog-build/package.json index 07800af3..f5686acc 100644 --- a/tools/catalog-build/package.json +++ b/tools/catalog-build/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "build": "node index.js", - "check": "node index.js --check", + "check": "node index.js --check && node test-traffic-workflow.js", + "check:traffic-workflow": "node test-traffic-workflow.js", "build:stats": "node build-stats.js", "check:stats": "node build-stats.js --check", "create:vote-discussions": "node create-vote-discussions.js" diff --git a/tools/catalog-build/test-traffic-workflow.js b/tools/catalog-build/test-traffic-workflow.js new file mode 100644 index 00000000..3e93ccf6 --- /dev/null +++ b/tools/catalog-build/test-traffic-workflow.js @@ -0,0 +1,67 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as yaml from 'js-yaml'; + +const toolDirectory = dirname(fileURLToPath(import.meta.url)); +const workflowPath = join(toolDirectory, '..', '..', '.github', 'workflows', 'traffic-stats.yml'); +const workflow = yaml.load(readFileSync(workflowPath, 'utf8')); +const steps = workflow.jobs['collect-traffic'].steps; +const byId = new Map(steps.filter(step => step.id).map(step => [step.id, step])); + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +const githubTraffic = byId.get('github-traffic'); +const stats = byId.get('stats'); +const publish = byId.get('publish'); +const finalFailure = steps.find(step => step.name === 'Fail when collection or publishing was incomplete'); + +assert(githubTraffic?.['continue-on-error'] === true, + 'GitHub traffic failure must not block the independent Clarity source.'); +assert(stats?.['continue-on-error'] === true && stats.run.includes('--require-clarity'), + 'Clarity must report unavailability without blocking a healthy GitHub snapshot.'); +assert(stats?.if === 'always()', + 'Stats must run even when GitHub traffic collection fails.'); +assert(publish?.['continue-on-error'] === true, + 'Publishing failures must reach the explicit reporting and final failure steps.'); +assert(publish.run.includes('if [ "${{ steps.stats.outputs.generated }}" = "true" ]; then') && + publish.run.includes('git add resource-stats.json resource-discussions.json traffic-data/clarity-views.json') && + !publish.run.includes('git add traffic-data/ resource-stats.json'), + 'Generated stats must only be staged after the builder marks them safe.'); + +function evaluateCondition(expression, state) { + const replacements = new Map([ + ['always()', true], + ["steps.github-traffic.outcome == 'success'", state.github], + ["steps.github-traffic.outcome == 'failure'", !state.github], + ["steps.stats.outputs.clarity_available == 'true'", state.clarity], + ["steps.stats.outcome == 'failure'", !state.clarity], + ["steps.publish.outcome == 'failure'", state.publishFailed] + ]); + let evaluable = expression; + for (const [token, value] of replacements) { + evaluable = evaluable.replaceAll(token, String(value)); + } + assert(!evaluable.includes('steps.') && /^[\s()!&|truefals]+$/.test(evaluable), + `Unsupported workflow condition: ${expression}`); + return Function(`"use strict"; return Boolean(${evaluable});`)(); +} + +const combinations = [ + { github: true, clarity: true, publishFailed: false, publish: true, fail: false }, + { github: true, clarity: false, publishFailed: false, publish: true, fail: true }, + { github: false, clarity: true, publishFailed: false, publish: true, fail: true }, + { github: false, clarity: false, publishFailed: false, publish: false, fail: true }, + { github: true, clarity: true, publishFailed: true, publish: true, fail: true } +]; + +for (const combination of combinations) { + assert(evaluateCondition(publish.if, combination) === combination.publish, + `Unexpected publishing decision for GitHub=${combination.github}, Clarity=${combination.clarity}.`); + assert(evaluateCondition(finalFailure.if, combination) === combination.fail, + `Unexpected final status for GitHub=${combination.github}, Clarity=${combination.clarity}.`); +} + +console.log('Traffic workflow source availability matrix is valid.');