0.2.64 update
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
**
|
||||
!Dockerfile
|
||||
!release.sh
|
||||
!controller.go
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user