From 941d4fb10d15213c7600aca51ba0e7a83346c037 Mon Sep 17 00:00:00 2001 From: abrown38 Date: Thu, 22 Oct 2026 16:44:58 +0100 Subject: [PATCH] Release setup: tarballs, Gitea workflow, apt registry make dist builds tarballs for linux/amd64 and linux/arm64. A pushed v* tag runs .gitea/workflows/release.yml, which checks the tag against VERSION and the CHANGELOG, tests, builds the tarballs and the .debs (amd64, arm64) and publishes them with scripts/gitea-release.sh: a Gitea release, and the .debs in the Debian registry (stable, or testing for a release candidate). As in Housecarl and Steward; no cli-dist, Guard is Linux only. docs/development.md has the steps, docs/install.md the apt source. Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/release.yml | 68 ++++++++++++++++ AGENTS.md | 3 +- CHANGELOG.md | 4 +- Makefile | 13 ++- README.md | 1 + docs/development.md | 49 ++++++++++++ docs/install.md | 19 ++++- scripts/gitea-release.sh | 150 +++++++++++++++++++++++++++++++++++ 8 files changed, 302 insertions(+), 5 deletions(-) create mode 100644 .gitea/workflows/release.yml create mode 100644 docs/development.md create mode 100755 scripts/gitea-release.sh diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..abf316e --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,68 @@ +# Builds and publishes a release when a version tag (v0.2.0, v1.0.0-rc.1) +# is pushed: +# * a Gitea release with the CHANGELOG notes, the linux tarballs, the +# .debs and SHA256SUMS; +# * the .debs (amd64, arm64) in the owner's Debian package registry: +# distribution "stable", or "testing" for a pre-release (a version +# with a hyphen), which Gitea also marks as a pre-release. +# +# Needs a repository secret RELEASE_TOKEN: an access token of a user who +# can write to the repository, with the write:repository and write:package +# scopes. docs/development.md ("Releasing") has the steps. +name: release + +on: + push: + tags: ["v*"] + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Check the tag against VERSION and CHANGELOG.md + run: | + version=$(cat VERSION) + if [ "$GITHUB_REF_NAME" != "v$version" ]; then + echo "::error::tag $GITHUB_REF_NAME does not match VERSION ($version)" + exit 1 + fi + # The release notes (a pre-release may use its final version's + # section or Unreleased; see scripts/gitea-release.sh). + if ! scripts/gitea-release.sh notes > /dev/null; then + echo "::error::no release notes for $version in CHANGELOG.md" + exit 1 + fi + + - name: Tools + run: | + missing= + for t in jq dpkg-deb curl; do command -v $t >/dev/null || missing="$missing $t"; done + if [ -n "$missing" ]; then + sudo=; [ "$(id -u)" = 0 ] || sudo=sudo + $sudo apt-get update -qq && $sudo apt-get install -y -qq jq dpkg curl + fi + + - name: Test + run: make test + + - name: Build + env: + # Checked against VERSION above; don't depend on the checkout + # having the tag. + RELEASE_TAG: ${{ github.ref_name }} + run: | + export DEB_MAINTAINER="$(git log -1 --format='%an <%ae>')" + make dist + make deb DEB_ARCH=amd64 + make deb DEB_ARCH=arm64 + + - name: Publish + env: + GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: scripts/gitea-release.sh diff --git a/AGENTS.md b/AGENTS.md index 6450c18..b431f43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,8 @@ make e2e # with ../housecarl's lab: needs Docker (DOCKER_HOST=unix:///var/r As in Housecarl: commit only when asked, never push; branches `dev-X.Y.Z`; a release is a commit "Release X" setting `VERSION`, with -the annotated tag `vX` ("Guard X"). +the annotated tag `vX` ("Guard X"). Pushing the tag runs +`.gitea/workflows/release.yml` (docs/development.md, "Releasing"). ## Shell hygiene diff --git a/CHANGELOG.md b/CHANGELOG.md index eee497b..dce73f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,4 +25,6 @@ All notable changes to Guard are documented here. The format follows `test` (a watch's patterns over a log), `status` (through a local socket), `bans`, `ban`, `unban`, `version`. - A Debian package with a sandboxed systemd unit, as the `guard` user in - the `adm` and `systemd-journal` groups. + the `adm` and `systemd-journal` groups, published with each release to + the Gitea Debian registry (`stable`, or `testing` for a release + candidate) along with tarballs for Linux amd64 and arm64. diff --git a/Makefile b/Makefile index a60af62..bb53985 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,11 @@ # sudo make install install the binary, completion, systemd unit and sysusers file, # and an example /etc/guard/guard.yaml # make deb the Debian package in dist/ (DEB_ARCH=arm64 for another architecture) +# make dist static binaries for linux/amd64 and linux/arm64 in dist/ +# (guard--linux-.tar.gz) +# +# Guard is Linux only (it reads the journal), so there is no cli-dist as in +# Housecarl and Steward. VERSION ?= $(shell cat VERSION 2>/dev/null || echo 0.0.0-dev) RELEASE_TAG ?= $(shell t=v$(VERSION); git tag --points-at HEAD 2>/dev/null | grep -qx "$$t" && git diff --quiet HEAD 2>/dev/null && echo $$t) @@ -30,7 +35,7 @@ DEB_DEV_VERSION := $(shell [ -n '$(DESCRIBE)' ] || exit 0; \ echo '$(DESCRIBE)' | sed -E 's/^v(.*)-([0-9]+)-g([0-9a-f]{7})[0-9a-f]*$$/\1+git\2.g\3/' | tr - '~' | sed "s/$$/$$m/") DEB_VERSION ?= $(if $(RELEASE_TAG),$(subst -,~,$(VERSION)),$(or $(DEB_DEV_VERSION),$(subst -,~,$(VERSION)))) -.PHONY: all build completion test e2e install install-files uninstall deb clean +.PHONY: all build completion test e2e install install-files uninstall deb dist clean all: build @@ -81,5 +86,11 @@ uninstall: deb: build DEB_ARCH=$(DEB_ARCH) DEB_VERSION=$(DEB_VERSION) VERSION=$(VERSION) LDFLAGS="$(LDFLAGS)" GOFLAGS="$(GOFLAGS)" scripts/mkdeb.sh +dist: build + for arch in amd64 arm64; do \ + GOOS=linux GOARCH=$$arch go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o dist/guard-$(VERSION)-linux-$$arch/guard ./cmd/guard && \ + tar -C dist -czf dist/guard-$(VERSION)-linux-$$arch.tar.gz guard-$(VERSION)-linux-$$arch || exit 1; \ + done + clean: rm -rf bin dist diff --git a/README.md b/README.md index bc133fb..178fd6a 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ watch: * [configuration.md](docs/configuration.md): `/etc/guard/guard.yaml` * [presets.md](docs/presets.md): the presets, and writing your own patterns * [cli.md](docs/cli.md): the `guard` command +* [development.md](docs/development.md): building, testing, releasing * [CHANGELOG.md](CHANGELOG.md) Housecarl's side: its [docs/bans.md](https://gitea.adbraun.lol/adam/housecarl/src/branch/main/docs/bans.md). diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..2634cec --- /dev/null +++ b/docs/development.md @@ -0,0 +1,49 @@ +# Developing Guard + +[AGENTS.md](../AGENTS.md) has the layout and the ground rules. + +## Building and testing + +```sh +make # bin/guard and its bash completion +make test # go vet, gofmt check, go test ./... +make e2e # scripts/e2e.sh: needs Docker and ../housecarl +make deb # dist/guard__.deb +make dist # dist/guard--linux-.tar.gz (amd64, arm64) +``` + +**Unit tests** need neither Housecarl nor a journal: the daemon tests +ban into a fake Housecarl, the journal test runs a fake journalctl +(`GUARD_JOURNALCTL`), and `TestPresets` runs each preset over its sample +lines in `testdata/presets/`. + +**The e2e test** borrows Housecarl's lab: a router container (Debian 13, +nftables 1.1.3, the `housecarl-nft` image that Housecarl's `make e2e` +makes) runs housecarld built from `../housecarl` (or `HOUSECARL_SRC`), +a LAN server runs Guard over a log file, and a host on the internet +misbehaves. It checks a ban at the router (one port, for that server +alone), `never_ban`, bans waiting while Housecarl is down, and Guard on +the router banning scanners from Housecarl's log of dropped packets. +`KEEP=1` leaves the lab running; `DOCKER_HOST` points at another Docker +daemon. + +The systemd sandbox is only tested by installing the package in a +container that runs systemd (`--privileged --cgroupns=host /sbin/init`). + +## Releasing + +As for Jarl, Steward and Housecarl: + +1. Move "Unreleased" in `CHANGELOG.md` under `## [] - `. + A release candidate may leave it there. +2. Put the version in `VERSION`, and update the README's status line. +3. Run `make test && make e2e`. +4. Commit as "Release X", tag it `git tag -a vX -m "Guard X"`, and push. + +The tag starts `.gitea/workflows/release.yml`, which checks the tag +against `VERSION` and the CHANGELOG, runs the tests, builds the tarballs +and the `.deb`s (amd64, arm64), and publishes them +(`scripts/gitea-release.sh`): a Gitea release with the notes and the +files, and the `.deb`s in the owner's Debian registry, `stable` for a +release and `testing` for a release candidate. It needs the repository +secret `RELEASE_TOKEN` (`write:repository`, `write:package`). diff --git a/docs/install.md b/docs/install.md index 1877899..9ff806f 100644 --- a/docs/install.md +++ b/docs/install.md @@ -28,11 +28,26 @@ A watch that reads Housecarl's own log (`housecarl-drops`) also needs ## 2. The package +Guard's releases publish its Debian package to the same Gitea registry +as Jarl's, Steward's and Housecarl's: `stable` for releases, `testing` +for release candidates. If apt already has it, `sudo apt install guard` +is all it takes. Otherwise add it: + ```sh -sudo apt install ./guard_0.1.0_amd64.deb +sudo install -d /etc/apt/keyrings +sudo curl -fsSL https://gitea.adbraun.lol/api/packages/adam/debian/repository.key -o /etc/apt/keyrings/gitea-adam.asc +echo "deb [signed-by=/etc/apt/keyrings/gitea-adam.asc] https://gitea.adbraun.lol/api/packages/adam/debian stable main" \ + | sudo tee /etc/apt/sources.list.d/gitea-adam.list +sudo apt update +sudo apt install guard ``` -It makes the `guard` user (in the `adm` and `systemd-journal` groups, +For release candidates too, add the `testing` distribution: the same +`deb` line with `testing` instead of `stable`. The release page also has +the `.deb` files (`sudo apt install ./guard_0.1.0_amd64.deb`) and +tarballs for Linux amd64 and arm64. + +The package makes the `guard` user (in the `adm` and `systemd-journal` groups, to read logs), installs `guard.service` without starting it, and writes an example `/etc/guard/guard.yaml`. diff --git a/scripts/gitea-release.sh b/scripts/gitea-release.sh new file mode 100755 index 0000000..0daf893 --- /dev/null +++ b/scripts/gitea-release.sh @@ -0,0 +1,150 @@ +#!/bin/sh +# Publishes a built release (make dist, make deb for each +# architecture) to Gitea: +# +# * a release for tag v with the CHANGELOG section for VERSION as +# its notes, and every file in dist/ (tarballs, .debs) +# plus a SHA256SUMS of them attached; +# * the .debs in the owner's Debian package registry, so servers can +# 'apt install guard' and get updates with apt (docs/install.md). +# +# Run by .gitea/workflows/release.yml when a v* tag is pushed. Running it +# again for the same version replaces the attachments and packages. +# +# A version with a hyphen (0.2.0-rc.1) is a pre-release: Gitea marks the +# release so and doesn't count it as the latest, and its packages go to +# the "testing" distribution, so servers that follow "stable" never get +# a pre-release from apt. +# +# Release notes: the CHANGELOG section "## [VERSION]". A pre-release +# without one of its own uses the section of the version it leads up to +# ("## [0.2.0]"), or else "## [Unreleased]". +# +# 'gitea-release.sh notes' only prints the notes, failing if there are +# none (the workflow checks this before building). +# +# Variables: +# GITEA_URL e.g. https://gitea.example.com (in Actions: +# GITHUB_SERVER_URL) +# GITEA_REPO owner/name (in Actions: GITHUB_REPOSITORY) +# GITEA_TOKEN a token with write:repository and write:package +# VERSION default: the VERSION file +# DEB_DISTRIBUTION the apt distribution (default: stable, testing for +# a pre-release) +# DEB_COMPONENT the apt component (default: main) +# +# Needs curl and jq. +set -eu + +VERSION=${VERSION:-$(cat VERSION)} +prerelease=false +case $VERSION in *-*) prerelease=true ;; esac + +# section prints the CHANGELOG section headed "## [$1]" (without the +# heading); nothing if there is none. +section() { + awk -v v="$1" ' + /^## \[/ { if (on) exit; on = index($0, "## [" v "]") == 1; next } + on' CHANGELOG.md +} + +# The release notes (see above). A section with only blank lines counts +# as missing. +notes=$(section "$VERSION") +from="[$VERSION]" +if [ "$prerelease" = true ]; then + for s in "${VERSION%%-*}" Unreleased; do + [ -z "$(printf '%s' "$notes" | tr -d '[:space:]')" ] || break + notes=$(section "$s") + from="[$s]" + done +fi +if [ -z "$(printf '%s' "$notes" | tr -d '[:space:]')" ]; then + if [ "$prerelease" = true ]; then + echo "gitea-release: CHANGELOG.md has no '## [$VERSION]', '## [${VERSION%%-*}]' or '## [Unreleased]' section with notes" >&2 + else + echo "gitea-release: CHANGELOG.md has no '## [$VERSION]' section with notes" >&2 + fi + exit 1 +fi +if [ "${1:-}" = notes ]; then + echo "gitea-release: notes for $VERSION from CHANGELOG.md $from" >&2 + printf '%s\n' "$notes" + exit 0 +fi + +: "${GITEA_URL:=${GITHUB_SERVER_URL:-}}" "${GITEA_REPO:=${GITHUB_REPOSITORY:-}}" +: "${GITEA_URL:?set GITEA_URL}" "${GITEA_REPO:?set GITEA_REPO}" "${GITEA_TOKEN:?set GITEA_TOKEN}" +if [ "$prerelease" = true ]; then + DEB_DISTRIBUTION=${DEB_DISTRIBUTION:-testing} +else + DEB_DISTRIBUTION=${DEB_DISTRIBUTION:-stable} +fi +DEB_COMPONENT=${DEB_COMPONENT:-main} +GITEA_URL=${GITEA_URL%/} +owner=${GITEA_REPO%%/*} +tag=v$VERSION +api=$GITEA_URL/api/v1/repos/$GITEA_REPO + +# curl with the token; fails on HTTP errors except the codes in $ok. +call() { + ok=$1 + shift + out=$(mktemp) + code=$(curl -sS -o "$out" -w '%{http_code}' -H "Authorization: token $GITEA_TOKEN" "$@") + case " 200 201 204 $ok " in + *" $code "*) cat "$out"; rm -f "$out"; return 0 ;; + esac + echo "gitea-release: HTTP $code from $*" >&2 + cat "$out" >&2 + echo >&2 + rm -f "$out" + return 1 +} + +files=$(find dist -maxdepth 1 -type f ! -name SHA256SUMS | sort) +[ -n "$files" ] || { echo "gitea-release: nothing in dist/" >&2; exit 1; } +# (Debian package versions have ~ for -: guard_0.2.0~rc.1_amd64.deb.) +debversion=$(printf '%s' "$VERSION" | tr - '~') +for f in $files; do + case $f in *"$VERSION"* | *"$debversion"*) ;; *) echo "gitea-release: $f is not version $VERSION" >&2; exit 1 ;; esac +done +# shellcheck disable=SC2046 # file names have no spaces +(cd dist && sha256sum $(printf '%s\n' "$files" | sed 's|^dist/||')) > dist/SHA256SUMS +files="$files dist/SHA256SUMS" + +# The release (created, or found when publishing again). +release=$(call 404 "$api/releases/tags/$tag") +id=$(printf '%s\n' "$release" | jq -r '.id // empty') +if [ -z "$id" ]; then + body=$(jq -n --arg tag "$tag" --arg notes "$notes" --argjson pre "$prerelease" \ + '{tag_name: $tag, name: ("Guard " + $tag), body: $notes, prerelease: $pre}') + release=$(call "" -X POST -H 'Content-Type: application/json' -d "$body" "$api/releases") + id=$(printf '%s\n' "$release" | jq -r .id) + echo "gitea-release: created release $tag" +fi +for f in $files; do + name=${f#dist/} + old=$(printf '%s\n' "$release" | jq -r --arg n "$name" '.assets[]? | select(.name == $n) | .id') + [ -z "$old" ] || call "" -X DELETE "$api/releases/$id/assets/$old" >/dev/null + call "" -X POST -F "attachment=@$f" "$api/releases/$id/assets?name=$name" >/dev/null + echo "gitea-release: attached $name" +done + +# The Debian packages. Gitea refuses a package version it already has +# (409), so publishing again deletes it first. +pool=$GITEA_URL/api/packages/$owner/debian/pool/$DEB_DISTRIBUTION/$DEB_COMPONENT +for f in dist/*.deb; do + [ -e "$f" ] || continue + # shellcheck disable=SC2016,SC2046 # dpkg-deb's fields; split into "$@" + set -- $(dpkg-deb --showformat '${Package} ${Version} ${Architecture}' --show "$f") + if ! call 409 --upload-file "$f" "$pool/upload" >/dev/null; then + echo "gitea-release: upload of $f failed" >&2 + exit 1 + fi + if [ "$code" = 409 ]; then + call "" -X DELETE "$pool/$1/$2/$3" >/dev/null + call "" --upload-file "$f" "$pool/upload" >/dev/null + fi + echo "gitea-release: uploaded $1 $2 $3 to $DEB_DISTRIBUTION/$DEB_COMPONENT" +done