Add a Hearthforge CI pipeline
The pipeline lints and tests the workspace, builds the server, web UI and debug APK, and pushes an image to the forge registry. The image step packages the CI build through a new prebuilt stage in the Containerfile, so the image ships what the tests checked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A.hearthforge-ci.toml
@@ -0,0 +1,147 @@
# Steps run in file order, in one container, sharing /ci/build.
# Same image as the Containerfile's build stage, so the binary links against
# the glibc of the trixie runtime image.
image = "docker.io/library/rust:1-trixie"
work_dir = "/ci/build"
clone_project_to = "/ci/build/project"
shell_setup = """
set -euo pipefail
export CARGO_TARGET_DIR=/ci/cache/target
export ANDROID_HOME=/ci/cache/android-sdk
"""
timeout = 3600
memory_limit = "6g"
# CARGO_TARGET_DIR must stay outside clone_project_to: the checkout is
# extracted over that directory.
cache = [
{ path = "/ci/cache/target", max_size = "16g" },
{ path = "/usr/local/cargo/registry", max_size = "4g" },
# trunk downloads wasm-opt here.
{ path = "/root/.cache/trunk", max_size = "1g" },
{ path = "/ci/cache/android-sdk", max_size = "3g" },
{ path = "/root/.gradle", max_size = "4g" },
]
[on]
push = ["master"]
tag = true
[variables]
[variables.TRUNK_VERSION]
default = "0.21.14"
description = "Trunk release that builds the wasm frontend. Matches the Containerfile."
[[steps]]
name = "setup"
timeout = 900
run_sh = """
apt-get update -qq && apt-get install -y -qq --no-install-recommends \
podman-remote openjdk-21-jdk-headless > /dev/null
rustup component add rustfmt clippy
rustup target add wasm32-unknown-unknown
url="https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz"
curl -fsSL -o /tmp/trunk.tar.gz "$url"
curl -fsSL "$url.sha256" | awk '{print $1 " /tmp/trunk.tar.gz"}' | sha256sum -c -
tar xzf /tmp/trunk.tar.gz -C /usr/local/bin
rm -f /tmp/trunk.tar.gz
# Only for the license files. Gradle installs the platform and build-tools
# that the app needs on its own.
if [ ! -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
curl -fsSL -o /tmp/clt.zip \
"https://dl.google.com/android/repository/commandlinetools-linux-15859902_latest.zip"
echo "040d3996a65543d22ec4bf73e4c37aa37a8d4af4 /tmp/clt.zip" | sha1sum -c -
unzip -q /tmp/clt.zip -d /tmp/clt
mkdir -p "$ANDROID_HOME/cmdline-tools"
mv /tmp/clt/cmdline-tools "$ANDROID_HOME/cmdline-tools/latest"
rm -r /tmp/clt /tmp/clt.zip
fi
(yes || true) | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null
trunk --version && cargo fmt --version && cargo clippy --version && java -version
"""
# Reported, not gated: clippy findings change between toolchain versions.
[[steps]]
name = "lint"
warn_on_fail = true
run_sh = """
cd project
cargo fmt --check
cargo clippy --workspace --all-targets -- -D warnings
"""
[[steps]]
name = "test"
run_sh = "cd project && cargo test --workspace"
[[steps]]
name = "build"
timeout = 2400
run_sh = """
cd project
(cd web && trunk build --release)
cargo build --locked --release -p server
"""
[[steps]]
name = "android"
timeout = 1800
run_sh = """
cd project/android
./gradlew --no-daemon testDebugUnitTest assembleDebug
cp app/build/outputs/apk/debug/app-debug.apk /ci/build/opentracker-debug.apk
"""
publish_file = ["/ci/build/opentracker-debug.apk"]
[[steps]]
name = "android-lint"
warn_on_fail = true
run_sh = "cd project/android && ./gradlew --no-daemon lintDebug"
# Packages what the build step made via the Containerfile's prebuilt stage.
# Needs CI_ENGINE_SOCKET=1 on the server and the CI secret REGISTRY_PASSWORD
# (admin password). Every run pushes the short sha and "edge". A tag run also
# pushes the tag and "latest".
[[steps]]
name = "image"
engine_socket = true
timeout = 900
run_sh = """
cd project
mkdir -p ci-bin
cp "${CARGO_TARGET_DIR}/release/otserver" ci-bin/otserver
cp -r web/dist ci-bin/web
echo "$REGISTRY_PASSWORD" | podman-remote login "${CI_REGISTRY%%/*}" -u admin --password-stdin
# A remote build sends a seccomp profile path that the server opens. The
# client's default path may not exist on the server, so ask the server.
prof=$(podman-remote info --format '{{.Host.Security.SECCOMPProfilePath}}' 2>/dev/null || true)
if [ -n "$prof" ]; then
seccomp="seccomp=$prof"
else
seccomp="seccomp=unconfined"
fi
img="$CI_REGISTRY:$CI_COMMIT_SHORT_SHA"
podman-remote build --security-opt "$seccomp" \
-f Containerfile -t "$img" --build-arg BIN_STAGE=prebuilt .
podman-remote push "$img"
if [ -n "${CI_COMMIT_TAG:-}" ]; then
tags="$CI_COMMIT_TAG latest"
else
tags="edge"
fi
for t in $tags; do
podman-remote tag "$img" "$CI_REGISTRY:$t"
podman-remote push "$CI_REGISTRY:$t"
done
"""
MContainerfile
@@ -1,3 +1,5 @@
ARG BIN_STAGE=build
FROM docker.io/library/rust:1-trixie AS build
ARG TRUNK_VERSION=0.21.14
RUN rustup target add wasm32-unknown-unknown \
@@ -8,12 +10,20 @@ COPY . .
RUN cd web && trunk build --release
RUN cargo build --release -p server
# CI builds the server and web UI itself, puts them in ci-bin/ and selects this
# stage with --build-arg BIN_STAGE=prebuilt. An unreferenced stage is not built.
FROM scratch AS prebuilt
COPY ci-bin/otserver /src/target/release/otserver
COPY ci-bin/web /src/web/dist
FROM ${BIN_STAGE} AS bin
FROM docker.io/library/debian:trixie-slim
# webauthn-rs links OpenSSL.
RUN apt-get update && apt-get install -y --no-install-recommends libssl3t64 && rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 10001 ot && mkdir /data && chown ot /data
COPY --from=build /src/target/release/otserver /usr/local/bin/
COPY --from=build /src/web/dist /srv/web
COPY --from=bin /src/target/release/otserver /usr/local/bin/
COPY --from=bin /src/web/dist /srv/web
ENV OT_ADDR=0.0.0.0:8080 OT_DB=/data/ot.db OT_WEB_DIR=/srv/web
USER ot
VOLUME /data