Skip to content

smelt: .env.published was missing the indexer, and nothing could notice #19

smelt: .env.published was missing the indexer, and nothing could notice

smelt: .env.published was missing the indexer, and nothing could notice #19

Workflow file for this run

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