docs-updated #360
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Sync from Docs | |
| on: | |
| repository_dispatch: | |
| types: [docs-updated] | |
| workflow_dispatch: | |
| permissions: | |
| contents: write | |
| issues: write | |
| pull-requests: write | |
| jobs: | |
| create-task: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| outputs: | |
| issue_number: ${{ steps.create-issue.outputs.issue_number }} | |
| steps: | |
| - name: Validate sync token | |
| env: | |
| SYNC_GH_TOKEN: ${{ secrets.ADMIN_GITHUB_TOKEN || secrets.BOT_GITHUB_TOKEN }} | |
| run: | | |
| if [ -z "$SYNC_GH_TOKEN" ]; then | |
| echo "::error::Missing GitHub token. Set ADMIN_GITHUB_TOKEN or BOT_GITHUB_TOKEN." | |
| exit 1 | |
| fi | |
| - name: Checkout Docs | |
| uses: actions/checkout@v4 | |
| with: | |
| repository: AceDataCloud/Docs | |
| path: _docs | |
| token: ${{ secrets.ADMIN_GITHUB_TOKEN || secrets.BOT_GITHUB_TOKEN }} | |
| fetch-depth: 5 | |
| - name: Get Docs changes | |
| id: docs | |
| run: | | |
| cd _docs | |
| commit_info=$(git log -1 --pretty=format:"%h %s" 2>/dev/null || echo "unknown") | |
| echo "commit_info=$commit_info" >> "$GITHUB_OUTPUT" | |
| recent_changes=$(git log --oneline -5 2>/dev/null || echo "unknown") | |
| echo "recent_changes<<EOF" >> "$GITHUB_OUTPUT" | |
| echo "$recent_changes" >> "$GITHUB_OUTPUT" | |
| echo "EOF" >> "$GITHUB_OUTPUT" | |
| changed_services="${{ github.event.client_payload.changed_services }}" | |
| if [ -z "$changed_services" ]; then | |
| changed_services="all" | |
| fi | |
| echo "changed_services=$changed_services" >> "$GITHUB_OUTPUT" | |
| - name: Close stale sync issues and orphaned Copilot PRs | |
| env: | |
| GH_TOKEN: ${{ secrets.ADMIN_GITHUB_TOKEN || secrets.BOT_GITHUB_TOKEN }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| set +e | |
| # 1) Close prior auto-sync issues — a new dispatch supersedes them. | |
| gh issue list --repo "$REPO" --label "auto-sync" --state open --json number --jq '.[].number' | while read -r num; do | |
| [ -z "$num" ] && continue | |
| gh issue close "$num" --repo "$REPO" --comment "Superseded by new sync trigger." 2>/dev/null || true | |
| done | |
| # 2) Close every leftover open copilot/* PR — they are stale by definition once a new | |
| # Docs commit lands. Each new dispatch triggers a fresh Copilot session that will | |
| # open its own PR; old drafts (including 0-commit zombies) just clutter the queue. | |
| gh pr list --repo "$REPO" --state open --json number,headRefName \ | |
| --jq '.[] | select(.headRefName | startswith("copilot/")) | .number' | while read -r num; do | |
| [ -z "$num" ] && continue | |
| gh pr close "$num" --repo "$REPO" --delete-branch \ | |
| --comment "Superseded by new sync trigger; Copilot will open a fresh PR for the latest Docs state." 2>/dev/null || true | |
| done | |
| exit 0 | |
| - name: Create issue for Copilot | |
| id: create-issue | |
| env: | |
| GH_TOKEN: ${{ secrets.ADMIN_GITHUB_TOKEN || secrets.BOT_GITHUB_TOKEN }} | |
| COMMIT_INFO: ${{ steps.docs.outputs.commit_info }} | |
| RECENT_CHANGES: ${{ steps.docs.outputs.recent_changes }} | |
| CHANGED_SERVICES: ${{ steps.docs.outputs.changed_services }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| body=$(jq -n \ | |
| --arg changes "$RECENT_CHANGES" \ | |
| --arg services "$CHANGED_SERVICES" \ | |
| '"The upstream Docs have been updated. Ensure the SDK in this repo is in sync with the latest API specs.\n\n## Changed Services\n\n`" + $services + "`\n\n## Recent Docs Changes\n\n```\n" + $changes + "\n```\n\n## Instructions\n\nSee `.github/copilot-instructions.md` for sync rules.\n\nThis is a monorepo with `typescript/` and `python/` subdirectories.\n\nCheck the AceDataCloud/Docs repo OpenAPI specs at `openapi/<service>.json` for each changed service.\n\nCompare models, endpoints, parameters, and provider lists against the SDK code. Fix any differences.\n\nIf everything is already in sync, close this issue."') | |
| ISSUE_JSON=$(jq -n \ | |
| --arg title "sync: update from Docs ($COMMIT_INFO)" \ | |
| --argjson body "$body" \ | |
| '{title: $title, body: $body, labels: ["auto-sync"]}' \ | |
| | gh api \ | |
| --method POST \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -H "X-GitHub-Api-Version: 2022-11-28" \ | |
| /repos/"$REPO"/issues \ | |
| --input -) | |
| ISSUE_NUM=$(echo "$ISSUE_JSON" | jq -r '.number') | |
| ISSUE_NODE=$(echo "$ISSUE_JSON" | jq -r '.node_id') | |
| echo "Created issue #$ISSUE_NUM (node=$ISSUE_NODE)" | |
| OWNER="${REPO%/*}" | |
| NAME="${REPO#*/}" | |
| COPILOT_ID=$(gh api graphql \ | |
| -f query='query($owner:String!,$name:String!){ repository(owner:$owner,name:$name){ suggestedActors(capabilities:[CAN_BE_ASSIGNED], first:50){ nodes { __typename ... on Bot { login id } ... on User { login id } } } } }' \ | |
| -f owner="$OWNER" -f name="$NAME" \ | |
| --jq '[.data.repository.suggestedActors.nodes[] | select(.login=="copilot-swe-agent" or .login=="Copilot")] | .[0].id // empty') | |
| if [ -z "$COPILOT_ID" ]; then | |
| echo "::error::Copilot not found in suggestedActors. ADMIN_GITHUB_TOKEN must belong to a user with a Copilot seat in this org." | |
| gh issue comment "$ISSUE_NUM" --repo "$REPO" --body "Auto-assignment failed: Copilot not visible to ADMIN_GITHUB_TOKEN. Please assign Copilot manually." || true | |
| exit 1 | |
| fi | |
| gh api graphql \ | |
| -f query='mutation($a:ID!,$b:[ID!]!){ replaceActorsForAssignable(input:{assignableId:$a, actorIds:$b}){ assignable { ... on Issue { number assignees(first:5){ nodes { login } } } } } }' \ | |
| -f a="$ISSUE_NODE" -f b="$COPILOT_ID" | |
| echo "issue_number=$ISSUE_NUM" >> "$GITHUB_OUTPUT" | |
| wait-and-merge: | |
| needs: create-task | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 60 | |
| steps: | |
| - name: Wait for Copilot to finish | |
| id: wait | |
| env: | |
| GH_TOKEN: ${{ secrets.ADMIN_GITHUB_TOKEN || secrets.BOT_GITHUB_TOKEN }} | |
| ISSUE_NUMBER: ${{ needs.create-task.outputs.issue_number }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| set +e | |
| echo "Waiting for Copilot to process issue #$ISSUE_NUMBER..." | |
| PR_NUMBER="" | |
| PR_FOUND_AT=0 | |
| ZOMBIE_GRACE_SECONDS=1800 # close PR after 30 min if it still has 0 commits | |
| # 110 polls × 30s = 55 min, fits inside the 60-min job timeout. | |
| for i in $(seq 1 110); do | |
| now=$(date +%s) | |
| echo "Poll attempt $i/110 (elapsed=$((i * 30))s)..." | |
| # Fast path: Copilot closed the issue itself (no changes needed) | |
| ISSUE_STATE=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json state --jq '.state' 2>/dev/null) | |
| if [ "$ISSUE_STATE" = "CLOSED" ]; then | |
| echo "Issue #$ISSUE_NUMBER closed by Copilot — no changes needed." | |
| echo "result=no-op" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| # Locate the PR linked to THIS issue. Copilot puts "#<issue>" or "Closes #<issue>" | |
| # in the PR body when assigned to an issue. | |
| CANDIDATE=$(gh pr list --repo "$REPO" --state open \ | |
| --json number,headRefName,body,isDraft,additions,deletions,createdAt \ | |
| --jq "[.[] | select(.headRefName | startswith(\"copilot/\")) | select(.body // \"\" | test(\"#${ISSUE_NUMBER}( |$|\\\\b)\"))] | .[0] // empty" \ | |
| 2>/dev/null) | |
| # Fallback: if no PR mentions this issue but exactly one copilot/* PR exists, | |
| # take it. (Stale ones were already closed in create-task.) | |
| if [ -z "$CANDIDATE" ]; then | |
| CANDIDATE=$(gh pr list --repo "$REPO" --state open \ | |
| --json number,headRefName,body,isDraft,additions,deletions,createdAt \ | |
| --jq '[.[] | select(.headRefName | startswith("copilot/"))] | if length == 1 then .[0] else empty end' \ | |
| 2>/dev/null) | |
| fi | |
| if [ -n "$CANDIDATE" ]; then | |
| PR_NUMBER=$(echo "$CANDIDATE" | jq -r '.number') | |
| IS_DRAFT=$(echo "$CANDIDATE" | jq -r '.isDraft') | |
| ADDITIONS=$(echo "$CANDIDATE" | jq -r '.additions') | |
| DELETIONS=$(echo "$CANDIDATE" | jq -r '.deletions') | |
| CREATED_AT=$(echo "$CANDIDATE" | jq -r '.createdAt') | |
| if [ "$PR_FOUND_AT" -eq 0 ]; then | |
| PR_FOUND_AT=$now | |
| echo "Tracking PR #$PR_NUMBER (draft=$IS_DRAFT, +$ADDITIONS/-$DELETIONS)" | |
| fi | |
| # Reliable completion signal: Copilot marks the PR ready-for-review | |
| # itself when its agent finishes successfully. | |
| if [ "$IS_DRAFT" = "false" ]; then | |
| echo "PR #$PR_NUMBER is ready for review — Copilot finished." | |
| echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" | |
| echo "result=ready" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| # Zombie guard: if PR has been open >30 min with 0 commits, give up. | |
| if [ "$ADDITIONS" = "0" ] && [ "$DELETIONS" = "0" ]; then | |
| created_epoch=$(date -d "$CREATED_AT" +%s 2>/dev/null || python3 -c "import datetime,sys; print(int(datetime.datetime.fromisoformat(sys.argv[1].replace('Z','+00:00')).timestamp()))" "$CREATED_AT") | |
| age=$((now - created_epoch)) | |
| if [ "$age" -gt "$ZOMBIE_GRACE_SECONDS" ]; then | |
| echo "::warning::PR #$PR_NUMBER has 0 commits after ${age}s — closing as zombie." | |
| gh pr close "$PR_NUMBER" --repo "$REPO" --delete-branch \ | |
| --comment "Auto-closed: Copilot opened this PR but produced no commits within ${ZOMBIE_GRACE_SECONDS}s. Triggering workflow can be re-dispatched if a sync is still needed." 2>/dev/null || true | |
| echo "result=zombie-closed" >> "$GITHUB_OUTPUT" | |
| exit 1 | |
| fi | |
| fi | |
| fi | |
| sleep 30 | |
| done | |
| echo "::warning::Timed out waiting for Copilot to mark PR ready for review." | |
| if [ -n "$PR_NUMBER" ]; then | |
| echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" | |
| echo "result=timeout-with-pr" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "result=timeout-no-pr" >> "$GITHUB_OUTPUT" | |
| fi | |
| exit 1 | |
| - name: Merge PR | |
| if: steps.wait.outputs.pr_number != '' | |
| env: | |
| GH_TOKEN: ${{ secrets.ADMIN_GITHUB_TOKEN || secrets.BOT_GITHUB_TOKEN }} | |
| PR_NUMBER: ${{ steps.wait.outputs.pr_number }} | |
| ISSUE_NUMBER: ${{ needs.create-task.outputs.issue_number }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| set +e | |
| echo "Preparing to merge PR #$PR_NUMBER..." | |
| # Idempotent: makes draft → ready-for-review. Harmless if already ready. | |
| gh pr ready "$PR_NUMBER" --repo "$REPO" 2>/dev/null || true | |
| # Wait briefly for required checks (validate.yml) to settle. We use --admin | |
| # at merge time so this is best-effort: we surface check failures but don't | |
| # let a single transient flake block the sync. | |
| for i in $(seq 1 24); do | |
| CHECKS=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json statusCheckRollup \ | |
| --jq '[.statusCheckRollup[]? | {name, status, conclusion}]' 2>/dev/null) | |
| FAILING=$(echo "$CHECKS" | jq '[.[] | select(.conclusion == "failure" or .conclusion == "cancelled" or .conclusion == "timed_out")] | length' 2>/dev/null || echo 0) | |
| PENDING=$(echo "$CHECKS" | jq '[.[] | select(.status != "completed")] | length' 2>/dev/null || echo 0) | |
| TOTAL=$(echo "$CHECKS" | jq 'length' 2>/dev/null || echo 0) | |
| echo "Checks: total=$TOTAL pending=$PENDING failing=$FAILING" | |
| if [ "${FAILING:-0}" -gt 0 ]; then | |
| echo "::warning::PR #$PR_NUMBER has $FAILING failing check(s) — proceeding with --admin merge anyway. Investigate after merge." | |
| break | |
| fi | |
| if [ "${TOTAL:-0}" -gt 0 ] && [ "${PENDING:-0}" -eq 0 ]; then | |
| echo "All checks settled." | |
| break | |
| fi | |
| sleep 15 | |
| done | |
| # Try the merge up to 3 times — covers transient API hiccups. | |
| for attempt in 1 2 3; do | |
| echo "Merge attempt $attempt/3..." | |
| if gh pr merge "$PR_NUMBER" --repo "$REPO" --squash --admin --delete-branch \ | |
| --subject "sync: update from Docs [automated] (#$PR_NUMBER)" \ | |
| --body "Closes #$ISSUE_NUMBER"; then | |
| echo "::notice::Merged PR #$PR_NUMBER" | |
| exit 0 | |
| fi | |
| echo "Merge attempt $attempt failed, retrying in 20s..." | |
| sleep 20 | |
| done | |
| echo "::error::Failed to merge PR #$PR_NUMBER after 3 attempts. Manual intervention required." | |
| gh pr comment "$PR_NUMBER" --repo "$REPO" --body "Auto-merge failed after 3 attempts. Please review and merge manually." 2>/dev/null || true | |
| exit 1 |