Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Assisted-by: GLM 5.3
425 lines
17 KiB
YAML
425 lines
17 KiB
YAML
# Release, Go binaries. Runs on version tags (v1.2.3) pushed to main.
|
|
#
|
|
# The ci skill's go-release template, adapted only for the binary name, the
|
|
# matrix and the version subcommand. The release job's steps are the template's
|
|
# verbatim: the read-back rides the release download route, which is the
|
|
# verified one, not the API attachment route, which serves a stale body for
|
|
# the first attachment after creation. The checksums file is volumen's own
|
|
# addition beside the template: the admin self-update verifies every download
|
|
# against it.
|
|
#
|
|
# 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:
|
|
# 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
|
|
run: go test -count=1 -timeout 10m -coverprofile=coverage.out ./...
|
|
|
|
- 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, which has no loong64 port and whose
|
|
# riscv64 build does not run. No 32-bit, no wasm, no macOS, no Windows.
|
|
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/volumen-${VERSION_NO_V}-${GOOS}-${GOARCH}" ./cmd/volumen
|
|
|
|
# Artifacts stay on v3: v4 and later detect Gitea as GHES and abort.
|
|
- name: Upload artifact
|
|
uses: actions/upload-artifact@v3
|
|
with:
|
|
name: volumen-${{ matrix.goos }}-${{ matrix.goarch }}
|
|
path: bin/volumen-${{ 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/volumen-${{ 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 artifacts
|
|
uses: actions/download-artifact@v3
|
|
with:
|
|
path: dist
|
|
|
|
- name: Install Perl
|
|
run: dnf install -y perl
|
|
|
|
- name: Build the checksums file
|
|
# The admin self-update verifies every download against this file, so
|
|
# it is part of the update contract: one line per asset, hash then
|
|
# bare file name, matching volumen-<version>-<os>-<arch>. Every line
|
|
# is built from a validated digest and the written file is re-read and
|
|
# re-checked, because a hashless line here silently breaks the update
|
|
# contract for every binary at once.
|
|
run: |
|
|
perl -e '
|
|
chdir(q{dist}) or die qq{cannot enter dist: $!\n};
|
|
my @paths = grep { -f $_ } (sort(glob(q{*/*})));
|
|
@paths or die qq{ERROR: no assets under dist\n};
|
|
open(my $out, q{>}, q{checksums.txt}) or die qq{checksums.txt: $!\n};
|
|
for my $path (@paths) {
|
|
my @cmd = (q{sha256sum}, $path);
|
|
open(my $sum, q{-|}, @cmd) or die qq{sha256sum: $!\n};
|
|
my $line = <$sum>;
|
|
my @rest = <$sum>;
|
|
close($sum) or die qq{sha256sum failed for $path\n};
|
|
@rest and die qq{ERROR: unexpected extra sha256sum output for $path\n};
|
|
$line = defined $line ? $line : q{};
|
|
$line =~ s/\r?\n\z//;
|
|
my ($digest, $seen) = split / /, $line, 2;
|
|
defined $digest && defined $seen
|
|
or die qq{ERROR: malformed sha256sum output for $path: $line\n};
|
|
$digest =~ m{^[0-9a-f]{64}\z}
|
|
or die qq{ERROR: no sha256 digest in sha256sum output for $path: $line\n};
|
|
(my $name = $path) =~ s{.*/}{};
|
|
$seen eq $path
|
|
or die qq{ERROR: sha256sum named $seen for the path $path\n};
|
|
print {$out} qq{$digest $name\n};
|
|
}
|
|
close($out) or die qq{cannot flush checksums.txt: $!\n};
|
|
open(my $back, q{<}, q{checksums.txt}) or die qq{re-read: $!\n};
|
|
my $count = 0;
|
|
while (my $line = <$back>) {
|
|
$line =~ m{^[0-9a-f]{64} \S}
|
|
or die qq{ERROR: hashless line in the written file: $line\n};
|
|
$count++;
|
|
}
|
|
close($back);
|
|
$count == @paths
|
|
or die qq{ERROR: wrote $count lines for } . scalar(@paths) . qq{ assets\n};
|
|
print qq{checksums.txt written: $count verified lines\n};
|
|
'
|
|
|
|
- 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: $!\n};
|
|
my $id = <$f>;
|
|
close($f);
|
|
chomp $id;
|
|
my @files = grep { -f $_ } (glob(q{dist/*/*}), q{dist/checksums.txt});
|
|
@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},
|
|
# The @ must not sit inside a qq{} string: there it starts an
|
|
# array interpolation and the upload body collapses to empty,
|
|
# which Gitea stores as a 201-created zero-byte attachment.
|
|
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/*/*}), q{dist/checksums.txt});
|
|
@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);
|
|
'
|