SPEC §9: peer-derived text in observer-facing errors (boundary unresolved) #3329
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |