Repository navigation
Expand file tree
/
Copy pathMakefile
More file actions
546 lines (523 loc) · 28.4 KB
/
Copy pathMakefile
File metadata and controls
546 lines (523 loc) · 28.4 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
# make ci is the test gate: every check that can fail a change runs
# inside it, so what passes locally and what passes in CI cannot diverge.
#
# TWO TARGETS SIT OUTSIDE IT and each says why where it is defined. The
# short of it: one needs a tool this module cannot require, and the other
# needs a push range, which a working copy does not have.
export CGO_ENABLED := 0
# EVERY TARGET RUNS THE GO RELEASE GO.MOD NAMES. The toolchain line in
# go.mod is a floor under Go's default behaviour and not a pin: a machine
# whose own Go is newer runs every command as-is, and then disagrees with
# CI, which installs exactly the release the line names. Rows that pass on
# the named release have failed on a newer one over nothing in this
# module: the same source, under two releases. So a local green was
# evidence about a toolchain CI does not use.
#
# Exporting GOTOOLCHAIN makes every target run exactly the module's
# release. Go fetches it once and checks it against the checksum database;
# where the local Go already is that release, nothing is fetched. The
# version is read from go.mod and never written here, so there is one copy
# to move.
#
# A value set this way beats one in the caller's environment. The two ways
# past it are naming GOTOOLCHAIN on make's own command line and `make -e`,
# and both are kept on purpose: running the suite under another release is
# how a move to that release is measured before it is made. If they are
# ever closed, do it with `override GOTOOLCHAIN := …` and a separate
# `export GOTOOLCHAIN` line. The one-line `export override` form loses the
# export under the GNU make that macOS ships, and the pin silently stops.
#
# `go mod tidy` drops a toolchain line equal to the go line, which is why
# the go line stays at a major and minor release and the toolchain line
# names the exact one.
GO_TOOLCHAIN := $(shell sed -n 's/^toolchain //p' go.mod 2>/dev/null)
ifeq ($(GO_TOOLCHAIN),)
$(error go.mod names no toolchain line, so make has no Go version to run every target under. Keep the go line at a major and minor release and name the exact release on a toolchain line)
endif
export GOTOOLCHAIN := $(GO_TOOLCHAIN)
.PHONY: build test test-go test-npm test-race e2e-npm exit-test vet fmt lint snapshot surface-check leak-scan leak-scan-private hooks release-log ci guard-a-branch-to-work-on
build:
go build -trimpath ./...
go build -trimpath -o bin/curious ./cmd/curious
# test grows as suites arrive that go test cannot see on its own (a
# release-build check, a wrapper package's own test runner, a pass under
# the race detector) — each lands here as an additional half, so one
# command still sees the whole repository.
#
# -count=1 disables the test cache, and it is LOAD BEARING rather than a
# habit. The guards in internal/guard read state Go does not track as an
# input to their package: the whole module's source tree, its dependency
# graph, and a manifest file. Go's cache key covers the guard package's
# own files, so a violation introduced anywhere else leaves the cached
# PASS valid and `make ci` reports green against a tree that breaks the
# rule.
#
# That is not hypothetical — it was measured. A banned telemetry import
# added to a test file went undetected on a cached run and failed
# immediately with -count=1. A guard that can report a stale pass is
# worse than no guard, because it is trusted.
#
# THE SUITE RUNS THROUGH A WRAPPER, and the reason is the same shape one
# line up: a run can report a pass it has not earned. A skipped row
# prints NOTHING without -v, so a green tick over a row that stopped
# running looks exactly like a green tick over one that passed — and
# several rows here skip on conditions that are honest on one platform
# and a broken environment on another. The wrapper prints every skip with
# the reason its author wrote, checks it against
# scripts/expected-skips.txt, and fails the run on one nobody declared.
# It passes its arguments straight through and returns the same exit
# code; what it adds is the section at the end.
#
# Making the whole suite verbose would surface the same three lines
# inside ten thousand, which is a way of hiding them that also annoys
# everybody.
# EVERY HALF RUNS, AND THE RESULT IS THE AGGREGATE. This was a
# prerequisite list until the race pass arrived, and a prerequisite list stops at the
# first failure — which defeats the sentence above it. The moment the
# stall-window registry reds on a leg nobody has measured yet, which is
# its designed state, make would stop and the race pass beside it would
# never run at all. On the leg that is pending, that is exactly the run
# whose output somebody needs.
#
# So the halves are invoked in sequence and the status is collected.
# THE RACE PASS GOES FIRST for a reason worth stating: the stall windows
# are margins over a gap the detector widens — threefold on darwin — so
# the number a pending leg has to report is the one taken under it, and a
# run that stopped before the race pass would hand an operator the
# friendlier of two figures with nothing on the line to say which it was.
# THE WRAPPER'S SUITE NOW RUNS BEFORE THE GO HALF, and the order is a
# dependency rather than a preference. test-npm is what installs the
# parser the copy audit reads the wrapper's own sources with, and that
# audit runs in test-go — so with the old order a clean checkout failed
# in test-go, complaining about a package the very next target would have
# installed. The Go row says so when it cannot find it, but an ordering
# that makes the message necessary is the wrong ordering.
#
# THIS PUTS NO NEW REQUIREMENT ON THE GATE, which is worth saying because
# it looks like it does. The wrapper's suite has always been part of
# `make test`, and installing that package's dependencies is what running
# it means; the gate has therefore always needed the registry here. What
# changed is that one devDependency now exists to install.
test:
@status=0; \
$(MAKE) test-race || status=1; \
$(MAKE) test-npm || status=1; \
$(MAKE) test-go || status=1; \
exit $$status
# -timeout FOR THE SAME REASON THE RACE PASS CARRIES ONE, and it was
# learned the same way: the hosted linux runner killed this pass at Go's
# ten-minute default inside internal/flow, so a leg that has no recorded
# measurement produced no measurement — which is the one outcome a
# pending leg must not have. A run that is too slow should say so with
# its numbers in hand.
# THE MEASURING PACKAGES ARE NOT IN THIS LIST, and that is not them
# escaping the gate — test-race below runs them TWICE, in both of the
# conditions this repository sizes its windows under, and it does so
# with -v so each pass publishes its numbers. Left in here as well they
# would run three times, and the third run would be the one whose
# figures nobody can read.
#
# internal/citations JOINED THEM on 2026-09-14. Its tokeniser-cost row
# measures a ratio against a provisional ceiling, and the ceiling is to
# be re-ruled from hosted readings that could not exist while the row
# ran here without -v. The package has no skips; if it grows one, the
# skip check on this line does not see it, and that is the price of the
# package publishing its number.
test-go:
go run ./tools/skipcheck -- -count=1 -timeout 25m $$(go list ./... | grep -vE '/internal/(flow|timing|citations)$$')
# THE RACE PASS OVER THE STALL-WINDOW MACHINERY, and it is reached from
# test so that CI gets it without a second entry point: the workflow runs
# `make ci` and nothing else, so a check that is not reachable from here
# is a check CI does not run.
#
# WHY IT NAMES internal/flow AND NOT ONLY internal/timing. The ruling
# that produced this target says "the timing package's CI invocation",
# and internal/timing on its own would catch NOTHING: it is a registry
# and two AST guards, and it starts no goroutine. The data race that
# invalidated a whole round of measurements lived one package over — a
# receive-buffer size written onto the object-store fixture AFTER its
# listener had started accepting, read by the accept path — so the
# package that has to be under the detector is the one with the fixture
# in it. Naming only the registry would be a gate in the shape of the
# rule with none of its subject.
#
# WHAT IT COSTS AND WHAT THAT BUYS. internal/flow is the slowest package
# here, because five of its rows are timing probes that pace real bytes
# over loopback; under the detector it is about the same wall time, since
# the cost is sleeps rather than instructions. The gate therefore runs
# that package twice. It is worth it: the defect this catches is one that
# leaves every row GREEN and every number wrong, which is the only kind
# of defect a test suite cannot report on its own.
#
# THE STALL WINDOWS ARE SIZED UNDER THIS CONDITION. A margin has to hold
# in every condition the gate runs the row in, and the detector is one of
# them — see internal/timing, where each leg's number names the detector
# it was taken under.
#
# CGO_ENABLED=1 because the race detector needs a C toolchain on most
# platforms. The file-level export sets it to 0 for every other target,
# which is deliberate; this is the one place that has to differ, and it
# differs in the recipe rather than by moving the default.
# -timeout IS EXPLICIT AND IT IS NOT A ROUND NUMBER PULLED OUT OF THE
# AIR. Go defaults to ten minutes per test binary, and internal/flow
# under the detector on a two-core CI runner does not fit: the linux leg
# panicked at exactly 10m0s inside the upload probe, having produced no
# measurement at all, which is the worst of both — the money spent and no
# number back. The package takes about two minutes there without the
# detector and something over five times that with it.
# -v BECAUSE THESE PACKAGES MEASURE THINGS. Five of these rows are
# probes whose whole product is a number, and t.Logf output is invisible
# without it — so a green run published nothing and the only way to read
# a leg's measurement was to make its test FAIL. That is backwards: the
# numbers a live run brings back are the whole reason for spending the
# run, and a gate that hides them until something breaks is a gate that
# has to be broken to be read. Measured 2026-09-11, when the macOS
# runner's own classified figures could not be recovered from a passing
# job at all.
#
# tools/leakscan joins the raced pass because its tree walk has a worker
# pool. Its ordinary suite asserts that no job or error is dropped; the
# detector asserts the other half, that workers share no mutable map.
# Only the measuring packages are verbose, the two timing packages and
# internal/citations, whose tokeniser-cost row publishes a ratio: their
# measurements, rather than merely their verdicts, are part of the gate's
# output.
# BOTH CONDITIONS ARE RUN HERE, and both print. The rule these packages
# keep is that a leg's number is the WORSE of the two conditions the
# gate runs it in — and for as long as only the raced pass carried -v,
# half of that comparison was unreadable: a green run published one
# figure and the other existed nowhere. A rule about two numbers needs
# both of them on the log.
test-race:
CGO_ENABLED=1 go test -race -count=1 -timeout 25m ./tools/leakscan/
CGO_ENABLED=1 go test -race -count=1 -timeout 25m -v ./internal/timing/ ./internal/flow/ ./internal/citations/
go test -count=1 -timeout 25m -v ./internal/timing/ ./internal/flow/ ./internal/citations/
# The wrapper package's own suite. It is a PREREQUISITE OF test rather
# than a separate command somebody has to know about, because a check
# outside the gate is a check nobody runs before pushing — and this one
# covers a postinstall script that downloads and executes a binary on
# other people's machines.
#
# IT FAILS RATHER THAN SKIPS when Node is absent, and that is the same
# ruling as the skip manifest one line up: a check that quietly does not
# run looks exactly like one that passed. The floor is the package's own
# declared engines range, and the suite reports what it found.
#
# The pattern is quoted so the glob reaches the test runner rather than
# the shell, and it is a glob rather than a directory because a
# directory argument is not one the runner accepts.
test-npm:
@command -v node >/dev/null 2>&1 || { \
echo "node is not installed, and the npm wrapper's suite is part of this gate."; \
echo "Install Node (the floor is in npm/package.json), or run make test-go for the Go half."; \
exit 1; \
}
cd npm && npm ci --ignore-scripts --no-audit --no-fund
cd npm && node --test "test/**/*.test.js"
# The end-to-end run: pack the wrapper, serve a built binary from this
# machine, install the tarball into a temporary prefix and run the
# command it installs. It is NOT part of ci: it builds the real binary
# and stands up a server, which is minutes rather than seconds.
e2e-npm:
npm/test/e2e-local.sh
# The exit test: the whole client, end to end, against a stack running on
# this machine. It is NOT part of ci and could not be — it needs a
# running service, a container runtime and a mail catcher, none of which
# a checkout has. Everything it demonstrates that CAN be established
# without those is already in the gate.
#
# It takes every address it uses from the environment and compiles none
# of them in; the script's own header lists them and says what each is
# for. It never starts the service it tests, because a script that owns
# the lifecycle of the thing under test can pass for reasons that have
# nothing to do with it.
exit-test:
scripts/exit-test.sh
# The first line is the one every log carries, saying which Go this run used.
vet:
go version
go vet ./...
# fmt uses the toolchain's own gofmt, built from its source: the gofmt on
# PATH belongs to whatever Go is installed, not the module's.
fmt:
@unformatted="$$(go run cmd/gofmt -l .)"; \
if [ -n "$$unformatted" ]; then \
echo "$$unformatted"; \
exit 1; \
fi
# lint runs golangci-lint over the whole module.
#
# ON CI IT IS INSTALLED, SO AN ABSENT LINTER IS A FAILURE. This comment
# used to say the opposite: that lint must never fail ci for the reason
# "no linter is installed". While that held, no workflow installed one,
# every leg printed the skip line below inside its make ci step, and lint
# ran nowhere with nothing to say so but that line. The workflow now
# installs the linter at an exact version on every leg before make ci, so
# on CI there is nothing to tolerate: a skip there would be this target
# reporting that it ran when it did not. CI is told apart the way
# guard-a-branch-to-work-on tells it apart, by a CI variable that is set
# and not empty.
#
# ON A MACHINE WITHOUT IT, it still skips, and says so. The linter is not
# a build dependency of this module, and a contributor has no reason to
# hold it. The skip line is kept word for word because it is what a CI log
# is searched for to show the linter did not run, and a reworded skip
# would make that search find nothing whether or not it ran.
#
# EVERY FINDING IS PRINTED. By default the linter caps how many findings
# it prints per linter and how many share one message, and keeps one
# finding per line, so a new finding can land inside a group it has
# already collapsed and the count it prints does not move. A report that
# drops findings cannot be read as a list of them; these flags turn all
# three reductions off.
lint:
@if command -v golangci-lint >/dev/null 2>&1; then \
golangci-lint run --max-issues-per-linter=0 --max-same-issues=0 --uniq-by-line=false ./...; \
elif [ -n "$$CI" ]; then \
echo "golangci-lint is not installed, and on CI that is a failure rather than a skip."; \
echo "The workflow installs it at an exact version before make ci runs, so if it"; \
echo "is missing here, that step did not run or did not put it on PATH."; \
exit 1; \
else \
echo "golangci-lint not installed; skipping lint"; \
fi
# snapshot builds the entire release matrix — every platform, every
# archive, the checksum file — and uploads nothing. It is what makes a
# broken release pipeline a pull-request failure instead of a discovery
# made while cutting a release.
#
# IT IS NOT A PREREQUISITE OF ci, and that is a decision rather than an
# omission. It needs a tool this module does not build and cannot
# require, and it costs minutes rather than seconds. What ci DOES carry
# is the release tool's own validation of the configuration: the guard
# suite runs it when the tool is present and records a declared skip when
# it is not, so the check travels with the suite instead of needing a
# second place in this file that could disagree with it.
#
# The workflow that runs this on every change installs the tool at an
# exact pinned version and then runs this target, so the command CI types
# is the command a person types.
#
# THIS TARGET SIGNS, and the flag that used to stop it is gone. A
# snapshot skips announcing, publishing and validation; it does NOT skip
# signing, and this target used to pass --skip=sign so that a machine
# with no signing tool could still run it.
#
# THE ARGUMENT FOR THAT FLAG WAS HALF RIGHT, AND THE WRONG HALF COST A
# RELEASE. It ran: asking for a signature here would mean either a check
# that cannot pass, or an identity on a pull request from a stranger, and
# the second is worse. The second clause still holds and always will — no
# release identity is ever minted for a pull request. The first was false
# and nobody tested it: a signature does not need an identity, it needs a
# KEY, and a key pair generated in the job costs nothing and belongs to
# nobody. While that flag stood, the signing path ran for the first time
# at the first release and failed in it.
#
# So the key pair is generated here, used, and thrown away with the
# directory. It signs nothing anyone will verify against a public root of
# trust; its whole job is to make the pinned signer execute the argument
# list the release will use, on every change, so a pin that moves under
# that list reds in a pull request instead of at a tag.
#
# WHEN THE SIGNER IS ABSENT this target says so and skips signing rather
# than failing, because it is not a build dependency of this module and a
# contributor who has never cut a release has no reason to hold it. CI is
# the other way round: the workflow installs it at a pinned version, so
# an absent signer there is a failure and never a quiet pass.
#
# THE macOS SIGNATURE FOLLOWS THE SAME ARGUMENT, and a throwaway
# certificate chain is generated beside the key pair for the same reason.
# Unlike the signer above, the tool that generates it is on every machine
# that can run this target, so there is no skip for it.
#
# THE CHECK AFTER THE BUILD is the only thing that can see a
# member-count error or a misspelled archive name: the release tool's
# own validator reads the schema, and a format that cannot hold what it
# was given fails in the pipe rather than in the document. It asks the
# wrapper's own mapping for the six names it expects, so the release
# template and the install script's table are tied together instead of
# being two restatements of the same six names in different files.
snapshot:
@rm -rf .snapshot-keys && mkdir -p .snapshot-keys
@scripts/snapshot-macos-cert.sh .snapshot-keys
@if command -v cosign >/dev/null 2>&1; then \
COSIGN_PASSWORD=snapshot COSIGN_YES=true \
cosign generate-key-pair --output-key-prefix .snapshot-keys/cosign >/dev/null; \
COSIGN_PASSWORD=snapshot goreleaser release --snapshot --clean; \
else \
echo "cosign is not installed here, so the snapshot's signing step is skipped."; \
echo "CI installs it at a pinned version and does not skip it; see the row in"; \
echo "internal/guard and its line in scripts/expected-skips.txt."; \
goreleaser release --snapshot --clean --skip=sign; \
fi
node scripts/check-release-assets.js dist
node scripts/check-snapshot-signature.js dist
# surface-check reads the surfaces the test suite cannot: the messages,
# names and titles of a range being published. It is the same program the
# workflows run and the same one the pre-push hook runs, so the command
# that fails a push is a command a person can type.
#
# IT IS NOT A PREREQUISITE OF ci, and the reason is the shape of its
# subject rather than its cost. There is no push range in a working copy:
# a checkout is one state, and this check is about the difference between
# two. What ci DOES carry is the checker's own suite, the self-test
# included, so the instrument is tested by every run even though the
# measurement needs a range to point at.
#
# With no arguments it reads the workflow event it is running under. On a
# machine, pass the range: `-base <rev> -head <rev> -branch <name>`.
surface-check:
go run ./tools/surfacecheck $(ARGS)
# leak-scan reads everything this repository has ever published — every
# blob reachable from a ref that exists on origin — against the manifests
# in this repository, and fails on anything not recorded in
# scripts/leak-baseline.txt.
#
# IT IS IN ci, WHICH surface-check IS NOT, and the difference is the shape
# of the subject rather than a change of heart about cost. A range needs
# two endpoints and a working copy is one state; a history needs no
# endpoints at all. Every leg of every push already checks out the full
# history for the rows that read real commits, so the objects are there.
#
# WHAT IT COSTS, recorded here because the next person to ask "can we
# afford this in CI" should have a number instead of an opinion: about
# 10.8 seconds over 1,157 blobs and 20 MB, LOCAL, on Apple arm64,
# measured 2026-09-12 after object-walk membership landed: 1.6 seconds to
# enumerate every reachable object and blob-path membership, and 9.2
# seconds to read and match. The former one-name enumeration took about
# 0.6 seconds; complete membership and paths therefore add about a second.
# A hosted runner is unmeasured; the first CI run records it per leg, and
# the figure decays, because the cost grows with the history.
#
# IT NEEDS NO SECRET, which is what lets it run on a pull request from a
# stranger's fork like every other row here.
leak-scan:
go run ./tools/leakscan -public
# leak-scan-private is the MAINTAINER half and is deliberately not
# reachable from ci. It reads an operator's inventory of this project's
# own resource names, which lives outside this repository because a
# committed list of the exact names you are defending is a directory of
# them — and its findings are recorded beside that inventory, for the
# same reason: a public file cannot hold an exception to a private rule
# without becoming the leak.
#
# THE PATH IS REQUIRED AND THERE IS NO DEFAULT. A run asked for this half
# with nothing to read has not found the estate clean, it has not looked
# at it, and the command says so rather than passing.
leak-scan-private:
@if [ -z "$(INVENTORY)" ]; then echo "leak-scan-private needs the inventory to read:"; echo; echo " make leak-scan-private INVENTORY=<path outside this repository>"; echo; echo "There is no default. A scan that picked one would be a scan that"; echo "silently ran the half you were not asking for."; exit 1; fi
go run ./tools/leakscan -inventory "$(INVENTORY)"
# hooks installs the pre-push hook, and it is OPT IN because a hook is
# not the gate and must never be mistaken for one. A hook lives in a
# directory git does not clone, is skipped by --no-verify, and is absent
# on CI; anything that has to hold has to hold in the workflow. What this
# buys is the failure arriving in a couple of seconds on the machine that
# wrote the message rather than a couple of minutes later in a run.
#
# What lands in the hooks directory is a SHIM that runs the tracked
# script, so the hook cannot go stale when that script changes — a copy
# would, silently, and the copy nobody re-installed is the one running
# when it matters.
#
# It refuses to overwrite a hook it did not write. Somebody else's
# pre-push hook is somebody else's work.
hooks:
@dir="$$(git rev-parse --git-path hooks)"; \
hook="$$dir/pre-push"; \
mkdir -p "$$dir"; \
if [ -e "$$hook" ] && ! grep -q 'scripts/pre-push' "$$hook"; then \
echo "$$hook exists already and is not this shim, so it is somebody's"; \
echo "own work. Move it aside and run make hooks again."; \
exit 1; \
fi; \
printf '%s\n' \
'#!/bin/sh' \
'# Installed by `make hooks`. The hook is the tracked script; this' \
'# shim only finds it, so it cannot go stale when that one changes.' \
'exec sh "$$(git rev-parse --show-toplevel)/scripts/pre-push" "$$@"' \
> "$$hook"; \
chmod +x "$$hook"; \
echo "installed $$hook"; \
echo "it runs the same check the workflow does, over what you are about to push"
# release-log captures a release run. The output a live run brings back is
# the whole product of making it, and until now the only record of what a
# release printed was whatever was still in the operator's scrollback
# afterwards — a record with a half-life.
#
# make release-log VERSION=<version> [ARGS=--yes]
#
# VERSION IS REQUIRED AND HAS NO DEFAULT. A capture of a run nobody named
# is a log of a refusal, and this is the one sequence here that cannot be
# taken back.
#
# THE CONSENT GATE IS NOT COPIED HERE. The script already refuses anything
# that is not a version and already changes nothing without --yes, so ARGS
# goes straight through. A second gate in this file would be one an
# operator can skip by not typing this command, sitting next to the one
# they cannot.
#
# THE LOG LANDS OUTSIDE THIS REPOSITORY, which is the reason for the
# default rather than an accident of layout. A release run prints real
# addresses and real identifiers; this repository is world-readable, and a
# log written inside it would be an untracked file no ignore rule covers,
# which is exactly the set the guards read as published. The path is a
# variable, so it can be pointed elsewhere, and nothing here writes into
# the tree.
#
# THE EXIT STATUS IS CARRIED IN A SIDECAR, because the capture must not be
# what decides it. script(1) here returns the child's status; other
# implementations return their own, and a target whose verdict depends on
# which one is installed reports success for a refused release on somebody
# else's machine. The status is written beside the log, read back, appended
# to the log, and this target exits with it — and a status that cannot be
# read back counts as a failure rather than a pass.
#
# The invocation is the BSD/macOS form, `script -q <file> <command>`; a GNU
# one would need -c.
#
# IT IS NOT PART OF ci AND IS NOT A PREREQUISITE OF ANYTHING ci RUNS. It
# spends a real release; the gate has to stay a command anybody can type on
# a checkout.
OPERATOR_LOGS ?= ../operator-logs
release-log:
@if [ -z "$(VERSION)" ]; then \
echo "release-log needs the version it is capturing:"; \
echo; \
echo " make release-log VERSION=<version> [ARGS=--yes]"; \
echo; \
echo "There is no default. A capture of a run nobody named is a log"; \
echo "of a refusal."; \
exit 1; \
fi
@mkdir -p $(OPERATOR_LOGS)
@log="$(OPERATOR_LOGS)/release-$$(date -u +%Y%m%dT%H%M%SZ).log"; \
echo "operator log: $$log"; \
RELEASE_LOG="$$log" script -q "$$log" $(SHELL) -c 'scripts/cut-release.sh $(VERSION) $(ARGS); echo $$? > "$$RELEASE_LOG.rc"'; \
rc=$$(cat "$$log.rc" 2>/dev/null || echo 1); rm -f "$$log.rc"; \
echo "exit $$rc" >> "$$log"; \
echo "operator log written: $$log"; \
exit $$rc
# THE DEFAULT BRANCH IS NOT A PLACE TO WORK, and the gate says so before
# it spends twelve minutes proving the tree is fine.
#
# Ruled 2026-09-12 after four commits of a task landed on a local main
# rather than on a branch. Nothing reached the trunk — the ruleset on the
# remote requires a pull request and has no bypass actors, so the push
# would have been refused — but the ruleset was the ONLY thing standing
# between those commits and main, and it was never exercised because no
# push was attempted. That is luck rather than process, and luck is not a
# control.
#
# The refusal is here rather than in a hook because this is the command a
# person runs before committing: it is the last moment the mistake is
# free to undo.
#
# IT IS SKIPPED UNDER CI, where the default branch is a legitimate place
# to run: a push to main after a merge runs this workflow, and so does
# the merge queue's own ref. Nothing is being committed there.
ci: guard-a-branch-to-work-on fmt vet build leak-scan test lint
guard-a-branch-to-work-on:
@if [ -z "$$CI" ] && [ "$$(git rev-parse --abbrev-ref HEAD 2>/dev/null)" = "$(DEFAULT_BRANCH)" ]; then echo "make ci refuses to run on $(DEFAULT_BRANCH)."; echo; echo "Work happens on a branch. Committing here is one keystroke from a"; echo "trunk nobody reviewed, and the remote's ruleset is the only thing"; echo "that would catch it — which is a control you should not be spending."; echo; echo " git checkout -b <name>"; exit 1; fi
# DEFAULT_BRANCH is named once so the guard above and anything that grows
# beside it cannot disagree about which branch is the trunk.
DEFAULT_BRANCH := main