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/3] 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/3] 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.'); From 42d729e685c148e49ff4f5fc6c681d1461d9d632 Mon Sep 17 00:00:00 2001 From: soyalejolopez <88358406+soyalejolopez@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:00:15 -0500 Subject: [PATCH 3/3] Automate weekly traffic stats review Persist daily collection on a durable staging branch, publish a single weekly review branch, and reconcile stats across squash-merge cycles. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build-catalog.yml | 5 +- .github/workflows/traffic-stats.yml | 304 ++++++++++++++---- tools/catalog-build/build-stats.js | 64 ++-- tools/catalog-build/package.json | 4 +- .../catalog-build/reconcile-stats-conflict.js | 26 ++ tools/catalog-build/stats-state.js | 122 +++++++ tools/catalog-build/test-stats-state.js | 87 +++++ .../test-traffic-git-integration.js | 149 +++++++++ tools/catalog-build/test-traffic-workflow.js | 134 ++++++-- traffic-data/README.md | 10 +- 10 files changed, 794 insertions(+), 111 deletions(-) create mode 100644 tools/catalog-build/reconcile-stats-conflict.js create mode 100644 tools/catalog-build/stats-state.js create mode 100644 tools/catalog-build/test-stats-state.js create mode 100644 tools/catalog-build/test-traffic-git-integration.js diff --git a/.github/workflows/build-catalog.yml b/.github/workflows/build-catalog.yml index 4e5144d1..16199527 100644 --- a/.github/workflows/build-catalog.yml +++ b/.github/workflows/build-catalog.yml @@ -66,11 +66,8 @@ jobs: working-directory: tools/catalog-build - run: npm run build working-directory: tools/catalog-build - - run: npm run build:stats + - run: npm run build:stats -- --render-only working-directory: tools/catalog-build - env: - CLARITY_API_TOKEN: ${{ secrets.CLARITY_API_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Commit generated catalog env: # Prefer a PAT when configured; the built-in token can be blocked from diff --git a/.github/workflows/traffic-stats.yml b/.github/workflows/traffic-stats.yml index 4de0a790..16ef38fd 100644 --- a/.github/workflows/traffic-stats.yml +++ b/.github/workflows/traffic-stats.yml @@ -2,8 +2,11 @@ name: Collect Traffic Data on: schedule: - # Runs daily at 06:00 UTC to capture traffic before the 14-day window expires + # Daily collection persists the short-lived source data without opening a PR. - cron: "0 6 * * *" + # Weekly publishing follows Chicago local time, including daylight saving time. + - cron: "0 8 * * 5" + timezone: "America/Chicago" workflow_dispatch: # Allow manual runs permissions: @@ -18,9 +21,13 @@ concurrency: jobs: collect-traffic: - if: github.repository == 'microsoft/FastTrack' + if: >- + github.repository == 'microsoft/FastTrack' && + (github.event_name == 'workflow_dispatch' || github.event.schedule == '0 6 * * *') runs-on: ubuntu-latest timeout-minutes: 10 + env: + STAGING_BRANCH: automation/traffic-stats-staging steps: - name: Checkout repository @@ -29,6 +36,49 @@ jobs: # 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 }} + fetch-depth: 0 + + - name: Prepare durable staging branch + run: | + set -euo pipefail + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git fetch origin master + + if git fetch origin "$STAGING_BRANCH"; then + git checkout -B "$STAGING_BRANCH" "origin/$STAGING_BRANCH" + else + echo "::notice::Creating the first durable traffic staging branch from origin/master." + git checkout -B "$STAGING_BRANCH" origin/master + fi + + if ! git merge --no-edit origin/master; then + conflicts=$(git diff --name-only --diff-filter=U) + if [ -n "$conflicts" ] && + printf '%s\n' "$conflicts" | + grep -Ev '^(resource-stats\.json|design-concepts/resource-stats\.json|traffic-data/clarity-views\.json)$' >/dev/null; then + echo "::error::Staging cannot be synchronized safely because durable or source files conflict with master:" + printf '%s\n' "$conflicts" + git merge --abort + exit 1 + fi + + while IFS= read -r file; do + [ -n "$file" ] || continue + if [ "$file" = "traffic-data/clarity-views.json" ]; then + if ! node tools/catalog-build/reconcile-stats-conflict.js "$file"; then + git merge --abort + exit 1 + fi + else + echo "::notice::Keeping derived stats temporarily; collection will regenerate them from reconciled durable state." + git checkout --ours -- "$file" + fi + git add "$file" + done <<< "$conflicts" + git commit --no-edit + fi - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -231,29 +281,15 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: node tools/catalog-build/build-stats.js --require-clarity - - name: Commit and open PR - id: publish + - name: Persist collected data on staging branch + id: persist if: always() && (steps.github-traffic.outcome == 'success' || steps.stats.outputs.clarity_available == 'true') continue-on-error: true - env: - # 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 config user.email "41898282+github-actions[bot]@users.noreply.github.com" DATE=$(date -u +%Y-%m-%d) if [ "${{ steps.github-traffic.outcome }}" = "success" ]; then @@ -267,51 +303,49 @@ jobs: fi if git diff --cached --quiet; then - echo "No changes to commit" - exit 0 + echo "No new source data to commit." + else + git commit -m "chore: collect traffic data for $DATE" fi - 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" - git commit -m "📊 Traffic data for $DATE" - git push --set-upstream origin "$BRANCH" + if git push origin "HEAD:refs/heads/$STAGING_BRANCH"; then + echo "✅ Daily traffic state persisted on $STAGING_BRANCH. No pull request was attempted." + exit 0 + fi - # Create or update PR (--fill uses commit message as title/body) - existing_pr=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty') - if [ -n "$existing_pr" ]; then - echo "PR #$existing_pr already exists for $BRANCH" - else - if create_output=$(gh pr create \ - --title "📊 Traffic data for $DATE" \ - --body "Automated daily traffic data collection." \ - --head "$BRANCH" \ - --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" + echo "::warning::The staging branch advanced while this run was collecting. Merging once and retrying without force." + git fetch origin "$STAGING_BRANCH" + if ! git merge --no-edit "origin/$STAGING_BRANCH"; then + conflicts=$(git diff --name-only --diff-filter=U) + if [ -n "$conflicts" ] && + printf '%s\n' "$conflicts" | + grep -Ev '^(resource-stats\.json|design-concepts/resource-stats\.json|traffic-data/clarity-views\.json)$' >/dev/null; then + echo "::error::Concurrent staging updates could not be merged safely:" + printf '%s\n' "$conflicts" + git merge --abort exit 1 fi + while IFS= read -r file; do + [ -n "$file" ] || continue + if [ "$file" = "traffic-data/clarity-views.json" ]; then + if ! node tools/catalog-build/reconcile-stats-conflict.js "$file"; then + git merge --abort + exit 1 + fi + else + git checkout --ours -- "$file" + fi + git add "$file" + done <<< "$conflicts" + git commit --no-edit fi - # Enable auto-merge (requires repo setting "Allow auto-merge" to be on) - 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" + node tools/catalog-build/build-stats.js --render-only + git add resource-stats.json + if ! git diff --cached --quiet; then + git commit -m "chore: reconcile concurrent traffic state" fi + git push origin "HEAD:refs/heads/$STAGING_BRANCH" - name: Alert on GitHub traffic collection failure if: always() && steps.github-traffic.outcome == 'failure' @@ -385,12 +419,162 @@ jobs: 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' + if: always() && steps.persist.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." + echo "::error::Safe collected outputs could not be persisted to the durable staging branch." - name: Fail when collection or publishing was incomplete - if: always() && (steps.github-traffic.outcome == 'failure' || steps.stats.outcome == 'failure' || steps.publish.outcome == 'failure') + if: always() && (steps.github-traffic.outcome == 'failure' || steps.stats.outcome == 'failure' || steps.persist.outcome == 'failure') + run: | + echo "::error::One or more independent traffic sources or the staging persistence step failed. Successful source outputs were preserved when possible." + exit 1 + + publish-weekly: + if: >- + github.repository == 'microsoft/FastTrack' && + github.event_name == 'schedule' && + github.event.schedule == '0 8 * * 5' + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + STAGING_BRANCH: automation/traffic-stats-staging + PUBLISH_BRANCH: automation/traffic-stats-weekly + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + token: ${{ secrets.AUTOMATION_PAT || github.token }} + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 20 + cache: npm + cache-dependency-path: tools/catalog-build/package-lock.json + + - name: Install catalog build dependencies + run: npm ci + working-directory: tools/catalog-build + + - name: Synchronize weekly branch and render safe outputs + id: sync + run: | + set -euo pipefail + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git fetch origin master + if ! git fetch origin "$STAGING_BRANCH"; then + echo "::error::The durable staging branch does not exist yet. Run daily collection successfully before weekly publishing." + exit 1 + fi + + if git fetch origin "$PUBLISH_BRANCH"; then + git checkout -B "$PUBLISH_BRANCH" "origin/$PUBLISH_BRANCH" + else + echo "::notice::Creating the first weekly publication branch from origin/master." + git checkout -B "$PUBLISH_BRANCH" origin/master + fi + + merge_safely() { + local source="$1" + if git merge --no-edit "$source"; then + return 0 + fi + + conflicts=$(git diff --name-only --diff-filter=U) + if [ -n "$conflicts" ] && + printf '%s\n' "$conflicts" | + grep -Ev '^(resource-stats\.json|design-concepts/resource-stats\.json|traffic-data/clarity-views\.json)$' >/dev/null; then + echo "::error::Weekly publishing found a conflict in durable or source data while merging $source:" + printf '%s\n' "$conflicts" + git merge --abort + return 1 + fi + while IFS= read -r file; do + [ -n "$file" ] || continue + if [ "$file" = "traffic-data/clarity-views.json" ]; then + if ! node tools/catalog-build/reconcile-stats-conflict.js "$file"; then + git merge --abort + return 1 + fi + else + git checkout --ours -- "$file" + fi + git add "$file" + done <<< "$conflicts" + git commit --no-edit + } + + merge_safely origin/master + merge_safely "origin/$STAGING_BRANCH" + + node tools/catalog-build/build-stats.js --render-only + git add resource-stats.json + if git diff --cached --quiet; then + echo "Derived stats are already current." + else + git commit -m "chore: render weekly traffic stats" + fi + + git push origin "HEAD:refs/heads/$PUBLISH_BRANCH" + if git diff --quiet origin/master HEAD; then + echo "has_changes=false" >> "$GITHUB_OUTPUT" + echo "Staging has no unpublished traffic data." + else + echo "has_changes=true" >> "$GITHUB_OUTPUT" + fi + + - name: Prepare or update weekly pull request + id: weekly-pr + if: steps.sync.outputs.has_changes == 'true' + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.AUTOMATION_PAT || github.token }} + AUTOMATION_PAT_CONFIGURED: ${{ secrets.AUTOMATION_PAT != '' }} + run: | + set -euo pipefail + + TITLE="chore: publish weekly traffic stats" + BODY="Publishes the daily traffic and Clarity data accumulated on \`$STAGING_BRANCH\` through the Friday-only \`$PUBLISH_BRANCH\`. This pull request is intentionally review-required and is not auto-merged." + COMPARE_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/master...${PUBLISH_BRANCH}?expand=1" + existing_pr=$(gh pr list --head "$PUBLISH_BRANCH" --base master --state open \ + --json number --jq '.[0].number // empty') + + if [ -n "$existing_pr" ]; then + gh api --method PATCH "repos/${GITHUB_REPOSITORY}/pulls/${existing_pr}" \ + -f title="$TITLE" -f body="$BODY" >/dev/null + echo "✅ Updated the existing weekly PR #$existing_pr." + exit 0 + fi + + if [ "$AUTOMATION_PAT_CONFIGURED" != "true" ]; then + echo "::error::AUTOMATION_PAT is not configured, so the known organization policy blocks automatic PR creation. The staging branch is safe; open the weekly PR manually: $COMPARE_URL" + { + echo "### ⚠️ Weekly traffic data needs a manual PR" + echo "" + echo "Daily collection remains safely accumulated on \`$STAGING_BRANCH\`." + echo "" + echo "**Open the review-required PR:** $COMPARE_URL" + } >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + + if ! gh pr create --title "$TITLE" --body "$BODY" \ + --head "$PUBLISH_BRANCH" --base master; then + echo "::error::The approved token could not create the weekly PR. No data was lost; open it manually: $COMPARE_URL" + { + echo "### ⚠️ Weekly traffic data needs a manual PR" + echo "" + echo "**Open the review-required PR:** $COMPARE_URL" + } >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + + - name: Fail when weekly PR preparation was blocked + if: always() && steps.sync.outputs.has_changes == 'true' && steps.weekly-pr.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." + echo "::error::Weekly traffic data is staged safely, but its pull request requires the manual compare link shown above." exit 1 diff --git a/tools/catalog-build/build-stats.js b/tools/catalog-build/build-stats.js index 09cafaaa..55415c81 100644 --- a/tools/catalog-build/build-stats.js +++ b/tools/catalog-build/build-stats.js @@ -1,15 +1,18 @@ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { replaceClarityDays, unchangedStatsPayload } from './stats-state.js'; 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 renderOnly = process.argv.includes('--render-only'); const trafficDirectory = join(repositoryRoot, 'traffic-data'); const clarityStatePath = join(trafficDirectory, 'clarity-views.json'); const catalogPath = join(repositoryRoot, 'catalog.json'); const discussionsPath = join(repositoryRoot, 'resource-discussions.json'); +const statsPath = join(repositoryRoot, 'resource-stats.json'); const githubToken = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; const clarityToken = process.env.CLARITY_API_TOKEN; const dayMilliseconds = 24 * 60 * 60 * 1000; @@ -199,10 +202,6 @@ function subtractClarityTotals(larger, smaller) { return result; } -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`); @@ -210,6 +209,10 @@ function setActionOutput(name, value) { } async function updateClarityState(state, knownSlugs) { + if (renderOnly) { + console.log('Render-only mode; using persisted Clarity views without collecting.'); + return { available: false, changed: false }; + } if (!clarityToken) { console.log('CLARITY_API_TOKEN is not set; preserving accumulated Clarity views.'); return { available: false, changed: false }; @@ -218,7 +221,7 @@ async function updateClarityState(state, knownSlugs) { try { const threeDays = await fetchClarity(3, knownSlugs); if (threeDays.dated.size > 0) { - for (const [date, bucket] of threeDays.dated) state.days[date] = mapToObject(bucket); + replaceClarityDays(state, threeDays.dated, runDate); } else { // The API normally returns rolling aggregates rather than dates. Cumulative 1/2/3-day // windows are differenced so each run persists overlap-safe daily buckets. @@ -230,11 +233,12 @@ async function updateClarityState(state, knownSlugs) { new Date(Date.parse(`${runDate}T00:00:00Z`) - offset * dayMilliseconds) .toISOString().slice(0, 10) ); - state.days[dates[0]] = mapToObject(oneDay.totals); - state.days[dates[1]] = mapToObject(subtractClarityTotals(twoDays.totals, oneDay.totals)); - state.days[dates[2]] = mapToObject(subtractClarityTotals(threeDays.totals, twoDays.totals)); + replaceClarityDays(state, new Map([ + [dates[0], oneDay.totals], + [dates[1], subtractClarityTotals(twoDays.totals, oneDay.totals)], + [dates[2], subtractClarityTotals(threeDays.totals, twoDays.totals)] + ]), runDate); } - state.lastRun = runDate; return { available: true, changed: true }; } catch (error) { console.warn(`Warning: could not collect Clarity views: ${error.message}`); @@ -279,9 +283,13 @@ async function githubGraphql(query, variables) { async function collectDiscussions() { const discussions = []; + if (renderOnly) { + console.log('Render-only mode; using persisted discussion stats without collecting.'); + return { available: false, discussions }; + } if (!githubToken) { console.log('GITHUB_TOKEN or GH_TOKEN is not set; skipping discussion upvotes.'); - return discussions; + return { available: false, discussions }; } const query = `query($owner:String!,$name:String!,$after:String) { @@ -311,9 +319,9 @@ async function collectDiscussions() { } while (after); } catch (error) { console.warn(`Warning: could not collect GitHub Discussions: ${error.message}`); - return []; + return { available: false, discussions: [] }; } - return discussions; + return { available: true, discussions }; } function mapDiscussions(discussions, knownSlugs, explicitMap) { @@ -347,6 +355,20 @@ function mapDiscussions(discussions, knownSlugs, explicitMap) { return result; } +function previousUpvotes(previousStats, knownSlugs) { + const result = new Map(); + for (const [slug, stats] of Object.entries(previousStats?.resources ?? {})) { + if (!knownSlugs.has(slug) || !stats || typeof stats !== 'object') continue; + const upvotes = numberFrom(stats.upvotes); + if (upvotes === undefined) continue; + result.set(slug, { + upvotes: Math.round(upvotes), + ...(stats.discussion ? { discussion: stats.discussion } : {}) + }); + } + return result; +} + const catalog = readJson(catalogPath); const knownSlugs = new Set(catalog.resources.map(resource => resource.slug)); const resourceFolders = catalog.resources @@ -365,14 +387,17 @@ const discussionConfig = readOptionalJson(discussionsPath, { note: 'Maps gallery resource slug -> GitHub Discussion number in the Resource Votes category.', map: {} }); +const previousStats = readOptionalJson(statsPath, {}); const clarityState = readOptionalJson(clarityStatePath, { lastRun: '', days: {} }); if (!clarityState.days || typeof clarityState.days !== 'object') clarityState.days = {}; const clarityResult = await updateClarityState(clarityState, knownSlugs); const clarityViews = collectClarityViews(clarityState, knownSlugs); const fallbackViews = collectFallbackViews(resourceFolders); -const discussions = await collectDiscussions(); -const upvoteTotals = mapDiscussions(discussions, knownSlugs, discussionConfig.map ?? {}); +const discussionResult = await collectDiscussions(); +const upvoteTotals = discussionResult.available + ? mapDiscussions(discussionResult.discussions, knownSlugs, discussionConfig.map ?? {}) + : previousUpvotes(previousStats, knownSlugs); const resources = {}; let viewCount = 0; let upvoteCount = 0; @@ -399,18 +424,21 @@ for (const resource of catalog.resources) { if (Object.keys(stats).length > 0) resources[resource.slug] = stats; } -const output = `${JSON.stringify({ - generatedAt: new Date().toISOString(), +const payload = { sources: { views: 'clarity', upvotes: 'github-discussions' }, resources -}, null, 2)}\n`; +}; +const generatedAt = unchangedStatsPayload(previousStats, payload) && previousStats.generatedAt + ? previousStats.generatedAt + : new Date().toISOString(); +const output = `${JSON.stringify({ generatedAt, ...payload }, null, 2)}\n`; if (!checkOnly) { mkdirSync(trafficDirectory, { recursive: true }); - writeFileSync(join(repositoryRoot, 'resource-stats.json'), output); + writeFileSync(statsPath, output); if (!existsSync(discussionsPath)) { writeFileSync(discussionsPath, `${JSON.stringify(discussionConfig, null, 2)}\n`); } diff --git a/tools/catalog-build/package.json b/tools/catalog-build/package.json index 816c68a4..88002a92 100644 --- a/tools/catalog-build/package.json +++ b/tools/catalog-build/package.json @@ -5,8 +5,10 @@ "type": "module", "scripts": { "build": "node index.js", - "check": "node index.js --check && node test-traffic-workflow.js", + "check": "node index.js --check && node test-traffic-workflow.js && node test-stats-state.js && node test-traffic-git-integration.js", "check:traffic-workflow": "node test-traffic-workflow.js", + "check:stats-state": "node test-stats-state.js", + "check:traffic-git": "node test-traffic-git-integration.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/reconcile-stats-conflict.js b/tools/catalog-build/reconcile-stats-conflict.js new file mode 100644 index 00000000..132ad08f --- /dev/null +++ b/tools/catalog-build/reconcile-stats-conflict.js @@ -0,0 +1,26 @@ +import { execFileSync } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +import { reconcileClarityStates } from './stats-state.js'; + +const path = process.argv[2]; +if (path !== 'traffic-data/clarity-views.json') { + console.error(`Unsupported durable stats conflict: ${path ?? '(missing path)'}`); + process.exit(1); +} + +function readStage(stage) { + try { + return JSON.parse(execFileSync('git', ['show', `:${stage}:${path}`], { encoding: 'utf8' })); + } catch (error) { + throw new Error(`Could not read merge stage ${stage} for ${path}: ${error.message}`); + } +} + +try { + const { state, relation } = reconcileClarityStates(readStage(2), readStage(3)); + writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`); + console.log(`Reconciled ${path} using ${relation}; overlapping daily resource values were identical.`); +} catch (error) { + console.error(`Refusing to auto-resolve ${path}: ${error.message}`); + process.exit(1); +} diff --git a/tools/catalog-build/stats-state.js b/tools/catalog-build/stats-state.js new file mode 100644 index 00000000..c5272ad2 --- /dev/null +++ b/tools/catalog-build/stats-state.js @@ -0,0 +1,122 @@ +export function replaceClarityDays(state, buckets, lastRun) { + for (const [date, metrics] of buckets) { + state.days[date] = Object.fromEntries( + [...metrics].sort(([left], [right]) => left.localeCompare(right)) + ); + } + state.lastRun = lastRun; +} + +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function sortedRecord(entries) { + return Object.fromEntries([...entries].sort(([left], [right]) => left.localeCompare(right))); +} + +function validateClarityState(state, label) { + if (!isRecord(state) || !isRecord(state.days)) { + throw new Error(`${label} Clarity state must contain a days object.`); + } + if (typeof state.lastRun !== 'string' || + (state.lastRun !== '' && !/^\d{4}-\d{2}-\d{2}$/.test(state.lastRun))) { + throw new Error(`${label} Clarity state has an invalid lastRun.`); + } + for (const [date, resources] of Object.entries(state.days)) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !isRecord(resources)) { + throw new Error(`${label} Clarity state has an invalid day bucket: ${date}.`); + } + for (const [slug, metrics] of Object.entries(resources)) { + if (!slug || !isRecord(metrics)) { + throw new Error(`${label} Clarity state has invalid metrics for ${date}/${slug}.`); + } + for (const [metric, value] of Object.entries(metrics)) { + if (!['views', 'uniques'].includes(metric) || + !Number.isFinite(value) || value < 0 || !Number.isInteger(value)) { + throw new Error(`${label} Clarity state has invalid ${metric} for ${date}/${slug}.`); + } + } + } + } +} + +function equalJson(left, right) { + return JSON.stringify(left) === JSON.stringify(right); +} + +export function clarityStateContains(container, candidate) { + validateClarityState(container, 'Container'); + validateClarityState(candidate, 'Candidate'); + return Object.entries(candidate.days).every(([date, resources]) => + Object.entries(resources).every(([slug, metrics]) => + equalJson(container.days[date]?.[slug], metrics) + ) + ); +} + +export function reconcileClarityStates(ours, theirs) { + validateClarityState(ours, 'Ours'); + validateClarityState(theirs, 'Theirs'); + + const oursContainsTheirs = clarityStateContains(ours, theirs); + const theirsContainsOurs = clarityStateContains(theirs, ours); + const newerState = ours.lastRun === theirs.lastRun + ? null + : (ours.lastRun > theirs.lastRun ? ours : theirs); + const days = {}; + + for (const date of [...new Set([...Object.keys(ours.days), ...Object.keys(theirs.days)])].sort()) { + const resources = new Map(); + for (const [state, source] of [ + [ours, ours.days[date] ?? {}], + [theirs, theirs.days[date] ?? {}] + ]) { + for (const [slug, metrics] of Object.entries(source)) { + const existing = resources.get(slug); + if (existing && !equalJson(existing, metrics)) { + if (!newerState) { + resources.set(slug, sortedRecord(new Map( + [...new Set([...Object.keys(existing), ...Object.keys(metrics)])] + .map(metric => [metric, Math.max(existing[metric] ?? 0, metrics[metric] ?? 0)]) + ))); + continue; + } + if (state !== newerState) continue; + } + resources.set(slug, metrics); + } + } + days[date] = sortedRecord(resources); + } + + const extraKeys = new Set([ + ...Object.keys(ours).filter(key => !['lastRun', 'days'].includes(key)), + ...Object.keys(theirs).filter(key => !['lastRun', 'days'].includes(key)) + ]); + const extras = {}; + for (const key of extraKeys) { + if (Object.hasOwn(ours, key) && Object.hasOwn(theirs, key) && + !equalJson(ours[key], theirs[key])) { + throw new Error(`Clarity state has competing top-level ${key} values.`); + } + extras[key] = Object.hasOwn(theirs, key) ? theirs[key] : ours[key]; + } + + return { + state: { + ...sortedRecord(Object.entries(extras)), + lastRun: [ours.lastRun, theirs.lastRun].sort().at(-1), + days + }, + relation: oursContainsTheirs + ? (theirsContainsOurs ? 'equal' : 'ours-superset') + : (theirsContainsOurs ? 'theirs-superset' : 'structured-union') + }; +} + +export function unchangedStatsPayload(previous, next) { + if (!previous || typeof previous !== 'object') return false; + return JSON.stringify(previous.sources) === JSON.stringify(next.sources) && + JSON.stringify(previous.resources) === JSON.stringify(next.resources); +} diff --git a/tools/catalog-build/test-stats-state.js b/tools/catalog-build/test-stats-state.js new file mode 100644 index 00000000..ae7d1a3d --- /dev/null +++ b/tools/catalog-build/test-stats-state.js @@ -0,0 +1,87 @@ +import { + clarityStateContains, + reconcileClarityStates, + replaceClarityDays, + unchangedStatsPayload +} from './stats-state.js'; + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +const state = { + lastRun: '2026-09-10', + days: { + '2026-09-10': { existing: { views: 4 } }, + '2026-09-11': { resource: { views: 2 } } + } +}; + +replaceClarityDays(state, new Map([ + ['2026-09-11', new Map([['resource', { views: 5 }]])], + ['2026-09-12', new Map([['new-resource', { views: 3 }]])] +]), '2026-09-12'); + +assert(state.days['2026-09-10'].existing.views === 4, + 'Daily collection must retain accumulated days outside the rolling export.'); +assert(state.days['2026-09-11'].resource.views === 5, + 'A rerun must replace an overlapping daily bucket instead of double-counting it.'); +assert(state.days['2026-09-12']['new-resource'].views === 3, + 'Daily collection must append newly available Clarity days.'); +assert(state.lastRun === '2026-09-12', 'The state must record the latest successful run.'); + +const payload = { + sources: { views: 'clarity', upvotes: 'github-discussions' }, + resources: { resource: { views: 5 } } +}; +assert(unchangedStatsPayload({ generatedAt: 'old', ...payload }, payload), + 'Timestamp-only differences must not create a new stats commit.'); +assert(!unchangedStatsPayload({ ...payload, resources: {} }, payload), + 'Substantive resource changes must produce a new stats output.'); + +const published = { + lastRun: '2026-09-11', + days: { + '2026-09-10': { existing: { views: 4 } }, + '2026-09-11': { resource: { views: 5 } } + } +}; +const staged = { + lastRun: '2026-09-13', + days: { + ...published.days, + '2026-09-12': { resource: { views: 3 } }, + '2026-09-13': { new: { views: 2, uniques: 1 } } + } +}; +const superset = reconcileClarityStates(published, staged); +assert(superset.relation === 'theirs-superset' && clarityStateContains(superset.state, staged), + 'A newer staging state must win over an older weekly publication without losing days.'); + +const union = reconcileClarityStates(staged, { + lastRun: '2026-09-14', + days: { + ...published.days, + '2026-09-12': { resource: { views: 8 } }, + '2026-09-13': { extra: { views: 7 } }, + '2026-09-14': { new: { views: 1 } } + } +}); +assert(union.relation === 'structured-union' && + union.state.days['2026-09-12'].resource.views === 8 && + union.state.days['2026-09-13'].new.views === 2 && + union.state.days['2026-09-13'].extra.views === 7, + 'Independent resources must be unioned while newer rolling-window values replace older ones.'); + +const sameRun = reconcileClarityStates(staged, { + ...staged, + days: { + ...staged.days, + '2026-09-13': { new: { views: 3, uniques: 4 } } + } +}); +assert(sameRun.state.days['2026-09-13'].new.views === 3 && + sameRun.state.days['2026-09-13'].new.uniques === 4, + 'Same-run branch states must reconcile counters monotonically.'); + +console.log('Stats accumulation and idempotency behavior is valid.'); diff --git a/tools/catalog-build/test-traffic-git-integration.js b/tools/catalog-build/test-traffic-git-integration.js new file mode 100644 index 00000000..ce0c7ca7 --- /dev/null +++ b/tools/catalog-build/test-traffic-git-integration.js @@ -0,0 +1,149 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const toolDirectory = dirname(fileURLToPath(import.meta.url)); +const resolver = join(toolDirectory, 'reconcile-stats-conflict.js'); +const testRoot = mkdtempSync(join(toolDirectory, '.traffic-reconcile-')); +const repository = join(testRoot, 'repository'); +const statePath = join(repository, 'traffic-data', 'clarity-views.json'); + +function git(...args) { + return execFileSync('git', ['-C', repository, ...args], { encoding: 'utf8' }).trim(); +} + +function runGit(...args) { + return spawnSync('git', ['-C', repository, ...args], { encoding: 'utf8' }); +} + +function state(lastRun, days) { + writeFileSync(statePath, `${JSON.stringify({ lastRun, days }, null, 2)}\n`); +} + +function readState() { + return JSON.parse(readFileSync(statePath, 'utf8')); +} + +function commit(message) { + git('add', '.'); + git('commit', '-m', message); +} + +function mergeDurable(source) { + const result = runGit('merge', '--no-edit', source); + if (result.status === 0) return false; + const conflicts = git('diff', '--name-only', '--diff-filter=U').split(/\r?\n/).filter(Boolean); + if (conflicts.length !== 1 || conflicts[0] !== 'traffic-data/clarity-views.json') { + throw new Error(`Unexpected conflicts while merging ${source}: ${conflicts.join(', ')}`); + } + execFileSync(process.execPath, [resolver, 'traffic-data/clarity-views.json'], { + cwd: repository, + stdio: 'pipe' + }); + git('add', 'traffic-data/clarity-views.json'); + git('commit', '--no-edit'); + return true; +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +try { + execFileSync('git', ['init', '-b', 'master', repository]); + git('config', 'user.name', 'Traffic Test'); + git('config', 'user.email', 'traffic-test@example.invalid'); + execFileSync('node', ['-e', + "require('fs').mkdirSync(process.argv[1], { recursive: true })", + dirname(statePath) + ]); + + state('2026-09-10', { '2026-09-10': { base: { views: 1 } } }); + writeFileSync(join(repository, 'source.js'), 'export const version = 1;\n'); + commit('initial source'); + + git('checkout', '-b', 'staging'); + state('2026-09-11', { + '2026-09-10': { base: { views: 1 } }, + '2026-09-11': { staged: { views: 2 } } + }); + commit('daily staging'); + + git('checkout', '-b', 'weekly', 'master'); + git('merge', '--no-ff', '--no-edit', 'staging'); + + git('checkout', 'master'); + git('merge', '--squash', 'weekly'); + commit('squash weekly publication'); + state('2026-09-12', { + ...readState().days, + '2026-09-12': { upstream: { views: 3 } } + }); + writeFileSync(join(repository, 'source.js'), 'export const version = 2;\n'); + commit('upstream source progress'); + + git('checkout', 'staging'); + state('2026-09-12', { + ...readState().days, + '2026-09-11': { staged: { views: 5 } }, + '2026-09-12': { daily: { views: 4 } } + }); + commit('next daily collection'); + assert(mergeDurable('master'), + 'Daily staging must reproduce the post-squash Clarity merge conflict.'); + let reconciled = readState(); + assert(reconciled.days['2026-09-11'].staged.views === 5 && + reconciled.days['2026-09-12'].daily.views === 4 && + reconciled.days['2026-09-12'].upstream.views === 3, + 'Daily synchronization must retain newer rolling-window values and union independent resources.'); + assert(readFileSync(join(repository, 'source.js'), 'utf8').includes('version = 2'), + 'Daily synchronization must retain non-conflicting upstream source changes.'); + + git('checkout', 'weekly'); + mergeDurable('master'); + mergeDurable('staging'); + reconciled = readState(); + assert(reconciled.days['2026-09-12'].daily.views === 4, + 'Weekly synchronization must bring newer staging state, not retain the older publication.'); + + git('checkout', 'master'); + git('merge', '--squash', 'weekly'); + commit('second squash weekly publication'); + + git('checkout', 'staging'); + state('2026-09-13', { + ...readState().days, + '2026-09-13': { daily: { views: 5 } } + }); + commit('second cycle daily collection'); + mergeDurable('master'); + + git('checkout', 'weekly'); + mergeDurable('master'); + mergeDurable('staging'); + assert(readState().days['2026-09-13'].daily.views === 5, + 'A second squash cycle must preserve the newly accumulated day.'); + + git('checkout', '-b', 'unsafe-master', 'master'); + writeFileSync(join(repository, 'source.js'), 'export const version = "master";\n'); + commit('master code edit'); + git('checkout', '-b', 'unsafe-staging', 'master'); + writeFileSync(join(repository, 'source.js'), 'export const version = "staging";\n'); + commit('staging code edit'); + const unsafeMerge = runGit('merge', '--no-edit', 'unsafe-master'); + assert(unsafeMerge.status !== 0 && + git('diff', '--name-only', '--diff-filter=U') === 'source.js', + 'The regression setup must produce a real source-code conflict.'); + const refused = spawnSync(process.execPath, [resolver, 'source.js'], { + cwd: repository, + encoding: 'utf8' + }); + assert(refused.status !== 0 && refused.stderr.includes('Unsupported durable stats conflict'), + 'The resolver must refuse source-code conflicts instead of choosing a side.'); + git('merge', '--abort'); + + console.log('Squash-cycle traffic branch reconciliation is valid.'); +} finally { + rmSync(testRoot, { recursive: true, force: true }); +} diff --git a/tools/catalog-build/test-traffic-workflow.js b/tools/catalog-build/test-traffic-workflow.js index 3e93ccf6..ca5c5f77 100644 --- a/tools/catalog-build/test-traffic-workflow.js +++ b/tools/catalog-build/test-traffic-workflow.js @@ -4,31 +4,84 @@ 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])); +const repositoryRoot = join(toolDirectory, '..', '..'); +const workflowPath = join(repositoryRoot, '.github', 'workflows', 'traffic-stats.yml'); +const catalogWorkflowPath = join(repositoryRoot, '.github', 'workflows', 'build-catalog.yml'); +const workflowSource = readFileSync(workflowPath, 'utf8'); +const workflow = yaml.load(workflowSource); +const catalogWorkflow = yaml.load(readFileSync(catalogWorkflowPath, 'utf8')); 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'); +const schedules = workflow.on.schedule; +const dailySchedule = schedules.find(schedule => schedule.cron === '0 6 * * *'); +const weeklySchedule = schedules.find(schedule => schedule.cron === '0 8 * * 5'); +assert(dailySchedule, 'Daily collection must continue at 06:00 UTC.'); +assert(weeklySchedule?.timezone === 'America/Chicago', + 'Weekly publishing must run Friday at 08:00 America/Chicago so DST is handled honestly.'); +const collectJob = workflow.jobs['collect-traffic']; +const weeklyJob = workflow.jobs['publish-weekly']; +assert(collectJob.if.includes("github.event.schedule == '0 6 * * *'"), + 'The daily schedule must run collection only.'); +assert(weeklyJob.if.includes("github.event.schedule == '0 8 * * 5'"), + 'The Friday schedule must run weekly publishing only.'); +assert(collectJob.env.STAGING_BRANCH === weeklyJob.env.STAGING_BRANCH, + 'Daily collection and weekly publishing must share one durable staging branch.'); +assert(weeklyJob.env.PUBLISH_BRANCH && + weeklyJob.env.PUBLISH_BRANCH !== weeklyJob.env.STAGING_BRANCH, + 'The Friday PR head must be separate so daily staging pushes do not mutate an open review.'); + +const collectSteps = collectJob.steps; +const collectById = new Map(collectSteps.filter(step => step.id).map(step => [step.id, step])); +const githubTraffic = collectById.get('github-traffic'); +const stats = collectById.get('stats'); +const persist = collectById.get('persist'); +const prepare = collectSteps.find(step => step.name === 'Prepare durable staging branch'); +const finalFailure = collectSteps.find( + step => step.name === 'Fail when collection or publishing was incomplete' +); + +assert(prepare.run.includes('git checkout -B "$STAGING_BRANCH" origin/master') && + prepare.run.includes('git merge --no-edit origin/master') && + prepare.run.includes('git merge --abort'), + 'First-run staging creation and non-destructive master synchronization must be explicit.'); +assert(!prepare.run.includes('reset --hard') && !prepare.run.includes('push --force'), + 'The durable branch must never be reset or force-pushed.'); +assert(prepare.run.includes("grep -Ev '^(resource-stats\\.json|design-concepts/resource-stats\\.json|traffic-data/clarity-views\\.json)$'") && + prepare.run.includes('reconcile-stats-conflict.js "$file"') && + !prepare.run.includes("! printf '%s\\n' \"$conflicts\""), + 'Master synchronization must reconcile only validated durable state and regenerable files.'); +const safeConflict = /^(resource-stats\.json|design-concepts\/resource-stats\.json|traffic-data\/clarity-views\.json)$/; +const hasUnsafeConflict = conflicts => conflicts.some(file => !safeConflict.test(file)); +assert(!hasUnsafeConflict(['resource-stats.json', 'design-concepts/resource-stats.json']), + 'Derived-only conflicts must be eligible for regeneration.'); +assert(!hasUnsafeConflict(['traffic-data/clarity-views.json']), + 'A durable Clarity state conflict must be eligible for structured reconciliation.'); +assert(hasUnsafeConflict(['resource-stats.json', 'traffic-data/2026-09-14.json']), + 'A mixed conflict set must stop synchronization instead of overwriting source data.'); 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'), +assert(stats?.if === 'always()', 'Stats must run even when GitHub traffic collection fails.'); +assert(persist?.['continue-on-error'] === true, + 'Staging failures must reach the explicit reporting and final failure steps.'); +assert(persist.run.includes('git push origin "HEAD:refs/heads/$STAGING_BRANCH"') && + persist.run.includes('No pull request was attempted') && + !persist.run.includes('gh pr create') && + !persist.run.includes('gh pr merge'), + 'Daily collection must persist safely without attempting a PR or auto-merge.'); +assert(persist.run.includes('git fetch origin "$STAGING_BRANCH"') && + persist.run.includes('Merging once and retrying without force') && + persist.run.includes('reconcile-stats-conflict.js "$file"') && + persist.run.includes('build-stats.js --render-only'), + 'A concurrent staging advance must receive one bounded merge-and-push retry.'); +assert(persist.run.includes('if [ "${{ steps.stats.outputs.generated }}" = "true" ]; then') && + persist.run.includes('git add resource-stats.json resource-discussions.json traffic-data/clarity-views.json') && + !persist.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) { @@ -38,30 +91,57 @@ function evaluateCondition(expression, state) { ["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] + ["steps.persist.outcome == 'failure'", state.persistFailed] ]); let evaluable = expression; - for (const [token, value] of replacements) { - evaluable = evaluable.replaceAll(token, String(value)); - } + 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 } + { github: true, clarity: true, persistFailed: false, persist: true, fail: false }, + { github: true, clarity: false, persistFailed: false, persist: true, fail: true }, + { github: false, clarity: true, persistFailed: false, persist: true, fail: true }, + { github: false, clarity: false, persistFailed: false, persist: false, fail: true }, + { github: true, clarity: true, persistFailed: true, persist: 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(persist.if, combination) === combination.persist, + `Unexpected staging 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.'); +const weeklySteps = weeklyJob.steps; +const weeklyById = new Map(weeklySteps.filter(step => step.id).map(step => [step.id, step])); +const sync = weeklyById.get('sync'); +const weeklyPr = weeklyById.get('weekly-pr'); +assert(sync.run.includes('node tools/catalog-build/build-stats.js --render-only'), + 'Weekly synchronization must render from persisted source state without recollecting.'); +assert(sync.run.includes('merge_safely "origin/$STAGING_BRANCH"') && + sync.run.includes('HEAD:refs/heads/$PUBLISH_BRANCH') && + sync.run.includes('reconcile-stats-conflict.js "$file"'), + 'Friday publishing must copy accumulated state to a separate weekly branch.'); +assert(sync.run.includes('git diff --quiet origin/master HEAD'), + 'Friday publishing must skip PR work when staging has no unpublished content.'); +assert(weeklyPr.run.includes('gh pr list --head "$PUBLISH_BRANCH" --base master --state open') && + weeklyPr.run.includes('gh api --method PATCH') && + weeklyPr.run.includes('gh pr create'), + 'Friday publishing must update one open PR before attempting to create another.'); +assert(weeklyPr.run.includes('AUTOMATION_PAT_CONFIGURED') && + weeklyPr.run.includes('COMPARE_URL') && + !weeklyPr.run.includes('gh pr merge'), + 'Blocked PR creation must provide a manual compare URL and remain review-required.'); + +const catalogStats = catalogWorkflow.jobs.publish.steps.find( + step => typeof step.run === 'string' && step.run.includes('build:stats') +); +assert(catalogStats.run.includes('--render-only') && !catalogStats.env, + 'Catalog publishing must render persisted stats without collecting short-lived sources.'); +assert(!workflowSource.includes('BRANCH="traffic-data/$DATE"'), + 'The workflow must not create date-named daily PR branches.'); + +console.log('Daily staging and weekly traffic publishing workflow is valid.'); diff --git a/traffic-data/README.md b/traffic-data/README.md index b52dc02d..08500f23 100644 --- a/traffic-data/README.md +++ b/traffic-data/README.md @@ -1,8 +1,16 @@ # Traffic Data This directory is automatically populated by the [traffic-stats workflow](../.github/workflows/traffic-stats.yml). +Daily collection is committed to the durable `automation/traffic-stats-staging` +branch so Clarity's three-day export window is preserved without daily pull requests. **Files:** - `YYYY-MM-DD.json` — Full daily snapshot (views, clones, referrers, popular paths) -Data is collected daily at 06:00 UTC via GitHub Actions. +Data is collected daily at 06:00 UTC via GitHub Actions. Each Friday at 08:00 +`America/Chicago` (including daylight saving time), the workflow updates one +review-required pull request from the Friday-only `automation/traffic-stats-weekly` +branch to `master`. Daily pushes to the staging branch therefore do not change the +open weekly review. If the optional `AUTOMATION_PAT` is not configured or cannot +create pull requests, the workflow prints a manual compare URL; daily collection +continues safely on the staging branch.