Repository navigation
smelt: .env.published was missing the indexer, and nothing could notice #19
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: 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 |