9 Commits

Author SHA1 Message Date
ml f65b9be1df Adding publishing of artifacts
release / build (push) Successful in 4m53s
2026-07-17 20:45:04 +02:00
ml 5ac145bff6 Adding small note on new image build scripts 2026-07-04 16:25:05 +02:00
ml e07258c8bd Fix VICE cross-compile and image cache-warming for release builds
release / build (push) Successful in 5m34s
build-image/Dockerfile: add bison (VICE's configure needs a yacc-compatible
parser generator, same category as the earlier flex fix) and exclude
packageRelease/signReleaseBundle from the cache-warm stage's gradlew run,
since those always fail there for lack of a keystore that's never baked
into the image.

vice_jni.c: guard getFrameCount()/setSoundEnabled() with #ifdef
HAVE_VICE_SRC like their sibling functions already do — they referenced
globals that only exist when VICE is linked in, breaking the documented
no-VICE placeholder build. Only surfaced now because the cache-warm stage
is the first thing to ever compile this file without VICE.

dist.sh: floor the untagged dev-build's versionCode at 1, since the
0.0.0-dev+<sha> placeholder otherwise computes to 0, which Android's
Gradle plugin rejects.

run-image.sh: wipe generated build artifacts (vice-src, vice-libs,
nibtools-src/libs, app/build, .cxx) before each run so it exercises a true
from-scratch build like CI does, instead of silently reusing artifacts
left over from a previous local run. Also chown the bind-mounted repo back
to the host user on exit, since the container runs as root and was
otherwise leaving root-owned files behind.
2026-07-04 15:11:53 +02:00
ml 8881241fd8 Split the monolithic build-image.sh into three scripts: build-image.sh now only builds locally, run-image.sh
runs dist.sh inside that local image via a bind mount, andcupload-image.sh pushes the already-built image.
The Dockerfile also warms the Gradle dependency cache in a throwaway build stage, and pre-installs
the pinned CMake version, so `run-image.sh` doesn't re-download the same Maven dependencies on every run.

dist.sh now strips the -pre+meta suffix before writing package.json's version, since Pebble's build
tooling parses it strictly as X.Y.Z integers and was rejecting suffixed versions.
2026-07-04 05:36:14 +02:00
ml ad887c8bc8 Add flex to build image and VICE preflight check
release / build (push) Failing after 1m53s
VICE's configure.ac uses AC_PROG_LEX, which needs flex — missing from
the build image caused a late, confusing "Could not find either flex
or lex!" failure deep inside configure. Add it to the Dockerfile and
to build_vice.sh's own preflight tool check so a missing flex fails
fast with a clear error. Bump build-image/VERSION to 2.
2026-07-03 06:14:15 +02:00
ml 2117043e31 More robust nibtools build
release / build (push) Failing after 2m7s
2026-07-02 14:27:55 +02:00
ml 84c47136df Setting bash as default shell for action
release / build (push) Failing after 2m3s
2026-07-02 14:13:37 +02:00
ml 23ad9ebc32 Adding container registry login for gitea action
release / build (push) Failing after 52s
2026-07-02 13:57:35 +02:00
ml f3f7442849 dist.sh builds versioned, signed artifacts for both apps from a git tag
release / build (push) Failing after 0s
(semver drives Android versionCode/versionName and package.json). The
build-image/ Dockerfile pins the same toolchain for a portable,
containerized Gitea Actions runner (build-image.sh builds and pushes
it) so releases don't depend on any one machine's local setup.
Pushing vX.Y.Z now builds and publishes a Gitea Release automatically.
2026-07-02 13:19:12 +02:00
16 changed files with 770 additions and 25 deletions
+32
View File
@@ -0,0 +1,32 @@
# Build context for build-image/Dockerfile. The context is the repo root
# (see build-image.sh) so the Gradle-cache-warming stage can COPY in the
# companion app's real project files — everything else is excluded to keep
# the context small and to make sure secrets never reach the Docker daemon.
.git
dist
docs
versions
SchwertUndMagieOnPebbleWatchApp
# Companion app: only the Gradle project files are needed (see the
# gradle-cache-warm stage) — not generated build output or the VICE/nibtools
# source tarballs (buildVice/buildNibtools are excluded from that stage's
# gradle invocation, so they're never unpacked there).
SchwertUndMagieOnPebbleCompanionApp/app/build
SchwertUndMagieOnPebbleCompanionApp/app/.cxx
SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/vice-src
SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/vice-libs
SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/nibtools-src
SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/nibtools-libs
SchwertUndMagieOnPebbleCompanionApp/res/*.tar.gz
SchwertUndMagieOnPebbleCompanionApp/.gradle
SchwertUndMagieOnPebbleCompanionApp/.idea
SchwertUndMagieOnPebbleCompanionApp/local.properties
SchwertUndMagieOnPebbleCompanionApp/build
SchwertUndMagieOnPebbleCompanionApp/captures
# Secrets — must never reach the Docker daemon, even unused.
registry.env
**/keystore.properties
**/*.keystore
**/*.jks
+101
View File
@@ -0,0 +1,101 @@
name: release
# Push a tag matching vX.Y.Z (e.g. v1.2.3) to build and publish a release.
# The tag drives the version — nothing to bump in source files beforehand.
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
jobs:
build:
# Needed to create the release and upload assets with GITEA_TOKEN below,
# regardless of this instance's default Actions permission mode.
permissions:
contents: write
# Runner defaults `run:` steps to `sh`, which doesn't understand
# `set -o pipefail` used below — force bash explicitly.
defaults:
run:
shell: bash
# Must match a label your act_runner is registered with. The runner's
# own default label-image is irrelevant here since `container:` below
# overrides the actual build image per-job.
runs-on: ubuntu-latest
container:
# :latest — always the most recently pushed build-image.sh output.
# Pin to a specific tag here (see build-image/VERSION) if you need a
# release build to be reproducible against an exact toolchain image.
image: cr.ladkau.de/schwert-und-magie/builder:latest
# Lets the runner pull a private image without a manual `docker login`
# on the runner host — see docs/publish.md §4.3.
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
steps:
- uses: actions/checkout@v4
- name: Write release signing credentials
run: |
set -euo pipefail
echo "${{ secrets.RELEASE_KEYSTORE_B64 }}" | base64 -d \
> SchwertUndMagieOnPebbleCompanionApp/release.keystore
printf '%s\n' "${{ secrets.RELEASE_KEYSTORE_PROPERTIES }}" \
> SchwertUndMagieOnPebbleCompanionApp/keystore.properties
- name: Build artifacts
run: |
set -euo pipefail
VERSION="${{ gitea.ref_name }}"
VERSION="${VERSION#v}"
VERSION="$VERSION" ./dist.sh
- name: Create release and upload artifacts
run: |
set -euo pipefail
API="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
AUTH="Authorization: token ${{ secrets.GITEA_TOKEN }}"
TAG="${{ gitea.ref_name }}"
# Reuse an existing release for this tag instead of failing outright
# (curl -f exit 22) if a prior run already created it — e.g. a retry
# after a later step failed. (No -f here: a 404 for "no release yet"
# is expected, not an error — it just leaves .id empty below.)
RELEASE_ID="$(curl -s "$API/releases/tags/$TAG" -H "$AUTH" | jq -r '.id // empty')"
if [ -z "$RELEASE_ID" ]; then
RELEASE_ID="$(curl -sf -X POST "$API/releases" \
-H "$AUTH" -H "Content-Type: application/json" \
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\"}" \
| jq -r .id)"
fi
for f in dist/*; do
NAME="$(basename "$f")"
# Same idempotency concern for assets: a retry re-uploading a name
# that's already attached would 409, so replace it instead.
EXISTING_ID="$(curl -sf "$API/releases/$RELEASE_ID/assets" -H "$AUTH" \
| jq -r --arg n "$NAME" '.[] | select(.name == $n) | .id')"
if [ -n "$EXISTING_ID" ]; then
curl -sf -X DELETE "$API/releases/$RELEASE_ID/assets/$EXISTING_ID" -H "$AUTH"
fi
curl -sf -X POST "$API/releases/$RELEASE_ID/assets?name=$NAME" \
-H "$AUTH" -F "attachment=@$f"
done
- name: Publish to dl.ladkau.de
run: |
set -euo pipefail
mkdir -p ~/.ssh
echo "${{ secrets.DL_SFTP_KEY }}" > ~/.ssh/dl_sftp_key
chmod 600 ~/.ssh/dl_sftp_key
BATCH="$(mktemp)"
{
echo "-mkdir files/schwert-und-magie"
for f in dist/*; do
echo "put $f files/schwert-und-magie/$(basename "$f")"
done
} > "$BATCH"
sftp -i ~/.ssh/dl_sftp_key -P 2223 \
-o StrictHostKeyChecking=accept-new \
-b "$BATCH" uploader@dl.ladkau.de
+4 -1
View File
@@ -6,7 +6,7 @@
.cxx .cxx
local.properties local.properties
.lock* .lock*
.idea
/SchwertUndMagieOnPebbleCompanionApp/local.properties /SchwertUndMagieOnPebbleCompanionApp/local.properties
/SchwertUndMagieOnPebbleCompanionApp/.idea/caches /SchwertUndMagieOnPebbleCompanionApp/.idea/caches
/SchwertUndMagieOnPebbleCompanionApp/.idea/libraries /SchwertUndMagieOnPebbleCompanionApp/.idea/libraries
@@ -32,3 +32,6 @@ local.properties
*.jks *.jks
*.keystore *.keystore
keystore.properties keystore.properties
# Container registry push credentials for build-image.sh. See docs/publish.md.
registry.env
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Matthias Ladkau
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+35 -1
View File
@@ -38,9 +38,18 @@ SchwertUndMagieOnPebbleWatchApp/ Pebble watchapp (C + PebbleKit JS)
SchwertUndMagieOnPebbleCompanionApp/ Android companion app (Kotlin + NDK) SchwertUndMagieOnPebbleCompanionApp/ Android companion app (Kotlin + NDK)
versions/ Original .d64 disk images (4 disks) versions/ Original .d64 disk images (4 disks)
docs/ Architecture notes and diagrams docs/ Architecture notes and diagrams
dist/ Built APKs (gitignored) build-image/ Dockerfile for the release build environment
dist/ Build artifacts (gitignored) — see dist.sh
``` ```
Run `./dist.sh` to build release artifacts for both apps in one step:
`dist/schwert-und-magie-<version>.{aab,apk,pbw}` — the signed Android AAB
(Play Store), APK (sideload), and Pebble `.pbw` (Rebble / direct install).
Requires the Android release keystore to already be configured
(`docs/publish.md` §2.1-2.3). The version comes from the current git tag by
default (`git tag v1.2.3`); see `docs/publish.md` §4 for pushing that tag to
trigger an automated, containerized build via Gitea Actions instead.
## How it works ## How it works
Three processes cooperate across two devices: Three processes cooperate across two devices:
@@ -135,6 +144,31 @@ ensure register consistency — see `docs/architecture.md §4.4`.
See [`docs/publish.md`](docs/publish.md) for keystore setup, Google Play, See [`docs/publish.md`](docs/publish.md) for keystore setup, Google Play,
F-Droid, and Rebble submission. F-Droid, and Rebble submission.
### Automated releases (Gitea Actions)
Pushing a tag `vX.Y.Z` builds both apps in a containerized runner and
publishes a Gitea Release with the versioned artifacts attached — see
`docs/publish.md` §4. One-time setup:
1. Build the build environment image locally and push it (`./build-image.sh`
then `./upload-image.sh`, needs `registry.env` — copy from
`registry.env.example`). Use `./run-image.sh` in between to sanity-check
the image before pushing.
2. Register a self-hosted `act_runner` with a Docker executor.
3. Add repo secrets under **Settings → Actions → Secrets**:
| Secret | Value |
|---|---|
| `RELEASE_KEYSTORE_B64` | `base64 -w0 SchwertUndMagieOnPebbleCompanionApp/release.keystore` |
| `RELEASE_KEYSTORE_PROPERTIES` | full contents of `SchwertUndMagieOnPebbleCompanionApp/keystore.properties` |
| `REGISTRY_USER` / `REGISTRY_PASSWORD` | same as in `registry.env`, so the runner can pull the private build image |
`GITEA_TOKEN` is injected automatically per job — nothing to add for it.
4. `git tag v1.2.3 && git push origin v1.2.3`.
Full details, including runner registration commands, are in
`docs/publish.md` §4.
## License ## License
The watch app and companion app source code in this repository are released The watch app and companion app source code in this repository are released
@@ -42,7 +42,11 @@ fi
if [ ! -d "${SRC}" ]; then if [ ! -d "${SRC}" ]; then
echo "Unpacking nibtools..." echo "Unpacking nibtools..."
tar -xzf "${TARBALL}" -C "${SCRIPT_DIR}" tar -xzf "${TARBALL}" -C "${SCRIPT_DIR}"
mv "${SCRIPT_DIR}"/nibtools-*/ "${SRC}" # Hardcoded, not a glob: on a non-ephemeral runner workspace, a
# nibtools-*/ glob can also match SRC itself once it exists, or other
# stray nibtools-prefixed leftovers, and mv then fails with "target is
# not a directory" (multiple sources, no existing target dir).
mv "${SCRIPT_DIR}/nibtools-91344e0ee3" "${SRC}"
echo "Unpacked to ${SRC}" echo "Unpacked to ${SRC}"
fi fi
@@ -32,7 +32,7 @@ echo " TOOLCHAIN : ${TOOLCHAIN} [OK]"
# ---- Ensure required host tools are present --------------------------------- # ---- Ensure required host tools are present ---------------------------------
MISSING=() MISSING=()
for tool in dos2unix autoconf automake pkg-config xa; do for tool in dos2unix autoconf automake pkg-config xa flex; do
command -v "$tool" &>/dev/null || MISSING+=("$tool") command -v "$tool" &>/dev/null || MISSING+=("$tool")
done done
if [ ${#MISSING[@]} -gt 0 ]; then if [ ${#MISSING[@]} -gt 0 ]; then
@@ -807,7 +807,11 @@ JNI_FN(jboolean, getDriveLed)(JNIEnv *env, jobject obj) {
JNI_FN(jint, getFrameCount)(JNIEnv *env, jobject obj) { JNI_FN(jint, getFrameCount)(JNIEnv *env, jobject obj) {
(void)env; (void)obj; (void)env; (void)obj;
#ifdef HAVE_VICE_SRC
return (jint)g_frame_count; return (jint)g_frame_count;
#else
return 0;
#endif
} }
/* Schwert und Magie uploads a custom character set that redefines a handful of /* Schwert und Magie uploads a custom character set that redefines a handful of
@@ -895,7 +899,11 @@ JNI_FN(void, injectKey)(JNIEnv *env, jobject obj, jint keyCode, jboolean pressed
JNI_FN(void, setSoundEnabled)(JNIEnv *env, jobject obj, jboolean enabled) { JNI_FN(void, setSoundEnabled)(JNIEnv *env, jobject obj, jboolean enabled) {
(void)env; (void)obj; (void)env; (void)obj;
#ifdef HAVE_VICE_SRC
g_sound_enabled = enabled ? 1 : 0; g_sound_enabled = enabled ? 1 : 0;
#else
(void)enabled;
#endif
} }
JNI_FN(jboolean, saveState)(JNIEnv *env, jobject obj, jstring jpath) { JNI_FN(jboolean, saveState)(JNIEnv *env, jobject obj, jstring jpath) {
Executable
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Builds the release build environment image (build-image/Dockerfile)
# locally, tagged with build-image/VERSION and :latest. Does not push —
# test it with ./run-image.sh first, then publish with ./upload-image.sh.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
ROOT="$(pwd)"
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
command -v docker >/dev/null 2>&1 \
|| fail "docker not found in PATH"
[ -f "$ROOT/registry.env" ] \
|| fail "registry.env not found — copy registry.env.example to registry.env and fill in your cr.ladkau.de credentials"
# shellcheck disable=SC1091
source "$ROOT/registry.env"
: "${REGISTRY_IMAGE:?registry.env must set REGISTRY_IMAGE}"
VERSION="$(<"$ROOT/build-image/VERSION")"
[ -n "$VERSION" ] || fail "build-image/VERSION is empty"
echo "== Building $REGISTRY_IMAGE:$VERSION =="
# Context is the repo root (not build-image/) so the Dockerfile's
# gradle-cache-warm stage can COPY in the companion app's real Gradle
# project files — see .dockerignore for what's excluded from that context.
docker build \
-t "$REGISTRY_IMAGE:$VERSION" \
-t "$REGISTRY_IMAGE:latest" \
-f "$ROOT/build-image/Dockerfile" \
"$ROOT"
echo "== Done =="
echo "Image: $REGISTRY_IMAGE:$VERSION (and :latest)"
echo "Test it with ./run-image.sh, then publish with ./upload-image.sh"
+110
View File
@@ -0,0 +1,110 @@
# Build environment for schwert_und_magie_on_pebble release artifacts:
# Android SDK/NDK (companion app) + Pebble SDK (watch app), matching the
# versions pinned in app/build.gradle.kts and validated on the maintainer's
# dev machine. Rebuild with ../build-image.sh whenever a version below, or
# the companion app's ndkVersion/compileSdk, changes.
#
# Does NOT contain the release keystore or any secrets — those are injected
# at job runtime from Gitea Actions secrets, never baked into this image.
FROM eclipse-temurin:21-jdk-jammy AS base
ARG ANDROID_CMDLINE_TOOLS_VERSION=11076708
ARG ANDROID_PLATFORM=android-36
ARG ANDROID_BUILD_TOOLS=36.1.0
ARG ANDROID_NDK=30.0.14904198
ARG ANDROID_CMAKE=3.22.1
ARG PEBBLE_TOOL_VERSION=5.0.35
ARG PEBBLE_SDK_CORE_VERSION=4.9.169
ARG NODE_VERSION=24.16.0
ENV DEBIAN_FRONTEND=noninteractive \
ANDROID_HOME=/opt/android-sdk \
ANDROID_SDK_ROOT=/opt/android-sdk \
ANDROID_NDK_HOME=/opt/android-sdk/ndk/30.0.14904198 \
PATH=/root/.local/bin:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:${PATH}
# git/unzip/curl/jq: checkout, SDK downloads, and the release workflow's
# calls to the Gitea API (create release, upload assets).
# openssh-client: the release workflow's `sftp` upload to dl.ladkau.de.
# python3-venv: pebble-tool's `sdk install` creates a venv per SDK version.
# dos2unix/autoconf/automake/pkg-config/xa65/build-essential/gettext/flex/bison:
# host tools required by the companion app's build_vice.sh (see that file's
# own preflight check, plus flex/bison for VICE's AC_PROG_LEX/AC_PROG_YACC-
# based configure) to cross-compile VICE via autotools before NDK clang takes
# over for the actual target compilation.
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates git unzip tar xz-utils python3 python3-venv file jq openssh-client \
dos2unix autoconf automake pkg-config xa65 build-essential gettext flex bison \
&& rm -rf /var/lib/apt/lists/*
# --- Android SDK: cmdline-tools, platform, build-tools, NDK, CMake ---
# CMake version must match app/build.gradle.kts's externalNativeBuild.cmake.version
# — baking it in here avoids AGP installing it on first `docker run` instead.
RUN mkdir -p "$ANDROID_HOME/cmdline-tools" \
&& curl -sSL -o /tmp/cmdline-tools.zip \
"https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}_latest.zip" \
&& unzip -q /tmp/cmdline-tools.zip -d "$ANDROID_HOME/cmdline-tools" \
&& mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" \
&& rm /tmp/cmdline-tools.zip \
&& yes | sdkmanager --licenses >/dev/null \
&& sdkmanager --install \
"platform-tools" \
"platforms;${ANDROID_PLATFORM}" \
"build-tools;${ANDROID_BUILD_TOOLS}" \
"ndk;${ANDROID_NDK}" \
"cmake;${ANDROID_CMAKE}" \
>/dev/null
# Node.js: `pebble sdk install` below runs `npm install` for the SDK-core's
# bundled webpack tooling — it does not bring its own node/npm, only the JS
# deps themselves. Version matches what's validated on the maintainer's
# machine; sdk-core's own bundled arm-none-eabi toolchain needs nothing extra.
RUN curl -sSL -o /tmp/node.tar.xz \
"https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \
&& tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1 \
&& rm /tmp/node.tar.xz
# --- Pebble SDK: pebble-tool + sdk-core ---
RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
&& /root/.local/bin/uv tool install "pebble-tool==${PEBBLE_TOOL_VERSION}" \
&& /root/.local/bin/pebble sdk install "${PEBBLE_SDK_CORE_VERSION}"
# --- Warm the Gradle dependency cache ---
# The companion app's real Gradle project files (not the generated build/
# output, and not the VICE/nibtools source tarballs — see .dockerignore) are
# COPYed into a throwaway location and built once here, so every actual
# `docker run` of this image (which bind-mounts a fresh checkout over
# /workspace) hits a warm ~/.gradle cache instead of re-downloading the same
# Maven dependencies from dl.google.com/mavenCentral every single run.
#
# buildVice/buildNibtools are excluded (their source tarballs aren't in the
# build context) — CMake already handles that gracefully, falling back to a
# placeholder (see vice_jni.c / build_vice.sh's own header comment) — so this
# stage only ever warms the Gradle/Maven dependency cache, never bakes in
# compiled VICE/nibtools output.
#
# packageRelease/signReleaseBundle are also excluded — they're the only tasks
# that need the release keystore, which never exists here (secrets are
# injected at job runtime, never baked into the image). Excluding them lets
# everything upstream (dependency resolution, Kotlin/native compilation,
# resource merging, dexing) still run and get cached, without two guaranteed,
# noisy "missing storeFile" failures cluttering every image build.
#
# This is a pure optimization: if the app's dependencies change after this
# image was built, Gradle just downloads the delta against the warm cache at
# `docker run` time — same as it would without this stage, just slower for
# that one run, never broken. `|| true` means a transient network failure
# here only costs a slower first `docker run`, never breaks the image build.
FROM base AS gradle-cache-warm
COPY SchwertUndMagieOnPebbleCompanionApp /tmp/warm/SchwertUndMagieOnPebbleCompanionApp
WORKDIR /tmp/warm/SchwertUndMagieOnPebbleCompanionApp
RUN chmod +x gradlew \
&& (./gradlew bundleRelease assembleRelease \
-x buildVice -x buildNibtools \
-x packageRelease -x signReleaseBundle \
--continue || true)
FROM base
COPY --from=gradle-cache-warm /root/.gradle /root/.gradle
WORKDIR /workspace
+1
View File
@@ -0,0 +1 @@
4
Executable
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
# Builds release artifacts for both apps and drops them in dist/, named
# schwert-und-magie-<version>.{aab,apk,pbw}.
#
# Version comes from the current git tag (vX.Y.Z) by default — push a tag to
# drive a release. Override with VERSION=1.2.3 ./dist.sh, or just run it
# untagged for a local dev build (gets a 0.0.0-dev+<sha> placeholder version).
#
# Android output requires a release signing config — see docs/publish.md §2.1-2.3
# (keystore.properties + release.keystore in SchwertUndMagieOnPebbleCompanionApp/).
# Without it, Gradle still produces an unsigned/debug-signed build; that's not
# fatal (useful for local testing) so it's a warning, not a hard stop.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
ROOT="$(pwd)"
DIST="$ROOT/dist"
COMPANION="$ROOT/SchwertUndMagieOnPebbleCompanionApp"
WATCH="$ROOT/SchwertUndMagieOnPebbleWatchApp"
GRADLE_KTS="$COMPANION/app/build.gradle.kts"
WATCH_PKG_JSON="$WATCH/package.json"
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
warn() { echo "WARNING: $*" >&2; }
echo "== Preflight checks =="
command -v pebble >/dev/null 2>&1 \
|| fail "pebble CLI not found in PATH (needed to build the watch app)"
[ -x "$COMPANION/gradlew" ] \
|| fail "$COMPANION/gradlew missing or not executable"
SDK_DIR="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}"
if [ -f "$COMPANION/local.properties" ]; then
LOCAL_SDK="$(sed -n 's/^sdk\.dir=//p' "$COMPANION/local.properties")"
[ -n "$LOCAL_SDK" ] && SDK_DIR="$LOCAL_SDK"
fi
[ -n "$SDK_DIR" ] && [ -d "$SDK_DIR" ] \
|| fail "Android SDK not found (checked local.properties sdk.dir, \$ANDROID_SDK_ROOT, \$ANDROID_HOME)"
NDK_VERSION="$(sed -n 's/.*ndkVersion *= *"\(.*\)".*/\1/p' "$GRADLE_KTS")"
[ -n "$NDK_VERSION" ] \
|| fail "could not read ndkVersion from app/build.gradle.kts"
[ -d "$SDK_DIR/ndk/$NDK_VERSION" ] \
|| fail "NDK $NDK_VERSION not installed under $SDK_DIR/ndk (Android Studio > SDK Manager > SDK Tools > NDK side by side)"
[ -f "$COMPANION/res/vice-3.8.tar.gz" ] \
|| fail "$COMPANION/res/vice-3.8.tar.gz missing (VICE source tarball required by the buildVice Gradle task)"
compgen -G "$COMPANION/res/nibtools-*.tar.gz" >/dev/null \
|| fail "$COMPANION/res/nibtools-*.tar.gz missing (nibtools source tarball required by the buildNibtools Gradle task)"
# Signing config: missing/broken keystore.properties still builds (unsigned),
# so warn here and rely on the post-build apksigner check for the real answer.
if [ ! -f "$COMPANION/keystore.properties" ]; then
warn "keystore.properties not found — release build will likely be unsigned (see docs/publish.md §2.1-2.3)"
else
STORE_FILE="$(sed -n 's/^storeFile=//p' "$COMPANION/keystore.properties")"
if [ -z "$STORE_FILE" ] || [ ! -f "$COMPANION/$STORE_FILE" ]; then
warn "keystore.properties found but its storeFile ('$STORE_FILE') does not exist — release build will likely be unsigned"
fi
fi
echo "== Resolving version =="
if [ -z "${VERSION:-}" ]; then
if TAG="$(git describe --tags --exact-match --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null)"; then
VERSION="${TAG#v}"
else
VERSION="0.0.0-dev+$(git rev-parse --short HEAD)"
warn "HEAD is not on a vX.Y.Z tag — building placeholder version $VERSION (push a tag to drive a real release version)"
fi
fi
[[ "$VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]] \
|| fail "VERSION '$VERSION' is not a semantic version (expected X.Y.Z, optionally with a -pre+meta suffix)"
VERSION_CODE=$(( ${BASH_REMATCH[1]} * 10000 + ${BASH_REMATCH[2]} * 100 + ${BASH_REMATCH[3]} ))
# Android requires a positive versionCode — the untagged dev placeholder
# (0.0.0-dev+<sha>) would otherwise compute to 0 and fail Gradle configuration.
[ "$VERSION_CODE" -gt 0 ] || VERSION_CODE=1
echo "Version: $VERSION (Android versionCode $VERSION_CODE)"
# Patch versions into the tracked source files for this build only, then
# restore them — dist.sh must never leave the working tree dirty.
restore_version_files() {
git -C "$ROOT" checkout -- "$GRADLE_KTS" "$WATCH_PKG_JSON" 2>/dev/null || true
}
trap restore_version_files EXIT
sed -i \
-e "s/versionCode = [0-9]\+/versionCode = $VERSION_CODE/" \
-e "s/versionName = \"[^\"]*\"/versionName = \"$VERSION\"/" \
"$GRADLE_KTS"
# Pebble's own build tooling parses package.json's version strictly as
# X.Y.Z integers — it rejects the -pre+meta suffix dist.sh otherwise allows
# (including the default "0.0.0-dev+<sha>" placeholder), so strip it here.
PEBBLE_VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}"
sed -i -e "s/\"version\": \"[^\"]*\"/\"version\": \"$PEBBLE_VERSION\"/" "$WATCH_PKG_JSON"
mkdir -p "$DIST"
echo "== Android companion app =="
cd "$COMPANION"
./gradlew bundleRelease assembleRelease
cp -f app/build/outputs/bundle/release/app-release.aab "$DIST/schwert-und-magie-$VERSION.aab"
cp -f app/build/outputs/apk/release/app-release.apk "$DIST/schwert-und-magie-$VERSION.apk"
echo "== Pebble watch app =="
cd "$WATCH"
pebble build
cp -f build/SchwertUndMagieOnPebbleWatchApp.pbw "$DIST/schwert-und-magie-$VERSION.pbw"
echo "== Verifying APK signature =="
APK="$DIST/schwert-und-magie-$VERSION.apk"
APKSIGNER="$(compgen -G "$SDK_DIR/build-tools/*/apksigner" | sort -V | tail -1 || true)"
if [ -z "$APKSIGNER" ]; then
warn "apksigner not found under $SDK_DIR/build-tools — could not verify APK signature"
elif ! "$APKSIGNER" verify "$APK" >/dev/null 2>&1; then
warn "$APK is UNSIGNED (failed apksigner verification) — not installable/publishable as-is"
elif "$APKSIGNER" verify --print-certs "$APK" 2>/dev/null | grep -qi "CN=Android Debug"; then
warn "$APK is signed with the Android debug cert, not the release keystore"
fi
echo "== Done =="
ls -la "$DIST"
+168 -21
View File
@@ -89,14 +89,30 @@ the whole Gradle configuration.
### 2.4 Build the release bundle ### 2.4 Build the release bundle
```bash ```bash
cd SchwertUndMagieOnPebbleCompanionApp ./dist.sh # builds both apps, copies signed artifacts into dist/
./gradlew bundleRelease # produces app/build/outputs/bundle/release/app-release.aab
./gradlew assembleRelease # produces app/build/outputs/apk/release/app-release.apk, for sideload testing
``` ```
Play Store requires the **AAB** (`bundleRelease` output), not the APK — Play This runs `./gradlew bundleRelease assembleRelease` in
`SchwertUndMagieOnPebbleCompanionApp/` and `pebble build` in
`SchwertUndMagieOnPebbleWatchApp/`, then copies the outputs to
`dist/schwert-und-magie.{aab,apk,pbw}`.
Before building, the script hard-stops (nonzero exit, nothing written to
`dist/`) if the `pebble` CLI, `gradlew`, the Android SDK, the NDK version
pinned in `app/build.gradle.kts`, or the VICE/nibtools source tarballs are
missing — these are required for the build to succeed at all. It warns but
still builds if `keystore.properties` (§2.1-2.3) is missing or points at a
nonexistent keystore file, and checks the resulting APK's signature with
`apksigner` afterward, warning if it's unsigned or debug-signed. In practice
an incomplete signing config also makes Gradle's own `signReleaseBundle` task
fail outright, so `dist/` won't be overwritten with an unusable artifact
either way — but don't rely on that as the primary check; heed the warning.
Play Store requires the **AAB** (`schwert-und-magie.aab`), not the APK — Play
re-packages per-device APKs from it (including ABI splits, so `arm64-v8a`/ re-packages per-device APKs from it (including ABI splits, so `arm64-v8a`/
`x86_64` native VICE libraries each ship only to matching devices). `x86_64` native VICE libraries each ship only to matching devices). The APK
(`schwert-und-magie.apk`) is for direct sideload distribution (§2.6 below)
and F-Droid-style testing, not Play Store upload.
### 2.5 Play Console setup (one-time, per app listing) ### 2.5 Play Console setup (one-time, per app listing)
@@ -119,12 +135,11 @@ re-packages per-device APKs from it (including ABI splits, so `arm64-v8a`/
### 2.6 Versioning for future releases ### 2.6 Versioning for future releases
Bump both fields in `app/build.gradle.kts` before every release build: Comes from the git tag automatically — see §4. Pushing `vX.Y.Z` (or running
`VERSION=X.Y.Z ./dist.sh` locally) patches `versionCode` (derived as
```kotlin `X*10000 + Y*100 + Z`, which strictly increases as long as X.Y.Z itself does)
versionCode = 2 // must strictly increase on every Play Store upload and `versionName` in `app/build.gradle.kts` at build time, then reverts them —
versionName = "1.1" // user-visible, free-form no manual edit needed.
```
## 3. Android companion app → F-Droid ## 3. Android companion app → F-Droid
@@ -220,8 +235,131 @@ includes the app in the next index update (published roughly weekly).
### 3.5 Versioning for F-Droid updates ### 3.5 Versioning for F-Droid updates
Each new release requires a new `Builds` entry in the metadata file with an Each new release requires a new `Builds` entry in the metadata file with an
incremented `versionCode` and the corresponding git commit or tag. `versionCode` incremented `versionCode` and the corresponding git commit or tag.
must match the value in `app/build.gradle.kts`. `versionCode` must match what that tag produces — `X*10000 + Y*100 + Z` for
tag `vX.Y.Z` (§2.6, §4).
## 4. Automated releases via Gitea Actions
Pushing a tag `vX.Y.Z` builds both apps and publishes a Gitea Release with
`schwert-und-magie-X.Y.Z.{aab,apk,pbw}` attached — no local `./dist.sh` run
needed. The tag is the only source of truth for the version; nothing needs to
be bumped in source files beforehand (`dist.sh` patches `versionCode`/
`versionName`/`package.json` at build time and reverts them — see §4.4 for
running the same thing locally).
The whole toolchain (Android SDK/NDK, Pebble SDK) lives in
`build-image/Dockerfile`, built locally and pushed to a container registry by
three separate scripts (below). This keeps the setup portable: the runner
just needs Docker and pulls that image, so it isn't tied to any one machine's
local toolchain install and can be moved or re-registered elsewhere without
touching this repo's build scripts.
### 4.1 Build environment image
`build-image/Dockerfile` bakes in the Android SDK/NDK and Pebble SDK versions
pinned in `app/build.gradle.kts`, matching what's validated for local builds.
It does **not** contain the release keystore — that's injected at job runtime
from Actions secrets (§4.3), never baked into the image.
Three scripts, kept separate so a Dockerfile change can be built and tested
locally before anything is pushed to the registry:
```bash
cp registry.env.example registry.env # fill in your registry credentials
./build-image.sh # builds :latest and the pinned VERSION tag, locally only
./run-image.sh # runs dist.sh inside that local image — sanity-check before publishing
./upload-image.sh # pushes the already-built :latest and VERSION tag to cr.ladkau.de
```
Rebuild and push whenever `build-image/Dockerfile` changes (e.g. a Pebble SDK
or Android NDK version bump) — bump `build-image/VERSION` first so the tag is
meaningful. The workflow (§4.2) pulls `:latest` by default; if you need a
release to be reproducible against an exact toolchain image, pin the `image:`
line in `.gitea/workflows/release.yml` to the versioned tag instead.
`run-image.sh` bind-mounts this repo straight into the container, so before
each run it wipes generated build artifacts (`vice-src`, `vice-libs`,
`nibtools-src`, `nibtools-libs`, `app/build`, `app/.cxx`, the watch app's
`build/`) — otherwise leftovers from a previous local run would let
`build_vice.sh`/`build_nibtools.sh` skip work a real fresh CI checkout always
does, hiding bugs that only show up in CI. The container also runs as root
(needed for the baked-in SDK/NDK/Gradle setup), so it chowns the whole repo
back to your host user on exit — you shouldn't ever need `sudo` to clean up
after it.
### 4.2 Runner setup (one-time)
1. Enable Actions for the repo: repo Settings → Actions → enable, if not
already on by default for this Gitea instance.
2. Generate a runner registration token: Site Admin → Actions → Runners (or
the repo/org-scoped equivalent) → "Create new runner".
3. On any machine with Docker that can reach both your Gitea instance and
your container registry:
```bash
# https://gitea.com/gitea/act_runner — grab the latest release binary
./act_runner register --no-interactive \
--instance <your gitea URL> --token <token> \
--name <runner-name> --labels ubuntu-latest:docker://node:20-bookworm
./act_runner daemon
```
The image after `docker://` in `--labels` is only a fallback for jobs that
don't specify their own `container:` — irrelevant here since
`.gitea/workflows/release.yml` always pins its own image, but the runner
still needs a Docker-executor label registered to use that executor at
all. The label name itself (`ubuntu-latest` above) must match `runs-on:`
in `.gitea/workflows/release.yml` — edit both together if you rename it,
or reuse a label an existing runner already advertises (check Site Admin →
Actions → Runners) to skip registering a new one entirely.
No manual `docker login` needed on the runner host — the workflow's
`container:` block authenticates the image pull itself via the
`REGISTRY_USER`/`REGISTRY_PASSWORD` secrets (§4.3).
### 4.3 Repo secrets
Settings → Actions → Secrets, add:
| Secret | Value |
|---|---|
| `RELEASE_KEYSTORE_B64` | `base64 -w0 SchwertUndMagieOnPebbleCompanionApp/release.keystore` |
| `RELEASE_KEYSTORE_PROPERTIES` | the full contents of `SchwertUndMagieOnPebbleCompanionApp/keystore.properties` (§2.2) |
| `REGISTRY_USER` | same as `REGISTRY_USER` in `registry.env` |
| `REGISTRY_PASSWORD` | same as `REGISTRY_PASSWORD` in `registry.env` |
| `DL_SFTP_KEY` | private key (PEM) for the `uploader` SFTP account on dl.ladkau.de |
`secrets.GITEA_TOKEN` (used to create the release and upload assets) is
Gitea's own auto-generated per-job token — nothing to create or add yourself.
`.gitea/workflows/release.yml` requests `contents: write` explicitly so
release creation works regardless of this instance's default Actions
permission mode.
### 4.4 Cutting a release
```bash
git tag v1.2.3
git push origin v1.2.3
```
Watch the run under the repo's Actions tab. On success, the release appears
under the repo's Releases page with the three versioned artifacts attached,
and the same three files are uploaded over SFTP to
`dl.ladkau.de:files/schwert-und-magie/` (using the `DL_SFTP_KEY` secret,
§4.3) for direct download outside of Gitea.
To build the same versioned artifacts locally without pushing a tag (e.g. to
test before releasing), either run `dist.sh` directly with the host toolchain:
```bash
VERSION=1.2.3 ./dist.sh
```
or run it inside the build-image container (same environment the runner
uses — see §4.1):
```bash
./run-image.sh 1.2.3
```
## 5. Pebble watch app → Rebble app store / direct distribution ## 5. Pebble watch app → Rebble app store / direct distribution
@@ -233,12 +371,19 @@ this repo and changes independently of it.
### 5.1 Build the artifact ### 5.1 Build the artifact
```bash
./dist.sh # also builds the Android side; see §2.4
```
or, to build just the watch app:
```bash ```bash
cd SchwertUndMagieOnPebbleWatchApp cd SchwertUndMagieOnPebbleWatchApp
pebble build # produces build/SchwertUndMagieOnPebbleWatchApp.pbw pebble build # produces build/SchwertUndMagieOnPebbleWatchApp.pbw
``` ```
The `.pbw` is the complete distributable — it bundles all target platforms `dist.sh` copies the result to `dist/schwert-und-magie.pbw`. The `.pbw` is
the complete distributable — it bundles all target platforms
(`aplite`/`basalt`/`chalk`/`diorite`/`emery`/`flint`/`gabbro`) declared in (`aplite`/`basalt`/`chalk`/`diorite`/`emery`/`flint`/`gabbro`) declared in
`package.json`. There is no signing step analogous to Android; Pebble apps `package.json`. There is no signing step analogous to Android; Pebble apps
are not cryptographically signed by the developer. are not cryptographically signed by the developer.
@@ -267,19 +412,21 @@ pebble screenshot --vnc --no-open docs/screenshots/emery.png
### 5.4 Versioning ### 5.4 Versioning
Bump `version` in `SchwertUndMagieOnPebbleWatchApp/package.json` before each Comes from the git tag automatically — see §4. `dist.sh` patches
release build. `SchwertUndMagieOnPebbleWatchApp/package.json`'s `version` field at build
time and reverts it afterward; no manual edit needed.
## 6. Pre-publish checklist ## 6. Pre-publish checklist
- [ ] Release keystore generated, passwords saved in a password manager, both - [ ] Release keystore generated, passwords saved in a password manager, both
gitignored (§1, §2.1) gitignored (§1, §2.1)
- [ ] `keystore.properties` exists locally and is **not** tracked by git - [ ] `keystore.properties` exists locally and is **not** tracked by git
- [ ] `./gradlew bundleRelease` succeeds and installs/runs on a real device - [ ] `./dist.sh` (or a tag push, §4) succeeds and produces
from the resulting AAB (test via `bundletool` or Play internal testing) `dist/schwert-und-magie-<version>.{aab,apk,pbw}`
- [ ] AAB installs/runs on a real device (test via `bundletool` or Play
internal testing)
- [ ] Play Console store listing content complete (icon, screenshots, - [ ] Play Console store listing content complete (icon, screenshots,
privacy policy, content rating, data safety form) privacy policy, content rating, data safety form)
- [ ] F-Droid metadata YAML created and `fdroid build` passes locally (§3) - [ ] F-Droid metadata YAML created and `fdroid build` passes locally (§3)
- [ ] `pebble build` succeeds for all target platforms; `.pbw` sideloads and - [ ] `.pbw` sideloads and runs correctly against the signed companion app
runs correctly against the signed companion app build build
- [ ] `versionCode`/`versionName` (Android) and `version` (Pebble) bumped
+6
View File
@@ -0,0 +1,6 @@
# Copy to registry.env (gitignored, never commit the real values) and fill in.
# Used by build-image.sh to push the build environment image to cr.ladkau.de.
REGISTRY=cr.ladkau.de
REGISTRY_IMAGE=cr.ladkau.de/schwert-und-magie/builder
REGISTRY_USER=
REGISTRY_PASSWORD=
Executable
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Runs dist.sh inside the local build-image container, mirroring what the
# Gitea Actions release workflow does — useful for testing build-image
# changes (or dist.sh/build_vice.sh/build_nibtools.sh changes) locally
# before pushing anything to cr.ladkau.de.
#
# Usage: ./run-image.sh [VERSION]
# VERSION is passed through to dist.sh; omit it for dist.sh's own
# git-tag-based default (see dist.sh's header comment).
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
ROOT="$(pwd)"
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
command -v docker >/dev/null 2>&1 \
|| fail "docker not found in PATH"
[ -f "$ROOT/registry.env" ] \
|| fail "registry.env not found — copy registry.env.example to registry.env and fill in your cr.ladkau.de credentials"
# shellcheck disable=SC1091
source "$ROOT/registry.env"
: "${REGISTRY_IMAGE:?registry.env must set REGISTRY_IMAGE}"
IMAGE_TAG="$(<"$ROOT/build-image/VERSION")"
[ -n "$IMAGE_TAG" ] || fail "build-image/VERSION is empty"
IMAGE="$REGISTRY_IMAGE:$IMAGE_TAG"
docker image inspect "$IMAGE" >/dev/null 2>&1 \
|| fail "$IMAGE not found locally — run ./build-image.sh first"
VERSION="${1:-${VERSION:-}}"
# CI always starts from a fresh checkout, but this script bind-mounts the live
# host repo — so build outputs left over from a previous local run (e.g. a
# vice-src/ already configured, or a libvice.a that's already built) would
# make build_vice.sh/build_nibtools.sh skip work they'd have to do on a real
# fresh checkout, silently hiding bugs (like a missing host build tool) that
# only show up in CI. Wipe them first so every run exercises a true from-
# scratch build, same as CI.
JNI="$ROOT/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni"
echo "== Cleaning generated build artifacts for a fresh build =="
rm -rf \
"$JNI/vice-src" "$JNI/vice-libs" \
"$JNI/nibtools-src" "$JNI/nibtools-libs" \
"$ROOT/SchwertUndMagieOnPebbleCompanionApp/app/build" \
"$ROOT/SchwertUndMagieOnPebbleCompanionApp/app/.cxx" \
"$ROOT/SchwertUndMagieOnPebbleWatchApp/build"
echo "== Running dist.sh inside $IMAGE =="
# The container runs as root (needed for the SDK/NDK/Gradle setup baked into
# the image), so anything it writes into this bind mount — dist/, app/build,
# .cxx, etc. — would otherwise come back owned by root, leaving the host repo
# unusable without sudo. Chown everything back to the host user on exit,
# whether dist.sh succeeds or fails.
docker run --rm \
-v "$ROOT:/workspace" \
-w /workspace \
-e VERSION="$VERSION" \
-e HOST_UID="$(id -u)" \
-e HOST_GID="$(id -g)" \
"$IMAGE" \
bash -c '
git config --global --add safe.directory /workspace
trap "chown -R \"$HOST_UID:$HOST_GID\" /workspace" EXIT
./dist.sh
'
echo "== Done — artifacts in dist/ =="
ls -la "$ROOT/dist"
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Pushes the build environment image — already built locally with
# ./build-image.sh, and ideally verified with ./run-image.sh — to
# cr.ladkau.de. The Gitea Actions release workflow pulls this image to
# run dist.sh.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
ROOT="$(pwd)"
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
command -v docker >/dev/null 2>&1 \
|| fail "docker not found in PATH"
[ -f "$ROOT/registry.env" ] \
|| fail "registry.env not found — copy registry.env.example to registry.env and fill in your cr.ladkau.de credentials"
# shellcheck disable=SC1091
source "$ROOT/registry.env"
: "${REGISTRY:?registry.env must set REGISTRY}"
: "${REGISTRY_IMAGE:?registry.env must set REGISTRY_IMAGE}"
: "${REGISTRY_USER:?registry.env must set REGISTRY_USER}"
: "${REGISTRY_PASSWORD:?registry.env must set REGISTRY_PASSWORD}"
VERSION="$(<"$ROOT/build-image/VERSION")"
[ -n "$VERSION" ] || fail "build-image/VERSION is empty"
docker image inspect "$REGISTRY_IMAGE:$VERSION" >/dev/null 2>&1 \
|| fail "$REGISTRY_IMAGE:$VERSION not found locally — run ./build-image.sh first"
echo "== Logging in to $REGISTRY =="
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
echo "== Pushing $REGISTRY_IMAGE:$VERSION and :latest =="
docker push "$REGISTRY_IMAGE:$VERSION"
docker push "$REGISTRY_IMAGE:latest"
echo "== Done =="
echo "Image: $REGISTRY_IMAGE:$VERSION"