Skip to content

Commit 667941a

Browse files
fix: chain Speakeasy auto-merge into Generate run (#364)
2 parents 011fb81 + 0680f01 commit 667941a

3 files changed

Lines changed: 288 additions & 26 deletions

File tree

Lines changed: 151 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,27 @@
11
name: Auto-merge Speakeasy PR
22

3-
# Speakeasy mode:pr opens PRs (speakeasy-sdk-regen-*) and labels them
4-
# patch/minor/major. Merge automatically so sdk_publish.yaml can run.
3+
# Called as a follow-up job after Speakeasy Generate (mode: pr). Merges the
4+
# regen PR opened/updated in the same workflow run so sdk_publish.yaml can run.
5+
# Prefer branch_name from Generate logs; fall back to run_started_at heuristics.
6+
# Uses GH_DOCS_SYNC GitHub App token (not a PAT) so merges trigger downstream push.
57

68
on:
7-
pull_request:
8-
types: [labeled, opened]
9+
workflow_call:
10+
inputs:
11+
run_started_at:
12+
description: ISO timestamp when the parent Generate workflow run started
13+
required: true
14+
type: string
15+
branch_name:
16+
description: Speakeasy regen branch from the parent Generate run (optional)
17+
required: false
18+
type: string
19+
default: ""
20+
secrets:
21+
GH_DOCS_SYNC_APP_ID:
22+
required: true
23+
GH_DOCS_SYNC_APP_PRIVATE_KEY:
24+
required: true
925

1026
permissions:
1127
contents: write
@@ -17,18 +33,6 @@ concurrency:
1733

1834
jobs:
1935
auto-merge:
20-
if: |
21-
github.event.sender.login == 'github-actions[bot]' &&
22-
github.event.pull_request.user.login == 'github-actions[bot]' &&
23-
startsWith(github.event.pull_request.head.ref, 'speakeasy-sdk-regen-') &&
24-
contains(github.event.pull_request.title, '🐝 Update SDK') &&
25-
(
26-
(github.event.action == 'labeled' && contains(fromJSON('["patch", "minor", "major"]'), github.event.label.name)) ||
27-
(github.event.action == 'opened' &&
28-
(contains(github.event.pull_request.labels.*.name, 'patch') ||
29-
contains(github.event.pull_request.labels.*.name, 'minor') ||
30-
contains(github.event.pull_request.labels.*.name, 'major')))
31-
)
3236
runs-on: ubuntu-latest
3337
steps:
3438
- name: Generate GitHub App token
@@ -39,21 +43,80 @@ jobs:
3943
app-id: ${{ secrets.GH_DOCS_SYNC_APP_ID }}
4044
private-key: ${{ secrets.GH_DOCS_SYNC_APP_PRIVATE_KEY }}
4145

42-
- name: Close superseded Speakeasy PRs
46+
- name: Resolve Speakeasy regen PR
47+
id: pr
4348
env:
4449
GH_TOKEN: ${{ steps.app-token.outputs.token }}
4550
run: |
4651
set -euo pipefail
47-
CURRENT_PR="${{ github.event.pull_request.number }}"
4852
49-
PRIOR_JSON=$(gh pr list \
50-
--repo "${{ github.repository }}" \
53+
REPO="${{ github.repository }}"
54+
RUN_STARTED="${{ inputs.run_started_at }}"
55+
BRANCH="${{ inputs.branch_name }}"
56+
57+
PR_JSON=$(gh pr list \
58+
--repo "$REPO" \
5159
--state open \
5260
--search "head:speakeasy-sdk-regen-" \
5361
--limit 500 \
54-
--json number \
55-
| jq -r --arg cur "$CURRENT_PR" \
56-
'[.[].number | select(tostring != $cur)] | .[]')
62+
--json number,updatedAt,title,labels,author,headRefName)
63+
64+
PR_NUM=""
65+
if [ -n "$BRANCH" ]; then
66+
CANDIDATE=$(echo "$PR_JSON" | jq -r --arg branch "$BRANCH" \
67+
'[.[] | select(.headRefName == $branch)] | .[0] // empty')
68+
if [ -n "$CANDIDATE" ] && echo "$CANDIDATE" | jq -e '
69+
(.headRefName | startswith("speakeasy-sdk-regen-"))
70+
and (.title | contains("🐝 Update SDK"))
71+
and ([.labels[].name] | any(. == "patch" or . == "minor" or . == "major"))
72+
and (.author.is_bot == true)
73+
' > /dev/null; then
74+
PR_NUM=$(echo "$CANDIDATE" | jq -r '.number')
75+
echo "Resolved Speakeasy regen PR #$PR_NUM from branch $BRANCH"
76+
else
77+
echo "::warning::branch_name=$BRANCH did not match a valid open regen PR — falling back to run_started_at"
78+
fi
79+
fi
80+
81+
if [ -z "$PR_NUM" ]; then
82+
PR_NUM=$(echo "$PR_JSON" | jq -r --arg started "$RUN_STARTED" '
83+
[.[]
84+
| select(.headRefName | startswith("speakeasy-sdk-regen-"))
85+
| select(.title | contains("🐝 Update SDK"))
86+
| select([.labels[].name] | any(. == "patch" or . == "minor" or . == "major"))
87+
| select(.author.is_bot == true)
88+
| select(.updatedAt >= $started)
89+
] | sort_by(.updatedAt) | last | .number // empty')
90+
fi
91+
92+
if [ -z "$PR_NUM" ]; then
93+
echo "No Speakeasy regen PR for this Generate run — skipping auto-merge"
94+
echo "::notice title=auto-merge-skipped::No qualifying Speakeasy regen PR to merge"
95+
{
96+
echo "## Auto-merge skipped"
97+
echo "No qualifying \`speakeasy-sdk-regen-*\` PR was found."
98+
echo "- branch_name input: \`${BRANCH:-<empty>}\`"
99+
echo "- run_started_at: \`$RUN_STARTED\`"
100+
} >> "$GITHUB_STEP_SUMMARY"
101+
echo "pr_num=" >> "$GITHUB_OUTPUT"
102+
exit 0
103+
fi
104+
105+
echo "pr_num=$PR_NUM" >> "$GITHUB_OUTPUT"
106+
echo "$PR_JSON" > /tmp/speakeasy_pr_json.json
107+
108+
- name: Close superseded Speakeasy PRs
109+
if: steps.pr.outputs.pr_num != ''
110+
env:
111+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
112+
run: |
113+
set -euo pipefail
114+
115+
CURRENT_PR="${{ steps.pr.outputs.pr_num }}"
116+
PR_JSON=$(cat /tmp/speakeasy_pr_json.json)
117+
118+
PRIOR_JSON=$(echo "$PR_JSON" | jq -r --arg current_pr "$CURRENT_PR" \
119+
'[.[].number | select(tostring != $current_pr)] | .[]')
57120
58121
if [ -z "$PRIOR_JSON" ]; then
59122
echo "No superseded Speakeasy PRs to close"
@@ -71,30 +134,92 @@ jobs:
71134
done
72135
73136
- name: Auto-merge Speakeasy PR
137+
if: steps.pr.outputs.pr_num != ''
74138
env:
75139
GH_TOKEN: ${{ steps.app-token.outputs.token }}
76140
run: |
77141
set -euo pipefail
78-
PR_NUM="${{ github.event.pull_request.number }}"
142+
143+
PR_NUM="${{ steps.pr.outputs.pr_num }}"
79144
REPO="${{ github.repository }}"
80145
146+
wait_for_checks() {
147+
local pr=$1
148+
local timeout=600
149+
local interval=15
150+
local elapsed=0
151+
152+
echo "Waiting for PR #$pr checks (timeout ${timeout}s)..."
153+
while [ "$elapsed" -lt "$timeout" ]; do
154+
TOTAL=$(gh pr view "$pr" --repo "$REPO" --json statusCheckRollup --jq \
155+
'.statusCheckRollup | length')
156+
PENDING=$(gh pr view "$pr" --repo "$REPO" --json statusCheckRollup --jq \
157+
'[.statusCheckRollup[]? | select(.status != "COMPLETED")] | length')
158+
FAILED=$(gh pr view "$pr" --repo "$REPO" --json statusCheckRollup --jq \
159+
'[.statusCheckRollup[]? | select(.status == "COMPLETED") | select(.conclusion != "SUCCESS" and .conclusion != "SKIPPED" and .conclusion != "NEUTRAL")] | length')
160+
161+
if [ "$TOTAL" -eq 0 ]; then
162+
# GITHUB_TOKEN-authored regen PRs get no pull_request checks at all,
163+
# so TOTAL stays 0 forever. Wait a short grace window in case checks
164+
# are merely slow to register, then proceed rather than time out.
165+
if [ "$elapsed" -ge 60 ]; then
166+
echo "No checks registered after ${elapsed}s — proceeding (checkless PR)"
167+
return 0
168+
fi
169+
echo "Checks not yet registered — waiting ${interval}s..."
170+
sleep "$interval"
171+
elapsed=$((elapsed + interval))
172+
continue
173+
fi
174+
175+
if [ "$PENDING" -eq 0 ]; then
176+
if [ "$FAILED" -gt 0 ]; then
177+
echo "::error::PR #$pr has failing checks — refusing direct merge"
178+
gh pr checks "$pr" --repo "$REPO" || true
179+
exit 1
180+
fi
181+
echo "All checks completed"
182+
return 0
183+
fi
184+
185+
echo "Checks pending ($PENDING) — waiting ${interval}s..."
186+
sleep "$interval"
187+
elapsed=$((elapsed + interval))
188+
done
189+
190+
echo "::error::Timed out waiting for PR #$pr checks"
191+
exit 1
192+
}
193+
81194
STATE=$(gh pr view "$PR_NUM" --repo "$REPO" --json state --jq .state)
82195
if [ "$STATE" != "OPEN" ]; then
83196
echo "PR #$PR_NUM is $STATE — skipping merge"
84197
exit 0
85198
fi
86199
200+
MERGE_METHOD=""
87201
# Prefer GitHub auto-merge when required checks exist; otherwise
88-
# squash-merge directly (same pattern as sdk-release-prs.yaml).
202+
# squash-merge directly after checks pass (sdk-release-prs.yaml pattern).
89203
AUTO_MERGE_NOOP_PATTERN='is in clean status|protected branch rules|Branch does not have required protected branch rules'
90204
if gh pr merge "$PR_NUM" --repo "$REPO" --squash --auto --delete-branch 2> /tmp/gh-merge.err; then
205+
MERGE_METHOD="auto-merge queued"
91206
echo "Auto-merge enabled for Speakeasy PR #$PR_NUM"
92207
elif grep -qiE "$AUTO_MERGE_NOOP_PATTERN" /tmp/gh-merge.err; then
93-
echo "Auto-merge not applicable — merging PR #$PR_NUM directly"
208+
echo "Auto-merge not applicable — waiting for checks, then merging PR #$PR_NUM directly"
94209
cat /tmp/gh-merge.err >&2
210+
wait_for_checks "$PR_NUM"
95211
gh pr merge "$PR_NUM" --repo "$REPO" --squash --delete-branch
212+
MERGE_METHOD="direct squash (checks passed)"
96213
else
97214
echo "::error::Failed to enable auto-merge on Speakeasy PR #$PR_NUM"
98215
cat /tmp/gh-merge.err >&2
99216
exit 1
100217
fi
218+
219+
echo "::notice title=auto-merge-pr::Merged Speakeasy PR #$PR_NUM ($MERGE_METHOD)"
220+
{
221+
echo "## Auto-merge complete"
222+
echo "- PR: #$PR_NUM"
223+
echo "- Method: $MERGE_METHOD"
224+
echo "- Token: GH_DOCS_SYNC GitHub App"
225+
} >> "$GITHUB_STEP_SUMMARY"

.github/workflows/sdk_generation.yaml

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ permissions:
2121
types:
2222
- labeled
2323
- unlabeled
24+
25+
concurrency:
26+
group: speakeasy-generate
27+
cancel-in-progress: false
28+
2429
jobs:
2530
generate:
2631
uses: speakeasy-api/sdk-generation-action/.github/workflows/workflow-executor.yaml@v15
@@ -32,3 +37,67 @@ jobs:
3237
github_access_token: ${{ secrets.GITHUB_TOKEN }}
3338
pypi_token: ${{ secrets.PYPI_TOKEN }}
3439
speakeasy_api_key: ${{ secrets.SPEAKEASY_API_KEY }}
40+
41+
resolve-branch:
42+
needs: generate
43+
if: ${{ !cancelled() && needs.generate.result == 'success' }}
44+
runs-on: ubuntu-latest
45+
outputs:
46+
branch_name: ${{ steps.branch.outputs.branch_name }}
47+
run_started_at: ${{ steps.branch.outputs.run_started_at }}
48+
steps:
49+
- name: Extract branch from Generate logs
50+
id: branch
51+
env:
52+
GH_TOKEN: ${{ github.token }}
53+
run: |
54+
set -euo pipefail
55+
REPO="${{ github.repository }}"
56+
RUN_ID="${{ github.run_id }}"
57+
58+
RUN_STARTED="${{ github.run_started_at }}"
59+
if [ -z "$RUN_STARTED" ]; then
60+
RUN_STARTED=$(gh run view "$RUN_ID" --repo "$REPO" --json startedAt --jq '.startedAt')
61+
echo "Resolved run_started_at from API: $RUN_STARTED"
62+
fi
63+
echo "run_started_at=$RUN_STARTED" >> "$GITHUB_OUTPUT"
64+
65+
# Full-run logs are unavailable while the workflow is in progress;
66+
# fetch logs from the completed generate job instead.
67+
JOB_ID=$(gh run view "$RUN_ID" --repo "$REPO" --json jobs --jq \
68+
'[.jobs[] | select(.name == "generate / Generate Target")] | .[0].databaseId // empty')
69+
70+
BRANCH=""
71+
if [ -z "$JOB_ID" ]; then
72+
echo "::warning::Could not find generate job id — auto-merge will use run_started_at fallback"
73+
else
74+
MAX_ATTEMPTS=12
75+
INTERVAL=10
76+
for attempt in $(seq 1 "$MAX_ATTEMPTS"); do
77+
BRANCH=$(gh run view "$RUN_ID" --repo "$REPO" --job "$JOB_ID" --log 2>/dev/null \
78+
| grep -oE 'branch_name=speakeasy-sdk-regen-[0-9]+' | tail -1 | cut -d= -f2 || true)
79+
if [ -n "$BRANCH" ]; then
80+
echo "Extracted branch_name=$BRANCH (attempt $attempt)"
81+
break
82+
fi
83+
if [ "$attempt" -lt "$MAX_ATTEMPTS" ]; then
84+
echo "Job logs not ready — waiting ${INTERVAL}s (attempt $attempt/$MAX_ATTEMPTS)..."
85+
sleep "$INTERVAL"
86+
fi
87+
done
88+
if [ -z "$BRANCH" ]; then
89+
echo "::warning::Could not extract branch_name from Generate logs after ${MAX_ATTEMPTS} attempts — auto-merge will use run_started_at fallback"
90+
fi
91+
fi
92+
echo "branch_name=$BRANCH" >> "$GITHUB_OUTPUT"
93+
94+
auto-merge:
95+
needs: [generate, resolve-branch]
96+
if: ${{ !cancelled() && needs.generate.result == 'success' }}
97+
uses: ./.github/workflows/auto-merge-speakeasy-pr.yaml
98+
with:
99+
run_started_at: ${{ needs.resolve-branch.outputs.run_started_at }}
100+
branch_name: ${{ needs.resolve-branch.outputs.branch_name }}
101+
secrets:
102+
GH_DOCS_SYNC_APP_ID: ${{ secrets.GH_DOCS_SYNC_APP_ID }}
103+
GH_DOCS_SYNC_APP_PRIVATE_KEY: ${{ secrets.GH_DOCS_SYNC_APP_PRIVATE_KEY }}

.github/workflows/sdk_generation_for_spec_change.yaml

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ permissions:
2222
paths:
2323
- .speakeasy/in.openapi.yaml
2424

25+
concurrency:
26+
group: speakeasy-generate-spec-change
27+
cancel-in-progress: false
28+
2529
jobs:
2630
generate:
2731
if: github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch'
@@ -34,3 +38,67 @@ jobs:
3438
github_access_token: ${{ secrets.GITHUB_TOKEN }}
3539
pypi_token: ${{ secrets.PYPI_TOKEN }}
3640
speakeasy_api_key: ${{ secrets.SPEAKEASY_API_KEY }}
41+
42+
resolve-branch:
43+
needs: generate
44+
if: ${{ !cancelled() && needs.generate.result == 'success' }}
45+
runs-on: ubuntu-latest
46+
outputs:
47+
branch_name: ${{ steps.branch.outputs.branch_name }}
48+
run_started_at: ${{ steps.branch.outputs.run_started_at }}
49+
steps:
50+
- name: Extract branch from Generate logs
51+
id: branch
52+
env:
53+
GH_TOKEN: ${{ github.token }}
54+
run: |
55+
set -euo pipefail
56+
REPO="${{ github.repository }}"
57+
RUN_ID="${{ github.run_id }}"
58+
59+
RUN_STARTED="${{ github.run_started_at }}"
60+
if [ -z "$RUN_STARTED" ]; then
61+
RUN_STARTED=$(gh run view "$RUN_ID" --repo "$REPO" --json startedAt --jq '.startedAt')
62+
echo "Resolved run_started_at from API: $RUN_STARTED"
63+
fi
64+
echo "run_started_at=$RUN_STARTED" >> "$GITHUB_OUTPUT"
65+
66+
# Full-run logs are unavailable while the workflow is in progress;
67+
# fetch logs from the completed generate job instead.
68+
JOB_ID=$(gh run view "$RUN_ID" --repo "$REPO" --json jobs --jq \
69+
'[.jobs[] | select(.name == "generate / Generate Target")] | .[0].databaseId // empty')
70+
71+
BRANCH=""
72+
if [ -z "$JOB_ID" ]; then
73+
echo "::warning::Could not find generate job id — auto-merge will use run_started_at fallback"
74+
else
75+
MAX_ATTEMPTS=12
76+
INTERVAL=10
77+
for attempt in $(seq 1 "$MAX_ATTEMPTS"); do
78+
BRANCH=$(gh run view "$RUN_ID" --repo "$REPO" --job "$JOB_ID" --log 2>/dev/null \
79+
| grep -oE 'branch_name=speakeasy-sdk-regen-[0-9]+' | tail -1 | cut -d= -f2 || true)
80+
if [ -n "$BRANCH" ]; then
81+
echo "Extracted branch_name=$BRANCH (attempt $attempt)"
82+
break
83+
fi
84+
if [ "$attempt" -lt "$MAX_ATTEMPTS" ]; then
85+
echo "Job logs not ready — waiting ${INTERVAL}s (attempt $attempt/$MAX_ATTEMPTS)..."
86+
sleep "$INTERVAL"
87+
fi
88+
done
89+
if [ -z "$BRANCH" ]; then
90+
echo "::warning::Could not extract branch_name from Generate logs after ${MAX_ATTEMPTS} attempts — auto-merge will use run_started_at fallback"
91+
fi
92+
fi
93+
echo "branch_name=$BRANCH" >> "$GITHUB_OUTPUT"
94+
95+
auto-merge:
96+
needs: [generate, resolve-branch]
97+
if: ${{ !cancelled() && needs.generate.result == 'success' }}
98+
uses: ./.github/workflows/auto-merge-speakeasy-pr.yaml
99+
with:
100+
run_started_at: ${{ needs.resolve-branch.outputs.run_started_at }}
101+
branch_name: ${{ needs.resolve-branch.outputs.branch_name }}
102+
secrets:
103+
GH_DOCS_SYNC_APP_ID: ${{ secrets.GH_DOCS_SYNC_APP_ID }}
104+
GH_DOCS_SYNC_APP_PRIVATE_KEY: ${{ secrets.GH_DOCS_SYNC_APP_PRIVATE_KEY }}

0 commit comments

Comments
 (0)