diff --git a/.gitea/workflows/race.yml b/.gitea/workflows/race.yml new file mode 100644 index 0000000..d853ae0 --- /dev/null +++ b/.gitea/workflows/race.yml @@ -0,0 +1,36 @@ +# 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: + GOAMD64: v3 + # 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 index df506b3..c1ddeeb 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,71 +1,185 @@ -# Release — gasm binaries. Runs on version tags (v0.28.0) pushed to main. +# 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. Putting them inside the matrix +# would run the whole suite and the race detector 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: + GOAMD64: v3 + # 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: 25 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Install Perl and gcc + # Perl for the steps below, gcc for the race detector. Both are no-ops where the + # package is already present. + run: dnf install -y perl gcc + + - 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: 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 + # The same command as in test.yml, so the floor is the same number everywhere. + run: go test -count=1 -timeout 30m -coverprofile=coverage.out -coverpkg=./arch/...,./asm/...,./ast/...,./disasm/...,./format/...,./lexer/...,./lint/...,./lsp/...,./parser/...,./token/...,./verify/... ./... + + - 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: Race + run: go test -race -count=1 -timeout 30m ./... + build: runs-on: fedora + timeout-minutes: 25 + needs: gates strategy: fail-fast: false matrix: + # Portable targets: amd64 (v3 baseline), arm64, loong64 and riscv64 on Linux. + # No 32-bit, no wasm, no macOS, no Windows. FreeBSD waits until verify/jit.go + # ports off syscall.Mprotect, which the freebsd build does not carry. include: - goos: linux goarch: amd64 - goos: linux goarch: arm64 - - goos: linux - goarch: riscv64 - goos: linux goarch: loong64 + - goos: linux + goarch: riscv64 steps: - uses: actions/checkout@v7 - uses: actions/setup-go@v6 with: - go-version: "1.27" + go-version-file: go.mod + cache: true - - name: Download dependencies - run: go mod download + - name: Install Perl + run: dnf install -y perl - - name: Validate tag and build - id: build + - name: Validate the tag + id: version env: VERSION: ${{ gitea.ref_name }} run: | - set -euo pipefail + 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}; + (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}; + ' - if ! echo "$VERSION" | grep -qE '^v[0-9]+(\.[0-9]+){0,2}([-+].*)?$'; then - echo "ERROR: expected a semver tag like v1.2.3, got: '$VERSION'" - exit 1 - fi - - VERSION_NO_V="${VERSION#v}" - echo "version_no_v=${VERSION_NO_V}" >> "$GITEA_OUTPUT" - - mkdir -p bin - GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} CGO_ENABLED=0 \ - go build -ldflags "-s -w -X main.version=${VERSION_NO_V}" \ - -o "bin/gasm-${VERSION_NO_V}-${{ matrix.goos }}-${{ matrix.goarch }}" \ - ./cmd/gasm + - 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/gasm-${VERSION_NO_V}-${GOOS}-${GOARCH}" ./cmd/gasm + # Artifacts stay on v3: v4 and later detect Gitea as GHES and abort. - name: Upload artifact uses: actions/upload-artifact@v3 with: name: gasm-${{ matrix.goos }}-${{ matrix.goarch }} - path: bin/gasm-${{ steps.build.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }} + path: bin/gasm-${{ 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/gasm-${{ steps.version.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }} run: | - chmod +x bin/gasm-${{ steps.build.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }} - ./bin/gasm-${{ steps.build.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }} --version + 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: releases: write @@ -77,81 +191,125 @@ jobs: with: path: dist - - name: Extract CHANGELOG section + - name: Install Perl + run: dnf install -y perl + + - name: Extract the CHANGELOG section env: VERSION: ${{ gitea.ref_name }} run: | - set -euo pipefail - VERSION_NO_V="${VERSION#v}" + # 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; + ' - sed -n "/^## \[${VERSION_NO_V}\] /,/^## \[/p" CHANGELOG.md \ - | sed '$d' \ - | tail -n +2 \ - > release-body.md + - 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}; + ' - if [ ! -s release-body.md ]; then - echo "ERROR: no CHANGELOG section found for ${VERSION_NO_V}" - echo "Expected a heading like: ## [${VERSION_NO_V}] — YYYY-MM-DD" - exit 1 - fi - - - name: Create release + - name: Create the release env: GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_SERVER_URL: ${{ gitea.server_url }} GITEA_REPOSITORY: ${{ gitea.repository }} - GITEA_REF_NAME: ${{ gitea.ref_name }} run: | - set -euo pipefail - - BODY=$(sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\t/\\t/g' -e 's/\r//g' release-body.md | sed ':a;N;$!ba;s/\n/\\n/g') - BODY="\"${BODY}\"" - - response=$(curl -sS -w '\n%{http_code}' \ - -H "Authorization: token ${GITEA_TOKEN}" \ - -H "Content-Type: application/json" \ - -X POST \ - "${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases" \ - -d "{\"tag_name\":\"${GITEA_REF_NAME}\",\"name\":\"${GITEA_REF_NAME}\",\"body\":${BODY},\"draft\":false,\"prerelease\":false}") - - http_code=$(echo "$response" | tail -1) - payload=$(echo "$response" | sed '$d') - - echo "HTTP ${http_code}" - if [ "$http_code" != "201" ]; then - echo "Failed to create release: ${payload}" - exit 1 - fi - - RELEASE_ID=$(echo "$payload" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+') - echo "Created release ID=${RELEASE_ID}" - printf '%s' "${RELEASE_ID}" > release-id.txt + 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 }} - GITEA_REF_NAME: ${{ gitea.ref_name }} run: | - set -euo pipefail - RELEASE_ID=$(cat release-id.txt) - - for binary in dist/gasm-*/gasm-*; do - [ -f "$binary" ] || continue - fname=$(basename "$binary") - echo "Uploading ${fname}..." - http_code=$(curl -sS -o /dev/null -w '%{http_code}' \ - -H "Authorization: token ${GITEA_TOKEN}" \ - -H "Content-Type: application/octet-stream" \ - -X POST \ - --data-binary "@${binary}" \ - "${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets?name=${fname}") - echo " HTTP ${http_code}" - if [ "$http_code" != "201" ]; then - echo "Failed to upload ${fname}" - exit 1 - fi - done - - echo "Release ${GITEA_REF_NAME} is live." + 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}, qq{@$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); + ' diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 1a064b6..21e7d03 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -1,4 +1,17 @@ -# Test — gasm-devkit. Runs on push and pull request to development. +# 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 it lives in race.yml. +# 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. name: Test on: @@ -7,90 +20,65 @@ on: pull_request: branches: [development] +env: + # Portable baseline, x86-64-v3 minimum. Other GOARCH values ignore it. + GOAMD64: v3 + # One core: parallelism buys no speed here and costs memory the box does not have. + GOFLAGS: -p=1 + GOMAXPROCS: "2" + jobs: - vet: - runs-on: fedora - steps: - - uses: actions/checkout@v7 - - - uses: actions/setup-go@v6 - with: - go-version: "1.27" - - - name: Download dependencies - run: go mod download - - - name: gofmt - run: | - set -euo pipefail - unformatted=$(gofmt -l .) - if [ -n "$unformatted" ]; then - echo "These files need gofmt:" - echo "$unformatted" - exit 1 - fi - - - name: go vet - run: go vet ./... - test: runs-on: fedora - needs: vet + timeout-minutes: 20 steps: - uses: actions/checkout@v7 - uses: actions/setup-go@v6 with: - go-version: "1.27" + # The module is the source of truth for the version, so it cannot drift. + go-version-file: go.mod + cache: true - - name: Download dependencies - run: go mod download + - 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: Install gcc - run: dnf install -y gcc - - - name: go test -race - run: go test -race -count=1 ./... - - - name: Coverage gate — 80 % minimum + - name: Format run: | - set -euo pipefail - # Exclude packages inherently untestable without hardware: - # debug — interactive ptrace, requires a live process - # cmd/gasm — CLI glue, covered by integration tests - go test -coverprofile=coverage.out \ - sourcedock.dev/petrbalvin/gasm-devkit/arch \ - sourcedock.dev/petrbalvin/gasm-devkit/asm \ - sourcedock.dev/petrbalvin/gasm-devkit/ast \ - sourcedock.dev/petrbalvin/gasm-devkit/format \ - sourcedock.dev/petrbalvin/gasm-devkit/lexer \ - sourcedock.dev/petrbalvin/gasm-devkit/lint \ - sourcedock.dev/petrbalvin/gasm-devkit/lsp \ - sourcedock.dev/petrbalvin/gasm-devkit/parser \ - sourcedock.dev/petrbalvin/gasm-devkit/token \ - sourcedock.dev/petrbalvin/gasm-devkit/verify - coverage=$(go tool cover -func=coverage.out | awk '/^total:/ { gsub("%", "", $3); print $3 }') - echo "Total coverage: ${coverage}%" - if awk -v c="$coverage" 'BEGIN { exit !(c+0 < 80) }'; then - echo "ERROR: coverage ${coverage}% is below the 80% threshold" - exit 1 - fi + 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); + ' - build: - runs-on: fedora - needs: test - steps: - - uses: actions/checkout@v7 + - name: Vet + run: go vet ./... - - uses: actions/setup-go@v6 - with: - go-version: "1.27" - - - name: Download dependencies - run: go mod download + - 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 -ldflags="-s -w" -o bin/gasm ./cmd/gasm + run: go build ./... - - name: Smoke test - run: ./bin/gasm --version + - name: Tests + # The sweep is `packages` in the project's justfile and the floor is computed over + # the same product packages as the justfile's `coverpkg`, so the number is the one + # `just test` reports locally. + run: go test -count=1 -timeout 30m -coverprofile=coverage.out -coverpkg=./arch/...,./asm/...,./ast/...,./disasm/...,./format/...,./lexer/...,./lint/...,./lsp/...,./parser/...,./token/...,./verify/... ./... + + - 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); + '