Files

229 lines
10 KiB
YAML

# Attaches binaries to a release when a version tag is pushed.
#
# Without this a release is a git tag, and installing llmgw on a node means
# having a Go toolchain and the source. The onboarding script copies a binary
# from wherever it is run, which works and quietly makes whoever runs it the
# build server.
name: Release
on:
push:
tags: ["v*"]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
# The built-in token every job is given carries whatever the instance's
# default mode allows, and under the restricted default that is read-only
# on releases — so the build would succeed and the publish would be
# refused. `contents` covers code and releases together, which is what
# creating a release needs: `releases: write` alone was refused, because
# creating one can create its tag.
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
# Tags are what the version string is built from, and a shallow clone
# has none of them.
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Build
run: |
set -euo pipefail
version="${GITHUB_REF_NAME:-$(git describe --tags --always)}"
mkdir -p dist
# CGO_ENABLED=0 is the whole point: the binary has to run on a node
# whose libc is older than the builder's, and llmgw has no C
# dependency — modernc.org/sqlite is pure Go precisely so this stays
# true.
for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do
os="${target%/*}"; arch="${target#*/}"
name="llmgw-${version}-${os}-${arch}"
echo "building $name"
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build \
-trimpath \
-ldflags "-s -w -X gitea.mynodes.xyz/ilker/llmgw/internal/version.Version=${version}" \
-o "dist/${name}" ./cmd/llmgw
(cd dist && sha256sum "${name}" >> checksums.txt)
done
# llmgw-kb, which people run on their own machines to put their
# documents into a knowledge base: Windows too, which llmgw itself
# does not build for.
for target in windows/amd64 windows/arm64 darwin/amd64 darwin/arm64 linux/amd64 linux/arm64; do
os="${target%/*}"; arch="${target#*/}"
ext=""; [ "$os" = windows ] && ext=".exe"
name="llmgw-kb-${version}-${os}-${arch}${ext}"
echo "building $name"
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build \
-trimpath \
-ldflags "-s -w -X gitea.mynodes.xyz/ilker/llmgw/internal/version.Version=${version}" \
-o "dist/${name}" ./cmd/llmgw-kb
(cd dist && sha256sum "${name}" >> checksums.txt)
done
# The reverse proxy configurations, so a release page carries
# everything install.md asks for and the repository is not needed.
for f in deploy/proxy/Caddyfile deploy/proxy/nginx.conf; do
cp "$f" dist/
(cd dist && sha256sum "$(basename "$f")" >> checksums.txt)
done
# An NVML build for nodes with NVLink, which the portable binary
# cannot serve.
#
# NVML is the only source of NVLink topology, its bindings need cgo,
# and cgo is exactly what CGO_ENABLED=0 above exists to avoid. So the
# portable binary silently falls back to taking the lowest free GPU
# indices — right on an SXM box with NVSwitch, and wrong on a PCIe
# box where NVLink bridges pair specific cards: a tensor group
# straddling two bridges runs several times slower and nothing says
# so. Shipping only the portable build meant nobody could have that
# topology without building it themselves.
#
# Dynamically linked against the host's libc by necessity, so it is
# published beside the portable one rather than instead of it.
if command -v gcc >/dev/null; then
nvml_name="llmgw-${version}-linux-amd64-nvml"
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build \
-tags nvml -trimpath \
-ldflags "-s -w -X gitea.mynodes.xyz/ilker/llmgw/internal/version.Version=${version}" \
-o "dist/${nvml_name}" ./cmd/llmgw
(cd dist && sha256sum "${nvml_name}" >> checksums.txt)
else
echo "no C toolchain; skipping the nvml build" >&2
fi
# Proof the artifact runs at all before it is published. It cannot
# test the cross-compiled ones, which is why the native build is
# checked rather than assumed.
./dist/llmgw-${version}-linux-amd64 --version
./dist/llmgw-kb-${version}-linux-amd64 --version
cat dist/checksums.txt
# Signed, so a machine that trusts the key built into it installs
# nothing the server alone could have put there. `release sign` checks
# the signature against that key before writing it, so a secret that
# does not match trusted.pub stops the release too.
- name: Sign the checksums
env:
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
run: |
set -euo pipefail
version="${GITHUB_REF_NAME:-$(git describe --tags --always)}"
if [ -z "${RELEASE_SIGNING_KEY:-}" ]; then
# Builds that carry a trusted key install only signed releases:
# publishing an unsigned one would be a release no upgraded
# machine accepts, so it stops here instead.
if [ -s internal/release/trusted.pub ]; then
echo "internal/release/trusted.pub holds a key, and the RELEASE_SIGNING_KEY secret is not set:"
echo "add it under Settings -> Secrets, or this release could not be installed anywhere."
exit 1
fi
echo "no RELEASE_SIGNING_KEY secret and no trusted key; releases are not signed"
exit 0
fi
./dist/llmgw-${version}-linux-amd64 release sign dist/checksums.txt
echo "signed: dist/checksums.txt.sig"
- name: Attach to the release
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
# Gitea gives every job a GITEA_TOKEN, so this is not the usual
# "you forgot to add a secret" — an empty one means the job was denied
# a token, which is an instance setting rather than a repository one.
if [ -z "${TOKEN:-}" ]; then
echo "The job was given no GITEA_TOKEN, so nothing can be uploaded."
echo "Check Settings -> Actions -> General; the binaries are in dist/."
exit 1
fi
# workflow_dispatch can be fired from a branch, where GITHUB_REF_NAME
# is the branch name. Uploading then would create a release called
# "main". The binaries are still built and kept as a check that the
# release build works; only the publishing is a tag's business.
if [ "${GITHUB_REF_TYPE:-}" != "tag" ]; then
echo "not a tag (ref=${GITHUB_REF_NAME}); built, not published"
exit 0
fi
tag="${GITHUB_REF_NAME}"
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
# Gitea materialises a release row for every pushed tag, so this
# nearly always finds one and the create below is for a tag pushed
# some other way.
#
# The id is read with grep rather than sed because sed's .* is greedy:
# "id" appears again inside the nested author object, so the old
# pattern took the author's id when it matched at all — and it did not
# match "id": 13 with a space, which is what actually happened. The
# first "id" in a release object is the release's own.
release_id() {
curl -sS -H "Authorization: token $TOKEN" "$api/releases/tags/$tag" \
| grep -o '"id"[[:space:]]*:[[:space:]]*[0-9]*' \
| head -1 | grep -o '[0-9]*$'
}
id=$(release_id || true)
if [ -z "$id" ]; then
echo "no release for $tag yet; creating one"
out=$(curl -sS -w '\n%{http_code}' -X POST \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$tag\",\"name\":\"$tag\"}" \
"$api/releases")
echo "create returned $(printf '%s' "$out" | tail -1)"
# Re-read rather than parsing the create response: a 409 means it
# was there all along, which is a success for our purposes.
id=$(release_id || true)
[ -n "$id" ] || printf '%s\n' "$out" | head -5 >&2
fi
[ -n "$id" ] || { echo "could not find or create the release" >&2; exit 1; }
echo "release id $id"
for f in dist/*; do
echo "uploading $(basename "$f")"
curl -sS -f -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@$f" \
"$api/releases/$id/assets?name=$(basename "$f")" >/dev/null \
|| { echo "upload of $(basename "$f") was refused" >&2; exit 1; }
done
echo "attached $(ls dist | wc -l) files to $tag"
# The release page says what changed, copied from CHANGELOG.md. Gitea makes
# an empty release for every tag and the job above only attaches files, so
# every release until this existed was a list of binaries and nothing else.
#
# After the build, because that is what guarantees the release exists. Run by
# hand from a branch it syncs every version in the changelog, which is how
# the releases made before this got their notes.
notes:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Copy the changelog onto the release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_INSTANCE: ${{ github.server_url }}
GITEA_REPO: ${{ github.repository }}
run: |
set -euo pipefail
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
python3 scripts/gitea/release_notes.py --tag "${GITHUB_REF_NAME}"
else
python3 scripts/gitea/release_notes.py
fi