Add Gitea Actions CI and release workflows and deployment assets

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-26 03:05:28 -06:00
1 parent f521fa8668
commit 60be4d9ac8
12 files changed
+408 -1

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
# The image only packages the binaries that deploy/build.sh already built.
*
!dist/bin
+50
View File
@@ -0,0 +1,50 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
env:
GOPRIVATE: git.veillax.com
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
# Go fetches the other sharks modules straight from this server. This is harmless
# for public repositories and is what makes private ones work.
- name: Let Go fetch modules from this server
run: git config --global url."https://x-access-token:${{ secrets.GITHUB_TOKEN }}@git.veillax.com/".insteadOf "https://git.veillax.com/"
- name: Formatting
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "These files need gofmt:"
echo "$unformatted"
exit 1
fi
- name: Shell scripts parse
run: |
for f in deploy/*.sh; do sh -n "$f"; done
- name: Vet
run: go vet ./...
- name: Test
run: go test ./...
# Catches platform-specific breakage before a release is tagged.
- name: Every release platform compiles
run: |
for t in linux/amd64 linux/arm64 linux/arm windows/amd64 darwin/amd64 darwin/arm64; do
echo "building for $t"
GOOS=${t%/*} GOARCH=${t#*/} GOARM=7 CGO_ENABLED=0 go build -o /dev/null ./cmd/hammerhead
done
+62
View File
@@ -0,0 +1,62 @@
name: Release
# Push a tag such as v0.2.0 to build and publish a release:
# git tag -a v0.2.0 -m "hammerhead v0.2.0" && git push origin v0.2.0
on:
push:
tags: ['v*']
jobs:
release:
runs-on: ubuntu-latest
env:
GOPRIVATE: git.veillax.com
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # release notes come from the history since the previous tag
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Let Go fetch modules from this server
run: git config --global url."https://x-access-token:${{ secrets.GITHUB_TOKEN }}@git.veillax.com/".insteadOf "https://git.veillax.com/"
- name: Test
run: |
go vet ./...
go test ./...
- name: Build release archives
run: sh deploy/build.sh "${GITHUB_REF_NAME}"
# Optional secret RELEASE_TOKEN: an access token that can write releases, for
# servers where the automatic job token can't.
- name: Publish the release
env:
TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
run: sh deploy/publish-release.sh "${GITHUB_REF_NAME}"
# Skipped when the repository variable PUBLISH_DOCKER is "false", or fails alone
# if this runner has no Docker: the release above is already published by then.
# Optional secret REGISTRY_TOKEN (and variable REGISTRY_USER): an access token
# with package write permission, for servers where the job token can't push.
- name: Build and push the container image
if: ${{ vars.PUBLISH_DOCKER != 'false' }}
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || secrets.GITHUB_TOKEN }}
REGISTRY_USER: ${{ vars.REGISTRY_USER || github.actor }}
run: |
host=${GITHUB_SERVER_URL#*://}
owner=$(echo "$GITHUB_REPOSITORY_OWNER" | tr 'A-Z' 'a-z')
image="$host/$owner/hammerhead"
echo "$REGISTRY_TOKEN" | docker login "$host" -u "$REGISTRY_USER" --password-stdin
# a container-driver builder can build several architectures in one go
docker buildx create --name release --driver docker-container --use >/dev/null 2>&1 || docker buildx use release
tags="-t $image:$GITHUB_REF_NAME"
case "$GITHUB_REF_NAME" in
*-*) ;; # pre-releases (v1.0.0-rc1) don't move :latest
*) tags="$tags -t $image:latest" ;;
esac
docker buildx build --platform linux/amd64,linux/arm64 $tags --push .
+1
View File
@@ -5,3 +5,4 @@ bin/
*.db-wal *.db-wal
*.db-shm *.db-shm
master.key master.key
dist/
+22
View File
@@ -0,0 +1,22 @@
# syntax=docker/dockerfile:1
# Packages the binaries deploy/build.sh already built (dist/bin/linux_<arch>/hammerhead),
# so the arm64 image needs no emulation. Build it with: sh deploy/build.sh && docker buildx build .
FROM --platform=$BUILDPLATFORM alpine:3.22 AS prep
RUN apk add --no-cache ca-certificates \
&& mkdir /data && chown 65532:65532 /data
FROM scratch
ARG TARGETARCH
LABEL org.opencontainers.image.title="Hammerhead" \
org.opencontainers.image.description="Control plane for tunnelled ports and websites" \
org.opencontainers.image.source="https://git.veillax.com/Veillax/hammerhead" \
org.opencontainers.image.licenses="AGPL-3.0-only"
COPY --from=prep /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=prep --chown=65532:65532 /data /data
COPY dist/bin/linux_${TARGETARCH}/hammerhead /hammerhead
USER 65532:65532
ENV HAMMERHEAD_DATA=/data
VOLUME ["/data"]
EXPOSE 8080
ENTRYPOINT ["/hammerhead"]
CMD ["-listen", ":8080"]
+34
View File
@@ -39,6 +39,40 @@ bamboo -hammerhead https://hammerhead.example.com -token <site token> -docker
Open the edge's WireGuard UDP port (and any ports you open) in its firewall. Bamboo needs no inbound Open the edge's WireGuard UDP port (and any ports you open) in its firewall. Bamboo needs no inbound
ports. Then open ports on the **Resources** page. ports. Then open ports on the **Resources** page.
## Deploying
Releases are built by Gitea Actions. Pushing a tag such as `v0.2.0` runs `.gitea/workflows/release.yml`, which tests the code, builds Hammerhead for Linux (amd64, arm64, armv7), Windows (amd64) and macOS (amd64, arm64), publishes the archives with a `SHA256SUMS` file on the Releases page, and pushes a multi-architecture container image to this server's registry (`git.veillax.com/veillax/hammerhead`). Every push and pull request runs `.gitea/workflows/ci.yml`: formatting, vet, tests, and a check that every release platform still compiles.
**On a Linux server (systemd)**
```
curl -LO https://git.veillax.com/Veillax/hammerhead/releases/download/v0.1.0/hammerhead_v0.1.0_linux_amd64.tar.gz
curl -LO https://git.veillax.com/Veillax/hammerhead/releases/download/v0.1.0/SHA256SUMS
sha256sum -c --ignore-missing SHA256SUMS
tar xzf hammerhead_v0.1.0_linux_amd64.tar.gz && cd hammerhead_v0.1.0_linux_amd64
sudo ./install.sh # installs the binary and a hardened systemd service
sudoedit /etc/hammerhead/hammerhead.env # your settings and secrets
sudo systemctl start hammerhead
```
Use `linux_arm64` or `linux_arm` for Raspberry Pi and other ARM machines. To upgrade, repeat the same steps: your settings and data are kept. `sudo ./install.sh --uninstall` removes the service and binary but leaves `/etc/hammerhead` and `/var/lib/hammerhead` alone. `hammerhead -version` tells you which build is running.
**With Docker**
```
cp .env.example .env # fill it in
docker compose -f deploy/docker-compose.yml up -d
```
The image is `git.veillax.com/veillax/hammerhead:<version>` (and `:latest` for full releases). The compose file is in the repository and inside each Linux archive.
**Setting up the build (once)**
- A Gitea Actions runner with the label `ubuntu-latest`, and Docker on it if you want the container image.
- Nothing else is required: the workflows use the automatic job token. If your server doesn't let that token publish releases or packages, add the secrets `RELEASE_TOKEN` and `REGISTRY_TOKEN` (access tokens with release and package write permission) and optionally the variable `REGISTRY_USER`.
- To skip the container image, set the repository variable `PUBLISH_DOCKER` to `false`.
- Build locally with `sh deploy/build.sh` (archives in `dist/`).
## Configuration and secrets ## Configuration and secrets
All three programs read a `.env` file from their working directory before they start (or the file All three programs read a `.env` file from their working directory before they start (or the file
+9 -1
View File
@@ -15,6 +15,9 @@ import (
"git.veillax.com/Veillax/reef/dotenv" "git.veillax.com/Veillax/reef/dotenv"
) )
// version is set at build time (deploy/build.sh).
var version = "dev"
func env(k, def string) string { func env(k, def string) string {
if v := os.Getenv(k); v != "" { if v := os.Getenv(k); v != "" {
return v return v
@@ -36,7 +39,12 @@ func main() {
url := flag.String("url", env("HAMMERHEAD_URL", ""), "public URL people reach hammerhead at (used for login redirects and SSO); can also be set in Settings") url := flag.String("url", env("HAMMERHEAD_URL", ""), "public URL people reach hammerhead at (used for login redirects and SSO); can also be set in Settings")
flag.String("env", ".env", "file to read secrets and settings from") flag.String("env", ".env", "file to read secrets and settings from")
genkey := flag.Bool("genkey", false, "print a new random HAMMERHEAD_MASTER_KEY and exit") genkey := flag.Bool("genkey", false, "print a new random HAMMERHEAD_MASTER_KEY and exit")
showVersion := flag.Bool("version", false, "print the version and exit")
flag.Parse() flag.Parse()
if *showVersion {
os.Stdout.WriteString("hammerhead " + version + "\n")
return
}
if *genkey { if *genkey {
fmt.Println(secretbox.GenerateKey()) fmt.Println(secretbox.GenerateKey())
return return
@@ -55,7 +63,7 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
defer srv.Close() defer srv.Close()
log.Printf("hammerhead listening on %s (open it in a browser to finish setup)", *listen) log.Printf("hammerhead %s listening on %s (open it in a browser to finish setup)", version, *listen)
hs := &http.Server{Addr: *listen, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second} hs := &http.Server{Addr: *listen, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second}
log.Fatal(hs.ListenAndServe()) log.Fatal(hs.ListenAndServe())
} }
+50
View File
@@ -0,0 +1,50 @@
#!/bin/sh
# Builds hammerhead for every release platform and packs the archives into dist/.
#
# deploy/build.sh [version] version defaults to `git describe`
#
# Output:
# dist/bin/<os>_<arch>/hammerhead the raw binaries (the Docker image uses the linux ones)
# dist/hammerhead_<version>_<os>_<arch>.tar.gz or .zip
# dist/SHA256SUMS
#
# Set TARGETS="linux/amd64 linux/arm64" to build fewer platforms.
set -eu
NAME=hammerhead
cd "$(dirname "$0")/.."
VERSION=${1:-$(git describe --tags --always 2>/dev/null || echo dev)}
TARGETS=${TARGETS:-"linux/amd64 linux/arm64 linux/arm windows/amd64 darwin/amd64 darwin/arm64"}
rm -rf dist
mkdir -p dist/bin dist/stage
for t in $TARGETS; do
os=${t%/*}
arch=${t#*/}
ext=""
[ "$os" = windows ] && ext=".exe"
out="dist/bin/${os}_${arch}"
mkdir -p "$out"
echo "building $NAME $VERSION for $os/$arch"
CGO_ENABLED=0 GOOS=$os GOARCH=$arch GOARM=7 \
go build -trimpath -ldflags "-s -w -X main.version=$VERSION" -o "$out/$NAME$ext" "./cmd/$NAME"
pkg="${NAME}_${VERSION}_${os}_${arch}"
stage="dist/stage/$pkg"
mkdir -p "$stage"
cp "$out/$NAME$ext" "$stage/"
for f in LICENSE README.md .env.example; do
[ -f "$f" ] && cp "$f" "$stage/"
done
if [ "$os" = linux ]; then
cp deploy/install.sh "deploy/$NAME.service" deploy/docker-compose.yml "$stage/"
chmod +x "$stage/install.sh"
fi
if [ "$os" = windows ] && command -v zip >/dev/null 2>&1; then
(cd dist/stage && zip -qr "../$pkg.zip" "$pkg")
else
tar -C dist/stage -czf "dist/$pkg.tar.gz" "$pkg"
fi
done
rm -rf dist/stage
(cd dist && sha256sum $(ls | grep -E '\.(tar\.gz|zip)$') > SHA256SUMS)
echo "done: $(ls dist | grep -cE '\.(tar\.gz|zip)$') archives in dist/"
+18
View File
@@ -0,0 +1,18 @@
# Run Hammerhead with Docker:
# cp .env.example .env (then fill it in: at least HAMMERHEAD_MASTER_KEY, see `hammerhead -genkey`)
# docker compose up -d
#
# Put it behind HTTPS (a reverse proxy, or Blacktip once it's running) and set
# HAMMERHEAD_URL to the public address in .env.
services:
hammerhead:
image: git.veillax.com/veillax/hammerhead:latest
restart: unless-stopped
env_file: .env
ports:
- "8080:8080"
volumes:
- hammerhead-data:/data
volumes:
hammerhead-data:
+26
View File
@@ -0,0 +1,26 @@
[Unit]
Description=Hammerhead: control plane for tunnelled ports and websites
Documentation=https://git.veillax.com/Veillax/hammerhead
After=network-online.target
Wants=network-online.target
[Service]
User=hammerhead
Group=hammerhead
EnvironmentFile=-/etc/hammerhead/hammerhead.env
ExecStart=/usr/local/bin/hammerhead -listen :8080 -data /var/lib/hammerhead
WorkingDirectory=/var/lib/hammerhead
Restart=on-failure
RestartSec=3
# It only needs its own data folder and the network.
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/var/lib/hammerhead
ProtectHome=true
PrivateTmp=true
ProtectKernelTunables=true
ProtectControlGroups=true
[Install]
WantedBy=multi-user.target
+73
View File
@@ -0,0 +1,73 @@
#!/bin/sh
# Installs hammerhead as a systemd service from the files in this folder.
#
# sudo ./install.sh install or upgrade (your settings and data are kept)
# sudo ./install.sh --start the same, then start (or restart) the service
# sudo ./install.sh --uninstall remove the service and binary (keeps the settings and data)
#
# Files: /usr/local/bin/hammerhead, /etc/systemd/system/hammerhead.service,
# /etc/hammerhead/hammerhead.env (your settings and secrets), /var/lib/hammerhead (data).
# Set DESTDIR=/some/dir to stage everything there without touching the system.
set -eu
NAME=hammerhead
HERE=$(cd "$(dirname "$0")" && pwd)
ROOT=${DESTDIR:-}
BIN=$ROOT/usr/local/bin/$NAME
UNIT=$ROOT/etc/systemd/system/$NAME.service
CONF=$ROOT/etc/$NAME
ENVFILE=$CONF/$NAME.env
DATA=$ROOT/var/lib/$NAME
say() { printf '%s\n' "$*"; }
real() { [ -z "${DESTDIR:-}" ]; } # are we touching the real system?
if real; then
[ "$(id -u)" = 0 ] || { say "Run this as root: sudo $0 $*"; exit 1; }
command -v systemctl > /dev/null 2>&1 || { say "systemd wasn't found. Run $NAME directly (see the README) or use Docker."; exit 1; }
fi
if [ "${1:-}" = "--uninstall" ]; then
if real; then
systemctl disable --now "$NAME" > /dev/null 2>&1 || true
fi
rm -f "$UNIT" "$BIN"
if real; then
systemctl daemon-reload || true
fi
say "Removed $NAME. Left in place: $CONF and $DATA (delete them yourself if you want them gone)."
exit 0
fi
[ -f "$HERE/$NAME" ] || { say "Can't find the $NAME binary next to this script."; exit 1; }
if real && ! id "$NAME" > /dev/null 2>&1; then
useradd --system --home-dir "/var/lib/$NAME" --shell /usr/sbin/nologin "$NAME"
say "Created the system user '$NAME'."
fi
mkdir -p "$ROOT/usr/local/bin" "$ROOT/etc/systemd/system" "$CONF" "$DATA"
install -m 0755 "$HERE/$NAME" "$BIN.new"
mv -f "$BIN.new" "$BIN" # atomic, so upgrading a running service is safe
install -m 0644 "$HERE/$NAME.service" "$UNIT"
if [ ! -f "$ENVFILE" ]; then
install -m 0600 "$HERE/.env.example" "$ENVFILE"
say "Created $ENVFILE. Edit it before starting."
fi
if real; then
chown -R "$NAME:$NAME" "$DATA"
fi
if real; then
systemctl daemon-reload
systemctl enable "$NAME" > /dev/null 2>&1
if [ "${1:-}" = "--start" ] || systemctl is-active --quiet "$NAME"; then
systemctl restart "$NAME"
say "$NAME is running."
fi
fi
say "Installed $("$BIN" -version 2> /dev/null || echo "$NAME")."
say "Next:"
say " 1. edit $ENVFILE"
say " 2. systemctl start $NAME (or run this again with --start)"
say " 3. journalctl -u $NAME -f to watch it"
+60
View File
@@ -0,0 +1,60 @@
#!/bin/sh
# Creates (or updates) the Gitea release for a tag and uploads what deploy/build.sh made.
#
# TOKEN=<access token that can write releases> deploy/publish-release.sh v0.2.0
#
# In Gitea Actions GITHUB_SERVER_URL and GITHUB_REPOSITORY are already set. Safe to
# re-run: an existing release is reused and assets with the same name are replaced.
set -eu
TAG=${1:?usage: publish-release.sh <tag>}
: "${TOKEN:?set TOKEN to a Gitea access token that can write releases}"
SERVER=${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-https://git.veillax.com}}
REPO=${GITHUB_REPOSITORY:-Veillax/hammerhead}
API="$SERVER/api/v1/repos/$REPO"
AUTH="Authorization: token $TOKEN"
# a JSON helper without needing jq: any python will do
PY=python3
python3 -c 'pass' > /dev/null 2>&1 || PY=python
json() { "$PY" -c "$1"; }
# release notes: the commit subjects since the previous tag
prev=$(git describe --tags --abbrev=0 "$TAG^" 2>/dev/null || true)
if [ -n "$prev" ]; then
notes=$(git log --pretty='format:- %s' "$prev..$TAG")
else
notes=$(git log --pretty='format:- %s' "$TAG")
fi
prerelease=false
case "$TAG" in *-*) prerelease=true ;; esac
body=$(NOTES="$notes" TAG="$TAG" PRE="$prerelease" json '
import json, os
print(json.dumps({"tag_name": os.environ["TAG"], "name": os.environ["TAG"], "body": os.environ["NOTES"],
"draft": False, "prerelease": os.environ["PRE"] == "true"}))')
# reuse the release when a failed job is re-run
id=$(curl -fsS -H "$AUTH" "$API/releases/tags/$TAG" 2>/dev/null | json 'import sys, json; print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
if [ -z "$id" ]; then
echo "creating release $TAG"
id=$(curl -fsS -X POST -H "$AUTH" -H 'Content-Type: application/json' -d "$body" "$API/releases" | json 'import sys, json; print(json.load(sys.stdin)["id"])')
else
echo "release $TAG already exists (id $id): replacing its files"
fi
existing=$(curl -fsS -H "$AUTH" "$API/releases/$id/assets" | json 'import sys, json
for a in json.load(sys.stdin):
print(a["id"], a["name"])')
for f in dist/*.tar.gz dist/*.zip dist/SHA256SUMS; do
[ -f "$f" ] || continue
name=$(basename "$f")
echo "$existing" | while read -r aid aname; do
if [ "$aname" = "$name" ]; then
curl -fsS -X DELETE -H "$AUTH" "$API/releases/$id/assets/$aid" > /dev/null
fi
done
echo "uploading $name"
curl -fsS -X POST -H "$AUTH" -F "attachment=@$f" "$API/releases/$id/assets?name=$name" > /dev/null
done
echo "published $SERVER/$REPO/releases/tag/$TAG"