Skip to content

SPEC §9: peer-derived text in observer-facing errors (boundary unresolved) #3329

SPEC §9: peer-derived text in observer-facing errors (boundary unresolved)

SPEC §9: peer-derived text in observer-facing errors (boundary unresolved) #3329

Workflow file for this run

name: Test
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions: {}
jobs:
lint-actions:
name: GitHub Actions audit
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Run actionlint
uses: rhysd/actionlint@914e7df21a07ef503a81201c76d2b11c789d3fca # v1.7.12
- name: Run zizmor
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
with:
advanced-security: false
smithy:
uses: ./.github/workflows/smithy-verify.yml
permissions:
contents: read
provenance:
name: Provenance
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Verify embedded provenance is up to date
run: diff -q spec/api-provenance.json go/pkg/basecamp/api-provenance.json
fixture-coverage:
name: Fixture coverage
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
with:
ruby-version: "3.3"
# Run under LC_ALL=C so CI actually exercises the non-UTF-8-locale path
# (the reads are pinned to UTF-8; this proves it stays that way).
- name: Check fixture completeness + coverage invariant
run: ruby scripts/check-fixture-coverage.rb
env:
LC_ALL: C
- name: Fixture-guard self-test (negative + synthetic cases)
run: ruby scripts/test-check-fixture-coverage.rb
env:
LC_ALL: C
# Toolchain-light repo-wide gates from the `make check` line that previously
# ran on developer machines only. `make check` membership is not enforcement:
# a gate nothing in CI invokes is discipline, not a guarantee. These eight
# need bash + jq + ruby and no SDK toolchain, so they get one fast job rather
# than riding along inside a language job (which is how
# check-idempotency-parity ended up coupled to test-go).
#
# kt-check-drift belongs here despite the kt- prefix: it is jq/grep with no
# JVM (see its Makefile comment). The authoritative regenerate-and-diff
# Kotlin gate, kt-check-generated-drift, already runs in test-kotlin.
#
# One sibling deliberately lives elsewhere: behavior-model-check is a step in
# smithy-verify.yml, because it consumes
# spec/build/smithy/source/model/model.json, which only exists after
# `smithy build`.
#
# Invoke via `make <target>` so the recipe has exactly one definition and CI
# cannot drift from the Makefile.
# Ground truth, and the authoritative check: the static parser predicts
# that nothing writes a lockfile, this observes whether anything did. A CI
# checkout starts pristine, so the committed tree is the baseline. Catches
# a manifest that was modified AND one CREATED where none was tracked, and
# runs on failure too, so a job cannot fail and hide what it dirtied.
- name: Dependency manifests unchanged by this job
if: always()
working-directory: .
run: ./scripts/assert-lockfiles-unchanged --verify-clean
# Its own job purely to scope the token: this is the only check that needs
# `issues: read`, and folding it into Spec Gates would hand that permission to
# every gate in it. Cannot live in `make check` either — issue state needs the
# network, and check-bc3-route-parity's guarantee is that it has no skip path.
known-defect-issues:
name: Known-defect tracking issues are open
runs-on: ubuntu-latest
permissions:
contents: read
issues: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
with:
ruby-version: "3.3"
# Offline and first: the gate below is a no-op whenever the allowlist
# references no issues, so this is what proves it rejects anything.
- name: Self-test the known-defect gate
run: make test-check-known-defect-issues-open
- name: Referenced tracking issues are still open
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: make check-known-defect-issues-open
spec-gates:
name: Spec Gates
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
with:
ruby-version: "3.3"
- name: Spec version constants in sync with provenance
run: make sync-spec-version-check
- name: API version constants in sync with openapi.json
run: make sync-api-version-check
# Prose across the tracked Markdown restates constants and rosters that
# are derivable from machine-readable sources; scripts/sync-doc-constants.rb
# heads the current list, and spec/doc-constants.json states the rule that
# decides what joins it. Marked spans only — spec/api-gaps/
# legitimately cites historical SHAs — plus a committed exact marker
# count, so neither deleting a marker nor adding an unrecorded one can
# silence the gate. The target also runs scripts/test-doc-constants.rb,
# which asserts the gate rejects each failure mode; the live run alone
# only proves it can say yes.
- name: Documentation constants in sync with their sources
run: make doc-constants-check
env:
LC_ALL: C
- name: Bucket-scoped vs flat operation parity
run: make check-bucket-flat-parity
- name: api-gaps registry frontmatter + allowlist schema
run: make validate-api-gaps
env:
LC_ALL: C
- name: Deprecation signal parity across six SDKs
run: make check-deprecation-parity
- name: Generated Go optional fields can represent absence
run: make go-check-optional-pointers
- name: Request-body enhancer reachability (synthetic specs)
run: make test-enhance-request-reachability
- name: Kotlin service drift (static; jq/grep, no JVM)
run: make kt-check-drift
# Cross-SDK, and reads only committed generated files, so it needs no
# toolchain and belongs here rather than in any one SDK's job. The five
# split tables are hand-maintained copies of one mapping and nothing
# compared them TO EACH OTHER — every check-*-service-drift script
# validates one SDK against its own generator and can see no other SDK.
# doc-constants-check derives SPEC section 5's roster from the Kotlin and
# Swift accessors alone, which two omissions in the same place would leave
# agreeing.
#
# Under LC_ALL=C so CI exercises the non-UTF-8-locale path (the reads are
# pinned to UTF-8; this proves it stays that way). The accessor and Go
# client files carry non-ASCII text, so an unpinned read raises
# InvalidByteSequenceError before anything is compared.
- name: Six SDKs agree on which services exist
run: make check-service-inventory-parity
env:
LC_ALL: C
# The run above only ever exercises the PASSING case. This drives
# synthetic repository trees through the same gate, including the
# omitted-from-both-accessor-files case that is the whole reason it exists.
- name: Service inventory parity gate self-test (synthetic trees)
run: make test-check-service-inventory-parity
env:
LC_ALL: C
# The sibling of the two steps above and a different invariant: not which
# services exist, but which service each OPERATION hangs off. An operation
# routed to a different but already-existing service in ONE generator's
# table leaves the service-name union unchanged (so the gate above passes),
# the global operationId set unchanged (so every per-SDK
# check-*-service-drift passes), and the SDK compiling with its tests
# exercising the method wherever it now lives. Nothing else in this
# workflow looks at the pairing.
#
# Its own step rather than relying on `make check` membership: no CI job
# runs the full `make check`, so a gate that is only wired into that target
# is not covered here at all.
#
# Under LC_ALL=C so CI exercises the non-UTF-8-locale path (the reads are
# pinned to UTF-8; this proves it stays that way). The generated trees carry
# non-ASCII text in their @generated banners, so an unpinned read raises
# InvalidByteSequenceError before anything is compared.
- name: Five SDKs agree which service each operation lives on
run: make check-operation-assignment-parity
env:
LC_ALL: C
# The run above only ever exercises the PASSING case. This drives synthetic
# five-SDK trees through the same gate, including the wholesale class-swap
# case that a cheaper grouping comparison would pass.
- name: Operation assignment parity gate self-test (synthetic trees)
run: make test-check-operation-assignment-parity
env:
LC_ALL: C
# The third Go surface. go-check-drift compares generated operations
# against the hand-written go/pkg/basecamp wrappers; nothing watched the
# GROUPED client until this gate, which is how ArchiveProject and
# UnarchiveProject shipped missing from it through two green `make` runs.
#
# Under LC_ALL=C so CI exercises the non-UTF-8-locale path (the reads are
# pinned to UTF-8; this proves it stays that way). All three inputs carry
# non-ASCII text, so an unpinned read raises InvalidByteSequenceError and
# the gate fails before validating anything.
- name: Every operation accounted for on the Go grouped client
run: make check-grouped-client-coverage
env:
LC_ALL: C
# The run above only ever exercises the PASSING case. This drives
# adversarial inventories through the same gate, including the
# unaccounted-new-operation case that is the whole reason it exists.
- name: Grouped-client coverage gate self-test (adversarial inventories)
run: make test-check-grouped-client-coverage
env:
LC_ALL: C
- name: README env-var tables match what the SDKs actually read
run: make check-readme-env-vars
- name: README env-var gate self-test (synthetic repos)
run: make test-check-readme-env-vars
# Offline by construction — compares openapi.json against the vendored
# spec/bc3-routes.json, so it needs no bc3 checkout and no secret and
# therefore cannot skip. A gate that skips when its input is absent is how
# two 404ing routes shipped.
- name: SDK routes match the routes bc3 serves
run: make bc3-route-parity
env:
LC_ALL: C
# The run above only exercises the VALID allowlist, which proves nothing
# about `modeled_as` — the one disposition that asserts something is DONE.
# This drives adversarial substitutions through the same gate, including
# the interior-segment case an earlier subset rule accepted.
- name: bc3 route-parity gate self-test (adversarial allowlists)
run: make test-bc3-route-parity
env:
LC_ALL: C
# Static (bash+grep), so it runs here rather than in a language job: it
# reads the Makefile, this workflow, and the runner trees. Catches the
# #572 shape — a runner test file no target's discovery reaches.
- name: Conformance runner tests are all reachable
run: make check-runner-test-reachability
- name: Runner-test reachability guard self-test
run: ./scripts/check-runner-test-reachability --self-test
# Also static and also reads the Makefile — via `make -p`, so it sees the
# graph make sees rather than a hand-rolled parse. `make -j check` may not
# schedule two Gradle builds in one project directory (#674); the
# order-only edges that prevent it are invisible to every other gate, and
# deleting one leaves a build that still passes almost every time.
#
# Under LC_ALL=C so CI exercises the non-UTF-8-locale path: the gate reads
# a Makefile full of em-dashed comments, so its pipe reads are pinned to
# UTF-8 and this proves they stay that way. The unpinned first cut raised
# ArgumentError here and nowhere else.
- name: Gradle-backed check targets cannot run concurrently
run: make check-gradle-serialization
env:
LC_ALL: C
# The run above only ever sees a chained Makefile. This drives the gate at
# mutated copies — one edge deleted, then an unchained new Gradle target —
# and is the proof the edges are load-bearing.
- name: Gradle-serialization gate self-test (mutated Makefiles)
run: make test-check-gradle-serialization
env:
LC_ALL: C
# The replay runners' own coverage gates fire only during a live canary,
# and that canary skips when its secrets are unset — so four decoder maps
# drifted 20 operations behind the live fixture with CI fully green
# (#553). This is the static answer to the same question.
- name: Replay decoders cover every live fixture operation
run: make check-replay-decoder-parity
# `smithy validate` checks `@examples` against the SMITHY model, where a
# member that `jsonAdd` later appends to a schema's `required` array is
# still natively optional. Nothing compared the projected examples to the
# projected schema, so #637 published two `GetTodolistOrGroup` examples
# that could not satisfy their own contract and a bot reviewer, not CI,
# caught it. The target also runs the gate's self-test, whose first case
# is that defect reproduced exactly.
#
# Under LC_ALL=C so CI exercises the non-UTF-8-locale path (the reads are
# pinned to UTF-8; this proves it stays that way).
- name: Published examples satisfy the schema the projection publishes
run: make check-projected-examples
env:
LC_ALL: C
# Advisory: prints and exits 0. The guarantee is the byte comparison that
# runs at the end of every job that installs anything. This is the early
# warning the byte comparison cannot give — it names the file and line at
# the commit that reintroduces a writer, while the bytes stay identical
# until some npm's output diverges from the committed lockfile (#612).
- name: Diagnostic — npm invocations that could write a lockfile
run: make lint-npm-lockfile-writes
# The gate's live run only exercises the passing case. Its first cut was a
# denylist that `npm in`, `npm ins`, and `npm --prefix <path> install` all
# walked through — so the rejections need their own tests.
- name: npm lockfile diagnostic self-test (synthetic repos)
run: make test-lint-npm-lockfile-writes
# Ground truth, and the authoritative check: the static parser predicts
# that nothing writes a lockfile, this observes whether anything did. A CI
# checkout starts pristine, so the committed tree is the baseline. Catches
# a manifest that was modified AND one CREATED where none was tracked, and
# runs on failure too, so a job cannot fail and hide what it dirtied.
- name: Dependency manifests unchanged by this job
if: always()
working-directory: .
run: ./scripts/assert-lockfiles-unchanged --verify-clean
test-go:
name: Go Tests
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: go
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: 'go/go.mod'
cache-dependency-path: |
go/go.sum
conformance/runner/go/go.sum
- name: Build
run: go build ./...
- name: Check service layer drift
run: ../scripts/check-service-drift.sh
- name: Check wrapper field-level drift
# Pure-Go AST check (stdlib-only module in go.work) comparing wrapper
# structs in go/pkg/basecamp/ against generated structs in
# go/pkg/generated/client.gen.go; writes nothing to the tree. Runs
# from repo root regardless of this job's `working-directory: go`.
working-directory: .
run: make go-check-wrapper-drift
- name: Check generated client drift
run: ../scripts/check-go-generated-drift.sh
- name: Check url-routes freshness
# url-routes.json is a generated Go artifact (//go:embed in url.go).
# Runs from repo root regardless of this job's `working-directory: go`.
working-directory: .
run: make url-routes-check
- name: Check auth-routable consumer invariants
run: ../scripts/check-auth-routable-consumers.sh
- name: Check idempotency classification parity across SDKs
# Repo-wide bash+jq check (jq is preinstalled on ubuntu runners). Runs
# from repo root regardless of this job's `working-directory: go`.
working-directory: .
run: ./scripts/check-idempotency-parity
- name: Check retry metadata parity across SDKs
# Repo-wide python3 check (preinstalled on ubuntu runners). Runs from
# repo root regardless of this job's `working-directory: go`.
working-directory: .
run: make check-retry-metadata-parity
- name: Test with coverage
run: |
# Measure coverage for hand-written code only (generated code is excluded)
go test -v -coverprofile=coverage.out ./pkg/basecamp/... ./pkg/types/...
go tool cover -func=coverage.out | tail -1
COVERAGE=$(go tool cover -func=coverage.out | tail -1 | awk '{print $NF}' | sed 's/%//')
echo "Total coverage: ${COVERAGE}%"
if [ "$(echo "$COVERAGE < 25" | bc -l)" -eq 1 ]; then
echo "::error::Coverage ${COVERAGE}% is below minimum threshold of 25%"
exit 1
fi
- name: Run Go conformance runner unit tests
# Unit-tests the runner's own assertion helpers. Their bounds
# branches never execute against a fixture that passes, so a
# vacuous assertion (#563) survives a fully green conformance run.
#
# Through the make target, not a command spelled out here: #572 shipped
# two runner suites nothing executed because the Makefile and this
# workflow each enumerated filenames, and neither list covered them.
# scripts/check-runner-test-reachability fails if either regains a
# filename.
working-directory: .
run: make conformance-runner-tests-go
- name: Run Go conformance tests
working-directory: conformance/runner/go
run: |
go build -o conformance-runner .
./conformance-runner
- name: Upload go execution manifest
# One of the six exclusion sets the cross-runner gate compares (#602).
# Each runner's own census cannot see a case that EVERY runner excludes,
# because each one counted its own skip; the fan-in job below requires
# all six manifests and fails if any is absent.
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: execution-manifest-go
path: conformance/manifests/go.json
if-no-files-found: error
# Re-running a job creates a new ATTEMPT within the same run, and v4+
# artifacts are immutable per run — so without this the second attempt
# fails on a name conflict with the first attempt's manifest, before
# the fan-in gate can run. Overwriting is also what we want on the
# merits: the collecting gate must read THIS attempt's exclusion set,
# never a previous one.
overwrite: true
# Ground truth, and the authoritative check: the static parser predicts
# that nothing writes a lockfile, this observes whether anything did. A CI
# checkout starts pristine, so the committed tree is the baseline. Catches
# a manifest that was modified AND one CREATED where none was tracked, and
# runs on failure too, so a job cannot fail and hide what it dirtied.
- name: Dependency manifests unchanged by this job
if: always()
working-directory: .
run: ./scripts/assert-lockfiles-unchanged --verify-clean
test-typescript:
name: TypeScript Tests
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: typescript
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: |
typescript/package-lock.json
conformance/runner/typescript/package-lock.json
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Type check
run: npm run typecheck
- name: Test
run: npm run test
- name: Build
run: npm run build
- name: Check generated code drift
run: ../scripts/check-typescript-service-drift.sh
- name: Verify root pack includes compiled SDK
working-directory: .
run: |
FILES=$(npm pack --ignore-scripts --json | jq -r '.[0].files[].path')
echo "$FILES" | grep -q '^typescript/dist/index\.js$'
echo "$FILES" | grep -q '^typescript/dist/index\.d\.ts$'
# npx vitest run, not npm test: this skips the runner's pretest, which
# asserts the SDK build is fresh — a check this job satisfies by
# construction, having built it in the "Build" step above.
#
# The previous comment here claimed a "dist-freshness globalSetup" backed
# this up. There is none, and never was; the runner resolves the SDK
# through its package exports, so a stale dist/ would simply have been
# tested. conformance/runner/typescript/assert-sdk-built.mjs is that
# missing guard, and it now runs on every `npm test` in that directory.
- name: Run TypeScript conformance tests
working-directory: conformance/runner/typescript
run: |
npm ci
npx vitest run
- name: Upload typescript execution manifest
# One of the six exclusion sets the cross-runner gate compares (#602).
# Each runner's own census cannot see a case that EVERY runner excludes,
# because each one counted its own skip; the fan-in job below requires
# all six manifests and fails if any is absent.
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: execution-manifest-typescript
path: conformance/manifests/typescript.json
if-no-files-found: error
# Re-running a job creates a new ATTEMPT within the same run, and v4+
# artifacts are immutable per run — so without this the second attempt
# fails on a name conflict with the first attempt's manifest, before
# the fan-in gate can run. Overwriting is also what we want on the
# merits: the collecting gate must read THIS attempt's exclusion set,
# never a previous one.
overwrite: true
# The freshness guard's own live run only ever sees a fresh tree. Its
# first cut passed any existing dist/, its second passed a build whose
# source had been deleted — so the rejections need synthetic trees.
- name: SDK build-freshness guard self-test
working-directory: .
run: make test-assert-sdk-built
# The lockfile tripwire in `make check` only ever sees lockfiles that did
# not move. This shows it reacts when one does — including writes made via
# `eval` or a command held in a variable, which no text parser resolves.
- name: Lockfile tripwire self-test
working-directory: .
run: make test-assert-lockfiles-unchanged
# Ground truth, and the authoritative check: the static parser predicts
# that nothing writes a lockfile, this observes whether anything did. A CI
# checkout starts pristine, so the committed tree is the baseline. Catches
# a manifest that was modified AND one CREATED where none was tracked, and
# runs on failure too, so a job cannot fail and hide what it dirtied.
- name: Dependency manifests unchanged by this job
if: always()
working-directory: .
run: ./scripts/assert-lockfiles-unchanged --verify-clean
test-ruby:
name: Ruby ${{ matrix.ruby }}
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
matrix:
ruby: ['3.2', '3.3', '3.4', '4.0', 'head']
continue-on-error: ${{ matrix.ruby == 'head' }}
defaults:
run:
working-directory: ruby
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Ruby
if: matrix.ruby != 'head'
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
working-directory: ruby
- name: Set up Ruby head
if: matrix.ruby == 'head'
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
with:
ruby-version: ${{ matrix.ruby }}
bundler: default
bundler-cache: true
working-directory: ruby
- name: Lint
if: matrix.ruby == '3.3'
run: bundle exec rubocop
# Regenerate-and-diff freshness gate. Runs on the canonical matrix entry
# only (like Lint / conformance); the generated output is Ruby-version
# independent, so one entry is sufficient.
- name: Check generated code drift
if: matrix.ruby == '3.3'
run: ../scripts/check-ruby-service-drift.sh
- name: Test
if: matrix.ruby != '3.3'
run: bundle exec rake test
# File reads are pinned to encoding: "UTF-8"; this leg proves it stays
# that way by running the suite under the C locale (#702).
- name: Test (LC_ALL=C)
if: matrix.ruby == '3.3'
env:
LC_ALL: C
run: bundle exec rake test
- name: Run Ruby conformance tests
if: matrix.ruby == '3.3'
working-directory: conformance/runner/ruby
# Frozen: the runner Gemfile.lock is tracked (#670), so the install
# must fail fast rather than rewrite it.
env:
BUNDLE_FROZEN: 'true'
run: |
bundle install
ruby runner.rb
- name: Upload ruby execution manifest
# Same matrix condition as the conformance step above: that step runs on
# one version only, so an unconditional upload fires on every other one
# with nothing to upload and `if-no-files-found: error`.
if: matrix.ruby == '3.3'
# One of the six exclusion sets the cross-runner gate compares (#602).
# Each runner's own census cannot see a case that EVERY runner excludes,
# because each one counted its own skip; the fan-in job below requires
# all six manifests and fails if any is absent.
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: execution-manifest-ruby
path: conformance/manifests/ruby.json
if-no-files-found: error
# Re-running a job creates a new ATTEMPT within the same run, and v4+
# artifacts are immutable per run — so without this the second attempt
# fails on a name conflict with the first attempt's manifest, before
# the fan-in gate can run. Overwriting is also what we want on the
# merits: the collecting gate must read THIS attempt's exclusion set,
# never a previous one.
overwrite: true
- name: Run Ruby conformance runner unit tests
# Unit-tests the runner's own assertion helpers. Their bounds
# branches never execute against a fixture that passes, so a
# vacuous assertion (#563) survives a fully green conformance run.
# Through the make target so discovery has one definition (#572).
if: matrix.ruby == '3.3'
working-directory: .
run: make conformance-runner-tests-ruby
# Ground truth, and the authoritative check: the static parser predicts
# that nothing writes a lockfile, this observes whether anything did. A CI
# checkout starts pristine, so the committed tree is the baseline. Catches
# a manifest that was modified AND one CREATED where none was tracked, and
# runs on failure too, so a job cannot fail and hide what it dirtied.
- name: Dependency manifests unchanged by this job
if: always()
working-directory: .
run: ./scripts/assert-lockfiles-unchanged --verify-clean
test-python:
name: Python ${{ matrix.python }}
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
matrix:
python: ['3.11', '3.12', '3.13', '3.14']
continue-on-error: ${{ matrix.python == '3.14' }}
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- name: Set up Python
run: uv python install ${{ matrix.python }}
- name: Install dependencies
run: uv sync --dev --python ${{ matrix.python }}
- name: Lint
if: matrix.python == '3.13'
run: |
uv run ruff check src/ tests/
uv run ruff format --check src/ tests/
- name: Type check
if: matrix.python == '3.13'
run: uv run mypy src/basecamp/ --ignore-missing-imports
# Runs the same script `make py-check-drift` runs, matching the other five
# SDKs' drift steps. It regenerates into a tmpdir and diffs, rather than
# regenerating in place and reading `git status` — so the tree it judges
# is never the tree it mutates, and it detects extra committed artifacts
# as well as missing ones. Canonical matrix entry only; the generated
# output is Python-version independent.
- name: Check generated code drift
if: matrix.python == '3.13'
run: ../scripts/check-python-service-drift.sh
- name: Test with coverage
run: uv run pytest --cov --cov-report=term-missing --cov-fail-under=60
- name: Validate conformance fixtures against schema
# Authoritative enforcement of conformance/schema.json (incl. the
# mockResponses oneOf) and the event-feed families' schemas — the
# aggregate `make conformance` target is not run in PR CI, so the
# fixture gates are invoked here directly. uv is already installed, so
# these targets run check-jsonschema via uvx. Runs from repo root
# regardless of this job's working-directory: python.
if: matrix.python == '3.13'
working-directory: .
run: |
make conformance-fixtures-check
make event-feed-fixtures-check
make event-feed-digest-fixtures-check
- name: Run Python conformance tests
if: matrix.python == '3.13'
working-directory: conformance/runner/python
# --locked: the runner uv.lock is tracked (#670), so a missing or
# out-of-date lockfile must fail the install, not regenerate it.
run: |
uv sync --locked --python ${{ matrix.python }}
uv run --python ${{ matrix.python }} python runner.py
- name: Upload python execution manifest
# Same matrix condition as the conformance step above: that step runs on
# one version only, so an unconditional upload fires on every other one
# with nothing to upload and `if-no-files-found: error`.
if: matrix.python == '3.13'
# One of the six exclusion sets the cross-runner gate compares (#602).
# Each runner's own census cannot see a case that EVERY runner excludes,
# because each one counted its own skip; the fan-in job below requires
# all six manifests and fails if any is absent.
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: execution-manifest-python
path: conformance/manifests/python.json
if-no-files-found: error
# Re-running a job creates a new ATTEMPT within the same run, and v4+
# artifacts are immutable per run — so without this the second attempt
# fails on a name conflict with the first attempt's manifest, before
# the fan-in gate can run. Overwriting is also what we want on the
# merits: the collecting gate must read THIS attempt's exclusion set,
# never a previous one.
overwrite: true
- name: Run Python conformance runner unit tests
# Unit-tests the runner's own assertion helpers. Their bounds
# branches never execute against a fixture that passes, so a
# vacuous assertion (#563) survives a fully green conformance run.
# Through the make target so discovery has one definition (#572); the
# interpreter pin moves to UV_PYTHON, uv's env spelling of --python,
# so the target itself stays version-agnostic.
if: matrix.python == '3.13'
working-directory: .
env:
UV_PYTHON: ${{ matrix.python }}
run: make conformance-runner-tests-python
# Ground truth, and the authoritative check: the static parser predicts
# that nothing writes a lockfile, this observes whether anything did. A CI
# checkout starts pristine, so the committed tree is the baseline. Catches
# a manifest that was modified AND one CREATED where none was tracked, and
# runs on failure too, so a job cannot fail and hide what it dirtied.
- name: Dependency manifests unchanged by this job
if: always()
working-directory: .
run: ./scripts/assert-lockfiles-unchanged --verify-clean
test-swift:
name: Swift Tests
runs-on: macos-15
permissions:
contents: read
defaults:
run:
working-directory: swift
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Build
run: swift build
- name: Test
run: swift test
- name: Check generated code drift
# Generation needs only the swift toolchain, so the freshness gate keys
# off `make swift-check-drift` (HAS_SWIFT-gated), the single source of
# truth. This job defaults to `working-directory: swift`; the make
# target must run from repo root.
working-directory: .
run: make swift-check-drift
- name: Run Swift conformance runner unit tests
# Unit-tests the runner's own assertion helpers. Their bounds
# branches never execute against a fixture that passes, so a
# vacuous assertion (#563) survives a fully green conformance run.
working-directory: .
run: make conformance-runner-tests-swift
- name: Run Swift conformance tests
# Same single-source-of-truth pattern as the drift gate: the make
# target carries the platform gate (IS_MACOS), and this macos runner
# satisfies it. The target must run from repo root.
working-directory: .
run: make conformance-swift
- name: Upload swift execution manifest
# One of the six exclusion sets the cross-runner gate compares (#602).
# Each runner's own census cannot see a case that EVERY runner excludes,
# because each one counted its own skip; the fan-in job below requires
# all six manifests and fails if any is absent.
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: execution-manifest-swift
path: conformance/manifests/swift.json
if-no-files-found: error
# Re-running a job creates a new ATTEMPT within the same run, and v4+
# artifacts are immutable per run — so without this the second attempt
# fails on a name conflict with the first attempt's manifest, before
# the fan-in gate can run. Overwriting is also what we want on the
# merits: the collecting gate must read THIS attempt's exclusion set,
# never a previous one.
overwrite: true
# Ground truth, and the authoritative check: the static parser predicts
# that nothing writes a lockfile, this observes whether anything did. A CI
# checkout starts pristine, so the committed tree is the baseline. Catches
# a manifest that was modified AND one CREATED where none was tracked, and
# runs on failure too, so a job cannot fail and hide what it dirtied.
- name: Dependency manifests unchanged by this job
if: always()
working-directory: .
run: ./scripts/assert-lockfiles-unchanged --verify-clean
test-kotlin:
name: Kotlin Tests
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: kotlin
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-provider: basic
- name: Build
run: ./gradlew :basecamp-sdk:build
- name: Test
run: ./gradlew :basecamp-sdk:check
- name: Test generator
# Covers the emitters themselves, not their output: the append-only
# constructor-order rule for options classes and the frozen roster of
# PaginationOptions compatibility overloads.
run: ./gradlew :generator:test
- name: Check generated code drift
# Regenerate-and-diff freshness gate via the root Make target, which
# runs the shared non-mutating scripts/check-kotlin-generated-drift.sh
# (regenerates into a temp dir; never touches the working tree). Runs
# from repo root regardless of this job's `working-directory: kotlin`.
working-directory: .
run: make kt-check-generated-drift
- name: Check optional array/scalar invariant
run: ruby ../scripts/check-kotlin-optional-arrays-and-scalars.rb
- name: Run Kotlin conformance runner unit tests
# Unit-tests the runner's own assertion helpers. Their bounds
# branches never execute against a fixture that passes, so a
# vacuous assertion (#563) survives a fully green conformance run.
# Through the make target so discovery has one definition (#572).
working-directory: .
run: make conformance-runner-tests-kotlin
- name: Run Kotlin conformance tests
run: ./gradlew :conformance:run
- name: Upload kotlin execution manifest
# One of the six exclusion sets the cross-runner gate compares (#602).
# Each runner's own census cannot see a case that EVERY runner excludes,
# because each one counted its own skip; the fan-in job below requires
# all six manifests and fails if any is absent.
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: execution-manifest-kotlin
path: conformance/manifests/kotlin.json
if-no-files-found: error
# Re-running a job creates a new ATTEMPT within the same run, and v4+
# artifacts are immutable per run — so without this the second attempt
# fails on a name conflict with the first attempt's manifest, before
# the fan-in gate can run. Overwriting is also what we want on the
# merits: the collecting gate must read THIS attempt's exclusion set,
# never a previous one.
overwrite: true
# Ground truth, and the authoritative check: the static parser predicts
# that nothing writes a lockfile, this observes whether anything did. A CI
# checkout starts pristine, so the committed tree is the baseline. Catches
# a manifest that was modified AND one CREATED where none was tracked, and
# runs on failure too, so a job cannot fail and hide what it dirtied.
- name: Dependency manifests unchanged by this job
if: always()
working-directory: .
run: ./scripts/assert-lockfiles-unchanged --verify-clean
conformance:
# Fan-in gate: each language's conformance suite now runs inside its own
# test job (warm toolchain, built SDK), which removes the serial
# five-toolchain job that used to start only after every test job
# finished. This job keeps the required-check name "Conformance Tests"
# stable for the main-gate ruleset and fails if any language job —
# which includes its conformance step — did not succeed.
name: Conformance Tests
runs-on: ubuntu-latest
needs: [test-go, test-typescript, test-ruby, test-kotlin, test-python, test-swift, spec-gates]
if: always()
permissions:
contents: read
steps:
- name: Verify per-language conformance and spec-gate results
env:
RESULTS: ${{ toJSON(needs) }}
run: |
echo "$RESULTS" | jq .
NOT_GREEN=$(echo "$RESULTS" | jq -r 'to_entries[] | select(.value.result != "success") | "\(.key): \(.value.result)"')
if [ -n "$NOT_GREEN" ]; then
echo "::error::Required upstream jobs did not succeed (language test jobs run their own conformance suites; spec-gates runs the repo-wide gates):"
echo "$NOT_GREEN"
exit 1
fi
echo "All per-language conformance suites and spec gates green"
- name: Check out the gate
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
with:
ruby-version: '3.4'
- name: Collect execution manifests from all six runners
# merge-multiple flattens the six single-file artifacts back into one
# directory. Not `continue-on-error`: a download that silently produced
# nothing would leave the gate reading an empty directory, and it fails
# closed on that — but failing here names the real cause.
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: execution-manifest-*
merge-multiple: true
path: conformance/manifests/
- name: Fixture-execution gate self-test (crafts the all-six state)
# The live check below runs against committed inputs that are built to
# PASS — maximum overlap is 2 of 6 — so on its own it proves only that
# the gate can say yes. Without this step a regression that made the
# gate accept a missing manifest, or an all-six exclusion, would leave
# CI green: the self-test is otherwise reachable only through the Make
# target, which this workflow deliberately bypasses (it must not re-run
# the runners). Same reason the other gates' self-tests run in
# spec-gates.
run: ruby scripts/test-check-fixture-execution.rb
- name: No fixture case is executed by nothing (all six runners)
# THE #602 check, and the only place it can run. Each language job sees
# one runner's exclusion set; a case every runner excludes leaves all
# six censuses green because each counted its own skip. This job is the
# one place all six sets exist together — which is exactly why it runs
# in FULL mode, requiring six manifests and failing on absence. A
# missing manifest is never "that runner executed everything".
#
# ORDERED AFTER the results check above, deliberately. This job is
# `if: always()`, so it also runs when a language job failed — and a job
# that died before its upload step contributes no manifest. Running the
# gate first would then report "missing manifest: ruby" for what is
# really "the Ruby job failed", burying the actual cause behind a
# second, derived failure. With the results check first, the run stops
# there and says so; by the time this step runs, all six jobs succeeded,
# so all six manifests must exist and an absent one is a real defect.
run: ruby scripts/check-fixture-execution.rb
lint:
name: Lint
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: 'go/go.mod'
cache-dependency-path: |
go/go.sum
conformance/runner/go/go.sum
- name: golangci-lint
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
with:
working-directory: go
version: latest
args: --config .golangci.yml
# Ground truth, and the authoritative check: the static parser predicts
# that nothing writes a lockfile, this observes whether anything did. A CI
# checkout starts pristine, so the committed tree is the baseline. Catches
# a manifest that was modified AND one CREATED where none was tracked, and
# runs on failure too, so a job cannot fail and hide what it dirtied.
- name: Dependency manifests unchanged by this job
if: always()
working-directory: .
run: ./scripts/assert-lockfiles-unchanged --verify-clean
race:
name: Race Detection
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: go
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: 'go/go.mod'
cache-dependency-path: |
go/go.sum
conformance/runner/go/go.sum
- name: Test with race detector
run: go test -race -v ./...
# Ground truth, and the authoritative check: the static parser predicts
# that nothing writes a lockfile, this observes whether anything did. A CI
# checkout starts pristine, so the committed tree is the baseline. Catches
# a manifest that was modified AND one CREATED where none was tracked, and
# runs on failure too, so a job cannot fail and hide what it dirtied.
- name: Dependency manifests unchanged by this job
if: always()
working-directory: .
run: ./scripts/assert-lockfiles-unchanged --verify-clean
api-compat:
name: API Compatibility
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
permissions:
contents: read
# Reads this PR's labels back from the API when the gate is failing, to
# see the `breaking` acknowledgement as it stands now rather than as the
# event payload froze it. See breaking_acknowledged() below.
pull-requests: read
defaults:
run:
working-directory: go
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: 'go/go.mod'
cache-dependency-path: |
go/go.sum
conformance/runner/go/go.sum
- name: Cache Go tools
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/go/bin
key: go-tools-${{ runner.os }}-go${{ hashFiles('go/go.mod') }}-apidiff-v0.9.0
- name: Install apidiff
run: |
command -v apidiff || go install golang.org/x/exp/cmd/apidiff@v0.0.0-20241217172543-b2144cdd0a67
- name: Check for breaking API changes
env:
# The `breaking` acknowledgement is not passed in here as a
# pre-computed boolean; it is read from the API at the point of use,
# for the reasons on breaking_acknowledged() below. These two are the
# ingredients for that read, and PR_NUMBER is empty on any event that
# has no pull request.
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
# An export that cannot run must abort rather than leave a missing
# .api file for the comparison to read as "nothing changed" — the
# whole gate would then pass for the wrong reason.
set -eu
WORK="${RUNNER_TEMP}/apidiff"
mkdir -p "$WORK"
# Both sides of the comparison have to be taken from one fixed point.
# The PR side is whatever `actions/checkout` left here, which for a
# `pull_request` event is `refs/pull/N/merge`: this PR merged onto the
# base. The base side used to be `origin/${GITHUB_BASE_REF}`, a moving
# ref that `fetch-depth: 0` refreshes at job start, so the two sides
# could straddle a base that had advanced in between — and then a
# package the BASE gained is reported as one this PR REMOVED, failing
# a PR that changed nothing. `main` takes several commits on an
# ordinary day. Reading the `breaking` label live (below) widened the
# window further, by making "label it and re-run" a normal flow that
# can happen long after the merge commit was cut.
#
# The merge commit's own first parent is that fixed point: it is the
# base commit this tree was merged onto. It cannot drift, and it needs
# no second ref to look up.
ORIG_SHA="$(git rev-parse HEAD)"
if ! git rev-parse --verify --quiet "${ORIG_SHA}^2" > /dev/null; then
echo "::error::HEAD (${ORIG_SHA}) is not a merge commit, so its first parent is not this PR's base. The baseline here is derived from the checked-out refs/pull/N/merge commit; if the checkout step above is ever changed to fetch the branch head instead, this gate would silently narrow to 'did the last commit break the API'."
exit 1
fi
BASE_SHA="$(git rev-parse --verify "${ORIG_SHA}^1")"
echo "Comparing ${ORIG_SHA} (this PR merged onto its base) against ${BASE_SHA} (that base)."
# The base revision is exported from its own worktree rather than by
# checking it out over this one. Checking out meant deleting the files
# the PR added, deleting the directories that left empty, and checking
# the original tree back out — three steps whose only job was to undo
# the first, each able to leave `go/` dirty and fail the manifest
# guard that runs after this step. A worktree under RUNNER_TEMP is
# outside the repository and touches neither. It is not removed
# afterwards: the runner is ephemeral, and a removal on the success
# path alone would be skipped by every path that exits early.
BASE_TREE="${WORK}/base"
git worktree add --detach "$BASE_TREE" "$BASE_SHA"
# apidiff's module mode answers the whole scope question. `-m`
# compares every package in the module, classifies an added package as
# compatible and a removed one as incompatible, and drops `internal/`
# before comparing. This step used to hand-roll all of that around
# per-package exports: a `go list ./...` enumeration, an internal
# filter, underscore-slugged filenames (two import paths can slug to
# the same name), a removed-package check the per-package comparison
# structurally cannot make, and two comparison loops. Three review
# rounds found three defects and every one of them was in that
# machinery — none in the policy around it. Module mode is in the
# pinned build, so none of it needs owning here.
#
# GOWORK=off, for two reasons. The repository root carries a `go.work`
# covering three modules, so a bare `go list -m` prints all three and
# `apidiff -m` would be handed a "module path" three lines long — it
# fails, but only after the workspace has been read, and the failure
# names all three. And the module a consumer resolves is this one
# alone, from its own go.mod and go.sum, not one wired to sibling
# directories that are never published; measuring the published shape
# is the whole point of the gate. Both spellings export byte-identical
# data today, since nothing here depends on a workspace sibling.
export GOWORK=off
MODULE_PATH="$(go list -m)"
BASE_MODULE_PATH="$(cd "$BASE_TREE/go" && go list -m)"
apidiff -m -w "${WORK}/pr.api" "$MODULE_PATH"
( cd "$BASE_TREE/go" && apidiff -m -w "${WORK}/base.api" "$BASE_MODULE_PATH" )
# apidiff has three outcomes and only two of them are verdicts about
# the API: a clean comparison exits 0 and writes nothing to stdout, a
# comparison that found differences exits 0 and writes them to stdout,
# and a comparison that could not run exits non-zero with its reason
# on stderr. Both call sites used to be written `apidiff ... && [ -s
# report ]`, which folds the third outcome into the first — `set -e`
# does not fire for a command used as an `if` condition, so a failed
# comparison simply skipped the branch, left `breaking` at 0, and the
# gate printed "No breaking changes detected" and passed.
#
# The two streams are kept apart rather than merged with `2>&1`.
# Module mode writes one benign line per internal package to stderr
# ("Ignoring internal package ...") on every comparison; merging that
# into the report would announce it as a finding, which is how the
# previous shape turned excluding `internal/` from a nicety into a
# requirement. On exit 0 stdout is the entire verdict; on non-zero
# stderr is the entire reason, and this prints it — a reason captured
# to a file nothing ever prints is how the fail-open survived review.
run_apidiff() {
local out="$1"; shift
local status=0
apidiff "$@" > "$out" 2> "${out}.err" || status=$?
if [ "$status" -ne 0 ]; then
echo "::error::apidiff exited ${status} comparing ${*} — the comparison could not be run. This is a tool failure, not a verdict on the API, so it is not something the \`breaking\` label should be used to wave through."
echo '```'
cat "${out}.err"
echo '```'
exit 1
fi
}
echo "## API Compatibility Check"
echo ""
breaking=0
# `apidiff -m` keys packages by their path relative to the module, so
# a module whose path changed compares as though nothing moved and
# reports nothing at all. That is the one break module mode cannot
# see, and it is the break that invalidates every import at once.
if [ "$MODULE_PATH" != "$BASE_MODULE_PATH" ]; then
echo "### Module path changed: \`${BASE_MODULE_PATH}\` → \`${MODULE_PATH}\`"
echo ""
echo "Every existing import of the old path stops resolving."
echo ""
breaking=1
fi
run_apidiff "${WORK}/breaking.txt" -m -incompatible "${WORK}/base.api" "${WORK}/pr.api"
if [ -s "${WORK}/breaking.txt" ]; then
echo "### Breaking Changes Detected"
echo ""
echo "The following incompatible API changes were found:"
echo ""
echo '```'
cat "${WORK}/breaking.txt"
echo '```'
echo ""
breaking=1
fi
if [ "$breaking" -eq 0 ]; then
echo "No breaking changes detected."
fi
# Unqualified, and true: a module comparison covers the package set as
# well as each package's surface, so an added or removed package is a
# change this report has already named. The per-package shape could
# not say that — it walked one package list and skipped anything the
# other revision lacked, so "no API changes" could contradict a
# package removal reported four lines above it.
echo ""
echo "### All API Changes"
echo ""
run_apidiff "${WORK}/changes.txt" -m "${WORK}/base.api" "${WORK}/pr.api"
if [ -s "${WORK}/changes.txt" ]; then
echo '```'
cat "${WORK}/changes.txt"
echo '```'
else
echo "No API changes detected."
fi
# An intentional break is declared by labelling the PR `breaking`
# ("Breaking change to public API"). The gate still runs and still
# prints everything it found — the label suppresses the failure, not
# the report, so the break stays visible in the log and in the label
# itself rather than being argued in a thread nobody reads later.
# Without a hatch this gate could not block at all: the SDK is pre-1.0
# and removes surface on purpose (#504 withdrew GetEverythingBoosts),
# so a hard fail with no override would just be switched off the first
# time it was right.
#
# The label is read from the API here rather than out of the event
# payload, because the payload is a snapshot of the event that started
# the run and this workflow's `pull_request:` trigger does not fire on
# `labeled`. A payload read can therefore only ever see labels that
# predate the run: applying `breaking` after a failure starts no new
# run, and re-running replays the same payload, so the very remedy
# printed below — label it, re-run — was the one thing that could not
# work. Reading it live is what makes that instruction true.
#
# The reverse case is left open on purpose: removing `breaking` after
# a green run leaves that run green, because nothing re-runs to notice.
# Closing it needs a trigger that fires on label events, and both
# shapes cost more than the hole. Adding `labeled`/`unlabeled` here
# re-runs all 23 jobs in this workflow on every label event on every
# PR — and `labeler.yml` runs `actions/labeler` with `sync-labels:
# true` on every push, so label events are emitted automatically
# whenever a push moves the changed-path set across one of its 9
# globs. That is amplification on the ordinary path, not an occasional
# extra run. Splitting this job into its own workflow file would make
# the extra run one job rather than 23, but it renames the required
# check, so it cannot land without an admin changing branch protection
# in the same breath — and between the two, every PR blocks on a check
# that no longer reports.
#
# What is left open is narrow. `breaking` is not in
# `.github/labeler.yml`, so `sync-labels: true` cannot remove it: only
# a person can, deliberately. Doing so buys them nothing they did not
# already have, because the label acknowledges a break rather than
# approving a merge — anyone who can remove it can equally leave it on
# and merge. And it is not free elsewhere: `.github/release.yml` files
# `breaking` PRs under "Breaking Changes", so removing the label drops
# the break from the release notes, which is the consequence this gate
# could not have prevented in any case. The acknowledged-pass message
# below says out loud that the run is only as good as the label at the
# moment it read it.
#
# Revisit if `breaking` ever becomes automated, or if a merge queue
# lands: a queue re-runs required checks against the merge result,
# which closes this for free.
breaking_acknowledged() {
# Events with no pull request (a push to main, a manual dispatch)
# have nothing to carry a label, so say that rather than letting an
# empty label set pass for a considered "not acknowledged". The
# job's `if:` already keeps this step off those events; stating it
# here means the script does not silently depend on a condition
# written forty lines away and outside its own text.
if [ -z "${PR_NUMBER}" ]; then
echo "No pull request in scope, so there is no \`breaking\` label to honour."
return 1
fi
# Consulted only once the gate is already failing, which is what
# keeps a GitHub API outage away from every PR that has no break to
# acknowledge. On the ones that do, a failed read fails the step
# and says which half broke: staying quiet would wave real breaks
# through during an outage, and reporting a plain "unacknowledged"
# would send you off to re-apply a label that is already there.
if ! labels="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.labels[].name')"; then
echo "::error::Could not read this PR's labels, so the \`breaking\` acknowledgement could not be checked. This is a failed API read, not a verdict on the label — re-run this job."
exit 1
fi
printf '%s\n' "$labels" | grep -Fxq 'breaking'
}
if [ "$breaking" -eq 1 ]; then
echo ""
if breaking_acknowledged; then
echo "Incompatible changes are present and the PR carries the \`breaking\` label — not failing."
echo "Document them in the changelog. This run passed on the label as it stood when this step read it; removing the label later does not re-run this gate, so re-run it if the acknowledgement changes."
else
echo "::error::Incompatible public API changes. If they are intentional, label this PR \`breaking\` and re-run this job, and document them in the changelog."
exit 1
fi
fi
# Ground truth, and the authoritative check: the static parser predicts
# that nothing writes a lockfile, this observes whether anything did. A CI
# checkout starts pristine, so the committed tree is the baseline. Catches
# a manifest that was modified AND one CREATED where none was tracked, and
# runs on failure too, so a job cannot fail and hide what it dirtied.
- name: Dependency manifests unchanged by this job
if: always()
working-directory: .
run: ./scripts/assert-lockfiles-unchanged --verify-clean