From 3a7b1be8e6cbfe38a8a3e18e8ee765485b091143 Mon Sep 17 00:00:00 2001 From: soyalejolopez <88358406+soyalejolopez@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:33:55 -0500 Subject: [PATCH 1/4] Surface the real error when traffic collection fails The Collect Traffic Data workflow has failed every day since 7/6 with no usable diagnostic. Two bugs conspired to hide the cause: - gh_api_retry sent gh's stderr to /dev/null, so the retry warnings said "attempt N/3 failed" and nothing else. - fetch_required_json echoed its ::error:: to stdout while running inside a command substitution, so the error text was captured into the caller's variable instead of reaching the log. Now the real gh stderr is preserved and included in the retry warning, errors route to stderr and return instead of exiting from a subshell, and an empty TRAFFIC_TOKEN fails immediately with a message naming the secret and the administration:read scope it needs. Also skips the pointless sleep after the final attempt. The underlying failure is an expired TRAFFIC_TOKEN. This does not fix that, but it makes the next failure legible in one glance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 68fa07a3-7ce8-442f-a9e8-f6b1775d8a1f --- .github/workflows/traffic-stats.yml | 40 ++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/.github/workflows/traffic-stats.yml b/.github/workflows/traffic-stats.yml index d95a63d..f6c5a47 100644 --- a/.github/workflows/traffic-stats.yml +++ b/.github/workflows/traffic-stats.yml @@ -49,21 +49,33 @@ jobs: DATA_DIR="traffic-data" DATE=$(date -u +"%Y-%m-%d") + # Fail fast with a precise message instead of three opaque retries. + if [ -z "${GH_TOKEN:-}" ]; then + echo "::error::TRAFFIC_TOKEN secret is empty or unset. The traffic API needs a PAT with administration:read on $REPO — the built-in GITHUB_TOKEN cannot read traffic." + exit 1 + fi + gh_api_retry() { local max_attempts=3 local delay=5 local attempt=1 local output + local err_file + err_file=$(mktemp) while [ "$attempt" -le "$max_attempts" ]; do - if output=$(gh api "$@" 2>/dev/null); then + if output=$(gh api "$@" 2>"$err_file"); then + rm -f "$err_file" printf '%s' "$output" return 0 fi - echo "::warning::gh api attempt $attempt/$max_attempts failed, retrying in ${delay}s..." >&2 - sleep "$delay" + # 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 + [ "$attempt" -lt "$max_attempts" ] && sleep "$delay" delay=$((delay * 2)) attempt=$((attempt + 1)) done + rm -f "$err_file" return 1 } @@ -72,14 +84,17 @@ jobs: local endpoint="$2" local response + # These messages MUST go to stderr: this function runs inside a command + # 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 after retries" - exit 1 + 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 + return 1 fi if ! printf '%s' "$response" | jq -e . >/dev/null 2>&1; then - echo "::error::Invalid JSON returned for $label" - exit 1 + echo "::error::Invalid JSON returned for $label" >&2 + return 1 fi printf '%s' "$response" @@ -89,11 +104,12 @@ jobs: echo "📊 Fetching traffic data for $REPO on $DATE..." - # Fetch all four traffic endpoints - views=$(fetch_required_json "views" "repos/$REPO/traffic/views") - clones=$(fetch_required_json "clones" "repos/$REPO/traffic/clones") - referrers=$(fetch_required_json "referrers" "repos/$REPO/traffic/popular/referrers") - paths=$(fetch_required_json "paths" "repos/$REPO/traffic/popular/paths") + # Fetch all four traffic endpoints ("|| exit 1" is explicit: set -e already + # aborts on a failed assignment, but this makes the intent unmissable). + views=$(fetch_required_json "views" "repos/$REPO/traffic/views") || exit 1 + clones=$(fetch_required_json "clones" "repos/$REPO/traffic/clones") || exit 1 + referrers=$(fetch_required_json "referrers" "repos/$REPO/traffic/popular/referrers") || exit 1 + paths=$(fetch_required_json "paths" "repos/$REPO/traffic/popular/paths") || exit 1 # Build a combined JSON snapshot for today From 2ba403b19002fae78abbefbaa1ed73994dfe019e Mon Sep 17 00:00:00 2001 From: soyalejolopez <88358406+soyalejolopez@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:34:09 -0500 Subject: [PATCH 2/4] Stop the message center refresh dying on the Actions PR policy Every weekly run since 7/20 has pushed its branch successfully and then failed on gh pr create with: GitHub Actions is not permitted to create or approve pull requests That is a repo setting, not a bug in the run, and it should not throw away a branch that already landed. The step now recognises that specific error, emits a warning plus a job summary with a compare URL, and exits 0. Any other create failure still fails the job. Two supporting fixes: - --force-with-lease was failing with "stale info" because actions/checkout only fetches the default branch, so refs/remotes/origin/ never existed on a same-day rerun. The ref is now seeded before the push. - GH_TOKEN falls back to AUTOMATION_PAT when present, which sidesteps the policy entirely for repos that add one. Also adds set -euo pipefail and drops an || true that was masking gh pr list failures. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 68fa07a3-7ce8-442f-a9e8-f6b1775d8a1f --- .github/workflows/update-message-center.yml | 62 +++++++++++++++++---- 1 file changed, 50 insertions(+), 12 deletions(-) diff --git a/.github/workflows/update-message-center.yml b/.github/workflows/update-message-center.yml index de6b18e..4ba75fc 100644 --- a/.github/workflows/update-message-center.yml +++ b/.github/workflows/update-message-center.yml @@ -42,11 +42,13 @@ jobs: - name: Commit and open PR if: steps.changes.outputs.changed == 'true' env: - # Use the built-in GITHUB_TOKEN. The job already grants - # contents:write + pull-requests:write, which is sufficient to push - # the branch and open the PR without a separate long-lived PAT. - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Prefer a PAT when one is configured. Fall back to the built-in token, + # which can push the branch but may be blocked from opening the PR by the + # org/repo policy "Allow GitHub Actions to create and approve pull requests". + GH_TOKEN: ${{ secrets.AUTOMATION_PAT || secrets.GITHUB_TOKEN }} run: | + set -euo pipefail + git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add copilot-agent-strategy/copilot-agents-guide/index.html @@ -56,17 +58,53 @@ jobs: git checkout -b "$BRANCH" git commit -m "chore: weekly message center posts refresh $DATE" - git push --set-upstream origin "$BRANCH" + # A re-run on the same day must be able to update its own branch. Seed the + # remote-tracking ref first, otherwise --force-with-lease aborts on "stale + # info" because actions/checkout only fetched the default branch. + if git fetch origin "$BRANCH"; then + git update-ref "refs/remotes/origin/$BRANCH" FETCH_HEAD + fi + git push --force-with-lease --set-upstream origin "$BRANCH" + + COMPARE_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/master...${BRANCH}?expand=1" # Create a normal PR for human review if one doesn't already exist. 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 - gh pr create \ - --title "chore: weekly message center posts refresh $DATE" \ - --body "Automated weekly message center data update. This PR contains externally derived content and requires human review." \ - --head "$BRANCH" \ - --base master - echo "✅ PR created" + exit 0 + fi + + if create_output=$(gh pr create \ + --title "chore: weekly message center posts refresh $DATE" \ + --body "Automated weekly message center data update. This PR contains externally derived content and requires human review." \ + --head "$BRANCH" \ + --base master 2>&1); then + echo "✅ PR created: $create_output" + exit 0 fi + + echo "$create_output" + + # The refreshed content is already safely on the branch. A repo policy that + # forbids Actions from opening PRs must not destroy the week's refresh — warn + # and hand the maintainer a one-click link instead of failing the job. + if printf '%s' "$create_output" | grep -qi "not permitted to create or approve pull requests"; then + echo "::warning::Branch $BRANCH was pushed, but this repo blocks GitHub Actions from opening pull requests. Open it manually: $COMPARE_URL" + { + echo "### ⚠️ Message center refresh needs a manual PR" + echo "" + echo "The refreshed content was pushed to \`$BRANCH\`, but GitHub Actions is not" + echo "permitted to open pull requests in this repository." + echo "" + echo "**Open the PR:** $COMPARE_URL" + echo "" + echo "**Permanent fix:** Settings → Actions → General → enable" + echo "\"Allow GitHub Actions to create and approve pull requests\", or set an" + echo "\`AUTOMATION_PAT\` secret with \`pull-requests: write\`." + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + echo "::error::Failed to open the message center PR for $BRANCH." + exit 1 From 221ae0bbb7ec35ff3728c392e982d0f28a32a93e Mon Sep 17 00:00:00 2001 From: soyalejolopez <88358406+soyalejolopez@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:34:29 -0500 Subject: [PATCH 3/4] Route catalog rebuilds through a PR when master is protected Half the recent Build catalog runs failed with: remote: error: GH006: Protected branch update failed for refs/heads/master - Changes must be made through a pull request. The publish job pushes catalog.json, resource-stats.json, resource-discussions.json and traffic-data/clarity-views.json directly to master. Branch protection now forbids that, so the rebuild has been silently not landing. The runs that appeared to pass only did so through the "no changes" early exit. The step now escalates: direct push, then pull --rebase and retry for a plain race, then fall back to a catalog-refresh/- branch with a PR and auto-merge. If the repo also blocks Actions from opening PRs, it warns with a compare URL and exits 0 rather than failing. Errors are no longer swallowed. gh pr list dropped its || true, an unconfirmed PR after a successful branch push is a hard failure, and a failed auto-merge is a loud warning with the PR URL. The PR number is parsed from gh pr create's output URL rather than by re-listing, because listing immediately after creation races GitHub's PR index and can report a phantom failure. No retrigger loop: all four generated files sit outside this workflow's paths filters, so merging the catalog PR will not start another run. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 68fa07a3-7ce8-442f-a9e8-f6b1775d8a1f --- .github/workflows/build-catalog.yml | 79 ++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-catalog.yml b/.github/workflows/build-catalog.yml index ef39d04..6a7933e 100644 --- a/.github/workflows/build-catalog.yml +++ b/.github/workflows/build-catalog.yml @@ -52,6 +52,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write # Only the publish job may push the rebuilt catalog. + pull-requests: write # Needed when branch protection forces a PR instead. steps: - uses: actions/checkout@v4 with: @@ -71,7 +72,14 @@ jobs: 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 + # opening PRs by the "Allow GitHub Actions to create and approve pull + # requests" org/repo policy. + GH_TOKEN: ${{ secrets.AUTOMATION_PAT || secrets.GITHUB_TOKEN }} run: | + set -euo pipefail + git add catalog.json resource-stats.json traffic-data/clarity-views.json resource-discussions.json if [ -f design-concepts/resource-stats.json ]; then git add design-concepts/resource-stats.json @@ -83,4 +91,73 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git commit -m "chore: rebuild catalog [skip ci]" - git push + + # Fast path: push straight to the default branch. Another push landing + # first is normal on a busy branch, so rebase once and retry. + if git push || { git pull --rebase origin "${GITHUB_REF_NAME}" && git push; }; then + echo "✅ Catalog pushed to ${GITHUB_REF_NAME}." + exit 0 + fi + + # Branch protection ("Changes must be made through a pull request") rejects + # the direct push. Route the rebuild through a PR instead of losing it. + echo "::notice::Direct push rejected — falling back to a pull request." + + BRANCH="catalog-refresh/$(date -u +%Y-%m-%d)-${GITHUB_SHA:0:7}" + git checkout -b "$BRANCH" + # Seed the remote-tracking ref so --force-with-lease has a basis on re-runs. + if git fetch origin "$BRANCH"; then + git update-ref "refs/remotes/origin/$BRANCH" FETCH_HEAD + fi + git push --force-with-lease --set-upstream origin "$BRANCH" + + COMPARE_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/${GITHUB_REF_NAME}...${BRANCH}?expand=1" + + existing_pr=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty') + if [ -z "$existing_pr" ]; then + if create_output=$(gh pr create \ + --title "chore: rebuild catalog" \ + --body "Automated catalog + resource stats rebuild for ${GITHUB_SHA}." \ + --head "$BRANCH" \ + --base "${GITHUB_REF_NAME}" 2>&1); then + echo "✅ PR created: $create_output" + # Read the number straight out of the returned URL. Re-listing here + # would race GitHub's PR index and could report a phantom failure. + existing_pr=$(printf '%s' "$create_output" | grep -oE 'pull/[0-9]+' | tail -n1 | cut -d/ -f2) + 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 "### ⚠️ Catalog rebuild needs a manual PR" + echo "" + echo "The rebuilt catalog was pushed to \`$BRANCH\`." + echo "" + echo "**Open the PR:** $COMPARE_URL" + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + echo "::error::Failed to open the catalog rebuild PR for $BRANCH." + exit 1 + fi + fi + + # Reaching here without a PR means the rebuild is stranded on a branch and + # will never land — that is a failure, not a silent success. + if [ -z "$existing_pr" ]; then + echo "::error::Pushed $BRANCH but could not confirm an open pull request. Open it manually: $COMPARE_URL" + exit 1 + fi + + # Auto-merge needs the repo-level "Allow auto-merge" setting. The PR itself + # already exists and is reviewable, so this stays a warning — but a loud one. + if ! gh pr merge "$existing_pr" --auto --squash; then + echo "::warning::Could not enable auto-merge on PR #$existing_pr — merge it manually: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/$existing_pr" + { + echo "### ⚠️ Catalog rebuild PR #$existing_pr needs a manual merge" + echo "" + echo "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/$existing_pr" + } >> "$GITHUB_STEP_SUMMARY" + else + echo "✅ Auto-merge enabled for PR #$existing_pr" + fi From 274fcd6a5d9fe4c11cd6f3d648aa8f3e675900e2 Mon Sep 17 00:00:00 2001 From: soyalejolopez <88358406+soyalejolopez@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:34:45 -0500 Subject: [PATCH 4/4] Give the PR sweeper time to resolve a fork PR number Three runs on 7/22 aborted with "Could not resolve a trusted PR number from the workflow_run event". Fork PRs leave workflow_run.pull_requests[0].number empty, so the job falls back to looking the PR up by head SHA, and that lookup ran exactly once. PR #495 was created at 16:49:09Z and the report job queried at 16:49:37Z. Twenty-eight seconds. GitHub's commit-to-PR association index had not propagated yet; the same SHA resolves fine now. An earlier run the same afternoon hit the same empty value and resolved fine, which is what a propagation race looks like. The lookup now retries with backoff at 0, 3, 8 and 15 seconds and logs why each attempt came back empty. If it still cannot resolve, the job no longer exits 1. It warns, still posts the commit status, and returns. This does not weaken the gate: setStatus only needs the trusted head SHA, and the deterministic guardrails remain the authoritative merge check. Only the sticky comment is lost, and a red X on a healthy PR is worse than a missing comment. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 68fa07a3-7ce8-442f-a9e8-f6b1775d8a1f --- .github/scripts/pr-sweeper/report.mjs | 42 +++++++++++++++++++++------ 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/.github/scripts/pr-sweeper/report.mjs b/.github/scripts/pr-sweeper/report.mjs index 30f3976..77c6606 100644 --- a/.github/scripts/pr-sweeper/report.mjs +++ b/.github/scripts/pr-sweeper/report.mjs @@ -267,13 +267,31 @@ async function resolvePrNumberFromSha(sha) { // Bind the PR number to the TRUSTED head SHA from the workflow_run event. // Works for fork PRs (where workflow_run.pull_requests is empty) because the // head commit is reachable via refs/pull/N/head in the base repo. - const res = await gh("GET", `/repos/${REPO}/commits/${encodeURIComponent(sha)}/pulls?per_page=100`); - if (!res.ok) return null; - const pulls = await res.json(); - if (!Array.isArray(pulls) || pulls.length === 0) return null; - const match = - pulls.find((p) => p.state === "open" && p.base?.repo?.full_name === REPO) || pulls[0]; - return match?.number ?? null; + // + // Retried: GitHub's commit -> PR association index lags PR creation by tens of + // seconds, and this job routinely starts within that window on fork PRs. + const delays = [0, 3000, 8000, 15000]; + for (const wait of delays) { + if (wait) await sleep(wait); + const res = await gh("GET", `/repos/${REPO}/commits/${encodeURIComponent(sha)}/pulls?per_page=100`); + if (!res.ok) { + console.warn(`Commit -> PR lookup returned ${res.status}; retrying.`); + continue; + } + const pulls = await res.json(); + if (!Array.isArray(pulls) || pulls.length === 0) { + console.warn("Commit -> PR lookup returned no pull requests yet; retrying."); + continue; + } + const match = + pulls.find((p) => p.state === "open" && p.base?.repo?.full_name === REPO) || pulls[0]; + if (match?.number) return match.number; + } + return null; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); } async function main() { @@ -308,8 +326,14 @@ async function main() { if (!number && headSha) number = await resolvePrNumberFromSha(headSha); if (!number) { - console.error("Could not resolve a trusted PR number from the workflow_run event; aborting."); - process.exit(1); + // The security gate is the part that actually matters, and it only needs the + // trusted head SHA. Set it, then stop — a missing sticky comment is cosmetic + // and must not turn into a red workflow on every fork PR. + console.warn( + "Could not resolve a trusted PR number from the workflow_run event; posting the commit status only." + ); + await setStatus(headSha, guardrails); + return; } const body = buildComment(config, guardrails, ai, meta);