diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d110cc..12937c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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- 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 " (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 diff --git a/bootstrap_asset_test.go b/bootstrap_asset_test.go index a4c8161..476ce68 100644 --- a/bootstrap_asset_test.go +++ b/bootstrap_asset_test.go @@ -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) diff --git a/deploy/build-release-artifacts.sh b/deploy/build-release-artifacts.sh new file mode 100755 index 0000000..379da84 --- /dev/null +++ b/deploy/build-release-artifacts.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# Builds everything a Felis release installs, for every architecture, into one directory: +# +# felis-linux- the felis binary, panel included +# felis-image-felis-linux-.tar the control-plane image (OCI layout tars: +# felis-image-game-linux-.tar the limbo, lobby and paper images `ctr images import` +# felis-image-base-linux-.tar the registry and PostgreSQL reads them as-is) +# felis-images-linux-.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 +# 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="$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 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 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" < "${WORK}/SHA256SUMS" +mv "${WORK}/SHA256SUMS" "${OUT}/SHA256SUMS" +ls -l "$OUT" diff --git a/deploy/limbo/Dockerfile b/deploy/limbo/Dockerfile index b169bca..6d1d0dd 100644 --- a/deploy/limbo/Dockerfile +++ b/deploy/limbo/Dockerfile @@ -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 diff --git a/deploy/lobby/Dockerfile b/deploy/lobby/Dockerfile index ba5e525..4f8f729 100644 --- a/deploy/lobby/Dockerfile +++ b/deploy/lobby/Dockerfile @@ -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/