-
Notifications
You must be signed in to change notification settings - Fork 31
fix: chain Speakeasy auto-merge into Generate run #364
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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:-<empty>}\`" | ||
| 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [nit] the grace threshold Reviewed at |
||
| 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" | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[suggestion]
wait_for_checksnever returns 0 when the status-check rollup stays empty — the direct-merge fallback will burn the full 600s timeout and thenexit 1on every Speakeasy regen PR.Details
Why: When
gh pr merge --autois rejected (no required protected-branch rules), the code falls into theAUTO_MERGE_NOOP_PATTERNbranch and callswait_for_checks. But theTOTAL -eq 0arm onlycontinues — it never returns success — so if no checks ever register, the loop runs untilelapsed >= 600and the function exits 1, failing the job before the directgh pr merge --squashever runs.Speakeasy regen PRs are exactly this case: they are opened by
github-actions[bot]using the workflow'sGITHUB_TOKEN, which by design does not re-trigger downstreamon: pull_requestworkflows, so they carry zero status checks. I confirmed this empirically against the 8 most recentspeakeasy-sdk-regen-*PRs in this repo (#357, #352, #349, #347, #346, #344, #342, #340) — every one has an emptystatusCheckRollup, and open PR #357 reportsmergeStateStatus: CLEAN/mergeable: MERGEABLE, confirmingmaincarries no required checks. So the--autopath will no-op and the fallback path will reliably hit the 600s timeout.This is the same issue that was raised on the go-sdk port (OpenRouterTeam/go-sdk#318): the fix there added a short grace period after which a
TOTAL == 0rollup is treated as "no checks will run" and the function returns 0 to allow the direct squash-merge to proceed.Fix: bound the empty-rollup wait with a small grace window, then treat a persistently-empty rollup as "no checks to wait for" and return 0:
Ref: GitHub Actions — events that do not trigger workflows when using GITHUB_TOKEN
Prompt for agents
Reviewed at
186c107