Repository navigation
642 lines (622 loc) · 34.6 KB
/
Copy pathrelease.yml
File metadata and controls
642 lines (622 loc) · 34.6 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
name: release
# NOT ARMED, deliberately: no `push: tags:` trigger, and this workflow never
# creates a tag. It defaults to changing nothing, publishes nothing at all
# today (the `publish` step exits 1 on purpose), and exists so the release path
# is exercised now rather than discovered on the day it is first needed. Arming
# it is an `on:` block; the hard part is the ownership call.
#
# IT RUNS BY HAND AND ON A RELEASE PULL REQUEST. A pull request from a branch
# named `release/<svc>` builds that service and asserts its version stamping,
# publishing nothing -- so a release is proved buildable before it is cut,
# instead of the first dispatch after a tag discovering it is not. Every other
# pull request resolves no service and the build job skips, which costs the few
# seconds `plan` takes.
#
# DERIVED FROM THE BRANCH NAME, not from the diff, and the rejected alternative
# is worth recording. Firing when a `<svc>/version.json` changes fires on a
# subtree resync, which routinely carries upstream's own version bumps and is
# not a release -- up to four services at up to nine minutes each, on a pull
# request about something else. Narrowing that to "the diff is version bumps and
# nothing else" then excludes a release pull request that also edits a changelog.
# A branch name states the intent, which is the thing being detected.
#
# What that costs is a gap: bump a version on any other branch and nothing here
# looks, so a release can still be cut without this having run -- exactly the
# position every service is in today. It closes when the tag becomes something
# this workflow reacts to, which is the ownership call below.
#
# WHY NOT ARMED. Nothing outside this repository consumes a forge module
# version yet -- guppy and the other consumers still pin the polyrepo paths --
# and most of the fleet is barely tagged upstream. The roster is deliberately
# not enumerated here: it lives in the polyrepos, so nothing in CI can derive
# or check a copy. What decides it is narrower anyway. `indexing-service` has
# a live polyrepo release flow cutting its own tags, so cutting a tag here
# would give that service two sources of truth today. That is a decision about
# ownership, not a workflow change.
#
# WHAT IS AND IS NOT VERIFIED, precisely, because a vague claim here is worse
# than none in a repository whose rule 5 is "a green check is a claim about
# what ran".
#
# Run, THIS file: run 35777398448, a `release/ingot` pull request. plan
# resolved ingot in 11s and the release job built and asserted in 3m35s on
# ubuntu-24.04, with `require the tag` and `publish` skipped. That the
# release job ran at all is the evidence: it needs plan to have succeeded,
# `dir` non-empty, and the same-repository condition to hold.
# Run, AN EARLIER REVISION: both pull-request paths at b9fa2298, before the
# fork gate, the dry_run validation and the concurrency key. Run 35773432670
# from `release/ingot` built and asserted in 2m54s; run 35773389770 from an
# ordinary branch resolved nothing and skipped the release job.
#
# WHAT AN ORDINARY PULL REQUEST COSTS: `plan` runs, the build job does not, and
# it has never taken twenty seconds. A BOUND rather than a figure, on purpose.
# A single number was published here and in AGENTS.md and it was the slowest of
# the samples, overstating by about half; replacing it with the observed range
# then went stale on the next push, because the run that measures this text
# always postdates it. The bound is what a reader's decision turns on and it
# does not rot. To see the samples:
#
# gh run list --workflow release.yml --branch <branch> --event pull_request
# # then the `plan` job's duration on any of them
# Run, AN EARLIER FILE: two dry-run dispatches, ingot on ubuntu-24.04 (run
# 35762662020) and piri on macos-14 (run 35765210724). They are where the
# runner derivation, the skip list and the assertion were observed, and
# where the timings below come from -- but their `require the tag` and
# `publish` skips came from `if: ${{ !inputs.dry_run }}`, the spelling this
# file no longer uses. They say nothing about the current gating.
# Run: every shell step below, and the assertion script, against fixtures.
# Run on another machine: `goreleaser check` over all four configs and the
# tag-parsing measurements in MONOREPO_TODO.md, on v2.18.2. That is where
# the `format:` -> `formats:` fix came from.
# NOT run: a dispatch with dry_run off, which by design cannot go green --
# `publish` exits 1. Nor sprue or indexing-service, whose assertion refuses
# for the reason below. Nor anything behind the `docker` skip.
#
# WHAT A DRY RUN DOES PER SERVICE IS NOT WRITTEN DOWN HERE. It changes whenever
# the skip list or the assertion does, so narrating it is rule 3 in prose. Ask
# the command this workflow already runs:
#
# .github/scripts/assert-released-version.sh "<svc>/dist" "<version>"
#
# `ok` per binary, or `NOT ASSERTED`, or `CANNOT ASSERT` with the reason.
#
# WHY a service cannot be asserted is durable, being a property of its own code
# rather than of this file:
#
# indexing-service cmd/main.go is a urfave/cli App with no Version and no
# version command: the binary cannot be asked what it is.
# sprue the same from the other framework -- a cobra root with no
# `version` subcommand and no Version field.
# piri has a version surface. It is the expensive one -- macos-14,
# cross-compiling darwin/amd64 + darwin/arm64 with cgo
# against curio and lotus, then a universal binary, then two
# linux builds -- which is what `timeout-minutes` is sized
# for and what a release pull request costs.
# ingot has a version surface, and its dist carries images, which
# is why `docker` is in the skip list below.
#
# The fix for the first two is ~15 lines each, modelled on
# piri/cmd/cli/version.go, and is deliberately NOT bundled into this pull
# request: softening the assertion to make them pass is the one change that
# would make this whole file decorative.
on:
workflow_dispatch:
inputs:
service:
description: "Service directory to release (one that has a .goreleaser.yaml)"
required: true
type: string
dry_run:
description: "Build and assert, publish nothing. Leave on unless you mean it."
type: boolean
default: true
# No `paths:`, because there is no path to filter on -- what selects a service
# here is the branch name, and `plan` resolves none for an ordinary pull
# request. A filter would also report *skipped*, which never satisfies a
# required status check, if this ever becomes one.
pull_request:
concurrency:
# A dispatch is keyed by service; a pull request by its branch, so two release
# pull requests do not serialise behind each other. Superseding is right for a
# pull request (a new push obsoletes the run) and wrong for a dispatch, which
# someone is waiting on.
#
# The event is IN the key, not just in `cancel-in-progress`. Without it a
# dispatch of `ingot` and a pull request from a branch named `ingot` land in
# one group -- and since the joining run's setting decides, the pull request
# would cancel the dispatch, which is the thing the line below exists to
# prevent.
#
# The pull request's NUMBER, not its branch name, for the same reason one step
# further out: a fork's `release/ingot` and ours are the same `head_ref`, so a
# fork pull request would cancel our in-flight release build. The number is
# unique across both.
group: ${{ github.workflow }}-${{ github.event_name }}-${{ inputs.service || github.event.pull_request.number }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# Split from `release` because `runs-on` is fixed when a job starts, and the
# right runner is a property of the service: piri's .goreleaser.yaml builds
# darwin with CGO_ENABLED=1, and its own header says that "cannot be
# cross-compiled from linux". A single hardcoded ubuntu runner cannot build
# the one service in the fleet that has a real published tag.
plan:
runs-on: ubuntu-24.04
permissions:
contents: read
outputs:
dir: ${{ steps.svc.outputs.dir }}
version: ${{ steps.ver.outputs.version }}
runner: ${{ steps.svc.outputs.runner }}
needs_docker: ${{ steps.svc.outputs.needs_docker }}
# ONE mode value, read by every `if:` downstream. `inputs` does not exist
# on a pull_request event, so `inputs.dry_run` is null there and
# `!inputs.dry_run` -- which is how the steps below used to spell it --
# evaluates TRUE. That would run `require the tag` on a pull request whose
# tag is not supposed to exist yet, and hand GITHUB_TOKEN to a build
# triggered by a fork. Resolving the mode once, here, is the fix; there is
# no `inputs.dry_run` left in the release job.
dry_run: ${{ steps.svc.outputs.dry_run }}
steps:
- uses: actions/checkout@v4
with:
# Tags, for the "not already released" check below. A default checkout
# fetches none, which would make that check pass by seeing nothing.
# Not `fetch-tags: true` with a shallow clone, which would be cheaper:
# checkout ignores an input it does not know with a warning, so on an
# older v4 that spelling fails OPEN -- no tags, check green. Depth 0
# cannot do that, and measures ~6s on this repository.
fetch-depth: 0
# Which services are releasable is DERIVED from which ones carry a
# goreleaser config, so a service that gains or loses one needs no edit
# here. A free-text input is validated against that list rather than a
# `type: choice`, because a choice list is exactly the hand-maintained
# list this repository keeps getting wrong.
#
# Both spellings, because `check-goreleaser-ldflags.sh` accepts both. Two
# derivations of one list that disagree is rule 3 happening twice: the
# guard would lint a `.goreleaser.yml` service and pass, giving every
# impression it was wired in, and a dispatch for it would then be refused.
#
# The depth bound is a REMAINING difference, and deliberate: the guard
# lints configs at any depth, while a releasable *service* is a top-level
# directory. All four configs sit at depth 2 today, so nothing diverges
# yet -- but a nested one (ingot/itest/.goreleaser.yaml, say) would be
# linted and not dispatchable, which is the same shape as the spelling
# bug.
- name: resolve the service
id: svc
env:
SERVICE: ${{ inputs.service }}
DRY_RUN: ${{ inputs.dry_run }}
HEAD_REF: ${{ github.head_ref }}
run: |
set -euo pipefail
# WHICH SERVICE, and in which mode. Two events reach this job and they
# answer differently; everything after this point is common.
#
# HEAD_REF arrives by env, like SERVICE, and is only ever COMPARED
# against the releasable list below -- and `dir`, derived from it,
# reaches the next step by env too, never by an expression
# interpolated into a script. A branch name can hold `$ ( ) { }`; git
# rejects only a space, and `${IFS}` covers that. The one step that
# did interpolate `dir` ran the substitution, which is why none do
# now. (Spelling the interpolation out here would make actionlint
# parse this comment as an expression, so it is described instead.)
case "$GITHUB_EVENT_NAME" in
workflow_dispatch)
svc=${SERVICE%/}
# Exactly `true` or `false`, because every downstream test is
# `!= 'true'` and therefore FAILS OPEN: any other string -- and an
# empty one is reachable, since a dispatch through the API is not
# obliged to send the declared default -- selects real-release
# mode, which is the mode that passes GITHUB_TOKEN to goreleaser.
# `resolve the version` already refuses a version.json value
# outside its charset; the flag gating the token was taking any
# string at all.
case "$DRY_RUN" in
true|false) dry_run=$DRY_RUN ;;
*) echo "::error::dry_run must be true or false, not '$DRY_RUN'"
exit 1 ;;
esac
;;
pull_request)
dry_run=true
case "$HEAD_REF" in
release/*) svc=${HEAD_REF#release/} ;;
*)
echo "$HEAD_REF is not a release/* branch, so there is no" \
"service to release. Skipping the build."
# `runs-on:` reads `runner` even though the job is about to
# be skipped, and an empty string is not a runner label.
# Written once per path rather than as an overridden default,
# because a repeated key in $GITHUB_OUTPUT is not a documented
# last-one-wins.
{ echo "dir="
echo "dry_run=true"
echo "runner=ubuntu-24.04"; } >> "$GITHUB_OUTPUT"
exit 0
;;
esac
;;
*)
echo "::error::unhandled event $GITHUB_EVENT_NAME"
exit 1
;;
esac
echo "dry_run=$dry_run" >> "$GITHUB_OUTPUT"
mapfile -t releasable < <(find . -mindepth 2 -maxdepth 2 \
\( -name '.goreleaser.yaml' -o -name '.goreleaser.yml' \) \
-printf '%h\n' | sed 's|^\./||' | sort -u)
printf 'releasable: %s\n' "${releasable[*]}"
found=
for r in "${releasable[@]}"; do
[ "$r" = "$svc" ] && found=1 && break
done
if [ -z "$found" ]; then
echo "::error::'$svc' has no goreleaser config. Releasable:" \
"${releasable[*]}. (On a pull request the name comes from the" \
"branch: release/<svc>.)"
exit 1
fi
echo "dir=$svc" >> "$GITHUB_OUTPUT"
# `-print -quit`, not `| head -1`: head exits on its first line, find
# takes SIGPIPE, and pipefail turns that into a step failure with no
# message at all. find stopping itself has no pipe to break. This job
# always runs on ubuntu, so GNU find's -quit is available.
cfg=$(find "$svc" -maxdepth 1 \
\( -name '.goreleaser.yaml' -o -name '.goreleaser.yml' \) \
-print -quit)
# DERIVED, not tabulated: a config that builds darwin with cgo cannot
# be cross-compiled from linux, so it needs a macOS runner. A service
# that gains or drops a darwin cgo build moves itself.
#
# Parsed, not grepped: every config mentions darwin somewhere (a
# format_override, a comment), so a whole-file grep routes a config
# that sets CGO_ENABLED=1 for a LINUX build to macOS, where that
# build cannot be done. Read the builds: list. PyYAML is present on
# the ubuntu runner image, and this job always runs there.
needs_macos=$(python3 -c "import sys,yaml;c=yaml.safe_load(open(sys.argv[1])) or {};print('true' if any('darwin' in (b.get('goos') or []) and 'CGO_ENABLED=1' in ' '.join(b.get('env') or []) for b in (c.get('builds') or [])) else 'false')" "$cfg")
if [ "$needs_macos" = "true" ]; then
echo "runner=macos-14" >> "$GITHUB_OUTPUT"
else
echo "runner=ubuntu-24.04" >> "$GITHUB_OUTPUT"
fi
# Likewise for Docker: only a config with a docker block needs buildx,
# QEMU and a registry login. Both spellings, since v2 accepts
# `dockers_v2:` and missing it would skip QEMU for an arm64 build --
# the same two-spellings class handled above for the config filename.
if grep -qE '^dockers(_v2)?:' "$cfg"; then
needs_docker=true
else
needs_docker=false
fi
echo "needs_docker=$needs_docker" >> "$GITHUB_OUTPUT"
# Refuse a combination that cannot run rather than letting it surface
# as a buildx failure six steps later: GitHub's macOS runners ship no
# Docker daemon. Nothing hits this today -- piri is the only macOS
# service and has no docker block -- but both values above are built
# to move themselves, so nothing stops a future edit producing it.
if [ "$needs_macos" = "true" ] && [ "$needs_docker" = "true" ]; then
echo "::error::$svc needs a macOS runner (darwin cgo build) and also" \
"builds Docker images. GitHub's macOS runners have no Docker" \
"daemon, so this cannot be released by this workflow as configured."
exit 1
fi
cat "$GITHUB_OUTPUT"
# The version comes from the service's own version.json, which is the
# convention every one of these repos already used upstream (ipdxco's
# releaser reads the same file). Reading it here rather than taking it as
# an input means the workflow cannot be asked to build one version and
# label it another.
- name: resolve the version
id: ver
# `exit 0` above ends the step, not the job, so without this an ordinary
# pull request reaches here with no service and fails on a missing
# /version.json.
if: steps.svc.outputs.dir != ''
# BY env, NOT `${{ }}` INTO THE SCRIPT, which is how every other step
# here already takes a resolved value and is not a style preference:
# `${{ }}` is substituted textually before bash sees the line, so
# whatever `dir` holds becomes shell source. On a `pull_request`,
# `dir` is fork-controlled -- see the step above -- and the chain is
# short: a fork adds a directory literally named
# `evil$(touch${IFS}PWNED)x` with a .goreleaser.yaml, pushes the branch
# `release/evil$(touch${IFS}PWNED)x` (git accepts `$ ( ) { }` in a ref;
# only space is illegal, and ${IFS} covers that), the `find` below
# enumerates the PULL REQUEST's tree so the directory is in
# `releasable`, the name matches, and this line runs the substitution.
# Reproduced end to end. The same-repository gate above closes it too;
# this is the half that does not depend on getting a condition right.
env:
DIR: ${{ steps.svc.outputs.dir }}
run: |
set -euo pipefail
f="$DIR/version.json"
if [ ! -f "$f" ]; then
echo "::error::$f is missing, so there is no version to release."
exit 1
fi
v=$(jq -er .version "$f")
# Strict, because this value reaches both shell and GITHUB_OUTPUT.
# `case v[0-9]*` permitted anything after the first digit, including a
# NEWLINE -- and a newline in a GITHUB_OUTPUT write injects further
# outputs. A version.json of "v1.0.0\nrunner=evil" set `runner`, which
# is what `runs-on:` reads. Reject anything outside the semver charset.
case "$v" in
v[0-9]*) ;;
*) echo "::error::$f has version '$v', which is not vX.Y.Z"; exit 1 ;;
esac
if printf '%s' "$v" | LC_ALL=C grep -qE '[^0-9A-Za-z.+-]|^$' \
|| [ "$v" != "$(printf '%s' "$v" | tr -d '\n')" ]; then
echo "::error::$f has a version containing characters outside" \
"[0-9A-Za-z.+-]. Refusing rather than passing it to shell."
exit 1
fi
echo "version=$v" >> "$GITHUB_OUTPUT"
echo "Releasing $DIR $v"
# The mirror image of `require the tag`, which the release job runs for a
# real release: there the tag must already exist at this commit, here it
# must not exist at all. A release pull request proposing a version that
# is already cut is a forgotten bump, and merging it would either fail at
# tag time or quietly re-point a released version.
#
# IT CANNOT FAIL TODAY, which is worth saying rather than leaving a green
# check to imply otherwise: `git ls-remote --tags origin` is empty, so a
# runner's checkout has no tags of any kind to find and every service
# passes. It starts meaning something the day the first one is cut.
# Verified in both directions against a scratch tag, not by reading it.
#
# A working clone can disagree, and misleadingly: adding the upstream
# polyrepos as remotes brings their tags in, so `git tag` locally shows
# v0.0.0, v0.2.4 and v1.13.4 -- all unprefixed, none of them this
# repository's, and none of them on origin.
- name: the version must not already be released
if: steps.svc.outputs.dir != '' && github.event_name == 'pull_request'
env:
DIR: ${{ steps.svc.outputs.dir }}
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
tag="$DIR/$VERSION"
if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
at=$(git rev-parse "refs/tags/$tag^{commit}")
echo "::error::tag '$tag' already exists, at $at. Bump" \
"$DIR/version.json to the version you mean to release."
exit 1
fi
echo "$tag is not taken"
release:
needs: plan
# `dir` is empty on an ordinary pull request, so this is what keeps a pull
# request that is not a release down to the cost of `plan` alone.
#
# The `result` clause is explicit on purpose, and not redundant to delete.
# Whether a job's own `if:` overrides the default "skip me if a need failed"
# is a reading of the docs rather than something measured here, and a `plan`
# that failed AFTER resolving the service -- on the version, or on the tag
# check -- must not start a release build under EITHER reading.
#
# NO FORK CONDITION, and that is a decision rather than an omission: one was
# added here and then removed, so it is worth saying why it is not coming
# back. Pull requests on this repository come from the team, with push
# access; the only fork is `fil-forge/forge-2`, our own archived copy of the
# migration. `ci`, `e2e`, `images` and `itest` have all triggered on
# `pull_request` since before this file did, so a fork opening pull requests
# would already be spending 23 minutes of `itest` before reaching three
# minutes of ingot here.
#
# And there is nothing here for one to take. A `pull_request` from a fork
# runs in THIS repository's Actions with no secrets and a read-only token --
# which is what `pull_request_target` exists to provide and why that event,
# not this one, is the dangerous one. This tree references exactly one
# secret, GITHUB_TOKEN, which is provisioned rather than stored; every
# `permissions:` block in every workflow is `contents: read`; every runner is
# GitHub-hosted and ephemeral; and the repository is public, so there is
# nothing to exfiltrate. What is left is compute on free minutes.
#
# The condition also could not do what it looked like: a `pull_request` runs
# the workflow definitions from the PULL REQUEST's own commit, so a fork's
# copy of this file is whatever the fork wrote and the condition is not in
# the code that runs. A guard that reads as total and is not is the thing
# rule 5 says to ship nothing rather than -- and this one had already
# produced two wrong comments about itself.
#
# WHERE IT WOULD BECOME REAL is arming publishing, which needs
# packages:write and a registry credential. The permissions comment below
# says what that costs.
#
# What survived from all of it is unconditional and cheap: `dir` reaches a
# shell by `env:`, never by `${{ }}`, here and in every step of this file.
if: needs.plan.result == 'success' && needs.plan.outputs.dir != ''
runs-on: ${{ needs.plan.outputs.runner }}
# In line with e2e (25), images (40) and itest (45). Without it a hung
# goreleaser or a stalled module fetch sits for GitHub's 6-hour default,
# on a macOS runner for piri. Measured on the dry runs named in the header:
# piri 8m47s on macos-14 (6m56s of it goreleaser, 1m18s the setup-go cache
# save), ingot 3m11s on ubuntu-24.04.
timeout-minutes: 40
env:
# THE ARTIFACT MUST BE THE ONE CI PROVED. go.work at the root puts a
# service build in workspace mode, which resolves a different module
# graph: measured on this tree, `go list -m all` for indexing-service
# differs on 1402 lines between the two modes. ci.yml pins GOWORK=off for
# exactly this reason, and ingot's own CLAUDE.md says to release with it.
# Rule 4, test what ships.
GOWORK: "off"
# As ci.yml, e2e.yml and itest.yml all do: the comma-separated default
# falls back only on 404/410, and proxy.golang.org drops HTTP/2 streams
# (golang/go#51323). The pipe form falls back on any error.
GOPROXY: "https://proxy.golang.org|direct"
# READ ONLY, because at this head nothing here can use more: there is no
# ghcr login, `docker` and `publish` are both skipped so goreleaser creates
# no release and pushes no image (it runs happily with GITHUB_TOKEN unset --
# measured), and the publish step only echoes and exits 1.
#
# This is a SECOND fail-closed mechanism, independent of the skip list: if
# a future edit drops `publish` from the skips, goreleaser still cannot
# create the release, because the token cannot write.
#
# A job's permissions are fixed before any input or output can be read, so
# this cannot differ by mode -- which is the property that makes it a second
# mechanism rather than a restatement of the first.
#
# THIS JOB DOES NOT RUN FOR A FORK at all -- see the `if:` above, which
# requires a same-repository pull request -- so the fork question here is
# only about what arming publishing would cost. An earlier revision of this
# comment described the world before that gate, and listed three things
# holding a fork back that the gate then made beside the point.
#
# What still matters: a `pull_request` runs the workflow definitions from
# the pull request's own commit, so a fork's copy of this file is whatever
# the fork wrote, and neither the `if:` above nor this block is in it. Only
# GITHUB_TOKEN being read-only for a fork pull request survives that, and it
# survives it regardless of what is granted here.
#
# So arming publishing is not a matter of granting contents:write and
# packages:write here: doing that alone would widen what a same-repository
# pull request can do, since `runs-on` and the whole build come from the
# pull request's tree. It needs the grant conditional on the event, or the
# publish path moved to a workflow a pull request cannot trigger.
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
# goreleaser reads tags and commit history to derive the version and
# the changelog; a shallow clone makes it invent both.
fetch-depth: 0
# ci.yml carries a measured note for this repository: setup-go resolves
# its dependency file from the root, there is no root go.sum, and
# go.work.sum is gitignored -- so a bare `cache: true` restores nothing
# and fails only as a warning. Point it at the service being built.
- uses: actions/setup-go@v5
with:
go-version-file: go.work
cache: true
cache-dependency-path: ${{ needs.plan.outputs.dir }}/go.sum
# NO DOCKER STEPS, and `docker` is in the skip list below. It has to be
# named there explicitly: `--skip=publish` does not cover it, because the
# docker PUSH is a publisher and the docker BUILD is not. `docker` is its
# own skip value in goreleaser v2.18.2:
#
# valid options are announce, archive, aur, ..., docker, ..., publish, ...
#
# and running the real ingot config with exactly the flags below, on a
# machine with no docker daemon:
#
# • docker images
# • building docker image image=ghcr.io/fil-forge/ingot:0.0.0-arm64
# • building docker image image=ghcr.io/fil-forge/ingot:0.0.0-amd64
# ⨯ release failed after 2m24s
#
# On an ubuntu runner, which does have a daemon, that build proceeds --
# spending minutes on images this workflow says it does not build, then
# failing on the arm64 one for want of a binfmt handler.
#
# When publishing is armed, three things come back together and not
# separately: setup-qemu, setup-buildx, and dropping `docker` here.
#
# The consequence worth stating: a green dry run says nothing about
# `Dockerfile.release` or the arm64 emulation. Exercising those needs
# --snapshot (which stamps a version the assertion cannot check) or a
# separate build, and is a real gap rather than a solved problem.
# A real release needs the tag to exist already, AND to point at the
# commit being built. This workflow does not create one: tagging is the
# act that says "this is released", and it stays a deliberate human step
# rather than a side effect of a dispatch.
#
# Checking existence alone is not enough. With piri/v0.2.4 and
# piri/v0.2.5 both present and HEAD at v0.2.5, an existence check passes
# while goreleaser derives v0.2.5 from `git describe` and the assertion
# below checks for v0.2.4 -- publishing one version and verifying another.
# Via env, not `${{ }}` interpolated into the script. The `case` that
# validates version.json is `v[0-9]*`, whose `*` matches anything after
# the first digit, so a version.json value reaches shell in a job with
# write tokens. The `service` input is already handled this way; the
# file-derived value was not.
- name: require the tag, at this commit, for a real release
if: needs.plan.outputs.dry_run != 'true'
env:
DIR: ${{ needs.plan.outputs.dir }}
VERSION: ${{ needs.plan.outputs.version }}
run: |
set -euo pipefail
tag="$DIR/$VERSION"
if ! git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
echo "::error::tag '$tag' does not exist. Create it first --" \
"this workflow deliberately will not."
exit 1
fi
at=$(git rev-parse "refs/tags/$tag^{commit}")
if [ "$at" != "$GITHUB_SHA" ]; then
echo "::error::tag '$tag' is at $at, but this run is building" \
"$GITHUB_SHA. Release from the tagged commit."
exit 1
fi
echo "found $tag at $at"
# goreleaser cannot parse a `<svc>/vX.Y.Z` tag: MONOREPO_TODO.md records
# `failed to parse tag 'piri/v1.2.3' as semver` against a real run. So it
# is never shown one. GORELEASER_CURRENT_TAG carries the plain semver from
# version.json, which is the same string the assertion checks for, so the
# stamped version matches in BOTH modes. This is also why neither mode
# uses `--snapshot`: it stamps `0.0.1-SNAPSHOT-<sha>`, which the
# assertion can never match.
# PINNED, never `@latest`: that resolves and executes third-party code
# at run time and would silently pick up a v3. Rule 2, pin what we don't
# own. Every measurement this workflow reasons from was taken on v2.18.2.
- name: build (publishing nothing yet)
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: v2.18.2
workdir: ${{ needs.plan.outputs.dir }}
args: release --clean --skip=validate,publish,sign,announce,docker
env:
GORELEASER_CURRENT_TAG: ${{ needs.plan.outputs.version }}
# Not on the dry-run path: goreleaser's env pipe does not require the
# token when publishing is skipped, and the permissions comment above
# claims the dry run reaches no step that uses one.
GITHUB_TOKEN: ${{ needs.plan.outputs.dry_run != 'true' && secrets.GITHUB_TOKEN || '' }}
# BEFORE anything is published, which is the whole point: an assertion
# that trails `goreleaser release` finds the assets already live and
# already wrong, and only turns the job red afterwards.
# Via env, for the same reason as the tag step above.
- name: assert the binaries report their version
env:
DIR: ${{ needs.plan.outputs.dir }}
VERSION: ${{ needs.plan.outputs.version }}
run: |
.github/scripts/assert-released-version.sh "$DIR/dist" "$VERSION"
# PUBLISHING IS CLOSED, and this is not a placeholder: two properties of
# goreleaser's release pipe rule out the obvious implementation.
#
# goreleaser's release pipe calls GitHub's create-release API with
# TagName: ctx.Git.CurrentTag, and sets TargetCommitish only when a config
# asks for it -- none of the four do. GitHub creates a tag that does not
# exist. So feeding it the plain semver (which is what makes the version
# stamping work, above) would have it create an UNPREFIXED `v0.2.4` at the
# default branch head: exactly the tag this workflow's header promises it
# never creates, in the namespace ten services share.
#
# It is worse than that. All four configs set `release: mode:
# keep-existing`, and hilt, ingot and swarf all carry v0.0.0 in
# version.json. Releasing a second service at a version the first already
# used would upload its artifacts into the first service's release rather
# than failing.
#
# Both are consequences of publishing GitHub Releases from a monorepo with
# one tag namespace, and neither is fixable by a flag here. The choice --
# per-service `release: disable: true` plus some other artifact home, or a
# tag scheme goreleaser and Go both accept -- belongs with the ownership
# decision this workflow is already waiting on. Until then the build and
# the assertion above are the useful part, and they run in both modes.
- name: publish
if: needs.plan.outputs.dry_run != 'true'
run: |
echo "::error::Publishing is deliberately closed. goreleaser would" \
"create an unprefixed tag in a namespace ten services share," \
"and keep-existing would merge artifacts into another service's" \
"release. See the comment above this step, and MONOREPO_TODO's" \
"Phase 1 entry. The build and version assertion above have run" \
"and are the part that is ready."
exit 1