0.2.64 update

This commit is contained in:
ponzischeme89
2026-08-15 09:23:26 +12:00
parent a2ca7e8061
commit d5d47473a2
90 changed files with 9188 additions and 451 deletions
+4
View File
@@ -0,0 +1,4 @@
**
!Dockerfile
!release.sh
!controller.go
+54
View File
@@ -0,0 +1,54 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.26-alpine AS controller
WORKDIR /src
COPY controller.go .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/memby-builder-controller controller.go
FROM eclipse-temurin:17-jdk-jammy
ARG ANDROID_COMMAND_LINE_TOOLS_VERSION=15859902
ARG ANDROID_COMMAND_LINE_TOOLS_SHA256=4e4c464f145a7512b57d088ac6c278c03c9eea610886b35a5e0804e74eedf583
ARG ANDROID_PLATFORM=35
ARG ANDROID_BUILD_TOOLS=35.0.0
ENV ANDROID_HOME=/opt/android-sdk \
ANDROID_SDK_ROOT=/opt/android-sdk \
GRADLE_USER_HOME=/home/memby/.gradle \
PATH=/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:/opt/android-sdk/build-tools/35.0.0:${PATH}
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates curl git unzip && \
rm -rf /var/lib/apt/lists/*
RUN mkdir -p "${ANDROID_HOME}/cmdline-tools" /tmp/android-tools && \
curl --fail --location --show-error --silent \
"https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_COMMAND_LINE_TOOLS_VERSION}_latest.zip" \
--output /tmp/android-tools.zip && \
echo "${ANDROID_COMMAND_LINE_TOOLS_SHA256} /tmp/android-tools.zip" | sha256sum --check --strict && \
unzip -q /tmp/android-tools.zip -d /tmp/android-tools && \
mv /tmp/android-tools/cmdline-tools "${ANDROID_HOME}/cmdline-tools/latest" && \
yes | sdkmanager --licenses >/dev/null && \
sdkmanager \
"platform-tools" \
"platforms;android-${ANDROID_PLATFORM}" \
"build-tools;${ANDROID_BUILD_TOOLS}" && \
rm -rf /tmp/android-tools /tmp/android-tools.zip /root/.android
# Match the distroless gateway's non-root uid so both services can safely use the
# memby-releases volume. The signing material remains a read-only Compose secret.
RUN groupadd --gid 65532 memby && \
useradd --uid 65532 --gid 65532 --create-home --home-dir /home/memby memby && \
mkdir -p /work /data/releases "${GRADLE_USER_HOME}" && \
chown -R 65532:65532 /work /data/releases /home/memby
COPY --chmod=0755 release.sh /usr/local/bin/memby-builder
COPY --from=controller --chmod=0755 /out/memby-builder-controller /usr/local/bin/memby-builder-controller
USER 65532:65532
WORKDIR /work
ENTRYPOINT ["/usr/local/bin/memby-builder"]
CMD ["release"]
+91
View File
@@ -0,0 +1,91 @@
# Memby Docker release builder
The `memby-builder` Compose service is an isolated Android toolchain. In the normal stack,
its small internal controller waits for the **Build latest release** button on Admin
Console → App updates. It then fetches the newest semantic `v*.*.*` tag from GitHub,
builds that exact source with its Gradle wrapper, signs it with Memby's existing release
identity, verifies it, and publishes it through the running gateway. Its port is exposed
only to the Compose network; the browser never receives its address or release token.
The image pins JDK 17, Android command-line tools 15859902, Android platform 35 and build
tools 35.0.0. Android SDK packages stay in the image layer; the `memby-gradle-cache` volume
persists Gradle distributions and dependencies between releases.
## One-time NAS setup
Set `MEMBY_SECRETS_DIR` in the deployment `.env` to an absolute directory that is outside
the directory replaced by `deploy-server.ps1`. The standard NAS value is:
```dotenv
MEMBY_SECRETS_DIR=/share/Docker/Memby-secrets
```
Create that directory with restrictive permissions and place these five files in it:
```text
/share/Docker/Memby-secrets/
memby-release.jks
memby-keystore-password
memby-key-alias
memby-key-password
memby-release-publish-token
```
- `memby-release.jks` must be the existing Memby release keystore. Do not generate a new
key: Android would reject it as an upgrade for every installed television.
- The three signing text files contain only their respective existing value, with no
`NAME=` prefix.
- `memby-release-publish-token` contains the existing gateway release-publish token. Move
that value out of `.env`; the gateway and builder now read the same Compose secret.
On the first upgraded deployment, `deploy-server.ps1` migrates this value automatically
when it is still present in the previous deployed `.env`. It never generates a new one.
Docker mounts all five files read-only under `/run/secrets`. Their values are never image
layers, source files, Compose environment values, Gradle arguments or `docker inspect`
output. `deploy-server.ps1` keeps the NAS directory at mode `0700` and its files read-only
at `0444`. The file mode is necessary because Compose file secrets are bind mounts and
both Memby containers run as non-root; the protected parent directory prevents other NAS
accounts from reaching those files.
## Create a release
Open Admin Console → App updates and press **Build latest release**. The page shows live
status and the retained build output. Leave the tag override blank for the latest GitHub
tag, or enter an exact semantic tag for a deliberate recovery build. A required release
has an extra confirmation because it blocks older televisions until they update.
If the console or controller is unavailable, use the command-line fallback from the
deployed Memby directory on the NAS:
```sh
docker compose run --rm --build memby-builder release
```
By default the builder queries `https://github.com/ponzischeme89/memby.git` and selects the
highest semantic tag. To reproduce a particular tagged release, set an explicit tag for
one invocation:
```sh
MEMBY_RELEASE_TAG=v0.2.64 docker compose run --rm --build memby-builder release
```
Optional, non-secret settings are listed with blank values in `builder.env.example`.
`MEMBY_RELEASE_MANDATORY=true` makes the published release mandatory; tests run unless
`MEMBY_SKIP_APP_TESTS=true` is deliberately set.
The final files are in the shared `memby-releases` Docker volume under `/data/releases`:
```text
/data/releases/memby-<version>.apk
/data/releases/memby-<version>.apk.sha256
```
Publishing through the gateway also updates its existing database-backed version,
download URL, release notes, size and SHA-256 metadata atomically. Every run checks the
application id and version, runs `apksigner verify --verbose --print-certs`, and compares
the APK signer digest with the certificate exported from the mounted keystore before the
gateway receives the APK.
The Windows `release.ps1`, `deploy-tv.ps1`, and the existing app-release option in
`deploy-server.ps1` remain available for local workflows and continue to use the same
signing identity.
+203
View File
@@ -0,0 +1,203 @@
package main
import (
"bufio"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"regexp"
"strings"
"sync"
"time"
)
const maxLogLines = 400
var tagPattern = regexp.MustCompile(`^v\d+\.\d+\.\d+$`)
type releaseRequest struct {
Tag string `json:"tag"`
Notes string `json:"notes"`
Mandatory bool `json:"mandatory"`
}
type releaseStatus struct {
State string `json:"state"`
Tag string `json:"tag,omitempty"`
Mandatory bool `json:"mandatory"`
StartedAt time.Time `json:"startedAt,omitempty"`
FinishedAt time.Time `json:"finishedAt,omitempty"`
Message string `json:"message,omitempty"`
Logs []string `json:"logs"`
Fallback string `json:"fallback"`
}
type controller struct {
mu sync.RWMutex
status releaseStatus
token []byte
}
func main() {
token, err := readSecret("/run/secrets/memby_release_publish_token")
if err != nil {
log.Fatal(err)
}
c := &controller{token: token, status: releaseStatus{
State: "idle", Logs: []string{},
Fallback: "docker compose run --rm --build memby-builder release",
}}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
mux.Handle("GET /v1/status", c.authorise(http.HandlerFunc(c.handleStatus)))
mux.Handle("POST /v1/releases", c.authorise(http.HandlerFunc(c.handleRelease)))
server := &http.Server{Addr: ":8090", Handler: mux, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 30 * time.Second}
log.Printf("Memby release controller listening on %s", server.Addr)
log.Fatal(server.ListenAndServe())
}
func readSecret(path string) ([]byte, error) {
value, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("release controller token: %w", err)
}
value = []byte(strings.TrimSpace(string(value)))
if len(value) == 0 {
return nil, errors.New("release controller token is empty")
}
return value, nil
}
func (c *controller) authorise(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
presented := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if subtle.ConstantTimeCompare([]byte(presented), c.token) != 1 {
writeError(w, http.StatusUnauthorized, "invalid release token")
return
}
next.ServeHTTP(w, r)
})
}
func (c *controller) handleStatus(w http.ResponseWriter, _ *http.Request) {
c.mu.RLock()
status := c.status
// Start with a non-nil slice so an idle controller emits `[]`, not `null`. The Admin
// Console is still defensive for compatibility with already-deployed controllers.
status.Logs = append([]string{}, c.status.Logs...)
c.mu.RUnlock()
writeJSON(w, http.StatusOK, status)
}
func (c *controller) handleRelease(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
var request releaseRequest
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
writeError(w, http.StatusBadRequest, "invalid release request")
return
}
request.Tag = strings.TrimSpace(request.Tag)
request.Notes = strings.TrimSpace(request.Notes)
if request.Tag != "" && !tagPattern.MatchString(request.Tag) {
writeError(w, http.StatusBadRequest, "tag must be blank or look like v0.2.64")
return
}
if len(request.Notes) > 4000 {
writeError(w, http.StatusBadRequest, "release notes are too long")
return
}
c.mu.Lock()
if c.status.State == "running" {
c.mu.Unlock()
writeError(w, http.StatusConflict, "a Memby release is already running")
return
}
c.status = releaseStatus{
State: "running", Tag: request.Tag, Mandatory: request.Mandatory,
StartedAt: time.Now().UTC(), Message: "Preparing the Android release builder", Logs: []string{},
Fallback: "docker compose run --rm --build memby-builder release",
}
status := c.status
c.mu.Unlock()
go c.run(request)
writeJSON(w, http.StatusAccepted, status)
}
func (c *controller) run(request releaseRequest) {
command := exec.Command("/usr/local/bin/memby-builder", "release")
command.Env = append(os.Environ(),
"MEMBY_RELEASE_TAG="+request.Tag,
"MEMBY_RELEASE_NOTES="+request.Notes,
fmt.Sprintf("MEMBY_RELEASE_MANDATORY=%t", request.Mandatory),
)
stdout, err := command.StdoutPipe()
if err != nil {
c.finish(err)
return
}
command.Stderr = command.Stdout
if err := command.Start(); err != nil {
c.finish(err)
return
}
done := make(chan struct{})
go func() {
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
c.appendLog(scanner.Text())
}
if err := scanner.Err(); err != nil {
c.appendLog("Could not read complete build output: " + err.Error())
}
close(done)
}()
err = command.Wait()
<-done
c.finish(err)
}
func (c *controller) appendLog(line string) {
c.mu.Lock()
defer c.mu.Unlock()
line = strings.TrimSpace(line)
if line == "" {
return
}
c.status.Logs = append(c.status.Logs, line)
if len(c.status.Logs) > maxLogLines {
c.status.Logs = append([]string(nil), c.status.Logs[len(c.status.Logs)-maxLogLines:]...)
}
c.status.Message = line
}
func (c *controller) finish(err error) {
c.mu.Lock()
defer c.mu.Unlock()
c.status.FinishedAt = time.Now().UTC()
if err != nil {
c.status.State = "failed"
c.status.Message = "Release failed: " + err.Error()
return
}
c.status.State = "succeeded"
c.status.Message = "Release built, verified and published"
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
+18
View File
@@ -0,0 +1,18 @@
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestIdleStatusEmitsEmptyLogsArray(t *testing.T) {
c := &controller{status: releaseStatus{State: "idle"}}
recorder := httptest.NewRecorder()
c.handleStatus(recorder, httptest.NewRequest(http.MethodGet, "/v1/status", nil))
if got := recorder.Body.String(); !strings.Contains(got, `"logs":[]`) {
t.Fatalf("idle status must emit an empty logs array: %s", got)
}
}
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env bash
set -Eeuo pipefail
readonly KEYSTORE_SECRET="${MEMBY_KEYSTORE_SECRET:-/run/secrets/memby_android_keystore}"
readonly STORE_PASSWORD_SECRET="${MEMBY_KEYSTORE_PASSWORD_SECRET:-/run/secrets/memby_android_keystore_password}"
readonly KEY_ALIAS_SECRET="${MEMBY_KEY_ALIAS_SECRET:-/run/secrets/memby_android_key_alias}"
readonly KEY_PASSWORD_SECRET="${MEMBY_KEY_PASSWORD_SECRET:-/run/secrets/memby_android_key_password}"
readonly PUBLISH_TOKEN_SECRET="${MEMBY_RELEASE_PUBLISH_TOKEN_SECRET:-/run/secrets/memby_release_publish_token}"
readonly SOURCE_REPOSITORY="${MEMBY_SOURCE_REPOSITORY:-https://github.com/ponzischeme89/memby.git}"
readonly PUBLISH_URL="${MEMBY_RELEASE_PUBLISH_URL:-http://server:32768/admin/api/release}"
readonly EXPECTED_APPLICATION_ID="com.ponzischeme89.memby"
log() {
printf '[memby-builder] %s\n' "$*"
}
fail() {
printf '[memby-builder] ERROR: %s\n' "$*" >&2
exit 1
}
require_secret() {
local path="$1"
local label="$2"
[[ -r "$path" ]] || fail "$label secret is missing or unreadable at $path"
[[ -s "$path" ]] || fail "$label secret is empty at $path"
}
semantic_latest_tag() {
git ls-remote --tags --refs "$SOURCE_REPOSITORY" 'refs/tags/v[0-9]*' |
sed -n 's#^[^[:space:]]\+[[:space:]]\+refs/tags/\(v[0-9]\+\.[0-9]\+\.[0-9]\+\)$#\1#p' |
sort -V |
tail -n 1
}
release_notes() {
local source_dir="$1"
local version="$2"
local notes_file="$3"
if [[ -n "${MEMBY_RELEASE_NOTES_FILE:-}" ]]; then
[[ -r "$MEMBY_RELEASE_NOTES_FILE" ]] || fail "release notes file is unreadable"
cp "$MEMBY_RELEASE_NOTES_FILE" "$notes_file"
elif [[ -n "${MEMBY_RELEASE_NOTES:-}" ]]; then
printf '%s\n' "$MEMBY_RELEASE_NOTES" > "$notes_file"
elif [[ -f "$source_dir/CHANGELOG.md" ]]; then
awk -v version="$version" '
$0 ~ "^## " version "([[:space:]]|$)" { found=1; next }
found && /^## / { exit }
found && /^- / { sub(/^- /, ""); print }
' "$source_dir/CHANGELOG.md" > "$notes_file"
fi
if [[ ! -s "$notes_file" ]]; then
printf 'Memby %s release.\n' "$version" > "$notes_file"
fi
}
publish_release() {
local apk="$1"
local version="$2"
local sha256="$3"
local notes_file="$4"
local mandatory="$5"
local token
token="$(<"$PUBLISH_TOKEN_SECRET")"
# Feed the authorisation header through curl's stdin configuration. The token is
# never present in the container configuration, process arguments or command log.
printf 'header = "Authorization: Bearer %s"\n' "$token" |
curl --config - --fail-with-body --show-error --silent \
--output /dev/null \
--request POST \
--form "version=$version" \
--form "sha256=$sha256" \
--form "mandatory=$mandatory" \
--form "notes=<$notes_file" \
--form "apk=@$apk;type=application/vnd.android.package-archive" \
"$PUBLISH_URL"
}
release() {
require_secret "$KEYSTORE_SECRET" 'release keystore'
require_secret "$STORE_PASSWORD_SECRET" 'keystore password'
require_secret "$KEY_ALIAS_SECRET" 'key alias'
require_secret "$KEY_PASSWORD_SECRET" 'key password'
require_secret "$PUBLISH_TOKEN_SECRET" 'release publish token'
local tag="${MEMBY_RELEASE_TAG:-}"
if [[ -z "$tag" ]]; then
log "Reading the latest GitHub tag from $SOURCE_REPOSITORY"
tag="$(semantic_latest_tag)"
fi
[[ "$tag" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]] ||
fail "release tag must look like v0.2.64 (found '${tag:-none}')"
local version="${BASH_REMATCH[1]}"
local work_dir
work_dir="$(mktemp -d /work/memby-release.XXXXXX)"
trap 'rm -rf -- "$work_dir"' RETURN
local source_dir="$work_dir/source"
local notes_file="$work_dir/notes.txt"
local certificate_file="$work_dir/release-certificate.der"
log "Fetching $tag"
git init -q "$source_dir"
git -C "$source_dir" remote add origin "$SOURCE_REPOSITORY"
git -C "$source_dir" fetch --quiet --depth=1 origin "refs/tags/$tag:refs/tags/$tag"
git -C "$source_dir" checkout --quiet --detach "refs/tags/$tag"
export MEMBY_KEYSTORE="$KEYSTORE_SECRET"
export MEMBY_KEYSTORE_PASSWORD_FILE="$STORE_PASSWORD_SECRET"
export MEMBY_KEY_ALIAS_FILE="$KEY_ALIAS_SECRET"
export MEMBY_KEY_PASSWORD_FILE="$KEY_PASSWORD_SECRET"
# A fetched tag may predate *_FILE support in build.gradle.kts. Populate the
# established secure environment form as a compatibility bridge; these values are
# created inside the one-shot build process and never enter Compose or docker inspect.
export MEMBY_KEYSTORE_PASSWORD
MEMBY_KEYSTORE_PASSWORD="$(<"$STORE_PASSWORD_SECRET")"
export MEMBY_KEY_ALIAS
MEMBY_KEY_ALIAS="$(<"$KEY_ALIAS_SECRET")"
export MEMBY_KEY_PASSWORD
MEMBY_KEY_PASSWORD="$(<"$KEY_PASSWORD_SECRET")"
local source_url="${MEMBY_SOURCE_URL:-${SOURCE_REPOSITORY%.git}/tree/$tag}"
local -a gradle_tasks=()
if [[ "${MEMBY_SKIP_APP_TESTS:-false}" != 'true' ]]; then
gradle_tasks+=(testDebugUnitTest)
fi
gradle_tasks+=(assembleRelease)
log "Building Memby $version with JDK 17 and Android API 35"
chmod +x "$source_dir/gradlew"
"$source_dir/gradlew" --project-dir "$source_dir" --console=plain --no-daemon \
"${gradle_tasks[@]}" \
"-Pmemby.versionName=$version" \
"-Pmemby.sourceUrl=$source_url"
local apk="$source_dir/app/build/outputs/apk/release/app-release.apk"
[[ -f "$apk" ]] || {
[[ ! -f "$source_dir/app/build/outputs/apk/release/app-release-unsigned.apk" ]] ||
fail 'Gradle produced an unsigned APK; check the mounted signing secrets'
fail "signed release APK was not produced at $apk"
}
local package_line application_id built_version
package_line="$(aapt dump badging "$apk" | sed -n '1p')"
application_id="$(sed -n "s/.*package: name='\([^']*\)'.*/\1/p" <<<"$package_line")"
built_version="$(sed -n "s/.*versionName='\([^']*\)'.*/\1/p" <<<"$package_line")"
[[ "$application_id" == "$EXPECTED_APPLICATION_ID" ]] ||
fail "APK applicationId is $application_id, expected $EXPECTED_APPLICATION_ID"
[[ "$built_version" == "$version" ]] ||
fail "APK version is $built_version, expected $version"
log 'Verifying the APK signature'
local verification signer_digest keystore_digest
if ! verification="$(apksigner verify --verbose --print-certs "$apk" 2>&1)"; then
printf '%s\n' "$verification" >&2
fail 'apksigner verification failed'
fi
printf '%s\n' "$verification"
local key_alias
key_alias="$(<"$KEY_ALIAS_SECRET")"
keytool -exportcert \
-keystore "$KEYSTORE_SECRET" \
-alias "$key_alias" \
-storepass:file "$STORE_PASSWORD_SECRET" \
-file "$certificate_file" >/dev/null
keystore_digest="$(sha256sum "$certificate_file" | awk '{print $1}')"
signer_digest="$(sed -n 's/^Signer #1 certificate SHA-256 digest: //p' <<<"$verification" | head -n 1 | tr -d ':')"
[[ -n "$signer_digest" && "${signer_digest,,}" == "$keystore_digest" ]] ||
fail 'APK signer does not match the mounted Memby release certificate'
local sha256
sha256="$(sha256sum "$apk" | awk '{print $1}')"
release_notes "$source_dir" "$version" "$notes_file"
local mandatory="${MEMBY_RELEASE_MANDATORY:-false}"
[[ "$mandatory" == 'true' || "$mandatory" == 'false' ]] ||
fail 'MEMBY_RELEASE_MANDATORY must be true or false'
log "Publishing Memby $version to the gateway"
publish_release "$apk" "$version" "$sha256" "$notes_file" "$mandatory"
local published_apk="/data/releases/memby-$version.apk"
[[ -f "$published_apk" ]] || fail "gateway did not publish $published_apk"
[[ "$(sha256sum "$published_apk" | awk '{print $1}')" == "$sha256" ]] ||
fail 'published APK checksum does not match the verified build'
local checksum_file="/data/releases/memby-$version.apk.sha256"
local checksum_temp
checksum_temp="$(mktemp "/data/releases/.memby-$version.sha256.XXXXXX")"
printf '%s %s\n' "$sha256" "memby-$version.apk" > "$checksum_temp"
mv -f "$checksum_temp" "$checksum_file"
log "Release complete: $published_apk"
log "SHA-256: $sha256"
log "Signing certificate SHA-256: $keystore_digest"
}
case "${1:-release}" in
release)
release
;;
serve)
exec /usr/local/bin/memby-builder-controller
;;
*)
fail "unknown command '$1' (expected: release or serve)"
;;
esac