feat(release): CI 一次构建各架构二进制、镜像包与 velocity 插件

This commit is contained in:
Lemon-miaow committed 2026-09-26 14:22:46 +08:00
1 parent 7beb43195b
commit 9931f64fff
5 files changed
+264 -66

No files matched your search

+40 -59
View File
@@ -2,14 +2,15 @@
#
# Two things depend on this job. /repos/{repo}/releases/latest must ANSWER — that endpoint is
# what `felis update` polls (internal/updater/github.go) and what deploy/bootstrap.sh's default
# "release" channel resolves its ref from. And the binaries below are what that channel then
# INSTALLS: bootstrap downloads felis-linux-<arch> instead of compiling on the target host, so
# these are the shipped artifact, not a convenience.
# "release" channel resolves its ref from. And the assets below are what that channel then
# INSTALLS: bootstrap downloads the felis binary, the image tars, their listing and the Velocity
# plugin instead of building anything on the target host, so these are the shipped artifact,
# not a convenience. A host installing a release needs neither Docker, Gradle, Go nor Docker Hub.
#
# The asset NAME is a contract with deploy/bootstrap.sh (download_release_binary builds
# "felis-linux-${arch}"). It is deliberately a plain literal on both sides: a GitHub Actions
# YAML and a go:embed'ed shell script have no honest way to share a constant, and the failure
# mode is benign — bootstrap warns and falls back to a source build of the same tag.
# deploy/build-release-artifacts.sh builds every asset in one run and documents each name; the
# names are a contract with deploy/bootstrap.sh. They are plain literals on both sides: a YAML
# workflow and a go:embed'ed shell script have no honest way to share a constant, and the
# failure mode is benign — bootstrap warns and falls back to building on the host.
#
# The binary is built through the repo Dockerfile rather than a plain `go build`.
# internal/panel/static holds a tracked PLACEHOLDER index.html so the //go:embed
@@ -18,13 +19,12 @@
# build first, and is the same recipe bootstrap uses, so there is one way to build felis
# rather than two that can drift.
#
# SHA256SUMS is a contract with bootstrap too: download_release_binary refuses a binary whose
# hash is not listed there, BEFORE it runs it. A release without the file installs by source
# build instead.
# SHA256SUMS is a contract with bootstrap too: it refuses any asset whose hash is not listed
# there, BEFORE it runs or imports it.
#
# The write token never meets the test suite: `gates` (ci.yml) and `build` run the tests,
# Gradle and the Docker build (each of which executes third-party code) with a read-only
# token, and `build` hands the binaries over as a workflow artifact; `publish` holds contents:write and runs only
# Gradle and the Docker builds (each of which executes third-party code) with a read-only
# token, and `build` hands the assets over as a workflow artifact; `publish` holds contents:write and runs only
# pinned actions and gh. Every action is pinned to a commit SHA (the tag in the trailing
# comment is for humans); .github/dependabot.yml proposes the bumps.
name: release
@@ -52,69 +52,47 @@ jobs:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
# Both architectures, because bootstrap's default release channel DOWNLOADS these
# rather than compiling on the target host — an arm64 host with no asset silently
# falls back to a slow source build. Neither stage is emulated: the Dockerfile pins
# both build stages to $BUILDPLATFORM and the Go stage cross-compiles via TARGETARCH,
# so the second architecture costs about a minute.
# rather than building on the target host — an arm64 host with no asset falls back to
# a slow on-host build. The felis binary and the plugin jars cross-compile on the
# runner (their build stages are pinned to $BUILDPLATFORM); the game images' runtime
# stages run apt-get for the target platform, which for arm64 takes QEMU.
- uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Build the stamped binaries
run: |
docker buildx build --platform linux/amd64,linux/arm64 \
--build-arg FELIS_VERSION="${GITHUB_REF_NAME}" \
--output type=local,dest=out .
mv out/linux_amd64/usr/local/bin/felis ./felis-linux-amd64
mv out/linux_arm64/usr/local/bin/felis ./felis-linux-arm64
chmod +x ./felis-linux-amd64 ./felis-linux-arm64
# Two architectures of five images plus BuildKit's cache outgrow the runner's free disk
# with its preinstalled SDKs in place; none of them is used here.
- name: Free disk space
run: sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
# The stamp is the whole point and it fails silently: an unstamped binary reports
# "dev", which `felis update` refuses to compare, disabling update reporting for
# every install built from it. Assert it end to end instead of trusting the ARG
# reached the linker.
- name: Verify the version stamp
run: |
got="$(./felis-linux-amd64 version | head -1)"
echo "reported: ${got}"
[ "$got" = "felis ${GITHUB_REF_NAME}" ] \
|| { echo "expected 'felis ${GITHUB_REF_NAME}' — the -X main.version stamp did not reach the binary"; exit 1; }
# The arm64 binary is checked by ELF machine type, NOT by running it. Runners have
# binfmt/QEMU registered, so `./felis-linux-arm64 version` would happily succeed on
# an amd64 binary misnamed arm64 — which is exactly the failure the Dockerfile's
# ${TARGETARCH:-$(go env GOARCH)} fallback produces if buildx did not take. Both
# binaries come out of one RUN with one -ldflags string, so the stamp is checked once.
file ./felis-linux-arm64
file ./felis-linux-arm64 | grep -q 'ARM aarch64' \
|| { echo "felis-linux-arm64 is not an arm64 ELF — TARGETARCH did not reach the go build"; exit 1; }
- name: Checksum the binaries
run: sha256sum felis-linux-amd64 felis-linux-arm64 | tee SHA256SUMS
# The script checks what the old inline steps did: the host binary reports exactly
# "felis <tag>" (unstamped, `felis update` refuses to compare), and the other one is an
# ELF of its architecture, read with file(1) — binfmt would run a misnamed binary happily.
- name: Build the release assets
run: deploy/build-release-artifacts.sh "${GITHUB_REF_NAME}" dist
# A CycloneDX SBOM per binary: the Go modules (and versions) linked into it, read
# from the build info the linker embeds.
- uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
with:
file: felis-linux-amd64
file: dist/felis-linux-amd64
format: cyclonedx-json
output-file: felis-linux-amd64.cdx.json
output-file: sbom/felis-linux-amd64.cdx.json
upload-artifact: false
upload-release-assets: false
- uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
with:
file: felis-linux-arm64
file: dist/felis-linux-arm64
format: cyclonedx-json
output-file: felis-linux-arm64.cdx.json
output-file: sbom/felis-linux-arm64.cdx.json
upload-artifact: false
upload-release-assets: false
# Outside SHA256SUMS, which lists what bootstrap installs; beside it in the release.
- run: mv sbom/*.cdx.json dist/
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: release-assets
path: |
felis-linux-amd64
felis-linux-arm64
felis-linux-amd64.cdx.json
felis-linux-arm64.cdx.json
SHA256SUMS
path: dist/
if-no-files-found: error
retention-days: 7
@@ -134,7 +112,7 @@ jobs:
- run: sha256sum -c SHA256SUMS
# Signed SLSA provenance: which workflow run, commit and repository produced each
# binary. Check one with `gh attestation verify felis-linux-amd64 --repo FelisMC/Felis`.
# asset. Check one with `gh attestation verify felis-linux-amd64 --repo FelisMC/Felis`.
# GitHub only stores attestations for private repositories on Enterprise Cloud, and a
# failure here would block the release, so a private repository skips the step and
# relies on SHA256SUMS alone.
@@ -143,8 +121,9 @@ jobs:
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
with:
subject-path: |
felis-linux-amd64
felis-linux-arm64
felis-linux-*
felis-image-*.tar
felis-velocity.jar
# --verify-tag refuses to invent a release for a tag that is not pushed.
#
@@ -164,7 +143,9 @@ jobs:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
assets="felis-linux-amd64 felis-linux-arm64 felis-linux-amd64.cdx.json felis-linux-arm64.cdx.json SHA256SUMS"
# Every file the build handed over: SHA256SUMS names the installable ones, and the
# SBOMs ride along.
assets="$(ls)"
flags=""
case "$GITHUB_REF_NAME" in *-*) flags="--prerelease" ;; esac
if ! gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
+18 -5
View File
@@ -304,16 +304,19 @@ func TestDockerfileBaseImagesArePinnedByDigest(t *testing.T) {
}
}
// The plugin jars are built in three places the installer controls — the lobby and limbo
// image builds and bootstrap's Velocity build — and through each module's wrapper by a
// developer or CI. A tag alone is whatever it points at on build day, and two Gradle
// versions are two chances for a build to pass in one place and break in the other, so
// all of them run one image, pinned by digest, whose Gradle is the wrappers' Gradle.
// The plugin jars are built in four places the installer controls — the lobby and limbo
// image builds, bootstrap's Velocity build and the release build of the same jar — and
// through each module's wrapper by a developer or CI. A tag alone is whatever it points
// at on build day, and two Gradle versions are two chances for a build to pass in one
// place and break in the other, so all of them run one image, pinned by digest, whose
// Gradle is the wrappers' Gradle.
func TestPluginBuildsRunOnePinnedGradle(t *testing.T) {
sources := map[string]string{
"deploy/lobby/Dockerfile": readGameStackFile(t, "deploy/lobby/Dockerfile"),
"deploy/limbo/Dockerfile": readGameStackFile(t, "deploy/limbo/Dockerfile"),
"deploy/bootstrap.sh": BootstrapScript(),
// The release build compiles felis-velocity.jar for hosts that install prebuilt.
"deploy/build-release-artifacts.sh": readRepoFile(t, "deploy/build-release-artifacts.sh"),
}
anyRef := regexp.MustCompile(`gradle:[\w.-]+(@sha256:\w+)?`)
pinned := regexp.MustCompile(`^gradle:(\d+\.\d+(?:\.\d+)?)-jdk\d+@sha256:[0-9a-f]{64}$`)
@@ -487,6 +490,16 @@ func gameStackLock(t *testing.T) map[string]string {
return lock
}
// readRepoFile reads a file of the checkout that the binary does not embed.
func readRepoFile(t *testing.T, name string) string {
t.Helper()
b, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func readGameStackFile(t *testing.T, name string) string {
t.Helper()
b, err := gameStackAssets.ReadFile(name)
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env bash
# Builds everything a Felis release installs, for every architecture, into one directory:
#
# felis-linux-<arch> the felis binary, panel included
# felis-image-felis-linux-<arch>.tar the control-plane image (OCI layout tars:
# felis-image-game-linux-<arch>.tar the limbo, lobby and paper images `ctr images import`
# felis-image-base-linux-<arch>.tar the registry and PostgreSQL reads them as-is)
# felis-images-linux-<arch>.txt one "bundle role name manifest-digest config-digest"
# line per image in the three tars
# felis-velocity.jar the proxy plugin (JVM bytecode, one for every arch)
# SHA256SUMS the sha256 of every file above
#
# Every name above is a contract with deploy/bootstrap.sh, which installs from a release's
# assets (or from a directory like this one, FELIS_ARTIFACT_DIR) instead of building on the
# host: with them a host needs neither Docker, Gradle, Go nor Docker Hub. The images are split
# in three because they change at different rates: the control plane with every release, the
# game images when game-stack.lock or a plugin changes, the base images almost never. An
# upgrade downloads only the tars holding an image the host does not have yet.
#
# .github/workflows/release.yml runs this on a tag and publishes the directory; e2e.yml runs
# it on a branch and installs from the directory.
#
# Needs docker with buildx, and binfmt/QEMU for the architectures other than the builder's:
# the game images' runtime stages run apt-get on the target platform. The builder's own felis
# binary writes the image tars, so Go is not needed here either.
#
# Usage: deploy/build-release-artifacts.sh <version> <out-dir>
# FELIS_RELEASE_ARCHES the architectures to build (default "amd64 arm64")
set -Eeuo pipefail
log() { printf '\033[1;36m[release]\033[0m %s\n' "$*"; }
die() { printf '\033[1;31m[fail]\033[0m %s\n' "$*" >&2; exit 1; }
[ "$#" -eq 2 ] || die "usage: $0 <version> <out-dir>"
VERSION="$1"
OUT="$2"
ARCHES="${FELIS_RELEASE_ARCHES:-amd64 arm64}"
cd "$(dirname "$0")/.."
# The Gradle image the plugin builds run in: deploy/{limbo,lobby}/Dockerfile and bootstrap's
# Velocity build name the same one (bootstrap_asset_test.go holds them together).
PLUGIN_BUILD_IMAGE="gradle:9.8.0-jdk25@sha256:2b2fc1b1dfc3604a2acc916839f36eb5ee48fd7f232427fc5faca224c73bcb01"
# The names and pins bootstrap uses, read from bootstrap itself so the two cannot drift.
bootstrap_value() {
local v
v="$(sed -n "s/^$1=\"\(.*\)\"\$/\1/p" deploy/bootstrap.sh)"
[ -n "$v" ] || die "deploy/bootstrap.sh sets no $1"
printf '%s' "$v"
}
REGISTRY_URL="$(bootstrap_value REGISTRY_URL)"
REGISTRY_IMAGE="$(bootstrap_value REGISTRY_IMAGE)"
POSTGRES_IMAGE="$(bootstrap_value POSTGRES_IMAGE)"
# The final stage of the repo Dockerfile, the base every felis image runs on.
FELIS_BASE_IMAGE="$(awk '$1 == "FROM" && $2 ~ /^gcr\.io\/distroless\// { print $2 }' Dockerfile)"
[ -n "$FELIS_BASE_IMAGE" ] || die "the Dockerfile names no distroless base"
# lock_value reads one KEY=value line of deploy/game-stack.lock, which bootstrap reads the
# same way: never sourced, values limited to URL and version characters.
lock_value() {
local v
v="$(awk -F= -v k="$1" '$1 == k { print substr($0, length(k) + 2); exit }' deploy/game-stack.lock)"
case "$v" in
''|*[!A-Za-z0-9._:/+%-]*) die "deploy/game-stack.lock: $1 is missing or malformed" ;;
esac
printf '%s' "$v"
}
# image_tag_for_version is bootstrap's: the tag bootstrap names this release's image with.
image_tag_for_version() {
local v
v="$(printf '%s' "$1" | tr -c 'A-Za-z0-9_.-' '-')"
case "$v" in
""|dev|[!A-Za-z0-9_]*) printf 'demo' ;;
*) printf '%s' "${v:0:128}" ;;
esac
}
host_arch() {
case "$(uname -m)" in
x86_64|amd64) printf 'amd64' ;;
aarch64|arm64) printf 'arm64' ;;
*) die "unsupported builder architecture $(uname -m)" ;;
esac
}
mkdir -p "$OUT"
[ -z "$(ls -A "$OUT")" ] || die "${OUT} is not empty; SHA256SUMS must describe exactly what one run built"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
platforms=""
for arch in $ARCHES; do
case "$arch" in amd64|arm64) ;; *) die "unsupported architecture ${arch}" ;; esac
platforms="${platforms:+${platforms},}linux/${arch}"
done
# ---- the felis binaries --------------------------------------------------------------
# Through the repo Dockerfile, the one recipe that runs the panel build before go build:
# a plain `go build` compiles against the placeholder panel and ships it.
log "building felis ${VERSION} for ${platforms}"
docker buildx build --platform "$platforms" \
--build-arg FELIS_VERSION="$VERSION" \
--output "type=local,dest=${WORK}/bin" .
for arch in $ARCHES; do
src="${WORK}/bin/usr/local/bin/felis"
# buildx nests the output per platform only when it builds more than one.
[ -f "${WORK}/bin/linux_${arch}/usr/local/bin/felis" ] && src="${WORK}/bin/linux_${arch}/usr/local/bin/felis"
install -m 0755 "$src" "${OUT}/felis-linux-${arch}"
# By ELF machine, never by running it: binfmt would run the wrong architecture happily.
case "$arch" in
amd64) want='x86-64' ;;
arm64) want='ARM aarch64' ;;
esac
file "${OUT}/felis-linux-${arch}" | grep -q "$want" \
|| die "felis-linux-${arch} is not a ${want} ELF: TARGETARCH did not reach the go build"
done
HOST_FELIS="${OUT}/felis-linux-$(host_arch)"
[ -x "$HOST_FELIS" ] || die "no felis binary for the builder's own architecture ($(host_arch)); add it to FELIS_RELEASE_ARCHES"
got="$("$HOST_FELIS" version | head -n 1)"
[ "$got" = "felis ${VERSION}" ] || die "felis reports '${got}', want 'felis ${VERSION}': the version stamp did not reach the binary"
# ---- the Velocity plugin -------------------------------------------------------------
# The command bootstrap's source path runs, on a copy of the tree so the checkout stays clean.
log "building felis-velocity.jar in ${PLUGIN_BUILD_IMAGE%%@*}"
mkdir -p "${WORK}/velocity"
tar -C . --exclude=build --exclude=.gradle -cf - plugins/velocity plugins/shared | tar -C "${WORK}/velocity" -xf -
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -e GRADLE_USER_HOME=/tmp/gradle \
-v "${WORK}/velocity:/src" -w /src/plugins/velocity \
"$PLUGIN_BUILD_IMAGE" gradle --no-daemon clean build
jars=( "${WORK}"/velocity/plugins/velocity/build/libs/felis-velocity-*.jar )
[ "${#jars[@]}" -eq 1 ] && [ -f "${jars[0]}" ] || die "the felis-velocity build must produce exactly one plugin jar"
install -m 0644 "${jars[0]}" "${OUT}/felis-velocity.jar"
# ---- the images ----------------------------------------------------------------------
FELIS_IMAGE="${REGISTRY_URL}/felis/felis:$(image_tag_for_version "$VERSION")"
game_build_args=(
--build-arg "LIMBO_JAR_URL=$(lock_value LIMBO_JAR_URL)"
--build-arg "LIMBO_JAR_SHA256=$(lock_value LIMBO_JAR_SHA256)"
--build-arg "LIMBO_SCHEM_URL=$(lock_value LIMBO_SCHEM_URL)"
--build-arg "LIMBO_SCHEM_SHA256=$(lock_value LIMBO_SCHEM_SHA256)"
--build-arg "LIMBO_VERSION=$(lock_value LIMBO_VERSION)"
--build-arg "PAPER_JAR_URL=$(lock_value PAPER_JAR_URL)"
--build-arg "PAPER_JAR_SHA256=$(lock_value PAPER_JAR_SHA256)"
--build-arg "LUCKPERMS_JAR_URL=$(lock_value LUCKPERMS_JAR_URL)"
--build-arg "LUCKPERMS_JAR_SHA256=$(lock_value LUCKPERMS_JAR_SHA256)"
)
# oci_image <arch> <dest> <docker build args...> builds one image for one platform into an
# OCI layout tar. No attestations: the bundle carries images only.
oci_image() {
local arch="$1" dest="$2"
shift 2
docker buildx build --platform "linux/${arch}" --provenance=false --sbom=false \
--output "type=oci,dest=${dest}" "$@"
}
# bundle <arch> <group> <image-bundle flags...> writes one image tar and appends its lines
# to the architecture's listing.
bundle() {
local arch="$1" group="$2" name
shift 2
name="felis-image-${group}-linux-${arch}.tar"
"$HOST_FELIS" image-bundle --platform "linux/${arch}" \
--out "${OUT}/${name}" --list "${WORK}/${name}.txt" "$@"
awk -v b="$name" '{ print b, $0 }' "${WORK}/${name}.txt" >> "${OUT}/felis-images-linux-${arch}.txt"
}
for arch in $ARCHES; do
# The control-plane image wraps the released binary itself, byte for byte, the way
# bootstrap wraps a downloaded binary.
log "building the linux/${arch} images"
mkdir -p "${WORK}/felis-${arch}"
cp "${OUT}/felis-linux-${arch}" "${WORK}/felis-${arch}/felis"
cat > "${WORK}/felis-${arch}/Dockerfile" <<EOF
FROM ${FELIS_BASE_IMAGE}
ENV PATH=/usr/local/bin:/usr/bin:/bin
COPY --chmod=0755 felis /usr/local/bin/felis
USER 65532:65532
ENTRYPOINT ["/usr/local/bin/felis"]
EOF
oci_image "$arch" "${WORK}/felis-${arch}.tar" "${WORK}/felis-${arch}"
for role in limbo lobby paper; do
oci_image "$arch" "${WORK}/${role}-${arch}.tar" -f "deploy/${role}/Dockerfile" "${game_build_args[@]}" .
done
bundle "$arch" felis --layout "felis=${FELIS_IMAGE}=${WORK}/felis-${arch}.tar"
bundle "$arch" game \
--layout "limbo=${REGISTRY_URL}/felis/limbo:demo=${WORK}/limbo-${arch}.tar" \
--layout "lobby=${REGISTRY_URL}/felis/lobby:demo=${WORK}/lobby-${arch}.tar" \
--layout "paper=${REGISTRY_URL}/felis/paper:demo=${WORK}/paper-${arch}.tar"
bundle "$arch" base --pull "registry=${REGISTRY_IMAGE}" --pull "postgres=${POSTGRES_IMAGE}"
rm -f "${WORK}"/*-"${arch}".tar
done
log "writing SHA256SUMS"
(cd "$OUT" && sha256sum -- *) > "${WORK}/SHA256SUMS"
mv "${WORK}/SHA256SUMS" "${OUT}/SHA256SUMS"
ls -l "$OUT"
+4 -1
View File
@@ -33,7 +33,10 @@
# must be >= 21 because current LOOHP/Limbo releases ship Java 21 API classes
# (class-file major 65); a JDK 17 fails to read them with "wrong version 65.0, should be
# 61.0". build.gradle still targets release 17 bytecode so the plugin loads on Java 17+.
FROM gradle:9.8.0-jdk25@sha256:2b2fc1b1dfc3604a2acc916839f36eb5ee48fd7f232427fc5faca224c73bcb01 AS plugin
# On the build platform: the jar is plain bytecode, identical for every architecture, so a
# release building the arm64 image on an amd64 runner compiles it natively instead of under
# QEMU (deploy/build-release-artifacts.sh). The runtime stage below stays on the target.
FROM --platform=$BUILDPLATFORM gradle:9.8.0-jdk25@sha256:2b2fc1b1dfc3604a2acc916839f36eb5ee48fd7f232427fc5faca224c73bcb01 AS plugin
WORKDIR /src
# Copy what the limbo module needs: its own tree plus the shared link core it
# srcDir-includes (../shared/src/main/java → /src/plugins/shared/src/main/java), so
+4 -1
View File
@@ -32,7 +32,10 @@
# build rather than the module's wrapper, which would download the same distribution
# again on every image build. The build checks every dependency against
# plugins/paper/gradle/verification-metadata.xml and fails on a mismatch.
FROM gradle:9.8.0-jdk25@sha256:2b2fc1b1dfc3604a2acc916839f36eb5ee48fd7f232427fc5faca224c73bcb01 AS plugin
# On the build platform: the jar is plain bytecode, identical for every architecture, so a
# release building the arm64 image on an amd64 runner compiles it natively instead of under
# QEMU (deploy/build-release-artifacts.sh). The runtime stage below stays on the target.
FROM --platform=$BUILDPLATFORM gradle:9.8.0-jdk25@sha256:2b2fc1b1dfc3604a2acc916839f36eb5ee48fd7f232427fc5faca224c73bcb01 AS plugin
WORKDIR /src
COPY plugins/paper/ ./plugins/paper/
COPY plugins/shared/ ./plugins/shared/