OXYGENHUB

Releases built by OxygenFlow: the version in gradle.properties, and a release job that signs the APK with the app's key from the repository's secrets, checks it and keeps it with SHA256SUMS; 0.1.0 (refs #7) bfa2eff05d35

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key cfb5ece97d0d
When
· 2026-10-27 18:35 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
bfa2eff05d35b7dbb9b02d9c5d4c8ce681160955ef5713290cc8264b3aeeb767
Depends on
0a2858c0dad3 26530d9758ee 57c0824300db 8a657957d5a0 9481bfa7c5d0 e0e4a1c48a77 f821ee741b9f
Depended on by
34a44dcebe06 8de06aa06268 e4b11612e7de 739d58b12359 1b7703ec4b02 1b55d7f47d9f 4a6d7ccd0958 797eca1a3e64 1823bc12eda1 bd1d3ad70c70 740a452b2669 23279b95b5b2 db7727e5de19 aa21eb5e0e5b e0004d61958c
In bookmarks
main
In tags
v0.1.0 v0.1.1 v0.1.2 v0.1.3 v0.1.4 v0.1.5 v0.1.6 v0.1.7 v0.1.8 v0.1.9
Edits
10 atoms, 1 tree operation, 6 files

Browse the tree at this change

added scripts/release.sh +51 −0
1 +
#!/usr/bin/env bash
2 +
# The app's release job (#7), in OxygenFlow's Android image with no network:
3 +
# a signed release APK of the version in gradle.properties, checked with
4 +
# apksigner, in dist/ beside its SHA256SUMS, for a release on oxygenhub.io.
5 +
#
6 +
# The key comes from the job's secrets, mounted as files and never in the
7 +
# environment: android-keystore (the PKCS12 keystore, base64) and
8 +
# android-keystore-password. Locally, give the same two files:
9 +
#   KEYSTORE_B64=path PASSWORD_FILE=path scripts/release.sh
10 +
set -euo pipefail
11 +
cd "$(dirname "$0")/.."
12 +
b64=${KEYSTORE_B64:-/run/secrets/android-keystore}
13 +
password=${PASSWORD_FILE:-/run/secrets/android-keystore-password}
14 +
for f in "$b64" "$password"; do
15 +
    [ -s "$f" ] || { echo "release: no $f: the job needs the android-keystore secrets" >&2; exit 1; }
16 +
done
17 +
version=$(sed -n 's/^oxygenhub\.version=//p' gradle.properties)
18 +
[ -n "$version" ] || { echo "release: no oxygenhub.version in gradle.properties" >&2; exit 1; }
19 +
20 +
# As ci.sh: the image's JDKs for Gradle's daemon, never a download.
21 +
jdks=$( (ls -d /opt/gradle-home/jdks/*/ 2>/dev/null || true) | sed 's:/$::' | paste -sd, -)
22 +
if [ -n "$jdks" ] && [ -n "${GRADLE_USER_HOME:-}" ]; then
23 +
    mkdir -p "$GRADLE_USER_HOME"
24 +
    {
25 +
        echo "org.gradle.java.installations.paths=$jdks"
26 +
        echo "org.gradle.java.installations.auto-download=false"
27 +
    } >> "$GRADLE_USER_HOME/gradle.properties"
28 +
fi
29 +
30 +
# The keystore, decoded where only this job can read it, and gone after.
31 +
keys=$(mktemp -d)
32 +
trap 'rm -rf "$keys"' EXIT
33 +
chmod 700 "$keys"
34 +
base64 -d "$b64" > "$keys/release.p12"
35 +
export OXYGENHUB_KEYSTORE="$keys/release.p12" OXYGENHUB_KEYSTORE_PASSWORD_FILE="$password"
36 +
gradle=$(command -v gradle || echo ./gradlew)
37 +
"$gradle" --offline --no-daemon :app:assembleRelease
38 +
39 +
apk=app/build/outputs/apk/release/app-release.apk
40 +
[ -f "$apk" ] || { echo "release: no $apk (was it signed? an unsigned build is app-release-unsigned.apk)" >&2; exit 1; }
41 +
sdk=${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}
42 +
apksigner=$(ls -d "$sdk"/build-tools/*/apksigner 2>/dev/null | sort -V | tail -1)
43 +
"$apksigner" verify --print-certs "$apk" | grep -E '^Signer #1 certificate (DN|SHA-256)'
44 +
45 +
# Kept as an artifact by a fixed name; SHA256SUMS gives the name it takes
46 +
# in a release, with the version (as OxygenHub's and OxygenFlow's dist do).
47 +
rm -rf dist
48 +
mkdir -p dist
49 +
cp "$apk" dist/oxygenhub-android-universal.apk
50 +
echo "$(sha256sum < dist/oxygenhub-android-universal.apk | cut -d' ' -f1)  oxygenhub-android-$version-universal.apk" > dist/SHA256SUMS
51 +
echo "release: oxygenhub-android-$version-universal.apk"
modified .flow/pipeline.kdl +32 −0
…
48 unchanged lines
49 49
        }
50 50
    }
51 51
}
52 +
53 +
// A signed release APK when the version in gradle.properties changes (#7),
54 +
// for a release on oxygenhub.io. The keystore and its password are the
55 +
// repository's secrets, mounted as files (never in the environment).
56 +
job "release" {
57 +
    sandbox "android"
58 +
    timeout "60m"
59 +
    depends-on "test"
60 +
    scope "gradle.properties"
61 +
    mount {
62 +
        source "ephemeral://disk"
63 +
        target "/src/rust-core/target"
64 +
        size "20GiB"
65 +
        cache "rust-core-target"
66 +
        key "rust-core/Cargo.lock"
67 +
    }
68 +
    secret "android-keystore" {
69 +
        mount-path "/run/secrets/android-keystore"
70 +
    }
71 +
    secret "android-keystore-password" {
72 +
        mount-path "/run/secrets/android-keystore-password"
73 +
    }
74 +
    exec "bash" "scripts/release.sh"
75 +
    output {
76 +
        artifact "oxygenhub-android-universal.apk" {
77 +
            path "/src/dist/oxygenhub-android-universal.apk"
78 +
        }
79 +
        artifact "SHA256SUMS" {
80 +
            path "/src/dist/SHA256SUMS"
81 +
        }
82 +
    }
83 +
}
modified .oxyignore +1 −0
…
26 unchanged lines
27 27
# The CI job's reports (scripts/ci.sh).
28 28
/junit.xml
29 29
/coverage.xml
30 +
/dist/
modified CHANGELOG.md +4 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
## 0.1.0 (2026-10-27)
6 +
7 +
- **Releases built by OxygenFlow (#7):** the app's version lives in `gradle.properties` (`oxygenhub.version`; the version code follows from it), and when it changes the pipeline's `release` job builds a release APK signed with the app's own key (a PKCS12 keystore and its password, the repository's `android-keystore` and `android-keystore-password` secrets, mounted as files), checks it with apksigner and keeps it with its `SHA256SUMS` for a release on oxygenhub.io. `scripts/release.sh` does the same locally with the key's files. The key's SHA-256 certificate fingerprint is `B0:99:57:36:44:42:AF:B2:27:28:B7:EF:93:F8:23:2D:F1:41:17:DA:50:28:6D:45:93:04:0F:9C:9F:A2:2A:48`; a release-signed app replaces a debug build only after uninstalling it.
8 +
5 9
- **The phone runner on OxygenFlow 0.5.0 (#22):** the app pins OxygenFlow 0.5.0 and oxy 0.8.0, so the phone's runner takes the idle claim that fits its memory (oxygenflow#92), stops a WASM job promptly (#93), offers the images it holds (#94) and reports where a job's time went (#35). Reinstall the app to move the runner from 0.3.1.
6 10
- **CI on OxygenFlow (#19):** every push builds the app and runs its unit tests in OxygenFlow's Android image, with no network, and keeps a JUnit report (`junit.xml`, the hub's Tests tab) and JaCoCo coverage (`coverage.xml`, the Coverage tab); `scripts/ci.sh` is the job. Debug builds record unit-test coverage (`./gradlew :app:createDebugUnitTestCoverageReport`). The screenshot tests stay local: their references change with the clock, so are not kept.
7 11
- **Account and organisation pages:** an owner's page shows who they are (avatar, name, when they joined; an organisation's description and members, each opening their own page), their open project boards and the repositories of theirs you can read, newest first. Opened from a repository's top bar, from a member, and from hub links to `/{owner}` or `/orgs/{name}`. From `GET /users/{name}`, else `GET /orgs/{name}`.
…
10 unchanged lines
modified app/build.gradle.kts +26 −2
…
50 unchanged lines
51 51
52 52
tasks.named("preBuild") { dependsOn(generateRustBindings) }
53 53
54 +
// The version, from gradle.properties (#7).
55 +
val appVersion: String = providers.gradleProperty("oxygenhub.version").get()
56 +
val appVersionCode: Int = appVersion.split(".").map { it.toInt() }.let { (a, b, c) -> a * 10000 + b * 100 + c }
57 +
58 +
// Release signing (#7): the release job decodes its keystore secret to a
59 +
// file and names it and the password's file here. Without them a release
60 +
// build is unsigned, so nothing else needs the key.
61 +
val releaseKeystore: File? = providers.environmentVariable("OXYGENHUB_KEYSTORE").orNull?.let { File(it) }?.takeIf { it.isFile }
62 +
val releasePassword: String? =
63 +
    providers.environmentVariable("OXYGENHUB_KEYSTORE_PASSWORD_FILE").orNull?.let { File(it).readText().trim() }
64 +
54 65
android {
55 66
    namespace = "io.oxygenhub.android"
56 67
    compileSdk {
…
4 unchanged lines
61 72
        applicationId = "io.oxygenhub.android"
62 73
        minSdk = 31
63 74
        targetSdk = 37
64 -
        versionCode = 1
65 -
        versionName = "1.0"
75 +
        versionCode = appVersionCode
76 +
        versionName = appVersion
66 77
67 78
        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
79 +
    }
80 +
81 +
    signingConfigs {
82 +
        if (releaseKeystore != null && releasePassword != null) {
83 +
            create("release") {
84 +
                storeFile = releaseKeystore
85 +
                storeType = "PKCS12"
86 +
                storePassword = releasePassword
87 +
                keyAlias = "oxygenhub"
88 +
                keyPassword = releasePassword
89 +
            }
90 +
        }
68 91
    }
69 92
70 93
    buildTypes {
…
6 unchanged lines
77 100
            optimization {
78 101
                enable = false
79 102
            }
103 +
            signingConfigs.findByName("release")?.let { signingConfig = it }
80 104
        }
81 105
    }
82 106
    compileOptions {
…
54 unchanged lines
modified gradle.properties +3 −0
…
18 unchanged lines
19 19
kotlin.code.style=official
20 20
# Compose Preview Screenshot Testing (the README's screenshots).
21 21
android.experimental.enableScreenshotTest=true
22 +
# The app's version (#7): the release job runs when this changes, and the
23 +
# version code follows from it (major * 10000 + minor * 100 + patch).
24 +
oxygenhub.version=0.1.0