Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 151 additions & 26 deletions .github/workflows/auto-merge-speakeasy-pr.yaml
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
Expand All @@ -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
Expand All @@ -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"
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] wait_for_checks never returns 0 when the status-check rollup stays empty — the direct-merge fallback will burn the full 600s timeout and then exit 1 on every Speakeasy regen PR.

Details

Why: When gh pr merge --auto is rejected (no required protected-branch rules), the code falls into the AUTO_MERGE_NOOP_PATTERN branch and calls wait_for_checks. But the TOTAL -eq 0 arm only continues — it never returns success — so if no checks ever register, the loop runs until elapsed >= 600 and the function exits 1, failing the job before the direct gh pr merge --squash ever runs.

Speakeasy regen PRs are exactly this case: they are opened by github-actions[bot] using the workflow's GITHUB_TOKEN, which by design does not re-trigger downstream on: pull_request workflows, so they carry zero status checks. I confirmed this empirically against the 8 most recent speakeasy-sdk-regen-* PRs in this repo (#357, #352, #349, #347, #346, #344, #342, #340) — every one has an empty statusCheckRollup, and open PR #357 reports mergeStateStatus: CLEAN / mergeable: MERGEABLE, confirming main carries no required checks. So the --auto path 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 == 0 rollup 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:

local empty_grace=60   # seconds to allow checks to register
if [ "$TOTAL" -eq 0 ]; then
  if [ "$elapsed" -ge "$empty_grace" ]; then
    echo "No checks registered after ${empty_grace}s — proceeding (regen PRs have no checks)"
    return 0
  fi
  echo "Checks not yet registered — waiting ${interval}s..."
  sleep "$interval"
  elapsed=$((elapsed + interval))
  continue
fi

Ref: GitHub Actions — events that do not trigger workflows when using GITHUB_TOKEN

Prompt for agents
In .github/workflows/auto-merge-speakeasy-pr.yaml, the wait_for_checks() bash function (around line 158-197) has a branch `if [ "$TOTAL" -eq 0 ]` that only sleeps and continues, so when a PR has zero status checks the loop runs to the 600s timeout and the function exits 1. Speakeasy regen PRs are opened with the workflow GITHUB_TOKEN and carry no status checks, so the direct-merge fallback (the AUTO_MERGE_NOOP_PATTERN branch that calls wait_for_checks) will always fail this way. Fix: add a grace window (e.g. 60s) for the empty-rollup case; once elapsed exceeds the grace window while TOTAL is still 0, treat it as "no checks will run" and `return 0` so the subsequent `gh pr merge --squash --delete-branch` proceeds. Keep the existing PENDING/FAILED handling unchanged for PRs that do register checks.

Reviewed at 186c107

# 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] the grace threshold 60 is an inline literal; hoisting it into a named local (e.g. local empty_grace=60) alongside timeout/interval would document intent and keep the wait-tuning knobs in one place. Resolves my prior suggestion — thanks for adding the grace window.

Reviewed at 0680f01

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"
69 changes: 69 additions & 0 deletions .github/workflows/sdk_generation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }}
68 changes: 68 additions & 0 deletions .github/workflows/sdk_generation_for_spec_change.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 }}