Wave 3 (#3): harden release pipeline (pins, idempotency, hygiene, versionCode gate)
This commit is contained in:
1 parent
7d70931c86
commit
316b4e6055
1 file changed
+112
-28
+112
-28
@@ -2,39 +2,62 @@
|
|||||||
# Gitea release, so Obtainium can track updates. See issue #3.
|
# Gitea release, so Obtainium can track updates. See issue #3.
|
||||||
#
|
#
|
||||||
# Required repository secrets (Settings → Secrets → Actions):
|
# Required repository secrets (Settings → Secrets → Actions):
|
||||||
# KEYSTORE_PASSPHRASE — decrypts android/release-keystore.enc (committed)
|
# KEYSTORE_PASSPHRASE — decrypts android/release-keystore.enc (committed).
|
||||||
|
# Use 32+ random chars; enables rotation without code changes.
|
||||||
# RELEASE_STORE_PASSWORD — keystore password
|
# RELEASE_STORE_PASSWORD — keystore password
|
||||||
# RELEASE_KEY_ALIAS — key alias (mindlist)
|
# RELEASE_KEY_ALIAS — key alias (mindlist; not secret, kept as secret for uniformity)
|
||||||
# RELEASE_KEY_PASSWORD — key password
|
# RELEASE_KEY_PASSWORD — key password
|
||||||
#
|
#
|
||||||
# Obtainium: Add App → Gitea → paste the repo URL. If the repo is private,
|
# Obtainium: Add App → Gitea → paste the repo URL, APK filter
|
||||||
# enter an access token (account → Settings → Applications → token with
|
# `mindlist-.*\.apk`. If the repo is private, enter an access token
|
||||||
# repo scope) in the Obtainium source settings.
|
# (account → Settings → Applications → token with `repo` scope) in the
|
||||||
|
# Obtainium source settings. Keep "include prereleases" off: this pipeline
|
||||||
|
# only ships stable releases (prerelease:false).
|
||||||
|
|
||||||
name: Release APK
|
name: Release APK
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
# Semver tags only — vtest/vwip must never cut a public release.
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v[0-9]*.[0-9]*.[0-9]*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
dry_run:
|
||||||
|
description: 'Build + sign only, do not create a release'
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # needed for changelog + previous-tag versionCode check
|
||||||
|
|
||||||
|
- name: Validate tag
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
run: |
|
||||||
|
TAG="${GITHUB_REF_NAME}"
|
||||||
|
[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "::error::Tag $TAG is not semver vX.Y.Z"; exit 1; }
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
- name: Setup JDK 21
|
- name: Setup JDK 21
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: 21
|
java-version: 21
|
||||||
@@ -45,7 +68,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
SDK="$HOME/android-sdk"
|
SDK="$HOME/android-sdk"
|
||||||
mkdir -p "$SDK/cmdline-tools"
|
mkdir -p "$SDK/cmdline-tools"
|
||||||
curl -sS -o /tmp/cmdtools.zip https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
curl -fsSL --retry 3 -o /tmp/cmdtools.zip https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||||
|
echo "2d2d50857e4eb553af5a6dc3ad507a17adf43d115264b1afc116f95c92e5e258 /tmp/cmdtools.zip" | sha256sum -c -
|
||||||
unzip -q /tmp/cmdtools.zip -d "$SDK/cmdline-tools"
|
unzip -q /tmp/cmdtools.zip -d "$SDK/cmdline-tools"
|
||||||
mv "$SDK/cmdline-tools/cmdline-tools" "$SDK/cmdline-tools/latest"
|
mv "$SDK/cmdline-tools/cmdline-tools" "$SDK/cmdline-tools/latest"
|
||||||
export PATH="$SDK/cmdline-tools/latest/bin:$PATH"
|
export PATH="$SDK/cmdline-tools/latest/bin:$PATH"
|
||||||
@@ -61,6 +85,9 @@ jobs:
|
|||||||
echo "$SDK/cmdline-tools/latest/bin" >> "$GITHUB_PATH"
|
echo "$SDK/cmdline-tools/latest/bin" >> "$GITHUB_PATH"
|
||||||
echo "$SDK/platform-tools" >> "$GITHUB_PATH"
|
echo "$SDK/platform-tools" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
uses: gradle/actions/wrapper-validation@4c125117fe7c5aed11272ec4213f602f012f89f2 # v4
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
@@ -74,8 +101,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Restore release keystore
|
- name: Restore release keystore
|
||||||
# The keystore lives in git AES-encrypted (android/release-keystore.enc);
|
# The keystore lives in git AES-encrypted (android/release-keystore.enc);
|
||||||
# only this short passphrase travels as a secret, so there is no
|
# only the short passphrase travels as a secret, so there is no
|
||||||
# multi-KB paste to corrupt. Decryption is verified by hash + keytool.
|
# multi-KB paste to corrupt. Secrets via env: (never -pass pass:)
|
||||||
|
# so they don't appear in the process table.
|
||||||
env:
|
env:
|
||||||
ENC_PASS: ${{ secrets.KEYSTORE_PASSPHRASE }}
|
ENC_PASS: ${{ secrets.KEYSTORE_PASSPHRASE }}
|
||||||
STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }}
|
STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }}
|
||||||
@@ -84,7 +112,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
test -n "$ENC_PASS" || { echo "::error::Missing KEYSTORE_PASSPHRASE secret"; exit 1; }
|
test -n "$ENC_PASS" || { echo "::error::Missing KEYSTORE_PASSPHRASE secret"; exit 1; }
|
||||||
openssl enc -d -aes-256-cbc -pbkdf2 -in android/release-keystore.enc \
|
openssl enc -d -aes-256-cbc -pbkdf2 -in android/release-keystore.enc \
|
||||||
-out android/mindlist-release.keystore -pass pass:"$ENC_PASS"
|
-out android/mindlist-release.keystore -pass env:ENC_PASS
|
||||||
printf 'storeFile=../mindlist-release.keystore\nstorePassword=%s\nkeyAlias=%s\nkeyPassword=%s\n' \
|
printf 'storeFile=../mindlist-release.keystore\nstorePassword=%s\nkeyAlias=%s\nkeyPassword=%s\n' \
|
||||||
"$STORE_PASSWORD" "$KEY_ALIAS" "$KEY_PASSWORD" > android/keystore.properties
|
"$STORE_PASSWORD" "$KEY_ALIAS" "$KEY_PASSWORD" > android/keystore.properties
|
||||||
echo "sdk.dir=$ANDROID_HOME" > android/local.properties
|
echo "sdk.dir=$ANDROID_HOME" > android/local.properties
|
||||||
@@ -92,34 +120,90 @@ jobs:
|
|||||||
# (safe to publish: file is password-encrypted) and open with the
|
# (safe to publish: file is password-encrypted) and open with the
|
||||||
# given alias/password. Fails fast with a clear message otherwise.
|
# given alias/password. Fails fast with a clear message otherwise.
|
||||||
echo "632509e5f1bdda0633736e8bcdc0bc0ce405ebd02fd7187738029240b860b9b6 android/mindlist-release.keystore" | sha256sum -c -
|
echo "632509e5f1bdda0633736e8bcdc0bc0ce405ebd02fd7187738029240b860b9b6 android/mindlist-release.keystore" | sha256sum -c -
|
||||||
keytool -list -keystore android/mindlist-release.keystore -storepass "$STORE_PASSWORD" -alias "$KEY_ALIAS" | head -2
|
keytool -list -keystore android/mindlist-release.keystore -storepass:env STORE_PASSWORD -alias "$KEY_ALIAS" | head -2
|
||||||
|
|
||||||
|
- name: Check versionCode bump
|
||||||
|
# Android/Obtainium refuse to install over an equal-or-lower versionCode,
|
||||||
|
# so a tag without a bump would strand users on the old build.
|
||||||
|
run: |
|
||||||
|
CUR=$(grep -oP 'versionCode \K[0-9]+' android/app/build.gradle | head -1)
|
||||||
|
PREV_TAG=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -2 | tail -1)
|
||||||
|
if [ -n "$PREV_TAG" ]; then
|
||||||
|
PREV=$(git show "$PREV_TAG:android/app/build.gradle" | grep -oP 'versionCode \K[0-9]+' | head -1)
|
||||||
|
echo "versionCode: $PREV ($PREV_TAG) -> $CUR (${GITHUB_REF_NAME})"
|
||||||
|
[ "$CUR" -gt "$PREV" ] || { echo "::error::Bump versionCode in android/app/build.gradle above $PREV before tagging"; exit 1; }
|
||||||
|
else
|
||||||
|
echo "No previous release tag; versionCode=$CUR accepted"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Assemble signed release
|
- name: Assemble signed release
|
||||||
working-directory: android
|
working-directory: android
|
||||||
env:
|
|
||||||
JAVA_HOME: ${{ env.JAVA_HOME }}
|
|
||||||
run: ./gradlew assembleRelease
|
run: ./gradlew assembleRelease
|
||||||
|
|
||||||
- name: Verify signature
|
- name: Verify signature + fingerprint
|
||||||
run: $ANDROID_HOME/build-tools/36.0.0/apksigner verify app/build/outputs/apk/release/app-release.apk
|
# The fingerprint pins OUR key: a rotated/wrong secret would sign fine
|
||||||
|
# but break Obtainium updates (signature mismatch on install).
|
||||||
|
run: |
|
||||||
|
$ANDROID_HOME/build-tools/36.0.0/apksigner verify --print-certs app/build/outputs/apk/release/app-release.apk | tee /tmp/certs.txt
|
||||||
|
grep -q '60:C5:2C:FE:CA:C0:A5:2A:9D:B9:24:1A:25:A1:40:EA:79:F6:C3:FE:04:F8:18:DA:E9:16:93:76:8E:D0:DE:BA' /tmp/certs.txt \
|
||||||
|
|| { echo "::error::APK not signed with the Mindlist release key"; exit 1; }
|
||||||
working-directory: android
|
working-directory: android
|
||||||
|
|
||||||
- name: Stage artifact
|
- name: Stage artifact
|
||||||
run: cp android/app/build/outputs/apk/release/app-release.apk "mindlist-${GITHUB_REF_NAME}.apk"
|
run: |
|
||||||
|
cp android/app/build/outputs/apk/release/app-release.apk "mindlist-${GITHUB_REF_NAME}.apk"
|
||||||
|
sha256sum "mindlist-${GITHUB_REF_NAME}.apk" | cut -d' ' -f1 > "mindlist-${GITHUB_REF_NAME}.apk.sha256"
|
||||||
|
|
||||||
- name: Create Gitea release + upload APK
|
- name: Create Gitea release + upload APK
|
||||||
|
if: github.event_name == 'push'
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAG: ${{ github.ref_name }}
|
TAG: ${{ github.ref_name }}
|
||||||
SERVER: ${{ github.server_url }}
|
SERVER: ${{ github.server_url }}
|
||||||
REPO: ${{ github.repository }}
|
REPO: ${{ github.repository }}
|
||||||
run: |
|
run: |
|
||||||
REL=$(curl -sS -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
|
NOTES=$(git log --format='- %s' "$(git describe --tags --abbrev=0 HEAD~1 2>/dev/null || echo '')..HEAD" 2>/dev/null | head -30)
|
||||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"Mindlist $TAG\",\"draft\":false,\"prerelease\":false}" \
|
python3 - "$SERVER" "$TOKEN" "$REPO" "$TAG" "$NOTES" <<'EOF'
|
||||||
"$SERVER/api/v1/repos/$REPO/releases")
|
import json, sys, urllib.request, urllib.error
|
||||||
echo "$REL" | head -c 300; echo
|
server, token, repo, tag, notes = sys.argv[1:6]
|
||||||
RID=$(echo "$REL" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
def api(path, data=None, raw=None, ctype='application/json'):
|
||||||
curl -sS -H "Authorization: token $TOKEN" -H 'Content-Type: application/vnd.android.package-archive' \
|
req = urllib.request.Request(server + path, data=data,
|
||||||
--data-binary "@mindlist-$TAG.apk" \
|
headers={'Authorization': 'token ' + token, 'Content-Type': ctype})
|
||||||
"$SERVER/api/v1/repos/$REPO/releases/$RID/assets?name=mindlist-$TAG.apk"
|
try:
|
||||||
echo; echo "Uploaded mindlist-$TAG.apk to release $RID"
|
with urllib.request.urlopen(req) as r:
|
||||||
|
return r.status, r.read()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, e.read()
|
||||||
|
# Idempotent: reuse the release if the tag already has one (re-runs).
|
||||||
|
status, body = api(f'/api/v1/repos/{repo}/releases/tags/{tag}')
|
||||||
|
if status == 200:
|
||||||
|
rid = json.loads(body)['id']
|
||||||
|
print(f'Reusing existing release {rid} for {tag}')
|
||||||
|
else:
|
||||||
|
status, body = api(f'/api/v1/repos/{repo}/releases',
|
||||||
|
json.dumps({'tag_name': tag, 'name': f'Mindlist {tag}',
|
||||||
|
'body': notes, 'draft': False, 'prerelease': False}).encode())
|
||||||
|
assert status == 201, f'create release failed: {status} {body[:300]}'
|
||||||
|
rid = json.loads(body)['id']
|
||||||
|
print(f'Created release {rid} for {tag}')
|
||||||
|
for asset in (f'mindlist-{tag}.apk', f'mindlist-{tag}.apk.sha256'):
|
||||||
|
with open(asset, 'rb') as f:
|
||||||
|
blob = f.read()
|
||||||
|
# Replace same-name asset from an earlier run instead of duplicating.
|
||||||
|
status, body = api(f'/api/v1/repos/{repo}/releases/{rid}/assets')
|
||||||
|
for a in json.loads(body):
|
||||||
|
if a['name'] == asset:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f'{server}/api/v1/repos/{repo}/releases/{rid}/assets/{a["id"]}',
|
||||||
|
headers={'Authorization': 'token ' + token}, method='DELETE')
|
||||||
|
with urllib.request.urlopen(req) as r:
|
||||||
|
print(f'Deleted old asset {asset}: {r.status}')
|
||||||
|
status, body = api(f'/api/v1/repos/{repo}/releases/{rid}/assets?name={asset}',
|
||||||
|
blob, ctype='application/vnd.android.package-archive')
|
||||||
|
assert status == 201, f'upload {asset} failed: {status} {body[:300]}'
|
||||||
|
print(f'Uploaded {asset} ({len(blob)} bytes)')
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Scrub key material
|
||||||
|
if: always()
|
||||||
|
run: rm -f android/keystore.properties android/mindlist-release.keystore
|
||||||
Reference in new issue
Block a user