Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
229 lines
10 KiB
YAML
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
|