name: "Showcase: Docs Sync" on: # Auto-trigger disabled ahead of the shell-docs cutover (2026-05-19). # Shell-docs is becoming the canonical authoring source; an upstream→shell # sync would clobber edits made directly in showcase/shell-docs/. Re-enable # only if the cutover is reverted and docs/content/docs/ becomes upstream # again. Manual runs remain available via workflow_dispatch. workflow_dispatch: permissions: contents: read concurrency: group: showcase-docs-sync cancel-in-progress: false jobs: sync-docs: runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: write pull-requests: write env: SHOWCASE_BRANCH: main SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ env.SHOWCASE_BRANCH }} fetch-depth: 0 persist-credentials: false - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 20 cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Fetch main branch run: git fetch origin main - name: Run docs sync id: sync run: | set +e npx tsx showcase/scripts/sync-docs-from-main.ts > sync-output.txt 2>&1 EXIT_CODE=$? set -e cat sync-output.txt if [ $EXIT_CODE -eq 2 ]; then echo "No changes to sync" echo "action=none" >> "$GITHUB_OUTPUT" elif [ $EXIT_CODE -eq 0 ]; then echo "Clean transforms only" echo "action=auto_push" >> "$GITHUB_OUTPUT" elif [ $EXIT_CODE -eq 3 ]; then echo "Has review items + clean transforms" echo "action=push_and_pr" >> "$GITHUB_OUTPUT" # The sync script writes review-items.txt and emits # review_items_file= directly to GITHUB_OUTPUT. # Verify the file actually exists so downstream steps don't # silently read an empty path. if [ ! -f review-items.txt ]; then echo "::warning::review-items.txt not found despite exit code 3 — sync script may have a bug" fi else echo "Sync failed with exit code $EXIT_CODE" exit 1 fi # Generate a token from copilotkit-devops-bot (bypass actor on branch protection) - name: Generate bot token id: bot-token if: steps.sync.outputs.action == 'auto_push' || steps.sync.outputs.action == 'push_and_pr' uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: 1108748 private-key: ${{ secrets.DEVOPS_BOT_PRIVATE_KEY }} permission-contents: write permission-pull-requests: write # Create PR. For action=auto_push (no review items) the PR is # auto-merged via the devops bot (bypasses branch protection). For # action=push_and_pr the sync script has already written best-effort # 3-way merged content to disk — this step commits it and opens a PR # tagged [NEEDS REVIEW]. Auto-merge is DISABLED for needs-review PRs # so a human reconciles any upstream-wins overrides. - name: Create PR for docs sync id: push if: steps.sync.outputs.action == 'auto_push' || steps.sync.outputs.action == 'push_and_pr' env: GH_TOKEN: ${{ steps.bot-token.outputs.token }} ACTION: ${{ steps.sync.outputs.action }} REVIEW_ITEMS_FILE: ${{ steps.sync.outputs.review_items_file }} run: | # Trap-based cleanup so temp files are removed even on signal kill. CLEANUP_FILES=() cleanup() { for f in "${CLEANUP_FILES[@]}"; do [ -n "$f" ] && [ -e "$f" ] && rm -f "$f" || true done } trap cleanup EXIT INT TERM git config user.name "copilotkit-devops-bot[bot]" git config user.email "copilotkit-devops-bot[bot]@users.noreply.github.com" SHORT_SHA=$(git rev-parse --short origin/main) if [ "$ACTION" = "push_and_pr" ]; then # Dedupe against open needs-review PRs. If one already exists, # skip PR creation and let the Slack alert point at the existing # one (simpler than re-pushing to its branch). # # NOTE: `gh pr list --search "head:..."` is NOT supported — # GitHub's PR search syntax has no `head:` qualifier, so that # query always returns empty. Use jq on the full list instead # to filter by headRefName prefix client-side. # Fail the step on gh API failure rather than swallowing it — # `|| echo ""` would silently treat a transient 5xx as "no # existing PR" and open a duplicate. Retry once with backoff # to absorb blips; if both attempts fail, exit non-zero. gh_pr_list_existing() { gh pr list \ --state open \ --base "$SHOWCASE_BRANCH" \ --json number,url,headRefName \ --jq '[.[] | select(.headRefName | startswith("docs-sync/needs-review/"))] | .[0].url' } if ! EXISTING_PR=$(gh_pr_list_existing 2>gh-err.txt); then echo "::warning::gh pr list failed on first attempt — retrying after 5s" cat gh-err.txt || true sleep 5 if ! EXISTING_PR=$(gh_pr_list_existing 2>gh-err.txt); then echo "::error::gh pr list failed twice — aborting to avoid duplicate PR creation" cat gh-err.txt || true rm -f gh-err.txt exit 1 fi fi rm -f gh-err.txt if [ -n "$EXISTING_PR" ]; then echo "Open needs-review PR already exists: $EXISTING_PR" echo "Skipping new PR — Slack alert will point at the existing one." echo "files_changed=0" >> "$GITHUB_OUTPUT" echo "pr_url=${EXISTING_PR}" >> "$GITHUB_OUTPUT" echo "pr_opened=false" >> "$GITHUB_OUTPUT" echo "needs_review=true" >> "$GITHUB_OUTPUT" echo "existing_pr=true" >> "$GITHUB_OUTPUT" exit 0 fi BRANCH="docs-sync/needs-review/${SHORT_SHA}-$(date +%s)" COMMIT_SUBJECT="chore: docs sync from main — needs review ($(date +%Y-%m-%d))" else BRANCH="docs-sync/auto/${SHORT_SHA}-$(date +%s)" COMMIT_SUBJECT="chore: auto-sync docs from main ($(date +%Y-%m-%d))" fi git checkout -b "$BRANCH" # For push_and_pr: apply the conflict manifest FIRST (upstream-wins # content for conflicted files, written ONLY to the PR branch so # the main branch worktree stays as-is and future sync runs still # flag those files for review until this PR is merged). # # CRITICAL: This MUST run BEFORE `git add` below. `git add` stages # the current working tree, so any manifest files written after # that point would be left un-staged and never make it into the # commit. if [ "$ACTION" = "push_and_pr" ] && [ -f conflict-manifest.json ]; then CLEANUP_FILES+=("conflict-manifest.json") node -e ' const fs = require("fs"); const path = require("path"); const manifest = JSON.parse(fs.readFileSync("conflict-manifest.json", "utf-8")); for (const entry of manifest) { const target = path.resolve(entry.showcasePath); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, entry.content); console.log("Applied upstream-wins to PR branch: " + entry.showcasePath); } ' fi # Stage AFTER manifest has been applied so conflicted files land # in the commit. git add showcase/shell-docs/src/content/ showcase/shell-docs/.docs-sync-sha CHANGED=$(git diff --cached --name-only | wc -l | tr -d ' ') if [ "$CHANGED" = "0" ]; then echo "Nothing to commit — no PR will be opened (deliberate)" echo "files_changed=0" >> "$GITHUB_OUTPUT" # Explicit signal: no PR opened, and this is the intended outcome # (not an error path). Downstream alerts key on this. echo "pr_opened=false" >> "$GITHUB_OUTPUT" # Set needs_review explicitly so downstream `needs_review != 'true'` # gates (notify-auto-sync) don't misfire on this deliberate # no-op. Value depends on why we got here: push_and_pr path = # review still pending; auto_push path = no review needed. if [ "$ACTION" = "push_and_pr" ]; then echo "needs_review=true" >> "$GITHUB_OUTPUT" else echo "needs_review=false" >> "$GITHUB_OUTPUT" fi exit 0 fi git commit --no-verify -m "$COMMIT_SUBJECT" # Override git credential to use bot token (checkout configured GITHUB_TOKEN) git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" git push origin "$BRANCH" if [ "$ACTION" = "push_and_pr" ]; then # Build the PR body from review-items.txt plus a clear callout. REVIEW_ITEMS_CONTENT="(no review-items file produced)" if [ -n "${REVIEW_ITEMS_FILE:-}" ] && [ -f "$REVIEW_ITEMS_FILE" ]; then REVIEW_ITEMS_CONTENT=$(cat "$REVIEW_ITEMS_FILE") fi PR_BODY_FILE=$(mktemp) CLEANUP_FILES+=("$PR_BODY_FILE") { printf '%s\n' ':warning: **Docs sync — MANUAL REVIEW REQUIRED**' printf '\n' printf '%s\n' 'This PR was auto-opened because the docs-sync script detected' printf '%s\n' 'showcase-local modifications overlapping with upstream changes.' printf '\n' printf '%s\n' 'The script attempted a best-effort 3-way merge:' printf '\n' printf '%s\n' '- Where `git merge-file` produced a clean merge, the merged content was written.' printf '%s\n' '- Where `git merge-file` produced conflict markers, **upstream content was written as-is** and showcase-local modifications were overridden. **Manual review required.**' printf '\n' printf '%s\n' '### Review items' printf '\n' printf '%s\n' '```' # printf '%s\n' avoids running command substitution / backticks # embedded in review-items content (cat "$REVIEW_ITEMS_FILE" # would also work; printf is equivalent here since we already # captured the content). printf '%s\n' "$REVIEW_ITEMS_CONTENT" printf '%s\n' '```' printf '\n' printf '%s\n' '### Source' printf '\n' printf '%s\n' "- Upstream ref: [\`${SHORT_SHA}\`](https://github.com/${{ github.repository }}/commit/${SHORT_SHA})" printf '%s\n' "- Workflow run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" printf '\n' printf '%s\n' '**Review before merging.** Auto-merge is intentionally disabled' printf '%s\n' 'for `needs-review` PRs — confirm the upstream-wins sections' printf '%s\n' 'preserve any intentional showcase-local divergence you want to' printf '%s\n' 'keep, then merge manually.' } > "$PR_BODY_FILE" PR_URL=$(gh pr create \ --title "docs-sync(needs-review): sync from main (${SHORT_SHA}) [NEEDS REVIEW]" \ --body-file "$PR_BODY_FILE" \ --base "$SHOWCASE_BRANCH" \ --head "$BRANCH") echo "Created NEEDS-REVIEW PR: $PR_URL" echo "files_changed=${CHANGED}" >> "$GITHUB_OUTPUT" echo "pr_url=${PR_URL}" >> "$GITHUB_OUTPUT" # Only set after URL captured — error paths leave this unset so # downstream alerts fall through to failure(). echo "pr_opened=true" >> "$GITHUB_OUTPUT" echo "needs_review=true" >> "$GITHUB_OUTPUT" # Intentionally no `gh pr merge` — human must review & merge. else PR_URL=$(gh pr create \ --title "chore: auto-sync docs from main (${SHORT_SHA})" \ --body "Automated docs sync. Clean transforms / clean 3-way merges only." \ --base "$SHOWCASE_BRANCH" \ --head "$BRANCH") echo "Created PR: $PR_URL" echo "files_changed=${CHANGED}" >> "$GITHUB_OUTPUT" echo "pr_url=${PR_URL}" >> "$GITHUB_OUTPUT" echo "pr_opened=true" >> "$GITHUB_OUTPUT" echo "needs_review=false" >> "$GITHUB_OUTPUT" gh pr merge "$PR_URL" --merge fi # Build all Slack payloads via jq into tmpfiles. This guarantees any # review-item filename containing ", \, or control chars is safely # JSON-escaped (never string-interpolated into a JSON literal). - name: Build Slack payloads id: payloads if: always() env: RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} PR_URL: ${{ steps.push.outputs.pr_url }} FILES_CHANGED: ${{ steps.push.outputs.files_changed }} REVIEW_ITEMS_FILE: ${{ steps.sync.outputs.review_items_file }} SYNC_OUTCOME: ${{ steps.sync.outcome }} BOT_TOKEN_OUTCOME: ${{ steps.bot-token.outcome }} PUSH_OUTCOME: ${{ steps.push.outcome }} run: | set -euo pipefail mkdir -p slack-payloads # auto-sync success (PR merged) jq -n \ --arg pr_url "${PR_URL:-}" \ --arg files "${FILES_CHANGED:-?}" \ --arg run_url "$RUN_URL" \ '{text: (":arrows_counterclockwise: *Docs sync*: auto-merged " + $files + " file(s)\n" + $pr_url + "\n<" + $run_url + "|View run>")}' \ > slack-payloads/auto-sync.json # merge failed (PR exists but gh pr merge failed) jq -n \ --arg pr_url "${PR_URL:-}" \ --arg run_url "$RUN_URL" \ '{text: (":warning: *Docs sync*: PR created but auto-merge FAILED — needs manual merge\n" + $pr_url + "\n<" + $run_url + "|View run>")}' \ > slack-payloads/merge-failed.json # review-needed payloads: read items from file, let jq handle escaping REVIEW_ITEMS="" if [ -n "${REVIEW_ITEMS_FILE:-}" ] && [ -f "$REVIEW_ITEMS_FILE" ]; then REVIEW_ITEMS=$(cat "$REVIEW_ITEMS_FILE") fi jq -n \ --arg items "$REVIEW_ITEMS" \ --arg pr_url "${PR_URL:-}" \ --arg run_url "$RUN_URL" \ '{text: (":warning: *Docs sync*: auto-opened *NEEDS REVIEW* PR (best-effort 3-way merge; upstream-wins where conflicts) — human must review + merge\n```" + $items + "```\nReview: " + $pr_url + "\n<" + $run_url + "|View run>")}' \ > slack-payloads/review-with-pr.json # Collision path: new review items flagged but an existing open # needs-review PR already covers the territory; we did NOT open a # new PR. Point reviewers at the existing one. jq -n \ --arg items "$REVIEW_ITEMS" \ --arg pr_url "${PR_URL:-}" \ --arg run_url "$RUN_URL" \ '{text: (":warning: *Docs sync*: new review items detected, but an open *NEEDS REVIEW* PR already exists — skipped new PR creation to avoid collision. Please resolve the existing PR.\n```" + $items + "```\nExisting PR: " + $pr_url + "\n<" + $run_url + "|View run>")}' \ > slack-payloads/review-existing-pr.json # Fallback path: review items flagged but no PR opened (e.g. 3-way # merge produced bit-for-bit identical content to what's already on # disk so nothing to commit). Keep an alert so it's visible. jq -n \ --arg items "$REVIEW_ITEMS" \ --arg run_url "$RUN_URL" \ '{text: (":warning: *Docs sync*: review items flagged but produced no diff (no PR opened)\n```" + $items + "```\n<" + $run_url + "|View run>")}' \ > slack-payloads/review-no-pr.json # failure alert FAILED_STEP="unknown" if [ "${SYNC_OUTCOME:-}" = "failure" ]; then FAILED_STEP="sync-docs script" elif [ "${BOT_TOKEN_OUTCOME:-}" = "failure" ]; then FAILED_STEP="bot token generation (check DEVOPS_BOT_PRIVATE_KEY secret)" elif [ "${PUSH_OUTCOME:-}" = "failure" ]; then FAILED_STEP="push/PR creation" fi jq -n \ --arg failed_step "$FAILED_STEP" \ --arg run_url "$RUN_URL" \ '{text: (":x: *Docs sync*: workflow failed\n*Failed step:* " + $failed_step + " | <" + $run_url + "|View run>")}' \ > slack-payloads/failure.json - name: Notify Slack (auto-sync) id: notify-auto-sync if: always() && env.SLACK_WEBHOOK != '' && steps.push.outcome == 'success' && steps.push.outputs.files_changed != '0' && steps.push.outputs.needs_review != 'true' uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 # v2.1.0 with: webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }} webhook-type: incoming-webhook payload-file-path: slack-payloads/auto-sync.json - name: Notify Slack (merge failed) id: notify-merge-failed if: failure() && env.SLACK_WEBHOOK != '' && steps.push.outputs.pr_opened == 'true' && steps.push.outputs.needs_review != 'true' uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 # v2.1.0 with: webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }} webhook-type: incoming-webhook payload-file-path: slack-payloads/merge-failed.json # Review items = files the sync script flagged but did NOT write to disk # (local modifications, files deleted on main). A PR was opened for the # clean-transform portion — link it so reviewers can click through. # Gated on pr_opened == 'true' so it only fires when we actually have a # PR URL (not on any error path that leaves pr_url empty). - name: Notify Slack (review needed, with PR) id: notify-review-with-pr if: always() && env.SLACK_WEBHOOK != '' && steps.push.outputs.needs_review == 'true' && steps.push.outputs.pr_opened == 'true' uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 # v2.1.0 with: webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }} webhook-type: incoming-webhook payload-file-path: slack-payloads/review-with-pr.json # Review items but the clean-transform portion was empty so no PR was # opened. Gated on pr_opened == 'false' (deliberate no-PR path) — not # empty pr_url, which would also match error paths. Explicitly excludes # the existing-PR collision path, which gets its own step below. - name: Notify Slack (review needed, no PR) id: notify-review-no-pr if: always() && env.SLACK_WEBHOOK != '' && steps.sync.outputs.action == 'push_and_pr' && steps.push.outputs.pr_opened == 'false' && steps.push.outputs.existing_pr != 'true' uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 # v2.1.0 with: webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }} webhook-type: incoming-webhook payload-file-path: slack-payloads/review-no-pr.json # Collision path: new review items flagged but an existing open # needs-review PR already exists — point reviewers at it. - name: Notify Slack (review needed, existing PR) id: notify-review-existing-pr if: always() && env.SLACK_WEBHOOK != '' && steps.push.outputs.existing_pr == 'true' uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 # v2.1.0 with: webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }} webhook-type: incoming-webhook payload-file-path: slack-payloads/review-existing-pr.json - name: Notify Slack (failure) id: notify-failure if: failure() && env.SLACK_WEBHOOK != '' && steps.push.outputs.pr_opened != 'true' uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 # v2.1.0 with: webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }} webhook-type: incoming-webhook payload-file-path: slack-payloads/failure.json # Unconditional fallback: if any notify-* step above failed (webhook 5xx, # rate limit, malformed payload), fire a plain-text alert so we never # silently lose a review-needed or failure notification. Uses curl # directly so it doesn't share failure modes with the slackapi action. # Payload is inlined (not read from slack-payloads/) so this fallback # has no dependency on the Build Slack payloads step succeeding — if # that step broke (jq missing, mkdir failed, etc), every notify-* step # would fail AND the fallback could not read its file. RUN_URL is # constructed from GitHub-controlled env vars only (no user input), so # direct string interpolation into the JSON literal is safe — the # values cannot contain " or \. - name: Notify Slack (alert machinery failed) if: >- always() && env.SLACK_WEBHOOK != '' && ( steps.notify-auto-sync.outcome == 'failure' || steps.notify-merge-failed.outcome == 'failure' || steps.notify-review-with-pr.outcome == 'failure' || steps.notify-review-no-pr.outcome == 'failure' || steps.notify-review-existing-pr.outcome == 'failure' || steps.notify-failure.outcome == 'failure' ) env: SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }} run: | set -eu if [ -z "${SLACK_WEBHOOK:-}" ]; then echo "::warning::SLACK_WEBHOOK_OSS_ALERTS not set — cannot post fallback alert" exit 0 fi RUN_URL="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" curl -sS -X POST \ -H "Content-Type: application/json" \ --data "{\"text\": \":rotating_light: *Docs sync*: review/alert machinery failed — check Actions UI ${RUN_URL}\"}" \ "$SLACK_WEBHOOK" || echo "::warning::fallback Slack post also failed"