From 587f2c7cc0b91289d00fe4a25c21fb34c04c6a8b Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Fri, 29 May 2026 11:33:12 +0100 Subject: [PATCH 1/4] ci(spec-sdk-tests-vs-release): test PR's SDK against latest released Outpost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #926. Trigger: PRs touching sdks/outpost-typescript/** (where the Speakeasy bot regen PRs land). Resolves the latest non-prerelease Outpost tag dynamically via the GitHub releases API (the repo uses namespaced tags like sdks/outpost-typescript/v1.3.0 for SDK releases, so the bare vX.Y.Z pattern correctly picks out the Outpost release). Question this answers: "Will the newly-regen'd SDK in this PR work against the version of Outpost that customers are already running?" Distinct from the existing spec-sdk-tests.yml workflow which asks "does this PR's spec match this PR's server" — both are needed, neither subsumes the other. Job shape: pull hookdeck/outpost: as a docker image, run it alongside the same service containers as the sibling workflow (Postgres, redis-stack-server for RediSearch, RabbitMQ), build the SDK from the PR with no regen step (the regen IS the PR), run the contract suite. Not dogfooded on this PR — the trigger filter only matches SDK paths, which this PR doesn't touch. First real run will be on the next Speakeasy bot regen PR after this lands. --- .../workflows/spec-sdk-tests-vs-release.yml | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 .github/workflows/spec-sdk-tests-vs-release.yml diff --git a/.github/workflows/spec-sdk-tests-vs-release.yml b/.github/workflows/spec-sdk-tests-vs-release.yml new file mode 100644 index 00000000..23c37bf4 --- /dev/null +++ b/.github/workflows/spec-sdk-tests-vs-release.yml @@ -0,0 +1,207 @@ +# Runs spec-sdk-tests with the SDK from this PR against the latest released +# Outpost. Scenario 2 in the design discussion (see #921, #926). +# +# WHAT THIS WORKFLOW VALIDATES +# "Will the SDK in this PR work against the latest released Outpost?" +# For Speakeasy bot regen PRs that bump the TS SDK after an Outpost +# release, this confirms the newly-regen'd SDK doesn't break against the +# server version customers are already running. +# +# WHAT IT DOES NOT VALIDATE +# - That this PR's spec matches this PR's server. That's the separate +# spec-sdk-tests.yml workflow (#925) — different question entirely. +# - Compatibility against older releases, prereleases, or main. +# +# WHY THIS SHAPE +# Release order is: Outpost is tagged → sdk-generate-on-release fires → +# bot opens an SDK PR. By the time the bot PR exists, the released server +# exists too; testing the new SDK against that server answers the only +# question that matters before publishing the SDK to npm. +# +# Triggers: workflow_dispatch and PRs touching sdks/outpost-typescript/** +# (where the Speakeasy bot regen PRs land). +name: Spec SDK tests vs released Outpost + +on: + workflow_dispatch: + pull_request: + paths: + - "sdks/outpost-typescript/**" + - "spec-sdk-tests/**" + - ".github/workflows/spec-sdk-tests-vs-release.yml" + +jobs: + spec-sdk-tests-vs-release: + runs-on: ubuntu-latest + timeout-minutes: 20 + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: outpost + POSTGRES_PASSWORD: outpost + POSTGRES_DB: outpost + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U outpost" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + redis: + # redis-stack-server bundles RediSearch; tenants.list needs it. + image: redis/redis-stack-server:latest + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + rabbitmq: + image: rabbitmq:3-management + ports: + - 5672:5672 + options: >- + --health-cmd "rabbitmq-diagnostics -q ping" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + + env: + OUTPOST_API_KEY: ci-test-api-key + OUTPOST_TEST_TENANT: ci-test-tenant + TEST_TOPICS: "user.created,user.updated,order.created,heartbeat" + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: "20" + + - name: Resolve latest Outpost release tag + id: outpost-tag + env: + GH_TOKEN: ${{ github.token }} + # The repo uses namespaced tags (sdks/outpost-typescript/v1.3.0 etc.) + # for SDK releases, so `gh release view` returns the wrong thing. + # Filter on the bare vX.Y.Z pattern, skip prereleases, take the first. + run: | + tag=$(gh api repos/${{ github.repository }}/releases \ + --jq '[.[] | select(.tag_name | test("^v[0-9]+\\.[0-9]+\\.[0-9]+$")) | select(.prerelease == false)][0].tag_name') + if [ -z "$tag" ] || [ "$tag" = "null" ]; then + echo "::error::Could not resolve latest Outpost release tag" + exit 1 + fi + echo "tag=$tag" >> $GITHUB_OUTPUT + echo "Latest released Outpost: $tag" + + - name: Pull Outpost image + run: docker pull hookdeck/outpost:${{ steps.outpost-tag.outputs.tag }} + + - name: Write outpost config + run: | + mkdir -p /tmp/outpost-config + cat > /tmp/outpost-config/.outpost.yaml </dev/null; then + echo "Outpost API is healthy after ${i}s" + exit 0 + fi + sleep 1 + done + echo "::error::Outpost API did not become healthy within 60s" + docker logs outpost || true + exit 1 + + - name: Build TypeScript SDK from this PR + working-directory: sdks/outpost-typescript + # No regen — the SDK *is* what the PR is. We're testing whatever + # the bot generated (or whatever a human committed) as-is. + run: | + npm ci + npm run build + + - name: Install spec-sdk-tests dependencies + working-directory: spec-sdk-tests + # spec-sdk-tests/.gitignore excludes package-lock.json. + run: npm install + + - name: Configure spec-sdk-tests .env + run: | + cat > spec-sdk-tests/.env < Date: Fri, 29 May 2026 12:47:20 +0100 Subject: [PATCH 2/4] ci(spec-sdk-tests-vs-release): support sdk_version + outpost_version dispatch overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets you trigger the workflow from the Actions UI with optional inputs for ad-hoc compat testing: sdk_version pins the SDK to a specific release tag (or uses the dispatch branch's contents if empty). outpost_version pins the server to a specific Outpost release (or resolves the latest non-prerelease release if empty). Both accept "1.3.0" or "v1.3.0" — leading "v" is normalized. Inputs only affect workflow_dispatch runs; pull_request triggers ignore them, so the gate behaviour for bot regen PRs is unchanged. Single workflow rather than a sibling file — the job body is ~95% identical between PR gate and compat testing; the only material differences are two variables (which SDK, which Outpost). --- .../workflows/spec-sdk-tests-vs-release.yml | 62 +++++++++++++++---- 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/.github/workflows/spec-sdk-tests-vs-release.yml b/.github/workflows/spec-sdk-tests-vs-release.yml index 23c37bf4..7ace3b84 100644 --- a/.github/workflows/spec-sdk-tests-vs-release.yml +++ b/.github/workflows/spec-sdk-tests-vs-release.yml @@ -20,10 +20,25 @@ # # Triggers: workflow_dispatch and PRs touching sdks/outpost-typescript/** # (where the Speakeasy bot regen PRs land). +# +# workflow_dispatch inputs (UI overrides, ignored on PR runs): +# sdk_version — pin SDK to a specific TS release (e.g. "1.3.0" or "v1.3.0"). +# Empty = use the dispatch branch's current SDK contents. +# outpost_version — pin Outpost server to a specific release (e.g. "1.0.3" or "v1.0.3"). +# Empty = latest non-prerelease Outpost release. name: Spec SDK tests vs released Outpost on: workflow_dispatch: + inputs: + sdk_version: + description: 'TS SDK version to test (e.g. "1.3.0" or "v1.3.0"). Empty = use this branch''s SDK contents.' + required: false + type: string + outpost_version: + description: 'Outpost release to test against (e.g. "1.0.3" or "v1.0.3"). Empty = latest non-prerelease release.' + required: false + type: string pull_request: paths: - "sdks/outpost-typescript/**" @@ -85,22 +100,30 @@ jobs: with: node-version: "20" - - name: Resolve latest Outpost release tag + - name: Resolve Outpost release tag id: outpost-tag env: GH_TOKEN: ${{ github.token }} - # The repo uses namespaced tags (sdks/outpost-typescript/v1.3.0 etc.) - # for SDK releases, so `gh release view` returns the wrong thing. - # Filter on the bare vX.Y.Z pattern, skip prereleases, take the first. + OVERRIDE: ${{ inputs.outpost_version }} + # On workflow_dispatch with outpost_version set, use that value + # (normalize to a leading "v"). Otherwise resolve the latest + # non-prerelease bare vX.Y.Z release — the repo uses namespaced tags + # (sdks/outpost-typescript/v1.3.0 etc.) for SDK releases, so + # `gh release view` returns the wrong thing. run: | - tag=$(gh api repos/${{ github.repository }}/releases \ - --jq '[.[] | select(.tag_name | test("^v[0-9]+\\.[0-9]+\\.[0-9]+$")) | select(.prerelease == false)][0].tag_name') + if [ -n "$OVERRIDE" ]; then + tag="v${OVERRIDE#v}" + echo "Using outpost_version override: $tag" + else + tag=$(gh api repos/${{ github.repository }}/releases \ + --jq '[.[] | select(.tag_name | test("^v[0-9]+\\.[0-9]+\\.[0-9]+$")) | select(.prerelease == false)][0].tag_name') + echo "Latest released Outpost: $tag" + fi if [ -z "$tag" ] || [ "$tag" = "null" ]; then - echo "::error::Could not resolve latest Outpost release tag" + echo "::error::Could not resolve Outpost release tag" exit 1 fi echo "tag=$tag" >> $GITHUB_OUTPUT - echo "Latest released Outpost: $tag" - name: Pull Outpost image run: docker pull hookdeck/outpost:${{ steps.outpost-tag.outputs.tag }} @@ -172,10 +195,27 @@ jobs: docker logs outpost || true exit 1 - - name: Build TypeScript SDK from this PR + - name: Override SDK with sdk_version (workflow_dispatch only) + if: inputs.sdk_version != '' + # Replace the working tree's sdks/outpost-typescript with the contents + # at the SDK release tag. SDK tags are namespaced as + # sdks/outpost-typescript/v. Accept either "1.3.0" or "v1.3.0" by + # stripping any leading "v" and re-adding it. + env: + SDK_VERSION_INPUT: ${{ inputs.sdk_version }} + run: | + ver="${SDK_VERSION_INPUT#v}" + full_tag="sdks/outpost-typescript/v${ver}" + echo "Pinning SDK to $full_tag" + git fetch --depth=1 origin "refs/tags/$full_tag:refs/tags/$full_tag" + rm -rf sdks/outpost-typescript + git checkout "$full_tag" -- sdks/outpost-typescript + + - name: Build TypeScript SDK working-directory: sdks/outpost-typescript - # No regen — the SDK *is* what the PR is. We're testing whatever - # the bot generated (or whatever a human committed) as-is. + # No regen — the SDK *is* what the PR (or the sdk_version override) is. + # We're testing whatever the bot generated, the human committed, or the + # specific release tag — as-is. run: | npm ci npm run build From 1f0b884fec51af301080870b2cd9d476ec29b9fc Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Fri, 29 May 2026 15:03:34 +0100 Subject: [PATCH 3/4] ci(spec-sdk-tests-vs-release): guard inputs.* references with workflow_dispatch event check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defensive pattern flagged by Copilot review on #927: inputs.* context is officially only populated on workflow_dispatch (and workflow_call). Practically this works on PR events too — inputs.x evaluates to null which compares as empty — but the explicit guard is unambiguous and costs almost nothing. Two changes: * OVERRIDE env in the tag resolver uses the short-circuit ternary (github.event_name == 'workflow_dispatch' && inputs.x || ''). * SDK override step's if: prepends event_name == 'workflow_dispatch' so the inputs.sdk_version check is only evaluated on dispatch runs. --- .github/workflows/spec-sdk-tests-vs-release.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/spec-sdk-tests-vs-release.yml b/.github/workflows/spec-sdk-tests-vs-release.yml index 7ace3b84..14aa12f9 100644 --- a/.github/workflows/spec-sdk-tests-vs-release.yml +++ b/.github/workflows/spec-sdk-tests-vs-release.yml @@ -104,7 +104,10 @@ jobs: id: outpost-tag env: GH_TOKEN: ${{ github.token }} - OVERRIDE: ${{ inputs.outpost_version }} + # inputs.* is only populated on workflow_dispatch; short-circuit on + # other events so OVERRIDE stays empty and the resolver falls + # through to the latest-release path. + OVERRIDE: ${{ github.event_name == 'workflow_dispatch' && inputs.outpost_version || '' }} # On workflow_dispatch with outpost_version set, use that value # (normalize to a leading "v"). Otherwise resolve the latest # non-prerelease bare vX.Y.Z release — the repo uses namespaced tags @@ -196,7 +199,9 @@ jobs: exit 1 - name: Override SDK with sdk_version (workflow_dispatch only) - if: inputs.sdk_version != '' + # Guard event_name first so the inputs.* reference is short-circuited + # away on pull_request runs (where inputs.* isn't populated). + if: github.event_name == 'workflow_dispatch' && inputs.sdk_version != '' # Replace the working tree's sdks/outpost-typescript with the contents # at the SDK release tag. SDK tags are namespaced as # sdks/outpost-typescript/v. Accept either "1.3.0" or "v1.3.0" by From f8b8740e1b8653ba048450a44b3b4ee6fa952fad Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Fri, 29 May 2026 15:18:25 +0100 Subject: [PATCH 4/4] ci(spec-sdk-tests-vs-release): don't self-trigger on workflow file edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRs that touch only this workflow file would fire it against main's state — currently NEW tests + OLD SDK (regen still pending) + OLD released Outpost — and fail at TS compile with 'type does not exist in type DestinationUpdate'. That's predicted transitional-state noise, not a real bug, but it leaves a permanently-red dogfood result that future reviewers have to recognize as expected. Drop the workflow file from its own trigger paths. The actual scenario this workflow exists for — Speakeasy bot regen PRs — always touches sdks/outpost-typescript/**, so the gate still catches them. Local iteration on the workflow file itself uses 'gh workflow run --ref'. Spotted while inspecting failing PR runs on #927. --- .github/workflows/spec-sdk-tests-vs-release.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/spec-sdk-tests-vs-release.yml b/.github/workflows/spec-sdk-tests-vs-release.yml index 14aa12f9..75f13d78 100644 --- a/.github/workflows/spec-sdk-tests-vs-release.yml +++ b/.github/workflows/spec-sdk-tests-vs-release.yml @@ -40,10 +40,14 @@ on: required: false type: string pull_request: + # Deliberately not self-triggering on changes to this workflow file. + # The bot regen PRs (where this workflow is meant to fire) always + # touch sdks/outpost-typescript/**, so the gate still catches them. + # For local iteration on the workflow itself, use `gh workflow run + # --ref ` instead. paths: - "sdks/outpost-typescript/**" - "spec-sdk-tests/**" - - ".github/workflows/spec-sdk-tests-vs-release.yml" jobs: spec-sdk-tests-vs-release: