From 8266dd645010cc3d23a509b60c8fbddf259117bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Wed, 19 Aug 2026 16:08:00 +0200 Subject: [PATCH] ci: hand-written test, race and release pipelines Assisted-by: GLM 5.3 Flash --- .gitea/workflows/race.yml | 35 +++++++ .gitea/workflows/release.yml | 192 +++++++++++++++++++++++++++++++++++ .gitea/workflows/test.yml | 100 ++++++++++++++++++ 3 files changed, 327 insertions(+) create mode 100644 .gitea/workflows/race.yml create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitea/workflows/test.yml diff --git a/.gitea/workflows/race.yml b/.gitea/workflows/race.yml new file mode 100644 index 0000000..d8bb2c5 --- /dev/null +++ b/.gitea/workflows/race.yml @@ -0,0 +1,35 @@ +# Race, Go. Dispatched by hand, and run as part of the release gates. +# +# The race detector roughly doubles both time and memory, which the shared runner box +# cannot afford on every push. Locally it belongs to `just gates`, which runs it once per +# task; here it is an explicit decision rather than a routine. +# +# Every step is one command, so the step that fails is the gate that failed. +name: Race + +on: + workflow_dispatch: + +env: + # One core: parallelism buys no speed here and costs memory the box does not have. + GOFLAGS: -p=1 + GOMAXPROCS: "2" + +jobs: + race: + runs-on: fedora + timeout-minutes: 45 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Install gcc + # The race detector needs cgo and the runner image carries no C compiler. + run: dnf install -y gcc + + - name: Race + run: go test -race -count=1 -timeout 30m ./... diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..be2c7f9 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,192 @@ +# Release, Go library. Runs on version tags (v1.2.3) pushed to main. +# +# A library ships no binaries, so there is no build matrix and no smoke test: the release +# carries the CHANGELOG section as its body and nothing else. The gates still run first, +# in their own job and once, 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. The write permission +# sits on the release job alone, and the version contract these steps implement is in the +# `release` skill. +# +# Every step is one command, so the step that fails is the gate that failed, and no shell +# option has to be trusted for the run to stop. The scripted steps are Perl, not shell and +# not Python: Perl behaves the same on both runner images, there is no bashism to trip over +# on ash, and it is one language instead of two. The Perl uses builtins only, because +# Fedora packages the Perl modules separately and nothing beyond `perl` itself may be +# assumed present, which is why the release body is escaped by hand and curl is the +# transport. +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, since it runs the suite. + GOFLAGS: -p=1 + GOMAXPROCS: "2" + +jobs: + gates: + runs-on: fedora + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + # The module is the source of truth for the version, so it cannot drift. + 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,2}([-+].*)?$} + or die qq{ERROR: expected a semver tag like v1.2.3, got: $v\n}; + print qq{tag $v\n}; + ' + + - 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 + # Exits non-zero when it has something to rewrite, so it needs no output capture. + run: go fix -diff ./... + + - name: Build + run: go build ./... + + - name: Tests + # Keep the pattern equal to `packages` in the project's justfile. + run: go test -count=1 -timeout 30m -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); + ' + + release: + runs-on: fedora + timeout-minutes: 15 + needs: gates + 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: Install Perl + # The runner images are minimal and Perl is not guaranteed. The install is a + # no-op where it is already present; drop this step once verified on the box. + 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. The separator after the version is never read, so the heading format's + # separator stays free. + 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}; + print qq{release id $1\n}; + ' diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..d51615b --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,100 @@ +# Test, Go. Push and pull request to development. Never on main. +# +# The gates are the ones the justfile's `gates` recipe runs, minus race: the shared +# runner box cannot afford the race detector on every push, so race runs once inside +# the release pipeline instead. The box is one core and 2 GB beside Gitea, so +# parallelism is bounded on purpose and everything runs in one job. Extra jobs would +# duplicate the checkout, the Go setup and the dependency download three times without +# buying any parallelism. +# +# Every step is one command, so the step that fails is the gate that failed, and no shell +# option has to be trusted for the run to stop. The scripted steps are Perl, not shell and +# not Python: Perl behaves the same on both runner images, there is no bashism to trip over +# on ash, and it is one language instead of two. The Perl uses builtins only, because +# Fedora packages the Perl modules separately and nothing beyond `perl` itself may be +# assumed present. +# +# After the gates runs the toml-test compliance suite, which is this library's conformance +# record rather than a gate. It is fixed cases, not exploration, and it fits the box, so it +# is none of the three burdens the push pipeline refuses: no fuzz, no race, no heavy sweep. +name: Test + +on: + push: + branches: [development] + pull_request: + branches: [development] + +env: + # One core: parallelism buys no speed here and costs memory the box does not have. + GOFLAGS: -p=1 + GOMAXPROCS: "2" + +jobs: + test: + runs-on: fedora + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + # The module is the source of truth for the version, so it cannot drift. + go-version-file: go.mod + cache: true + + - name: Install Perl + # The runner images are minimal and Perl is not guaranteed. The install is a + # no-op where it is already present; drop this step once verified on the box. + run: dnf install -y perl + + - 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 + # Exits non-zero when it has something to rewrite, so it needs no output capture. + run: go fix -diff ./... + + - name: Build + run: go build ./... + + - name: Tests + # Scope the pattern to the packages that hold the logic when a thin cmd/ drags the + # total under the floor, and keep it equal to `packages` in the project's justfile. + run: go test -count=1 -timeout 30m -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); + ' + + - name: Install toml-test + # A dev and CI tool only; it is not a module dependency of interpres. GOBIN pins + # the destination, so the later step invokes the tool by path and no command + # output has to be captured into a variable. + env: + GOBIN: ${{ gitea.workspace }}/bin + run: go install github.com/toml-lang/toml-test/cmd/toml-test@v1.6.0 + + - name: Build the decoder + run: go build -o bin/interpres-decode ./cmd/interpres-decode + + - name: Compliance suite + run: bin/toml-test bin/interpres-decode