# Release, Go binaries. Runs on version tags (v1.2.3) pushed to main. # # The version contract these steps implement is in the `release` skill, and its point is # that nothing is injected: the toolchain records the tag into the binary's build # information, so the build simply has to happen at the tag, which the trigger guarantees. # # The gates run in their own job, once, before the matrix, minus the race detector: race # never runs on a push path or a tag, and the local gate raced this tree before the tag # was cut. Putting the gates inside the matrix would run the whole suite once per target # on the box that also hosts the forge. Each job validates the tag for itself rather than # passing a value between jobs, so no workflow feature has to be trusted for the version # to reach the file name. name: Release on: push: tags: ["v*"] env: # interpres is fetched directly from the self-hosted Gitea, not via # proxy.golang.org, and skips the public checksum database. GOPRIVATE: sourcedock.dev # The box is shared with the forge, so parallelism is bounded on purpose. The gates job # needs it most; the build jobs inherit it for their parallel compilation. GOFLAGS: -p=1 GOMAXPROCS: "2" jobs: gates: runs-on: fedora timeout-minutes: 10 steps: - uses: actions/checkout@v7 - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true - name: Install Perl # Perl for the steps below. The install is a no-op where the package # is already present. run: dnf install -y perl - name: Validate the tag env: VERSION: ${{ gitea.ref_name }} run: | perl -e ' my $v = $ENV{VERSION} // q{}; $v =~ m{^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$} or die qq{ERROR: expected a semver tag like v1.2.3, got: $v\n}; print qq{tag $v\n}; ' - name: Build run: go build ./... - name: Format run: | perl -e ' open(my $g, q{-|}, q{gofmt}, q{-l}, q{.}) or die qq{gofmt: $!}; my @bad = <$g>; close($g); print @bad; exit(@bad ? 1 : 0); ' - name: Vet run: go vet ./... - name: Modernise run: go fix -diff ./... - name: Tests # Equal to `packages` in the project's justfile: the logic packages hold the # floor, and the thin cmd/ would drag it under 80 %. run: go test -count=1 -timeout 10m -coverprofile=coverage.out ./internal/... - name: Coverage floor run: | perl -e ' open(my $c, q{-|}, q{go}, q{tool}, q{cover}, q{-func=coverage.out}) or die qq{cover: $!}; my $total; while (my $l = <$c>) { $total = $1 if $l =~ m{^total:\s+\S+\s+([0-9.]+)%} } close($c); die qq{no total line in coverage.out\n} unless defined $total; printf qq{Total coverage: %s%%\n}, $total; exit($total < 80 ? 1 : 0); ' build: runs-on: fedora timeout-minutes: 25 needs: gates strategy: fail-fast: false matrix: # Portable targets: amd64, arm64, loong64 and riscv64 on Linux; amd64 # and arm64 on FreeBSD, shipped as cross compiles and runtime untested. # freebsd/riscv64 is not a supported port and is not shipped. The build # constraints declare linux and freebsd only, so there is no macOS, no # Windows, no 32-bit, no wasm. Add GOOS=android GOARCH=arm64 where a # project ships Android. include: - goos: linux goarch: amd64 - goos: linux goarch: arm64 - goos: linux goarch: loong64 - goos: linux goarch: riscv64 - goos: freebsd goarch: amd64 - goos: freebsd goarch: arm64 steps: - uses: actions/checkout@v7 - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true - name: Install Perl run: dnf install -y perl - name: Validate the tag id: version env: VERSION: ${{ gitea.ref_name }} run: | perl -e ' my $v = $ENV{VERSION} // q{}; $v =~ m{^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$} or die qq{ERROR: expected a semver tag like v1.2.3, got: $v\n}; (my $nv = $v) =~ s{^v}{}; open(my $o, q{>>}, $ENV{GITEA_OUTPUT}) or die qq{GITEA_OUTPUT: $!}; print $o qq{version_no_v=$nv\n}; close($o); print qq{version $nv\n}; ' - name: Build env: VERSION_NO_V: ${{ steps.version.outputs.version_no_v }} GOOS: ${{ matrix.goos }} GOARCH: ${{ matrix.goarch }} CGO_ENABLED: "0" run: | # Nothing is injected. The toolchain records the tag into the binary's build # information, so the version is right because this build happens at the tag, and # there is no path for anyone to get wrong. -s -w only strips symbols. go build -ldflags "-s -w" -o "bin/nuntius-${VERSION_NO_V}-${GOOS}-${GOARCH}" ./cmd/server # Artefacts stay on v3: v4 and later detect Gitea as GHES and abort. - name: Upload artefact uses: actions/upload-artifact@v3 with: name: nuntius-${{ matrix.goos }}-${{ matrix.goarch }} path: bin/nuntius-${{ steps.version.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }} if-no-files-found: error - name: Smoke test # Only a binary matching the runner can be run here. The check is not that --version # exits cleanly but that it reports the tag and nothing more: a build outside version # control reports (devel), and a build whose tree was dirty reports +dirty, and both # would otherwise be published. if: matrix.goos == 'linux' && matrix.goarch == 'amd64' env: TAG: ${{ gitea.ref_name }} BIN: bin/nuntius-${{ steps.version.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }} run: | perl -e ' my $want = $ENV{TAG} // die qq{ERROR: no tag\n}; open(my $bin, q{-|}, $ENV{BIN}, q{--version}) or die qq{$ENV{BIN}: $!}; my $got = <$bin>; close($bin); $got = defined $got ? $got : q{}; chomp $got; index($got, $want) >= 0 or die qq{ERROR: the binary printed "$got", which does not contain $want. Version control was disabled, so there is no recorded version.\n}; index($got, q{+dirty}) < 0 or die qq{ERROR: the binary printed "$got". The tree was dirty at build time, which means the checkout was not the tag, or the build artefacts are not ignored.\n}; print qq{$ENV{BIN} reports $got\n}; ' release: runs-on: fedora timeout-minutes: 15 needs: build permissions: # contents: read is required for the checkout: a job that declares any # permissions gets a token scoped to exactly those, and releases: write # alone leaves the fetch with no read access, which Gitea answers with # a 404 "Repository not found". Verified on the instance 2026-09-16. contents: read releases: write steps: - uses: actions/checkout@v7 - name: Download all artefacts uses: actions/download-artifact@v3 with: path: dist - name: Install Perl run: dnf install -y perl - name: Extract the CHANGELOG section env: VERSION: ${{ gitea.ref_name }} run: | # Each step derives what it needs from the tag, so no value has to travel between # jobs. perl -e ' my $v = $ENV{VERSION} // q{}; $v =~ s{^v}{}; open(my $vout, q{>}, q{version-no-v.txt}) or die qq{version-no-v.txt: $!}; print $vout $v; close($vout); open(my $in, q{<}, q{CHANGELOG.md}) or die qq{CHANGELOG.md: $!}; my @lines = <$in>; close($in); my ($start, $end) = (-1, scalar @lines); for my $i (0 .. $#lines) { if ($start < 0) { $start = $i if $lines[$i] =~ m{^##\s+\[\Q$v\E\]} } elsif ($lines[$i] =~ m{^##\s+\[}) { $end = $i; last } } $start >= 0 or die qq{ERROR: no CHANGELOG section for $v, expected a heading like: ## [$v] - YYYY-MM-DD\n}; my @body = grep { m{\S} } @lines[$start + 1 .. $end - 1]; @body or die qq{ERROR: the CHANGELOG section for $v is empty\n}; open(my $out, q{>}, q{release-body.md}) or die qq{release-body.md: $!}; print $out @body; close($out); printf qq{notes for %s: %d lines\n}, $v, scalar @body; ' - name: Build the release request run: | perl -e ' open(my $vin, q{<}, q{version-no-v.txt}) or die qq{version-no-v.txt: $!}; my $v = <$vin>; close($vin); chomp $v; open(my $in, q{<:raw}, q{release-body.md}) or die qq{release-body.md: $!}; my $body = do { local $/; <$in> }; close($in); # Byte-oriented escaping: JSON is UTF-8, so non-ASCII passes through and only the # characters JSON forbids are rewritten. $body =~ s/([\\"])/\\$1/g; $body =~ s/\t/\\t/g; $body =~ s/\r//g; $body =~ s/\n/\\n/g; $body =~ s/([\x00-\x08\x0b\x0c\x0e-\x1f])/sprintf(q{\u%04x}, ord($1))/ge; my $json = sprintf(qq{{"tag_name":"v%s","name":"v%s","body":"%s","draft":false,"prerelease":false}}, $v, $v, $body); open(my $out, q{>}, q{release.json}) or die qq{release.json: $!}; print $out $json; close($out); print qq{release.json written for v$v\n}; ' - name: Create the release env: GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_SERVER_URL: ${{ gitea.server_url }} GITEA_REPOSITORY: ${{ gitea.repository }} run: | perl -e ' my @cmd = (q{curl}, q{-sS}, q{-o}, q{response.json}, q{-w}, q{%{http_code}}, q{-H}, qq{Authorization: token $ENV{GITEA_TOKEN}}, q{-H}, q{Content-Type: application/json}, q{-X}, q{POST}, qq{$ENV{GITEA_SERVER_URL}/api/v1/repos/$ENV{GITEA_REPOSITORY}/releases}, q{--data-binary}, q{@release.json}); open(my $curl, q{-|}, @cmd) or die qq{curl: $!}; my $code = <$curl>; my $ok = close($curl); my $exit = $? >> 8; $code = defined $code ? $code : q{}; $ok or die qq{ERROR: curl failed (exit $exit) calling $ENV{GITEA_SERVER_URL}\n}; open(my $r, q{<:raw}, q{response.json}) or die qq{response.json: $!}; my $body = do { local $/; <$r> }; close($r); $code eq q{201} or die qq{ERROR: the release was not created, HTTP $code: $body\n}; $body =~ m{"id"\s*:\s*([0-9]+)} or die qq{ERROR: no release id in the response: $body\n}; open(my $o, q{>}, q{release-id.txt}) or die qq{release-id.txt: $!}; print $o $1; close($o); print qq{release id $1\n}; ' - name: Upload assets env: GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_SERVER_URL: ${{ gitea.server_url }} GITEA_REPOSITORY: ${{ gitea.repository }} run: | perl -e ' open(my $f, q{<}, q{release-id.txt}) or die qq{release-id.txt: $!}; my $id = <$f>; close($f); chomp $id; my @files = grep { -f $_ } glob(q{dist/*/*}); @files or die qq{ERROR: no assets under dist/\n}; my $bad = 0; for my $path (@files) { (my $name = $path) =~ s{.*/}{}; my @cmd = (q{curl}, q{-sS}, q{-o}, q{/dev/null}, q{-w}, q{%{http_code}}, q{-H}, qq{Authorization: token $ENV{GITEA_TOKEN}}, q{-H}, q{Content-Type: application/octet-stream}, q{-X}, q{POST}, q{--data-binary}, q{@} . $path, qq{$ENV{GITEA_SERVER_URL}/api/v1/repos/$ENV{GITEA_REPOSITORY}/releases/$id/assets?name=$name}); open(my $curl, q{-|}, @cmd) or die qq{curl: $!}; my $code = <$curl>; my $ok = close($curl); my $exit = $? >> 8; $code = defined $code ? $code : q{}; unless ($ok) { printf qq{%s: curl failed (exit %d)\n}, $name, $exit; $bad = 1; next; } printf qq{%s: HTTP %s\n}, $name, $code; $bad = 1 if $code ne q{201}; } exit($bad ? 1 : 0); ' - name: Read the assets back # HTTP 201 from the upload alone lies: an attachment can be created # and still stored empty. Every asset is read back through the release # download route, and the served length must equal the sent file. env: GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_SERVER_URL: ${{ gitea.server_url }} GITEA_REPOSITORY: ${{ gitea.repository }} TAG: ${{ gitea.ref_name }} run: | perl -e ' my @files = grep { -f $_ } glob(q{dist/*/*}); @files or die qq{ERROR: no assets under dist/\n}; my $bad = 0; for my $path (@files) { (my $name = $path) =~ s{.*/}{}; my @cmd = (q{curl}, q{-sS}, q{-o}, q{asset-readback.bin}, q{-w}, q{%{http_code} %{size_download}}, q{-H}, qq{Authorization: token $ENV{GITEA_TOKEN}}, qq{$ENV{GITEA_SERVER_URL}/$ENV{GITEA_REPOSITORY}/releases/download/$ENV{TAG}/$name}); open(my $curl, q{-|}, @cmd) or die qq{curl: $!}; my $line = <$curl>; my $ok = close($curl); my $exit = $? >> 8; unless ($ok) { printf qq{%s: curl failed (exit %d)\n}, $name, $exit; $bad = 1; next; } $line = defined $line ? $line : q{}; chomp $line; my ($code, $served) = split q{ }, $line; $code //= q{}; $served //= 0; my $sent = -s $path; unless ($code eq q{200}) { printf qq{%s: HTTP %s on read-back\n}, $name, $code; $bad = 1; next; } unless ($served == $sent) { printf qq{%s: served %s bytes, sent %d\n}, $name, $served, $sent; $bad = 1; next; } printf qq{%s: read back, %d bytes\n}, $name, $served; } exit($bad ? 1 : 0); '