-
Notifications
You must be signed in to change notification settings - Fork 12
Expand file tree
/
Copy pathMakefile
More file actions
2012 lines (1800 loc) · 107 KB
/
Copy pathMakefile
File metadata and controls
2012 lines (1800 loc) · 107 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Basecamp SDK Makefile
#
# Orchestrates both Smithy spec and Go SDK
.PHONY: all check clean help setup tools provenance-sync provenance-check sync-status bump sync-spec-version sync-spec-version-check sync-api-version sync-api-version-check doc-constants-check release
# Default: run all checks
all: check
#------------------------------------------------------------------------------
# Smithy targets
#------------------------------------------------------------------------------
.PHONY: smithy-validate smithy-build smithy-check smithy-clean smithy-mapper smithy-mapper-test behavior-model behavior-model-check
# Validate Smithy spec
smithy-validate: smithy-mapper
@echo "==> Validating Smithy spec..."
cd spec && smithy validate
# Build the custom Smithy OpenAPI mapper
smithy-mapper:
@echo "==> Building Smithy OpenAPI mapper..."
cd spec/smithy-bare-arrays && ./gradlew publishToMavenLocal --quiet
# Unit-test the custom Smithy OpenAPI mappers.
#
# Wired into `make check` on purpose. The mappers own the difference between
# what Smithy's protocol forces (wrapped outputs) and what BC3 actually sends
# (bare bodies), and their tests were unrunnable for long enough that three of
# them had drifted out of agreement with the code: `./gradlew test` here could
# not even resolve JUnit, so nothing reported it.
#
# ORDER-ONLY EDGE: `| smithy-mapper` (#674).
#
# This is the anchor comment for the three `| ` edges added for that issue. The
# other two — on kt-test and conformance-kotlin — say "see smithy-mapper-test".
#
# Two Gradle invocations in one project directory are not a supported
# configuration: they share <project>/.gradle (the file-hash and task-history
# caches, i.e. the FilePageCache) and they write the same task output
# directories. Gradle's cross-process locks serialize the caches; nothing
# serializes the outputs. `check-targets` lists five Gradle-backed targets as
# INDEPENDENT prerequisites, so `make -j` is free to schedule any two of them at
# once — including this one against smithy-mapper, a second same-directory pair
# beyond the Kotlin trio #674 describes.
#
# Order-only rather than a normal prerequisite because it is a mutex, not a
# dependency: `./gradlew test` already builds main, so nothing here needs the
# published jar. Order-only over a per-target GRADLE_USER_HOME because
# GRADLE_USER_HOME isolates ~/.gradle and leaves <project>/.gradle and
# <project>/build — where the collision actually is — shared; it would also cost
# a second cold cache, and this repo already runs concurrent Gradle across ~30
# worktrees against one ~/.gradle without trouble.
#
# The cost of an order-only edge on a .PHONY target is that it RUNS: `make
# smithy-mapper-test` now publishes the jar first (~3s, and up-to-date on the
# second run). Direction chosen accordingly — see the Kotlin edges, where it
# decides which target absorbs the collateral.
#
# scripts/check-gradle-serialization.rb enforces that these edges exist and that
# every Gradle target reachable from check-targets is totally ordered.
smithy-mapper-test: | smithy-mapper
@echo "==> Testing Smithy OpenAPI mappers..."
cd spec/smithy-bare-arrays && ./gradlew test --quiet
# Build OpenAPI from Smithy (also regenerates behavior model + syncs API version)
smithy-build: behavior-model smithy-mapper
@$(MAKE) sync-spec-version
@echo "==> Building OpenAPI from Smithy..."
cd spec && smithy build
cp spec/build/smithy/openapi/openapi/Basecamp.openapi.json openapi.json
@echo "==> Post-processing OpenAPI for Go types..."
./scripts/enhance-openapi-go-types.sh
@echo "Updated openapi.json"
@$(MAKE) sync-api-version
# Check that openapi.json is up to date
smithy-check: smithy-validate smithy-mapper
@$(MAKE) sync-spec-version-check
@echo "==> Checking OpenAPI freshness..."
@cd spec && smithy build
@TMPFILE=$$(mktemp) && \
cp spec/build/smithy/openapi/openapi/Basecamp.openapi.json "$$TMPFILE" && \
./scripts/enhance-openapi-go-types.sh "$$TMPFILE" "$$TMPFILE" > /dev/null 2>&1 && \
(diff -q openapi.json "$$TMPFILE" > /dev/null 2>&1 || \
(rm -f "$$TMPFILE" && echo "ERROR: openapi.json is out of date. Run 'make smithy-build'" && exit 1)) && \
rm -f "$$TMPFILE"
@echo "openapi.json is up to date"
# Clean Smithy build artifacts
smithy-clean:
rm -rf spec/build spec/smithy-bare-arrays/build spec/smithy-bare-arrays/.gradle
# Generate behavior model from Smithy spec
behavior-model: smithy-mapper
@echo "==> Generating behavior model..."
@cd spec && smithy build
./scripts/generate-behavior-model
@echo "Updated behavior-model.json"
# Check that behavior-model.json is up to date
behavior-model-check:
@echo "==> Checking behavior model freshness..."
@./scripts/generate-behavior-model spec/build/smithy/source/model/model.json behavior-model.json.tmp
@diff -q behavior-model.json behavior-model.json.tmp > /dev/null 2>&1 || \
(rm -f behavior-model.json.tmp && echo "ERROR: behavior-model.json is out of date. Run 'make behavior-model'" && exit 1)
@rm -f behavior-model.json.tmp
@echo "behavior-model.json is up to date"
.PHONY: url-routes url-routes-check
# Generate url-routes.json from OpenAPI spec
url-routes:
@echo "==> Generating URL routes..."
./scripts/generate-url-routes
@echo "Updated go/pkg/basecamp/url-routes.json"
# Check that url-routes.json is up to date
url-routes-check:
@echo "==> Checking URL routes freshness..."
@./scripts/generate-url-routes openapi.json go/pkg/basecamp/url-routes.json.tmp
@diff -q go/pkg/basecamp/url-routes.json go/pkg/basecamp/url-routes.json.tmp > /dev/null 2>&1 || \
(rm -f go/pkg/basecamp/url-routes.json.tmp && echo "ERROR: url-routes.json is out of date. Run 'make url-routes'" && exit 1)
@rm -f go/pkg/basecamp/url-routes.json.tmp
@echo "url-routes.json is up to date"
.PHONY: catalog catalog-check
# Generate go/pkg/basecamp/catalog/catalog.json — the distilled, embedded tool
# Catalog. Joins openapi.json (identity, wire shape, body schemas) with
# behavior-model.json (per-operation traits) into one self-contained artifact
# (request-body $refs inlined) that downstream catalog consumers read straight
# from the pinned SDK dependency instead of vendoring and re-joining the two
# raw model files. `go run` uses the go.work module scripts/gen-catalog; the
# module is stdlib-only, so it needs no network. See that command's package
# doc for the join contract and the destructive-trait gap.
catalog:
@echo "==> Generating tool catalog..."
@go run ./scripts/gen-catalog/
@echo "Updated go/pkg/basecamp/catalog/catalog.json"
# Check that catalog.json is up to date. Byte-for-byte, matching the
# url-routes/behavior-model gates: the embedded catalog is a pure function of
# the two model files, so any drift means someone changed a model without
# regenerating. First runs the generator's own unit tests, then regenerates to
# a temp file and diffs.
catalog-check:
@echo "==> Running catalog generator tests..."
@go test ./scripts/gen-catalog/
@echo "==> Checking tool catalog freshness..."
@go run ./scripts/gen-catalog/ openapi.json behavior-model.json go/pkg/basecamp/catalog/catalog.json.tmp
@diff -q go/pkg/basecamp/catalog/catalog.json go/pkg/basecamp/catalog/catalog.json.tmp > /dev/null 2>&1 || \
(rm -f go/pkg/basecamp/catalog/catalog.json.tmp && echo "ERROR: catalog.json is out of date. Run 'make catalog'" && exit 1)
@rm -f go/pkg/basecamp/catalog/catalog.json.tmp
@echo "catalog.json is up to date"
.PHONY: bc3-routes bc3-route-parity test-bc3-route-parity bc3-routes-check check-known-defect-issues-open test-check-known-defect-issues-open
# Regenerate spec/bc3-routes.json — the vendored table of routes bc3 actually
# serves, extracted from its API docs at the pinned provenance revision.
# Needs a bc3 checkout; reads through `git show <pin>:` so the output is a pure
# function of the pin, not of whatever bc3's working tree happens to hold.
bc3-routes:
@echo "==> Extracting bc3 routes at the pinned revision..."
@./scripts/generate-bc3-routes
@echo "Updated spec/bc3-routes.json"
# Compare the SDK's declared routes against bc3's, both directions. Offline:
# reads only the vendored table, so it runs in `make check` and in every CI job
# with no bc3 checkout and no secret. A gate that skips when its input is absent
# cannot prevent anything, and skipping is how two 404ing routes shipped.
bc3-route-parity:
@echo "==> Checking bc3 route parity..."
@./scripts/check-bc3-route-parity
# The allowlist discharges claims by pointing at an issue number, and the gate
# above only checks that the number IS a number. #588 auto-closed while nine
# live 404s still pointed at it and that gate stayed green throughout. This
# verifies the referenced issues are still OPEN.
#
# Deliberately NOT in `check-targets` and NOT folded into the gate above: issue
# state needs the network, and that gate's whole guarantee is that it has no
# skip path. This one runs as its own CI job and fails closed.
check-known-defect-issues-open:
@echo "==> Checking known-defect tracking issues are open..."
@./scripts/check-known-defect-issues-open
# Drive that gate from outside. Its live run verifies whatever the allowlist
# and registry currently reference, and a green run proves only that those
# issues are open — so without this NOTHING exercises the closed-issue
# rejection, the fail-closed paths, or the second reference shape. Offline: PATH
# is stripped to a stub `gh` answering from a canned table, because a self-test
# that asked GitHub would assert against whatever is true this morning.
#
# Offline, but deliberately NOT in `check-targets` either: it belongs with the
# gate it tests, and that gate is a CI job of its own.
test-check-known-defect-issues-open:
@ruby ./scripts/test-check-known-defect-issues-open
# Drive that gate from outside with adversarial allowlists. The live run only
# exercises the VALID file, so nothing proves `modeled_as` — the one disposition
# asserting something is DONE, and the same class of claim that shipped
# ListForwards and RepositionTodolistGroup as 404s — rejects anything. Each case
# substitutes a REAL operationId for a REAL route via BC3_ROUTE_ALLOWLIST; the
# route tables are never faked.
test-bc3-route-parity:
@ruby ./scripts/test-check-bc3-route-parity.rb
# Freshness gate for the vendored table: regenerate at the current pin and diff.
#
# Needs BC3_REPO_PATH, so it is NOT in `make check` and NOT in CI — no workflow
# checks out bc3 today, and inventing a secret for one is a provisioning
# decision, not a code change. Run it by hand when you repin. Tracked in #589.
#
# What still holds without it: bc3-route-parity verifies the table's recorded
# revision equals the provenance pin, and verifies a SHA-256 fingerprint of this
# generator plus the normalizer, so a changed extractor with a stale table fails
# offline. What it cannot catch is a hand-edited `source.revision` that matches
# the pin without regeneration — that needs bc3, hence #589.
#
# Generates into a real temp dir rather than a sibling .tmp file: an in-tree temp
# path races under `make -j` and can miss extra files.
bc3-routes-check:
@echo "==> Checking bc3 route table freshness..."
@tmp=$$(mktemp -d) && trap 'rm -rf "$$tmp"' EXIT && \
./scripts/generate-bc3-routes "$$tmp/bc3-routes.json" && \
diff -q spec/bc3-routes.json "$$tmp/bc3-routes.json" > /dev/null 2>&1 || \
{ echo "ERROR: spec/bc3-routes.json is out of date for the current pin. Run 'make bc3-routes'"; exit 1; }
@echo "spec/bc3-routes.json is up to date"
#------------------------------------------------------------------------------
# API Provenance targets
#------------------------------------------------------------------------------
# Copy api-provenance.json into Go package for go:embed
provenance-sync:
@cp spec/api-provenance.json go/pkg/basecamp/api-provenance.json
# Check that the Go embedded provenance matches the canonical spec file
provenance-check:
@diff -q spec/api-provenance.json go/pkg/basecamp/api-provenance.json > /dev/null 2>&1 || \
(echo "ERROR: go/pkg/basecamp/api-provenance.json is out of date. Run 'make provenance-sync'" && exit 1)
@echo "api-provenance.json is up to date"
# Show upstream changes since last spec sync (queries GitHub via gh CLI).
BC3_REPO ?= basecamp/bc3
sync-status:
@command -v gh > /dev/null 2>&1 || { echo "ERROR: gh CLI not found. Install: https://cli.github.com"; exit 1; }
@command -v jq > /dev/null 2>&1 || { echo "ERROR: jq not found. Install: https://jqlang.github.io/jq/"; exit 1; }
@gh auth status > /dev/null 2>&1 || { echo "ERROR: gh not authenticated. Run: gh auth login"; exit 1; }
@BC3_REPO="$(BC3_REPO)" ./scripts/report-bc3-drift.sh \
"$$(jq -r '.bc3.revision // empty' spec/api-provenance.json)" \
"$$(jq -r '.bc3.branch // "master"' spec/api-provenance.json)" \
"primary"
@for COMPAT_KEY in $$(jq -r '.compatibility // {} | keys[]' spec/api-provenance.json); do \
echo ""; \
BC3_REPO="$(BC3_REPO)" ./scripts/report-bc3-drift.sh \
"$$(jq -r --arg k "$$COMPAT_KEY" '.compatibility[$$k].revision // empty' spec/api-provenance.json)" \
"$$(jq -r --arg k "$$COMPAT_KEY" '.compatibility[$$k].branch // "master"' spec/api-provenance.json)" \
"compat"; \
done
#------------------------------------------------------------------------------
# Version management
#------------------------------------------------------------------------------
# Bump SDK version across all languages: make bump VERSION=x.y.z
bump:
ifndef VERSION
$(error VERSION is required. Usage: make bump VERSION=x.y.z)
endif
@./scripts/bump-version.sh $(VERSION)
# Tag and push a global release: make release VERSION=x.y.z
release:
ifndef VERSION
$(error VERSION is required. Usage: make release VERSION=x.y.z)
endif
@echo "Releasing v$(VERSION)..."
@# Verify version constants match — every file scripts/bump-version.sh writes
@test "$$(jq -r '.version' package.json)" = "$(VERSION)" || \
{ echo "ERROR: Root package.json version does not match $(VERSION). Run 'make bump VERSION=$(VERSION)' first."; exit 1; }
@grep -qF 'Version = "$(VERSION)"' go/pkg/basecamp/version.go || \
{ echo "ERROR: Go version does not match $(VERSION). Run 'make bump VERSION=$(VERSION)' first."; exit 1; }
@test "$$(jq -r '.version' typescript/package.json)" = "$(VERSION)" || \
{ echo "ERROR: TypeScript version does not match $(VERSION). Run 'make bump VERSION=$(VERSION)' first."; exit 1; }
@grep -qxF 'export const VERSION = "$(VERSION)";' typescript/src/client.ts || \
{ echo "ERROR: TypeScript client VERSION constant does not match $(VERSION). Run 'make bump VERSION=$(VERSION)' first."; exit 1; }
@grep -qF 'VERSION = "$(VERSION)"' ruby/lib/basecamp/version.rb || \
{ echo "ERROR: Ruby version does not match $(VERSION). Run 'make bump VERSION=$(VERSION)' first."; exit 1; }
@grep -qF 'const val VERSION = "$(VERSION)"' kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampConfig.kt || \
{ echo "ERROR: Kotlin version does not match $(VERSION). Run 'make bump VERSION=$(VERSION)' first."; exit 1; }
@grep -qF 'version = "$(VERSION)"' kotlin/sdk/build.gradle.kts || \
{ echo "ERROR: Kotlin Gradle project version does not match $(VERSION). Run 'make bump VERSION=$(VERSION)' first."; exit 1; }
@grep -qF 'public static let version = "$(VERSION)"' swift/Sources/Basecamp/BasecampConfig.swift || \
{ echo "ERROR: Swift version does not match $(VERSION). Run 'make bump VERSION=$(VERSION)' first."; exit 1; }
@awk -v want='version = "$(VERSION)"' '/^\[/ { inproj = ($$0 == "[project]") } inproj && $$0 == want { found = 1 } END { exit !found }' python/pyproject.toml || \
{ echo "ERROR: Python pyproject.toml [project].version does not match $(VERSION). Run 'make bump VERSION=$(VERSION)' first."; exit 1; }
@grep -qF 'VERSION = "$(VERSION)"' python/src/basecamp/_version.py || \
{ echo "ERROR: Python version does not match $(VERSION). Run 'make bump VERSION=$(VERSION)' first."; exit 1; }
@# Rust: read back through cargo's own TOML parser, not a regex over the file
@test "$$(cd rust && cargo metadata --no-deps --locked --format-version 1 | jq -r '.packages[] | select(.name == "$(RS_CRATE)") | .version')" = "$(VERSION)" || \
{ echo "ERROR: Rust crate version does not match $(VERSION). Run 'make bump VERSION=$(VERSION)' first."; exit 1; }
@./scripts/promote-migrating.sh --check $(VERSION)
@# Verify lockfiles are frozen against their manifests
@test "$$(jq -r '.version' typescript/package-lock.json)" = "$(VERSION)" -a "$$(jq -r '.packages[""].version' typescript/package-lock.json)" = "$(VERSION)" || \
{ echo "ERROR: typescript/package-lock.json records a stale SDK version. Run 'make bump VERSION=$(VERSION)' first."; exit 1; }
@grep -qxF ' basecamp-sdk ($(VERSION))' ruby/Gemfile.lock || \
{ echo "ERROR: ruby/Gemfile.lock's PATH spec records a stale SDK version. Run 'make bump VERSION=$(VERSION)' first."; exit 1; }
@grep -qxF ' basecamp-sdk ($(VERSION))' ruby/Gemfile.lock || \
{ echo "ERROR: ruby/Gemfile.lock's CHECKSUMS entry records a stale SDK version. Run 'make bump VERSION=$(VERSION)' first."; exit 1; }
@cd python && uv lock --check || \
{ echo "ERROR: python/uv.lock is stale. Run 'make bump VERSION=$(VERSION)' first."; exit 1; }
@test "$$(jq -r '.packages["../../../typescript"].version' conformance/runner/typescript/package-lock.json)" = "$(VERSION)" || \
{ echo "ERROR: conformance/runner/typescript/package-lock.json records a stale SDK version. Run 'make bump VERSION=$(VERSION)' first."; exit 1; }
@# The conformance Ruby/Python runner lockfiles are tracked (#670), so they
@# are always present; a stale one breaks the frozen conformance installs (#671).
@grep -qxF ' basecamp-sdk ($(VERSION))' conformance/runner/ruby/Gemfile.lock || \
{ echo "ERROR: conformance/runner/ruby/Gemfile.lock records a stale SDK version. Run 'make bump VERSION=$(VERSION)' first."; exit 1; }
@grep -qxF ' basecamp-sdk ($(VERSION))' conformance/runner/ruby/Gemfile.lock || \
{ echo "ERROR: conformance/runner/ruby/Gemfile.lock's CHECKSUMS entry records a stale SDK version. Run 'make bump VERSION=$(VERSION)' first."; exit 1; }
@(cd conformance/runner/python && uv lock --check) || \
{ echo "ERROR: conformance/runner/python/uv.lock is stale. Run 'make bump VERSION=$(VERSION)' first."; exit 1; }
@# Both Rust lockfiles record the crate version; --locked fails on a stale one.
@# The runner's records it through its path dep on ../../../rust/basecamp-sdk.
@(cd rust && cargo metadata --locked --format-version 1 > /dev/null) || \
{ echo "ERROR: rust/Cargo.lock is stale. Run 'make bump VERSION=$(VERSION)' first."; exit 1; }
@(cd conformance/runner/rust && cargo metadata --locked --format-version 1 > /dev/null) || \
{ echo "ERROR: conformance/runner/rust/Cargo.lock is stale. Run 'make bump VERSION=$(VERSION)' first."; exit 1; }
@grep -qF 'version = "$(VERSION)"' conformance/runner/rust/Cargo.lock || \
{ echo "ERROR: conformance/runner/rust/Cargo.lock records a stale SDK version. Run 'make bump VERSION=$(VERSION)' first."; exit 1; }
@# Lock freshness says nothing about `include`, the README path or metadata.
@$(MAKE) rs-publish-check
@git diff --quiet && git diff --cached --quiet || \
{ echo "ERROR: Working tree has uncommitted changes. Commit first."; exit 1; }
@# Verify we're on main — release tags must be on the default branch
@BRANCH=$$(git rev-parse --abbrev-ref HEAD); \
if [ "$$BRANCH" != "main" ]; then \
echo "ERROR: Must be on main branch to release (currently on $$BRANCH)."; exit 1; \
fi
@# Push main first — release workflows verify the tag commit is reachable from origin/main
git push origin main
git tag "v$(VERSION)"
git push origin "v$(VERSION)"
@echo "Pushed v$(VERSION) — all SDK release workflows will trigger."
# Self-test for the MIGRATING.md heading promotion used by bump and release
.PHONY: test-promote-migrating
test-promote-migrating:
@./scripts/test-promote-migrating.sh
# Sync Smithy service version from spec/api-provenance.json
sync-spec-version:
@./scripts/sync-spec-version.sh
# Check that the Smithy service version matches spec/api-provenance.json
sync-spec-version-check:
@echo "==> Checking Smithy service version freshness..."
@command -v jq > /dev/null 2>&1 || { echo "ERROR: jq not found. Install jq to run sync-spec-version-check (used by 'make check')."; exit 1; }
@BC3_DATE=$$(jq -r '.bc3.date' spec/api-provenance.json); \
SMITHY_VER=$$(sed -n 's/^ version: "\(.*\)"/\1/p' spec/basecamp.smithy | head -1); \
if [ -z "$$BC3_DATE" ] || [ "$$BC3_DATE" = "null" ]; then echo "ERROR: Could not read bc3.date from spec/api-provenance.json"; exit 1; fi; \
if [ "$$SMITHY_VER" != "$$BC3_DATE" ]; then echo "ERROR: Smithy service version is out of date. Run 'make sync-spec-version'"; exit 1; fi
@echo "Smithy service version is up to date"
# Sync API_VERSION constants from openapi.json info.version
sync-api-version:
@./scripts/sync-api-version.sh
# Check that API_VERSION constants match openapi.json info.version
sync-api-version-check:
@echo "==> Checking API version freshness..."
@command -v jq > /dev/null 2>&1 || { echo "ERROR: jq not found. Install jq to run sync-api-version-check (used by 'make check')."; exit 1; }
@API_VER=$$(jq -r '.info.version' openapi.json); \
ok=true; \
grep -q "const APIVersion = \"$$API_VER\"" go/pkg/basecamp/version.go || ok=false; \
grep -q "export const API_VERSION = \"$$API_VER\"" typescript/src/client.ts || ok=false; \
grep -q "API_VERSION = \"$$API_VER\"" ruby/lib/basecamp/version.rb || ok=false; \
grep -q "const val API_VERSION = \"$$API_VER\"" kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampConfig.kt || ok=false; \
grep -q "public static let apiVersion = \"$$API_VER\"" swift/Sources/Basecamp/BasecampConfig.swift || ok=false; \
grep -q "API_VERSION = \"$$API_VER\"" python/src/basecamp/_version.py || ok=false; \
grep -q "pub const API_VERSION: &str = \"$$API_VER\";" rust/basecamp-sdk/src/generated/mod.rs || ok=false; \
if [ "$$ok" = false ]; then echo "ERROR: API_VERSION constants are out of date. Run 'make sync-api-version'"; exit 1; fi
@echo "API version constants are up to date"
# Check the constants restated in prose (API_VERSION, bc3 provenance pin,
# SPEC §19's assertion-type table) against their machine-readable sources.
# Only HTML-comment-marked spans are checked; spec/doc-constants.json commits
# the exact per-file marker count so neither deleting a marker nor quietly
# adding an unrecorded one can silence the gate.
# The live run only ever proves the gate can say yes, so the self-test follows:
# it crafts each failure mode and asserts the gate rejects it.
doc-constants-check:
@echo "==> Checking documentation constants..."
@./scripts/check-doc-constants.sh
@ruby ./scripts/test-doc-constants.rb
#------------------------------------------------------------------------------
# Go SDK targets (delegates to go/Makefile)
#------------------------------------------------------------------------------
.PHONY: go-test go-lint go-check go-clean go-clean-generated go-check-drift go-check-wrapper-drift go-check-generated-drift check-grouped-client-coverage test-check-grouped-client-coverage
go-test:
@$(MAKE) -C go test
go-lint:
@$(MAKE) -C go lint
go-check:
@$(MAKE) -C go check
go-clean:
@$(MAKE) -C go clean
# Remove the TRACKED generated Go client so `make -C go generate` rebuilds it.
# Deliberately not part of `clean`: clean must never delete tracked files (#668).
go-clean-generated:
@$(MAKE) -C go clean-generated
# Check for drift between generated client and service layer
go-check-drift:
@echo "==> Checking service layer drift..."
@./scripts/check-service-drift.sh
# Check that the committed generated Go client is current. Regenerates
# client.gen.go (oapi-codegen + normalization) into a temp location and diffs;
# non-mutative and safe under `make -j`. Distinct from go-check-drift
# (operation-level coverage) and go-check-wrapper-drift (field-level): this is
# output freshness of the generated file itself.
go-check-generated-drift:
@echo "==> Checking generated Go client drift..."
@./scripts/check-go-generated-drift.sh
# Total accounting for the GROUPED generated client (generated.Client.Todos(),
# .Projects(), …). The third Go surface, and the one nothing else watches:
# go-check-drift compares generated operations against the hand-written
# go/pkg/basecamp wrappers, go-check-wrapper-drift compares their fields, and
# neither looks at the `$$opid` chain in go/templates/client.tmpl. ArchiveProject
# and UnarchiveProject shipped missing from it through TWO fully green `make`
# runs; a human reviewer caught it, and a gate should have.
#
# Every operationId must appear exactly once across go/grouped-client-inventory.yml.
# "Expose every operation in the tag" is NOT the invariant — the grouped surface is
# deliberately curated, and that rule is false for 14 of its 15 services.
check-grouped-client-coverage:
@echo "==> Checking Go grouped-client coverage..."
@./scripts/check-grouped-client-coverage
# Drive that gate from outside with adversarial inventories. Its live run only
# ever exercises the PASSING case, so nothing there proves it rejects anything —
# and a coverage gate that cannot fail converts "nobody checked" into "the gate
# says it is fine". Each case drives the real checker through its env seams
# against synthetic inputs in a tmpdir; the tracked tree is never written to.
test-check-grouped-client-coverage:
@ruby ./scripts/test-check-grouped-client-coverage.rb
# Check for field-level drift between generated structs and hand-written
# wrappers in go/pkg/basecamp/. Sibling of go-check-drift; that check is
# operation-level, this one is field-level.
go-check-wrapper-drift:
@echo "==> Running wrapper-drift checker tests..."
@go test ./scripts/check-wrapper-drift/
@echo "==> Checking wrapper field-level drift..."
@go run ./scripts/check-wrapper-drift/
.PHONY: auth-routable-check
# Check that hop-2-only primitives are not called outside the authenticated
# download path. Guards against regressing the @basecampAuthRoutableUrl contract.
auth-routable-check:
@echo "==> Checking auth-routable consumer invariants..."
@./scripts/check-auth-routable-consumers.sh
#------------------------------------------------------------------------------
# TypeScript SDK targets
#------------------------------------------------------------------------------
.PHONY: ts-install ts-generate ts-generate-services ts-build ts-test ts-typecheck ts-check ts-check-drift ts-lint-test-timers ts-clean
TS_NODE_STAMP := typescript/node_modules/.install-stamp
$(TS_NODE_STAMP): typescript/package-lock.json typescript/package.json
@echo "==> Installing TypeScript dependencies..."
cd typescript && npm ci
@touch $(TS_NODE_STAMP)
ts-install: $(TS_NODE_STAMP)
ts-generate: ts-install
ts-generate-services: ts-install
ts-build: ts-install
ts-test: ts-install
ts-typecheck: ts-install
# Generate TypeScript types and metadata from OpenAPI
ts-generate:
@echo "==> Generating TypeScript SDK..."
cd typescript && npm run generate
# Generate TypeScript services from OpenAPI
ts-generate-services:
@echo "==> Generating TypeScript services..."
cd typescript && npx tsx scripts/generate-services.ts
# Build TypeScript SDK
ts-build:
@echo "==> Building TypeScript SDK..."
cd typescript && npm run build
# Run TypeScript tests
ts-test:
@echo "==> Running TypeScript tests..."
cd typescript && npm run test
# Run TypeScript type checking
ts-typecheck:
@echo "==> Type checking TypeScript SDK..."
cd typescript && npm run typecheck
# Check that committed generated TypeScript artifacts are current. Regenerates
# the whole src/generated/ tree (stripped OpenAPI, schema, metadata,
# path-mapping, services) into a temp project and diffs; non-mutative and safe
# under `make -j`.
ts-check-drift: ts-install
@echo "==> Checking TypeScript generated code drift..."
@./scripts/check-typescript-service-drift.sh
# Forbid scheduling an abort() on a wall-clock timer in typescript/tests/ — the
# shape that flaked in #655 and again in #783. An oxlint JS plugin rather than a
# grep: the AST can tell a timer that SCHEDULES the abort from one the abort is
# racing, and a proximity selector cannot (see the rule's header).
#
# The self-test runs beside it, not only when someone edits the rule. oxlint's
# JS plugin API is alpha and un-semvered behind a caret range, so a bump that
# stopped dispatching the visitor would leave lint-test-timers exiting 0 while
# matching nothing; the self-test asserts the rule still FIRES on the two forms
# #783 removed, which turns that fail-open into a build failure.
ts-lint-test-timers: ts-install
@echo "==> Checking for timer-scheduled aborts in TypeScript tests..."
cd typescript && npm run --silent test:lint-rules
cd typescript && npm run --silent lint:test-timers
# Run all TypeScript checks
# The compiled generators' half of the verb-inversion gate. Nothing else in the
# repository can see a regression in them: their drift checks regenerate from the
# committed openapi.json, which declares only verbs they emit, so all four could
# revert to silently skipping an unknown verb with every drift check still green.
# Each runs the real generator binary against a spec carrying a HEAD operation
# and asserts a named refusal that writes nothing and destroys nothing.
# scripts/test-generator-verb-inversion.rb covers Ruby, Python and jq.
.PHONY: ts-test-verb-refusal kt-test-verb-refusal swift-test-verb-refusal rs-test-verb-refusal
ts-test-verb-refusal: ts-install
@./scripts/test-compiled-generator-refusal typescript
# This target builds the generator distribution in kotlin/, so it collides with
# every other Gradle build in that project directory and joins the kotlin/ chain
# (rationale at smithy-mapper-test). It goes at the HEAD rather than the tail:
# scripts/test-check-gradle-serialization.rb chains its probe targets after
# conformance-kotlin, so the tail is load-bearing for that self-test and a new
# target appended there would read as an unordered sibling of every probe.
kt-test-verb-refusal:
cd kotlin && ./gradlew --quiet :generator:installDist
@./scripts/test-compiled-generator-refusal kotlin
# swift-test-verb-refusal is defined in the Swift section below, not here: it is
# HAS_SWIFT-gated and that variable is assigned further down the file, so an
# `ifdef` at this point would read as undefined and skip unconditionally.
rs-test-verb-refusal:
@./scripts/test-compiled-generator-refusal rust
ts-check: ts-check-drift ts-typecheck ts-lint-test-timers ts-test ts-test-verb-refusal
@echo "==> TypeScript SDK checks passed"
# Clean TypeScript build artifacts
ts-clean:
@echo "==> Cleaning TypeScript SDK..."
rm -rf typescript/dist typescript/node_modules
#------------------------------------------------------------------------------
# Ruby SDK targets
#------------------------------------------------------------------------------
.PHONY: rb-generate rb-generate-services rb-build rb-test rb-check rb-check-drift rb-doc rb-clean
# Generate Ruby types and metadata from OpenAPI
rb-generate:
@echo "==> Generating Ruby SDK types and metadata..."
# Redirected to a TEMPORARY file and moved into place, not straight at the
# committed artifact. The shell truncates a redirect target before the command
# runs, so `> metadata.json` emptied the committed file and only then let the
# extractor refuse — turning every fail-closed refusal in that walker into data
# loss, a layer below the generator's own ordering.
# Every path here is relative to ruby/, because the `cd` is still in effect in
# the `||` branch — a cleanup written as `ruby/lib/...` resolved to
# `ruby/ruby/lib/...` and left the fragment it was meant to remove.
cd ruby && { ruby scripts/generate-metadata.rb > lib/basecamp/generated/metadata.json.tmp \
&& mv lib/basecamp/generated/metadata.json.tmp lib/basecamp/generated/metadata.json \
|| { rm -f lib/basecamp/generated/metadata.json.tmp; exit 1; }; }
cd ruby && { ruby scripts/generate-types.rb > lib/basecamp/generated/types.rb.tmp \
&& mv lib/basecamp/generated/types.rb.tmp lib/basecamp/generated/types.rb \
|| { rm -f lib/basecamp/generated/types.rb.tmp; exit 1; }; }
@echo "Generated lib/basecamp/generated/metadata.json and types.rb"
# Generate Ruby services from OpenAPI
rb-generate-services:
@echo "==> Generating Ruby services..."
cd ruby && ruby scripts/generate-services.rb
# Build Ruby SDK (install deps)
RB_STAMP := ruby/.bundle/.install-stamp
$(RB_STAMP): ruby/Gemfile ruby/Gemfile.lock ruby/basecamp-sdk.gemspec
@echo "==> Installing Ruby dependencies..."
cd ruby && bundle install
@mkdir -p $(dir $(RB_STAMP))
@touch $(RB_STAMP)
rb-build: $(RB_STAMP)
# Run Ruby tests
rb-test: rb-build
@echo "==> Running Ruby tests..."
cd ruby && bundle exec rake test
# Check that committed generated Ruby artifacts are current. Regenerates
# metadata.json, types.rb, and the service files and diffs; non-mutative. The
# metadata/types diff canonicalizes the embedded generation timestamp.
rb-check-drift:
@echo "==> Checking Ruby generated code drift..."
@./scripts/check-ruby-service-drift.sh
# Run all Ruby checks
rb-check: rb-check-drift rb-test
@echo "==> Running Ruby linter..."
cd ruby && bundle exec rubocop
@echo "==> Ruby SDK checks passed"
# Generate Ruby documentation
rb-doc: rb-build
@echo "==> Generating Ruby documentation..."
cd ruby && bundle exec rake doc
@echo "Documentation generated in ruby/doc/"
# Clean Ruby build artifacts
rb-clean:
@echo "==> Cleaning Ruby SDK..."
rm -rf ruby/.bundle ruby/vendor ruby/doc ruby/coverage
#------------------------------------------------------------------------------
# Python SDK targets
#------------------------------------------------------------------------------
.PHONY: py-generate py-generate-services py-build py-test py-typecheck py-check py-check-drift py-clean
py-generate: py-generate-services
cd python && uv run python scripts/generate_types.py
cd python && uv run python scripts/generate_metadata.py
cd python && uv run ruff format src/basecamp/generated/
py-generate-services:
cd python && uv run python scripts/generate_services.py
py-build:
cd python && uv sync --dev
py-test:
cd python && uv run pytest --cov --cov-report=term-missing --cov-fail-under=60
py-typecheck:
cd python && uv run mypy src/basecamp/ --ignore-missing-imports
py-check: py-check-drift py-test py-typecheck
cd python && uv run ruff check src/ tests/
cd python && uv run ruff format --check src/ tests/
py-check-drift:
@echo "==> Checking Python service drift..."
@./scripts/check-python-service-drift.sh
py-clean:
rm -rf python/dist python/.pytest_cache python/src/*.egg-info python/.venv
#------------------------------------------------------------------------------
# Rust SDK targets
#------------------------------------------------------------------------------
# rust/ is a Cargo workspace (basecamp-sdk + generator); the conformance runner
# is its own workspace at conformance/runner/rust with a path dep on the crate.
# Every cargo invocation passes --locked: both Cargo.lock files are tracked, so
# a stale one fails the build here rather than being rewritten mid-check and
# tripping assert-lockfiles-unchanged after the fact.
RS_CRATE := basecamp-sdk
.PHONY: rs-build rs-test rs-lint rs-doc rs-deny rs-generate-services rs-check-drift rs-publish-check rs-check rs-clean rs-fmt
rs-build:
@echo "==> Building Rust SDK..."
cd rust && cargo build --workspace --locked
# Feature matrix (default, --no-default-features, --all-features) plus the
# compiled examples: a README snippet that stops compiling is a docs bug the
# doctests alone would miss when the fence is `no_run`. Without default
# features the crate ships no transport, so that lane builds the library and
# runs its unit tests; the integration tests under tests/ drive wiremock
# through the shipped reqwest client by design and run in the other two.
rs-test:
@echo "==> Running Rust tests..."
cd rust && cargo test --workspace --all-features --locked
cd rust && cargo build -p $(RS_CRATE) --no-default-features --locked
cd rust && cargo test -p $(RS_CRATE) --no-default-features --lib --locked
cd rust && cargo test -p $(RS_CRATE) --locked
cd rust && cargo build -p $(RS_CRATE) --examples --locked
rs-fmt:
cd rust && cargo fmt --all
rs-lint:
@echo "==> Linting Rust SDK..."
cd rust && cargo fmt --all --check
cd rust && cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
# Broken intra-doc links and missing docs fail the build; -D warnings is
# applied here rather than in [lints] so a local `cargo build` still succeeds
# with a warning the developer can see.
rs-doc:
@echo "==> Building Rust docs..."
cd rust && RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features -p $(RS_CRATE) --locked
# One advisory/licence/bans gate over BOTH Cargo workspaces, from the checked-in
# rust/deny.toml (the runner workspace points at the same file). cargo-audit is
# deliberately not run beside it — same RustSec database, one tool.
rs-deny:
@command -v cargo-deny >/dev/null || (echo "Install cargo-deny: cargo install cargo-deny --locked (or brew install cargo-deny)" && exit 1)
@echo "==> cargo deny (rust + conformance/runner/rust)..."
cargo deny --manifest-path rust/Cargo.toml --locked check
cargo deny --manifest-path conformance/runner/rust/Cargo.toml --config rust/deny.toml --locked check
# Regenerate rust/basecamp-sdk/src/generated from openapi.json + behavior-model.json
# The generator resolves openapi.json, behavior-model.json and its own names.toml
# from the repository root (`--root`, defaulting to the workspace's parent) and
# writes rust/basecamp-sdk/src/generated unless `--output` says otherwise.
rs-generate-services:
@echo "==> Generating Rust SDK from OpenAPI..."
cd rust && cargo run -q --locked -p $(RS_CRATE)-generator
# Non-mutating regenerate + diff (the Swift/Python shape)
rs-check-drift:
@echo "==> Checking Rust service drift..."
@./scripts/check-rust-service-drift.sh
# Metadata, `include`, README path, no path deps: everything crates.io would
# reject that lockfile freshness says nothing about. Run before the tag exists.
# --allow-dirty so the develop loop passes on an uncommitted edit; the release
# workflow packages from a clean checkout and asserts it.
rs-publish-check:
@echo "==> cargo publish --dry-run..."
cd rust && cargo publish -p $(RS_CRATE) --dry-run --locked --allow-dirty
# The {lang}-check contract: exactly what CI's test-rust job runs on stable.
rs-check: rs-lint rs-test rs-doc rs-deny rs-check-drift rs-publish-check rs-test-verb-refusal
rs-clean:
rm -rf rust/target conformance/runner/rust/target
#------------------------------------------------------------------------------
# Conformance Test targets
#------------------------------------------------------------------------------
.PHONY: conformance conformance-runner-tests conformance-runner-tests-go conformance-runner-tests-python conformance-runner-tests-ruby conformance-runner-tests-kotlin conformance-runner-tests-swift conformance-runner-tests-rust check-runner-test-reachability conformance-go conformance-go-replay conformance-kotlin conformance-kotlin-replay conformance-typescript conformance-typescript-live conformance-ruby conformance-ruby-replay conformance-python conformance-python-replay conformance-swift conformance-rust conformance-build conformance-live conformance-canary oauth-fixtures-check oauth-token-fixtures-check event-feed-fixtures-check event-feed-digest-fixtures-check conformance-fixtures-check check-search-fixture-copy check-fixture-execution
# NOTE: conformance-swift and conformance-runner-tests-swift are defined in the
# Swift SDK targets section below — their IS_MACOS conditional must parse after
# that variable is defined.
# Pinned validator for the data-only OAuth discovery fixtures. Run via uvx so the
# version is reproducible without a global install; the schema is separate from
# the operation-dispatch conformance/schema.json (unusable for OAuth data).
CHECK_JSONSCHEMA_VERSION := 0.35.0
# Validate OAuth resource-first discovery fixtures against their JSON Schema.
oauth-fixtures-check:
@echo "==> Validating OAuth discovery fixtures..."
uvx --from 'check-jsonschema==$(CHECK_JSONSCHEMA_VERSION)' check-jsonschema \
--schemafile conformance/oauth/schema.json conformance/oauth/fixtures/*.json
# Validate OAuth token wire-behavior fixtures (RFC 8707 resource echo/decode)
# against their JSON Schema. A separate family from conformance/oauth/ — that
# schema is discovery-only and every discovery harness globs its whole fixtures
# directory, so token cases must live here.
oauth-token-fixtures-check:
@echo "==> Validating OAuth token fixtures..."
uvx --from 'check-jsonschema==$(CHECK_JSONSCHEMA_VERSION)' check-jsonschema \
--schemafile conformance/oauth-token/schema.json conformance/oauth-token/fixtures/*.json
event-feed-fixtures-check:
@echo "==> Validating event-feed scenario fixtures..."
uvx --from 'check-jsonschema==$(CHECK_JSONSCHEMA_VERSION)' check-jsonschema \
--check-metaschema conformance/event-feed/schema.json
uvx --from 'check-jsonschema==$(CHECK_JSONSCHEMA_VERSION)' check-jsonschema \
--schemafile conformance/event-feed/schema.json conformance/event-feed/fixtures/*.json
@echo "==> Verifying event-feed schema pins via derived mutants..."
python3 scripts/check-event-feed-pin-probes.py \
conformance/event-feed/schema.json conformance/event-feed/pin-probes '$(CHECK_JSONSCHEMA_VERSION)'
event-feed-digest-fixtures-check:
@echo "==> Validating event-feed srv2 digest vectors..."
uvx --from 'check-jsonschema==$(CHECK_JSONSCHEMA_VERSION)' check-jsonschema \
--check-metaschema conformance/event-feed-digest/schema.json
uvx --from 'check-jsonschema==$(CHECK_JSONSCHEMA_VERSION)' check-jsonschema \
--schemafile conformance/event-feed-digest/schema.json conformance/event-feed-digest/fixtures/*.json
# Validate every conformance/tests/*.json entry against conformance/schema.json.
# This is the AUTHORITATIVE enforcement of the per-case schema — including the
# mockResponses oneOf (exactly one of status or networkError:true). The runners
# don't schema-validate fixtures, so without this a malformed fixture (e.g.
# {status:204, networkError:false}) would only be caught, if at all, by each
# runner's looser runtime backstop. tests.schema.json wraps schema.json as an
# array so check-jsonschema validates each element of the array-shaped files.
#
# Schema validation checks the fixture FORMAT, not that a mock body still
# decodes into the generated models. The runners enforce that (Kotlin/Swift
# fail loudly on a body that no longer matches) — except where a fixture
# declares errorRaised, which deliberately switches that policy off. The
# control-sibling gate below is what keeps those fixtures honest.
#
# The metaschema pass runs FIRST because validating fixtures against a schema
# that is not itself a valid schema proves nothing. Draft 2020-12 requires every
# value under `properties` to be a schema object or boolean, so an annotation
# like `$comment` placed there declares a property of that name with a string
# for a schema — accepted silently by the fixture pass, rejected by any
# validator that meta-validates first.
#
# The self-test runs after the live check for the same reason the gate exists:
# pointed only at the valid fixture set, the gate proves it can say yes and
# nothing else. Several of its rejections — a kill case answering 204 above all
# — are invisible to both the schema pass and every runner, so a regression that
# removed them would show up as a clean `make conformance` and nowhere else.
conformance-fixtures-check:
@echo "==> Validating conformance schemas against the JSON Schema metaschema..."
uvx --from 'check-jsonschema==$(CHECK_JSONSCHEMA_VERSION)' check-jsonschema \
--check-metaschema conformance/schema.json conformance/tests.schema.json
@echo "==> Validating conformance fixtures against schema.json..."
uvx --from 'check-jsonschema==$(CHECK_JSONSCHEMA_VERSION)' check-jsonschema \
--schemafile conformance/tests.schema.json conformance/tests/*.json
@echo "==> Checking errorRaised fixtures have body-pinning control siblings..."
python3 conformance/check_kill_case_controls.py
@echo "==> Self-testing the control-sibling gate's rejections..."
@python3 conformance/test_check_kill_case_controls.py
@$(MAKE) check-search-fixture-copy
# Pin conformance/tests/search.json's mock bodies to spec/fixtures/search/
# results.json. The two fixture systems have no $$ref mechanism between them, so
# the conformance bodies are a COPY, and conformance-fixtures-check validates
# their format rather than their fidelity. Named separately as well as run above
# so it can be invoked directly after editing either side.
check-search-fixture-copy:
@python3 scripts/check-search-fixture-copy.py
# Unit-test the runners' own assertion helpers.
#
# The runners are test harnesses, but their assertion logic is code like any
# other, and its bounds branches never execute against a fixture that passes.
# #563 shipped a delayBetweenRequests check that vacuously passed when the gap
# it named did not exist; nothing caught it because every committed fixture
# supplied the gap. These pin the branches the fixtures cannot reach.
#
# errorRaised (#576) is the same shape: every fixture declaring it is one the
# SDK does refuse, so its failing branch is unreachable from conformance/tests/
# and a handler that accepted everything would look green in all seven runners.
#
# Every recipe below DISCOVERS its suites; none names a test file. #572: the
# Python and Ruby lines used to name `test_delay_gaps.py` / `delay_gaps_test.rb`
# explicitly, so `test_replay_runner.py` and `replay_runner_test.rb` sat in the
# tree executed by nothing — the same "assertion code nothing exercises" shape
# these targets exist to close. Adding a runner test must be enough to run it.
# `scripts/check-runner-test-reachability` enforces both halves: no recipe (here
# or in CI) may name a test file, and every test-bearing file must sit where its
# language's discovery finds it.
#
# Split per language so CI's per-language jobs can call the make target for
# their own toolchain — one definition of what "run the runner tests" means,
# instead of a second enumeration in .github/workflows/test.yml.
conformance-runner-tests: conformance-runner-tests-go conformance-runner-tests-python conformance-runner-tests-ruby conformance-runner-tests-kotlin conformance-runner-tests-swift conformance-runner-tests-rust
@echo "==> Conformance runner unit tests passed"
conformance-runner-tests-go:
@echo "==> Running Go conformance runner unit tests..."
cd conformance/runner/go && go test ./...
# Bare `pytest` discovers test_*.py and *_test.py under the runner directory.
# Collecting nothing is exit 5, so an empty suite fails rather than green-passes.
#
# The runner lockfiles (Gemfile.lock, uv.lock) are TRACKED (#670), so every
# install here runs frozen: `uv sync --locked` fails if uv.lock is missing or
# out of date, and BUNDLE_FROZEN=true installs from Gemfile.lock without
# writing it back — a version bump or dependency change must regenerate the
# lockfile in the same commit instead of rewriting it silently at run time.
conformance-runner-tests-python:
@echo "==> Running Python conformance runner unit tests..."
cd conformance/runner/python && uv sync --locked --quiet && uv run python -m pytest -q
# Ruby has no runner-level test task, so discovery is hand-rolled here.
#
# It RECURSES, via find. `go test ./...`, pytest and vitest all walk
# subdirectories; a top-level `for f in *_test.rb` did not, which made Ruby the
# one arm where a test file's PLACEMENT silently decided whether it ran.
# conformance/runner/ruby/nested/probe_test.rb was executed by nothing while
# scripts/check-runner-test-reachability — which fnmatched basenames over a
# recursive find — certified it reachable. Recursing here is what makes that
# check's basename model true. The check derives this scope back out of the
# recipe below (see its `ruby_discovery_scope`), so reverting to a top-level
# glob re-arms the placement tooth rather than reopening the hole.
#
# vendor/ and .bundle/ are pruned: `bundle install --path` puts third-party gem
# suites there, and those are not ours to run.
#
# It aborts when discovery matches nothing: a rename that empties it must fail
# loudly, not report success over zero files.
conformance-runner-tests-ruby:
@echo "==> Running Ruby conformance runner unit tests..."
@cd conformance/runner/ruby && BUNDLE_FROZEN=true bundle install --quiet && \
files=$$(find . \( -name vendor -o -name .bundle \) -prune -o \
-type f -name '*_test.rb' -print | sort); \
if [ -z "$$files" ]; then \
echo "ERROR: no *_test.rb files found under conformance/runner/ruby" >&2; \
exit 1; \
fi; \
for f in $$files; do \
echo " --> $$f"; \
bundle exec ruby "$$f" || exit 1; \
done
# ORDER-ONLY EDGE: head of the kotlin/ chain; rationale at smithy-mapper-test.
conformance-runner-tests-kotlin: | kt-test-verb-refusal
@echo "==> Running Kotlin conformance runner unit tests..."
cd kotlin && ./gradlew --quiet :conformance:test
# `cargo test` compiles every #[test] in the runner crate's module tree, so
# discovery is the toolchain's and no file is named here (#572). --locked: the
# runner Cargo.lock is tracked and records the SDK's version through its path
# dep, so a stale one must fail rather than be rewritten mid-check.
conformance-runner-tests-rust:
@echo "==> Running Rust conformance runner unit tests..."
cd conformance/runner/rust && cargo test --locked
# Wipes the manifest directory before ANY conformance runner writes to it, so
# "seven manifests present" means "seven runners reported in this run" rather than
# "six reported and a seventh is left over from a different machine".
#
# An ORDER-ONLY prerequisite (`|`) shared by all seven language targets, which is
# what makes it correct under `make -j`: make builds a prerequisite to
# completion before any dependent starts, and builds this phony target once per
# invocation — so the reset cannot race the runners it protects. Ordering it as
# a plain prerequisite of the aggregate `conformance` target would not do that;
# sibling prerequisites may run concurrently, and a reset racing the runners
# would delete the output it exists to guarantee.
#
# It fires for a single-language run too (`make conformance-go` clears the
# directory). That is deliberate and fail-safe: the directory then holds only
# what this invocation produced, so the gate sees fewer manifests and goes
# partial rather than silently mixing runs.
.PHONY: conformance-manifests-reset
conformance-manifests-reset:
@rm -rf conformance/manifests
conformance-go conformance-kotlin conformance-typescript conformance-ruby \
conformance-python conformance-swift conformance-rust: | conformance-manifests-reset
# Build conformance test runner
conformance-build:
@echo "==> Building conformance test runner..."
cd conformance/runner/go && go build -o conformance-runner .
# Run Go conformance tests
conformance-go: conformance-build
@echo "==> Running Go conformance tests..."
cd conformance/runner/go && ./conformance-runner
# Run Go wire-replay against snapshots written by the TS live runner.
# Required env: WIRE_REPLAY_DIR, BASECAMP_BACKEND. Opt-in: not in `make check`.
conformance-go-replay:
@echo "==> Running Go wire-replay runner..."
@test -n "$$WIRE_REPLAY_DIR" || (echo "WIRE_REPLAY_DIR is required" >&2; exit 1)
@test -n "$$BASECAMP_BACKEND" || (echo "BASECAMP_BACKEND is required" >&2; exit 1)
cd conformance/runner/go && go run .
# Run Kotlin conformance tests
# ORDER-ONLY EDGE: tail of the kotlin/ chain (#674); rationale at
# smithy-mapper-test. Standalone `make conformance-kotlin` therefore also runs
# kt-test and the runner tests. CI is unaffected: its Kotlin job invokes
# `./gradlew :conformance:run` directly, not this target.
conformance-kotlin: | kt-test
@echo "==> Running Kotlin conformance tests..."
cd kotlin && ./gradlew :conformance:run