feat: cache Android AAB build; stamp versionCode + resign after cache hit

BuildAndroidRelease() drops all params and builds with --build-number 1
(no keystore injected, Gradle uses debug signing). The command is now
stable across all commits — full Dagger cache hit whenever source is
unchanged.

Three new Dagger functions handle the post-cache steps:
- StampAndroidVersionCode(aab, versionCode): pure-stdlib Python patches
  the AAB's compiled manifest proto (android:versionCode resource ID
  0x0101021b) and strips META-INF/ to clear the old signature.
- SignAndroidBundle(aab, keystoreBase64, keystorePassword): decodes the
  base64 keystore secret and re-signs with jarsigner.
- PublishAndroid(ctx, playStoreConfig, keystoreBase64, keystorePassword):
  chains all three + UploadToPlayStore, computing time.Now().Unix() as
  the versionCode internally.

Taskfile: build-android-bundle simplified (no keystore params); publish-
android now calls publish-android in a single Dagger call instead of the
two-step build-then-upload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas SharedInbox
2026-05-18 13:35:20 +02:00
co-authored by Claude Sonnet 4.6
parent f6bb6aed82
commit 2d559d4947
2 changed files with 142 additions and 15 deletions
+4 -10
View File
@@ -204,15 +204,10 @@ tasks:
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
build-android-bundle:
desc: Build signed AAB via Dagger and export to build/app/outputs/bundle/release/
preconditions:
- sh: test -n "$ANDROID_KEYSTORE_BASE64"
msg: "ANDROID_KEYSTORE_BASE64 is not set"
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
cmds:
- mkdir -p build/app/outputs/bundle/release
- dagger call --progress=plain -m ci --source=. build-android-release --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)" export --path build/app/outputs/bundle/release/app-release.aab
- dagger call --progress=plain -q -m ci --source=. build-android-release -o build/app/outputs/bundle/release/app-release.aab
upload-android-bundle:
desc: Upload AAB from build/ to Play Store via Dagger
@@ -225,7 +220,7 @@ tasks:
- dagger call --progress=plain -q -m ci --source=. upload-to-play-store --aab build/app/outputs/bundle/release/app-release.aab --play-store-config env:PLAY_STORE_CONFIG_JSON
publish-android:
desc: Build signed AAB and publish to Play Store via Dagger (build + upload)
desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger
preconditions:
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
msg: "PLAY_STORE_CONFIG_JSON is not set"
@@ -234,8 +229,7 @@ tasks:
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
cmds:
- task: build-android-bundle
- task: upload-android-bundle
- dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD
deploy-apk:
desc: Build and deploy Android APK via Dagger
+138 -5
View File
@@ -2,11 +2,99 @@ package main
import (
"context"
"dagger/ci/internal/dagger"
"fmt"
"time"
"dagger/ci/internal/dagger"
)
// patchAabScript patches android:versionCode in an AAB's compiled manifest proto.
// It strips META-INF/ (old signature) and repacks the ZIP. No external dependencies.
const patchAabScript = `#!/usr/bin/env python3
import sys, zipfile
MANIFEST = "base/manifest/AndroidManifest.xml"
VERSION_CODE_RID = 0x0101021b
def _vr(b, p):
n = s = 0
while True:
c = b[p]; p += 1; n |= (c & 127) << s
if not (c & 128): return n, p
s += 7
def _ve(n):
r = []
while n > 127: r.append((n & 127) | 128); n >>= 7
return bytes(r + [n])
def _parse(d):
p = 0
while p < len(d):
tag, p = _vr(d, p); fn, wt = tag >> 3, tag & 7
if wt == 0: v, p = _vr(d, p); yield fn, 0, v
elif wt == 2: ln, p = _vr(d, p); yield fn, 2, d[p:p+ln]; p += ln
else: raise ValueError(f"wire type {wt}")
def _enc(fn, wt, v):
t = _ve((fn << 3) | wt)
return t + (_ve(v) if wt == 0 else _ve(len(v)) + v)
def _patch_prim(d, vc):
out = bytearray()
for fn, wt, v in _parse(d):
out += _enc(6, 0, vc) if (fn == 6 and wt == 0) else _enc(fn, wt, v)
return bytes(out)
def _patch_item(d, vc):
out = bytearray()
for fn, wt, v in _parse(d):
out += _enc(7, 2, _patch_prim(v, vc)) if fn == 7 else _enc(fn, wt, v)
return bytes(out)
def _has_rid(d):
return any(fn == 5 and wt == 0 and v == VERSION_CODE_RID for fn, wt, v in _parse(d))
def _patch_attr(d, vc):
out = bytearray()
for fn, wt, v in _parse(d):
if fn == 3 and wt == 2: out += _enc(3, 2, str(vc).encode())
elif fn == 6 and wt == 2: out += _enc(6, 2, _patch_item(v, vc))
else: out += _enc(fn, wt, v)
return bytes(out)
def _patch_elem(d, vc):
out = bytearray()
for fn, wt, v in _parse(d):
out += _enc(4, 2, _patch_attr(v, vc)) if (fn == 4 and _has_rid(v)) else _enc(fn, wt, v)
return bytes(out)
def _patch_node(d, vc):
out = bytearray()
for fn, wt, v in _parse(d):
out += _enc(2, 2, _patch_elem(v, vc)) if fn == 2 else _enc(fn, wt, v)
return bytes(out)
def patch(src, dst, vc):
with zipfile.ZipFile(src) as z:
mf = z.read(MANIFEST)
patched = _patch_node(mf, vc)
with zipfile.ZipFile(src) as zin, zipfile.ZipFile(dst, 'w') as zout:
for info in zin.infolist():
if info.filename.startswith('META-INF/'):
continue # strip old signature; jarsigner re-signs after
d = patched if info.filename == MANIFEST else zin.read(info.filename)
zi = zipfile.ZipInfo(info.filename, info.date_time)
zi.compress_type = info.compress_type
zi.external_attr = info.external_attr
zout.writestr(zi, d)
print(f"versionCode={vc} -> {dst}")
if __name__ == "__main__":
if len(sys.argv) != 4:
sys.exit(f"usage: {sys.argv[0]} in.aab out.aab versionCode")
patch(sys.argv[1], sys.argv[2], int(sys.argv[3]))
`
type Ci struct {
// The source directory of the project, filtered surgically.
Source *dagger.Directory
@@ -382,10 +470,13 @@ func (m *Ci) DeployApk(
Stdout(ctx)
}
// Build and return the Android App Bundle (AAB) signed with the release key.
func (m *Ci) BuildAndroidRelease(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret, buildNumber string) *dagger.File {
return m.setupKeystore(keystoreBase64, keystorePassword).
WithExec([]string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", buildNumber}).
// Build and return the Android App Bundle (AAB).
// Uses --build-number 1 (fixed) so Dagger can fully cache this step.
// No keystore is injected; Gradle falls back to debug signing.
// versionCode and signing are applied separately via StampAndroidVersionCode + SignAndroidBundle.
func (m *Ci) BuildAndroidRelease() *dagger.File {
return m.Setup().
WithExec([]string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}).
File("build/app/outputs/bundle/release/app-release.aab")
}
@@ -410,3 +501,45 @@ func (m *Ci) UploadToPlayStore(
WithExec([]string{"python3", "scripts/deploy_playstore.py"}).
Stdout(ctx)
}
// StampAndroidVersionCode patches the versionCode in a built AAB without rebuilding.
// It rewrites the compiled manifest proto directly and strips the old signature.
func (m *Ci) StampAndroidVersionCode(aab *dagger.File, versionCode int) *dagger.File {
return dag.Container().
From("python:3.12-alpine").
WithNewFile("/patch.py", patchAabScript).
WithFile("/in.aab", aab).
WithExec([]string{"python3", "/patch.py", "/in.aab", "/out.aab", fmt.Sprintf("%d", versionCode)}).
File("/out.aab")
}
// SignAndroidBundle signs a stamp-patched AAB with the release upload key.
func (m *Ci) SignAndroidBundle(aab *dagger.File, keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.File {
return dag.Container().
From("eclipse-temurin:17-jdk-alpine").
WithFile("/app.aab", aab).
WithSecretVariable("KS_BASE64", keystoreBase64).
WithSecretVariable("KS_PASS", keystorePassword).
WithExec([]string{"sh", "-c",
`echo "$KS_BASE64" | base64 -d > /keystore.jks && \
jarsigner -sigalg SHA256withRSA -digestalg SHA-256 \
-signedjar /signed.aab \
-keystore /keystore.jks \
-storepass "$KS_PASS" -keypass "$KS_PASS" \
/app.aab upload`}).
File("/signed.aab")
}
// PublishAndroid builds a cached AAB, stamps the versionCode, re-signs, and uploads to Play Store.
func (m *Ci) PublishAndroid(
ctx context.Context,
playStoreConfig *dagger.Secret,
keystoreBase64 *dagger.Secret,
keystorePassword *dagger.Secret,
) (string, error) {
versionCode := int(time.Now().Unix())
aab := m.BuildAndroidRelease()
stamped := m.StampAndroidVersionCode(aab, versionCode)
signed := m.SignAndroidBundle(stamped, keystoreBase64, keystorePassword)
return m.UploadToPlayStore(ctx, signed, playStoreConfig)
}