diff --git a/.github/workflows/auto-merge-speakeasy-pr.yaml b/.github/workflows/auto-merge-speakeasy-pr.yaml index e0035f8..5ec1cce 100644 --- a/.github/workflows/auto-merge-speakeasy-pr.yaml +++ b/.github/workflows/auto-merge-speakeasy-pr.yaml @@ -1,11 +1,27 @@ name: Auto-merge Speakeasy PR -# Speakeasy mode:pr opens PRs (speakeasy-sdk-regen-*) and labels them -# patch/minor/major. Merge automatically so sdk_publish.yaml can run. +# Called as a follow-up job after Speakeasy Generate (mode: pr). Merges the +# regen PR opened/updated in the same workflow run so sdk_publish.yaml can run. +# Prefer branch_name from Generate logs; fall back to run_started_at heuristics. +# Uses GH_DOCS_SYNC GitHub App token (not a PAT) so merges trigger downstream push. on: - pull_request: - types: [labeled, opened] + workflow_call: + inputs: + run_started_at: + description: ISO timestamp when the parent Generate workflow run started + required: true + type: string + branch_name: + description: Speakeasy regen branch from the parent Generate run (optional) + required: false + type: string + default: "" + secrets: + GH_DOCS_SYNC_APP_ID: + required: true + GH_DOCS_SYNC_APP_PRIVATE_KEY: + required: true permissions: contents: write @@ -17,18 +33,6 @@ concurrency: jobs: auto-merge: - if: | - github.event.sender.login == 'github-actions[bot]' && - github.event.pull_request.user.login == 'github-actions[bot]' && - startsWith(github.event.pull_request.head.ref, 'speakeasy-sdk-regen-') && - contains(github.event.pull_request.title, '🐝 Update SDK') && - ( - (github.event.action == 'labeled' && contains(fromJSON('["patch", "minor", "major"]'), github.event.label.name)) || - (github.event.action == 'opened' && - (contains(github.event.pull_request.labels.*.name, 'patch') || - contains(github.event.pull_request.labels.*.name, 'minor') || - contains(github.event.pull_request.labels.*.name, 'major'))) - ) runs-on: ubuntu-latest steps: - name: Generate GitHub App token @@ -39,21 +43,80 @@ jobs: app-id: ${{ secrets.GH_DOCS_SYNC_APP_ID }} private-key: ${{ secrets.GH_DOCS_SYNC_APP_PRIVATE_KEY }} - - name: Close superseded Speakeasy PRs + - name: Resolve Speakeasy regen PR + id: pr env: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | set -euo pipefail - CURRENT_PR="${{ github.event.pull_request.number }}" - PRIOR_JSON=$(gh pr list \ - --repo "${{ github.repository }}" \ + REPO="${{ github.repository }}" + RUN_STARTED="${{ inputs.run_started_at }}" + BRANCH="${{ inputs.branch_name }}" + + PR_JSON=$(gh pr list \ + --repo "$REPO" \ --state open \ --search "head:speakeasy-sdk-regen-" \ --limit 500 \ - --json number \ - | jq -r --arg cur "$CURRENT_PR" \ - '[.[].number | select(tostring != $cur)] | .[]') + --json number,updatedAt,title,labels,author,headRefName) + + PR_NUM="" + if [ -n "$BRANCH" ]; then + CANDIDATE=$(echo "$PR_JSON" | jq -r --arg branch "$BRANCH" \ + '[.[] | select(.headRefName == $branch)] | .[0] // empty') + if [ -n "$CANDIDATE" ] && echo "$CANDIDATE" | jq -e ' + (.headRefName | startswith("speakeasy-sdk-regen-")) + and (.title | contains("🐝 Update SDK")) + and ([.labels[].name] | any(. == "patch" or . == "minor" or . == "major")) + and (.author.is_bot == true) + ' > /dev/null; then + PR_NUM=$(echo "$CANDIDATE" | jq -r '.number') + echo "Resolved Speakeasy regen PR #$PR_NUM from branch $BRANCH" + else + echo "::warning::branch_name=$BRANCH did not match a valid open regen PR — falling back to run_started_at" + fi + fi + + if [ -z "$PR_NUM" ]; then + PR_NUM=$(echo "$PR_JSON" | jq -r --arg started "$RUN_STARTED" ' + [.[] + | select(.headRefName | startswith("speakeasy-sdk-regen-")) + | select(.title | contains("🐝 Update SDK")) + | select([.labels[].name] | any(. == "patch" or . == "minor" or . == "major")) + | select(.author.is_bot == true) + | select(.updatedAt >= $started) + ] | sort_by(.updatedAt) | last | .number // empty') + fi + + if [ -z "$PR_NUM" ]; then + echo "No Speakeasy regen PR for this Generate run — skipping auto-merge" + echo "::notice title=auto-merge-skipped::No qualifying Speakeasy regen PR to merge" + { + echo "## Auto-merge skipped" + echo "No qualifying \`speakeasy-sdk-regen-*\` PR was found." + echo "- branch_name input: \`${BRANCH:-}\`" + echo "- run_started_at: \`$RUN_STARTED\`" + } >> "$GITHUB_STEP_SUMMARY" + echo "pr_num=" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "pr_num=$PR_NUM" >> "$GITHUB_OUTPUT" + echo "$PR_JSON" > /tmp/speakeasy_pr_json.json + + - name: Close superseded Speakeasy PRs + if: steps.pr.outputs.pr_num != '' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + + CURRENT_PR="${{ steps.pr.outputs.pr_num }}" + PR_JSON=$(cat /tmp/speakeasy_pr_json.json) + + PRIOR_JSON=$(echo "$PR_JSON" | jq -r --arg current_pr "$CURRENT_PR" \ + '[.[].number | select(tostring != $current_pr)] | .[]') if [ -z "$PRIOR_JSON" ]; then echo "No superseded Speakeasy PRs to close" @@ -71,30 +134,92 @@ jobs: done - name: Auto-merge Speakeasy PR + if: steps.pr.outputs.pr_num != '' env: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | set -euo pipefail - PR_NUM="${{ github.event.pull_request.number }}" + + PR_NUM="${{ steps.pr.outputs.pr_num }}" REPO="${{ github.repository }}" + wait_for_checks() { + local pr=$1 + local timeout=600 + local interval=15 + local elapsed=0 + + echo "Waiting for PR #$pr checks (timeout ${timeout}s)..." + while [ "$elapsed" -lt "$timeout" ]; do + TOTAL=$(gh pr view "$pr" --repo "$REPO" --json statusCheckRollup --jq \ + '.statusCheckRollup | length') + PENDING=$(gh pr view "$pr" --repo "$REPO" --json statusCheckRollup --jq \ + '[.statusCheckRollup[]? | select(.status != "COMPLETED")] | length') + FAILED=$(gh pr view "$pr" --repo "$REPO" --json statusCheckRollup --jq \ + '[.statusCheckRollup[]? | select(.status == "COMPLETED") | select(.conclusion != "SUCCESS" and .conclusion != "SKIPPED" and .conclusion != "NEUTRAL")] | length') + + if [ "$TOTAL" -eq 0 ]; then + # GITHUB_TOKEN-authored regen PRs get no pull_request checks at all, + # so TOTAL stays 0 forever. Wait a short grace window in case checks + # are merely slow to register, then proceed rather than time out. + if [ "$elapsed" -ge 60 ]; then + echo "No checks registered after ${elapsed}s — proceeding (checkless PR)" + return 0 + fi + echo "Checks not yet registered — waiting ${interval}s..." + sleep "$interval" + elapsed=$((elapsed + interval)) + continue + fi + + if [ "$PENDING" -eq 0 ]; then + if [ "$FAILED" -gt 0 ]; then + echo "::error::PR #$pr has failing checks — refusing direct merge" + gh pr checks "$pr" --repo "$REPO" || true + exit 1 + fi + echo "All checks completed" + return 0 + fi + + echo "Checks pending ($PENDING) — waiting ${interval}s..." + sleep "$interval" + elapsed=$((elapsed + interval)) + done + + echo "::error::Timed out waiting for PR #$pr checks" + exit 1 + } + STATE=$(gh pr view "$PR_NUM" --repo "$REPO" --json state --jq .state) if [ "$STATE" != "OPEN" ]; then echo "PR #$PR_NUM is $STATE — skipping merge" exit 0 fi + MERGE_METHOD="" # Prefer GitHub auto-merge when required checks exist; otherwise - # squash-merge directly (same pattern as sdk-release-prs.yaml). + # squash-merge directly after checks pass (sdk-release-prs.yaml pattern). AUTO_MERGE_NOOP_PATTERN='is in clean status|protected branch rules|Branch does not have required protected branch rules' if gh pr merge "$PR_NUM" --repo "$REPO" --squash --auto --delete-branch 2> /tmp/gh-merge.err; then + MERGE_METHOD="auto-merge queued" echo "Auto-merge enabled for Speakeasy PR #$PR_NUM" elif grep -qiE "$AUTO_MERGE_NOOP_PATTERN" /tmp/gh-merge.err; then - echo "Auto-merge not applicable — merging PR #$PR_NUM directly" + echo "Auto-merge not applicable — waiting for checks, then merging PR #$PR_NUM directly" cat /tmp/gh-merge.err >&2 + wait_for_checks "$PR_NUM" gh pr merge "$PR_NUM" --repo "$REPO" --squash --delete-branch + MERGE_METHOD="direct squash (checks passed)" else echo "::error::Failed to enable auto-merge on Speakeasy PR #$PR_NUM" cat /tmp/gh-merge.err >&2 exit 1 fi + + echo "::notice title=auto-merge-pr::Merged Speakeasy PR #$PR_NUM ($MERGE_METHOD)" + { + echo "## Auto-merge complete" + echo "- PR: #$PR_NUM" + echo "- Method: $MERGE_METHOD" + echo "- Token: GH_DOCS_SYNC GitHub App" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/sdk_generation.yaml b/.github/workflows/sdk_generation.yaml index 95343ca..24847f3 100644 --- a/.github/workflows/sdk_generation.yaml +++ b/.github/workflows/sdk_generation.yaml @@ -21,6 +21,11 @@ permissions: types: - labeled - unlabeled + +concurrency: + group: speakeasy-generate + cancel-in-progress: false + jobs: generate: uses: speakeasy-api/sdk-generation-action/.github/workflows/workflow-executor.yaml@v15 @@ -32,3 +37,67 @@ jobs: github_access_token: ${{ secrets.GITHUB_TOKEN }} pypi_token: ${{ secrets.PYPI_TOKEN }} speakeasy_api_key: ${{ secrets.SPEAKEASY_API_KEY }} + + resolve-branch: + needs: generate + if: ${{ !cancelled() && needs.generate.result == 'success' }} + runs-on: ubuntu-latest + outputs: + branch_name: ${{ steps.branch.outputs.branch_name }} + run_started_at: ${{ steps.branch.outputs.run_started_at }} + steps: + - name: Extract branch from Generate logs + id: branch + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + REPO="${{ github.repository }}" + RUN_ID="${{ github.run_id }}" + + RUN_STARTED="${{ github.run_started_at }}" + if [ -z "$RUN_STARTED" ]; then + RUN_STARTED=$(gh run view "$RUN_ID" --repo "$REPO" --json startedAt --jq '.startedAt') + echo "Resolved run_started_at from API: $RUN_STARTED" + fi + echo "run_started_at=$RUN_STARTED" >> "$GITHUB_OUTPUT" + + # Full-run logs are unavailable while the workflow is in progress; + # fetch logs from the completed generate job instead. + JOB_ID=$(gh run view "$RUN_ID" --repo "$REPO" --json jobs --jq \ + '[.jobs[] | select(.name == "generate / Generate Target")] | .[0].databaseId // empty') + + BRANCH="" + if [ -z "$JOB_ID" ]; then + echo "::warning::Could not find generate job id — auto-merge will use run_started_at fallback" + else + MAX_ATTEMPTS=12 + INTERVAL=10 + for attempt in $(seq 1 "$MAX_ATTEMPTS"); do + BRANCH=$(gh run view "$RUN_ID" --repo "$REPO" --job "$JOB_ID" --log 2>/dev/null \ + | grep -oE 'branch_name=speakeasy-sdk-regen-[0-9]+' | tail -1 | cut -d= -f2 || true) + if [ -n "$BRANCH" ]; then + echo "Extracted branch_name=$BRANCH (attempt $attempt)" + break + fi + if [ "$attempt" -lt "$MAX_ATTEMPTS" ]; then + echo "Job logs not ready — waiting ${INTERVAL}s (attempt $attempt/$MAX_ATTEMPTS)..." + sleep "$INTERVAL" + fi + done + if [ -z "$BRANCH" ]; then + echo "::warning::Could not extract branch_name from Generate logs after ${MAX_ATTEMPTS} attempts — auto-merge will use run_started_at fallback" + fi + fi + echo "branch_name=$BRANCH" >> "$GITHUB_OUTPUT" + + auto-merge: + needs: [generate, resolve-branch] + if: ${{ !cancelled() && needs.generate.result == 'success' }} + uses: ./.github/workflows/auto-merge-speakeasy-pr.yaml + with: + run_started_at: ${{ needs.resolve-branch.outputs.run_started_at }} + branch_name: ${{ needs.resolve-branch.outputs.branch_name }} + secrets: + GH_DOCS_SYNC_APP_ID: ${{ secrets.GH_DOCS_SYNC_APP_ID }} + GH_DOCS_SYNC_APP_PRIVATE_KEY: ${{ secrets.GH_DOCS_SYNC_APP_PRIVATE_KEY }} diff --git a/.github/workflows/sdk_generation_for_spec_change.yaml b/.github/workflows/sdk_generation_for_spec_change.yaml index 870935d..697b0a6 100644 --- a/.github/workflows/sdk_generation_for_spec_change.yaml +++ b/.github/workflows/sdk_generation_for_spec_change.yaml @@ -22,6 +22,10 @@ permissions: paths: - .speakeasy/in.openapi.yaml +concurrency: + group: speakeasy-generate-spec-change + cancel-in-progress: false + jobs: generate: if: github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' @@ -34,3 +38,67 @@ jobs: github_access_token: ${{ secrets.GITHUB_TOKEN }} pypi_token: ${{ secrets.PYPI_TOKEN }} speakeasy_api_key: ${{ secrets.SPEAKEASY_API_KEY }} + + resolve-branch: + needs: generate + if: ${{ !cancelled() && needs.generate.result == 'success' }} + runs-on: ubuntu-latest + outputs: + branch_name: ${{ steps.branch.outputs.branch_name }} + run_started_at: ${{ steps.branch.outputs.run_started_at }} + steps: + - name: Extract branch from Generate logs + id: branch + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + REPO="${{ github.repository }}" + RUN_ID="${{ github.run_id }}" + + RUN_STARTED="${{ github.run_started_at }}" + if [ -z "$RUN_STARTED" ]; then + RUN_STARTED=$(gh run view "$RUN_ID" --repo "$REPO" --json startedAt --jq '.startedAt') + echo "Resolved run_started_at from API: $RUN_STARTED" + fi + echo "run_started_at=$RUN_STARTED" >> "$GITHUB_OUTPUT" + + # Full-run logs are unavailable while the workflow is in progress; + # fetch logs from the completed generate job instead. + JOB_ID=$(gh run view "$RUN_ID" --repo "$REPO" --json jobs --jq \ + '[.jobs[] | select(.name == "generate / Generate Target")] | .[0].databaseId // empty') + + BRANCH="" + if [ -z "$JOB_ID" ]; then + echo "::warning::Could not find generate job id — auto-merge will use run_started_at fallback" + else + MAX_ATTEMPTS=12 + INTERVAL=10 + for attempt in $(seq 1 "$MAX_ATTEMPTS"); do + BRANCH=$(gh run view "$RUN_ID" --repo "$REPO" --job "$JOB_ID" --log 2>/dev/null \ + | grep -oE 'branch_name=speakeasy-sdk-regen-[0-9]+' | tail -1 | cut -d= -f2 || true) + if [ -n "$BRANCH" ]; then + echo "Extracted branch_name=$BRANCH (attempt $attempt)" + break + fi + if [ "$attempt" -lt "$MAX_ATTEMPTS" ]; then + echo "Job logs not ready — waiting ${INTERVAL}s (attempt $attempt/$MAX_ATTEMPTS)..." + sleep "$INTERVAL" + fi + done + if [ -z "$BRANCH" ]; then + echo "::warning::Could not extract branch_name from Generate logs after ${MAX_ATTEMPTS} attempts — auto-merge will use run_started_at fallback" + fi + fi + echo "branch_name=$BRANCH" >> "$GITHUB_OUTPUT" + + auto-merge: + needs: [generate, resolve-branch] + if: ${{ !cancelled() && needs.generate.result == 'success' }} + uses: ./.github/workflows/auto-merge-speakeasy-pr.yaml + with: + run_started_at: ${{ needs.resolve-branch.outputs.run_started_at }} + branch_name: ${{ needs.resolve-branch.outputs.branch_name }} + secrets: + GH_DOCS_SYNC_APP_ID: ${{ secrets.GH_DOCS_SYNC_APP_ID }} + GH_DOCS_SYNC_APP_PRIVATE_KEY: ${{ secrets.GH_DOCS_SYNC_APP_PRIVATE_KEY }}