mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
ci(android): publish per-push dev builds to the Play internal track
Fold per-push Android dev distribution into build-android.yml (which already builds a signed APK on every master push) instead of a second workflow that duplicated the whole build. On non-tag master pushes it stamps a dev versionCode into the working copy of build.gradle and, after the existing build, ships the play APK to the Play 'internal' track so a fresh build auto-updates on the phone. Internal testing skips review, so builds land in minutes; dev builds (code base+1..999) and releases (base+9000) share the track but stay distinct by versionCode. All new steps are gated to refs/heads/master (non-tag); the tag/release path is untouched, and every dev step is continue-on-error so it can never break the release-critical build. versionCode = last stable release's code + commits-since-that-tag (first-parent), staying in the 999-slot band above the release and below the next version's base. The base is derived from the last stable tag (not the working tree), so a version bump landing on master before its tag is pushed can't make dev codes jump ahead and then regress. Logic lives in tools/android-dev-version-code.js (reusing getAndroidVersionInfo) with unit tests; it degrades to skip on any anomaly. Reuses existing keystore/Play/Unsplash secrets; no new secrets.
This commit is contained in:
parent
b60845b42c
commit
911f39402d
4 changed files with 236 additions and 1 deletions
66
.github/workflows/build-android.yml
vendored
66
.github/workflows/build-android.yml
vendored
|
|
@ -10,6 +10,13 @@ on:
|
|||
permissions:
|
||||
contents: write
|
||||
|
||||
# Per-ref so rapid non-tag master pushes supersede each other (newest dev build
|
||||
# wins). Groups are keyed by ref, so a master push can never cancel a `v*` tag
|
||||
# release build (different ref = different group).
|
||||
concurrency:
|
||||
group: build-android-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-android:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -19,6 +26,11 @@ jobs:
|
|||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
with:
|
||||
# Full commit/tag graph (blobless, so cheap) is needed to derive the
|
||||
# per-push dev versionCode from the last stable tag.
|
||||
fetch-depth: 0
|
||||
filter: blob:none
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
|
@ -61,6 +73,37 @@ jobs:
|
|||
run: |
|
||||
echo "${{ secrets.DROID_KEYSTORE_BASE_64 }}" | base64 --decode > keystore.jks
|
||||
|
||||
# --- Per-push dev build (non-tag master pushes only) --------------------
|
||||
# On plain master pushes the release-signed APK is otherwise artifact-only;
|
||||
# here we stamp it with a dev versionCode and (after Build) ship it to the
|
||||
# Play `internal` track so a fresh build auto-updates on the phone. All
|
||||
# steps are gated to refs/heads/master (non-tag), so the tag/release path
|
||||
# is untouched. Nothing is committed — the versionCode is only sed'd into
|
||||
# the working copy of build.gradle at build time.
|
||||
- name: Compute dev versionCode
|
||||
id: devvc
|
||||
# Dev distribution must never redden the release-critical build; on any
|
||||
# failure the downstream gates read '' and simply skip.
|
||||
continue-on-error: true
|
||||
if: github.ref == 'refs/heads/master'
|
||||
run: node tools/android-dev-version-code.js
|
||||
- name: Apply dev versionCode
|
||||
id: applyvc
|
||||
# continue-on-error: a dev-versioning hiccup must never break the
|
||||
# release-critical build; on failure `applied` stays unset and the Play
|
||||
# upload below is skipped (the artifact build still succeeds).
|
||||
continue-on-error: true
|
||||
if: github.ref == 'refs/heads/master' && steps.devvc.outputs.skip == 'false'
|
||||
env:
|
||||
CODE: ${{ steps.devvc.outputs.code }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sed -i -E "s/versionCode [0-9_]+/versionCode ${CODE}/" android/app/build.gradle
|
||||
grep -qE "versionCode ${CODE}( |$)" android/app/build.gradle \
|
||||
|| { echo "::error::versionCode override did not apply"; exit 1; }
|
||||
grep -nE 'versionCode [0-9_]+' android/app/build.gradle
|
||||
echo "applied=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build
|
||||
run: npm run dist:android:prod
|
||||
env:
|
||||
|
|
@ -83,6 +126,29 @@ jobs:
|
|||
name: sup-android-release
|
||||
path: android/app/build/outputs/apk/**/*.apk
|
||||
|
||||
# Ship the dev-versioned APK to the Play `internal` track (non-tag master
|
||||
# pushes only, and only when the versionCode was actually applied). This is
|
||||
# the same track the tag build stages real releases on below; dev builds
|
||||
# (code base+1..999) and releases (base+9000) stay distinct by versionCode,
|
||||
# so they interleave on the track without colliding. Chosen over a closed
|
||||
# track because internal testing skips review, so builds land in minutes.
|
||||
- name: Upload dev build to Play (internal track)
|
||||
# continue-on-error: re-runs (same commit -> same versionCode, which Play
|
||||
# rejects as already used), the post-release window (a just-tagged release
|
||||
# has a higher code on this track until the tag propagates to the dev
|
||||
# step — closed by `git push --follow-tags`), and transient Play API
|
||||
# errors must not fail the release-critical workflow.
|
||||
continue-on-error: true
|
||||
if: github.ref == 'refs/heads/master' && steps.applyvc.outputs.applied == 'true'
|
||||
uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1.1.5
|
||||
with:
|
||||
serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
packageName: com.superproductivity.superproductivity
|
||||
releaseFiles: android/app/build/outputs/apk/play/release/*.apk
|
||||
releaseName: 'dev ${{ steps.devvc.outputs.code }} (${{ steps.devvc.outputs.sha }})'
|
||||
tracks: internal
|
||||
status: completed
|
||||
|
||||
- name: Wait for main release to be created
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: |
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@
|
|||
"release-notes:generate:codex": "cross-env SP_RELEASE_NOTES_AI=codex node ./tools/release-notes.js generate",
|
||||
"release-notes:generate:claude": "cross-env SP_RELEASE_NOTES_AI=claude node ./tools/release-notes.js generate",
|
||||
"release-notes:prepare-play-store": "node ./tools/release-notes.js prepare-play-store",
|
||||
"release-notes:test": "node --test tools/release-notes.test.js tools/prepare-appstore-release-notes.test.js",
|
||||
"release-notes:test": "node --test tools/release-notes.test.js tools/prepare-appstore-release-notes.test.js tools/android-dev-version-code.test.js",
|
||||
"clean:translations": "node ./tools/clean-translations.js",
|
||||
"plugin-api:build": "cd packages/plugin-api && npm run build",
|
||||
"packages:test": "npm run sync-core:test && npm run sync-providers:test",
|
||||
|
|
|
|||
114
tools/android-dev-version-code.js
Normal file
114
tools/android-dev-version-code.js
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
'use strict';
|
||||
|
||||
// Computes the versionCode for a per-push Android *dev* build that
|
||||
// build-android.yml publishes to the Play `internal` track on non-tag master
|
||||
// pushes (see its "dev versionCode" steps).
|
||||
//
|
||||
// Scheme: dev code = <last stable release's versionCode> + <commits since that
|
||||
// tag, first-parent>. For 18.13.1 (getAndroidVersionInfo -> 1_813_019_000) dev
|
||||
// builds land in 1_813_019_001..999 — above that release, below the next
|
||||
// version's base (18.13.2 -> 1_813_020_000), strictly increasing within a
|
||||
// cycle, and reset to a fresh band by each release. Because real releases use
|
||||
// base+9000 and pre-releases base+N, staying under the next version's base
|
||||
// guarantees a dev code can never collide with a real release/pre-release code.
|
||||
//
|
||||
// The base is derived from the last stable *tag*, NOT the working-tree
|
||||
// build.gradle: a version-bump commit that reaches master before its tag is
|
||||
// pushed must not make dev codes jump ahead of the release and then regress
|
||||
// once the tag appears. Anchored to the tag, dev codes stay in the previous
|
||||
// release's band until the new tag is visible, then step cleanly up. Pre-release
|
||||
// (`-rc`) tags are ignored for the same reason and because they never reach Play.
|
||||
//
|
||||
// This never fails the build: any unexpected condition degrades to skip=true
|
||||
// with a warning annotation, so dev-build distribution can't break the
|
||||
// release-critical build-android.yml job it lives in. The pure band math is
|
||||
// isolated in computeDevVersionCode() and unit-tested (irreversible Play codes
|
||||
// warrant a test); the git/IO glue in main() is thin.
|
||||
|
||||
const fs = require('fs');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { getAndroidVersionInfo } = require('./release-notes');
|
||||
|
||||
// Reserved slots between a release code (base+9000) and the next version's base
|
||||
// (base+10000): base+9001 .. base+9999. Dev codes must stay under this ceiling.
|
||||
const BAND_SIZE = 1000;
|
||||
|
||||
/**
|
||||
* Pure band math — no git, no I/O. Returns {skip:true, reason} or
|
||||
* {skip:false, code}. Throws only on nonsensical input (caught by main()).
|
||||
*/
|
||||
const computeDevVersionCode = ({ baseCode, commits }) => {
|
||||
if (!Number.isInteger(baseCode) || baseCode <= 0) {
|
||||
throw new Error(`invalid baseCode: ${baseCode}`);
|
||||
}
|
||||
if (!Number.isInteger(commits) || commits < 0) {
|
||||
throw new Error(`invalid commit count: ${commits}`);
|
||||
}
|
||||
if (commits === 0) {
|
||||
// HEAD is exactly the last stable release commit; the tag build handles Play.
|
||||
return { skip: true, reason: 'HEAD is the last stable release commit' };
|
||||
}
|
||||
if (commits >= BAND_SIZE) {
|
||||
return {
|
||||
skip: true,
|
||||
reason: `${commits} commits since the last release — dev versionCode band (${BAND_SIZE - 1} slots) exhausted; cut a release to reset it`,
|
||||
};
|
||||
}
|
||||
return { skip: false, code: baseCode + commits };
|
||||
};
|
||||
|
||||
const lastStableTag = () =>
|
||||
execFileSync('git', ['tag', '--merged', 'HEAD', '--sort=-v:refname'], {
|
||||
encoding: 'utf8',
|
||||
})
|
||||
.split('\n')
|
||||
.map((t) => t.trim())
|
||||
.find((t) => /^v\d+\.\d+\.\d+$/.test(t));
|
||||
|
||||
const countCommitsSince = (tag) =>
|
||||
Number(
|
||||
execFileSync('git', ['rev-list', '--count', '--first-parent', `${tag}..HEAD`], {
|
||||
encoding: 'utf8',
|
||||
}).trim(),
|
||||
);
|
||||
|
||||
const setOutput = (obj) => {
|
||||
const line =
|
||||
Object.entries(obj)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('\n') + '\n';
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, line);
|
||||
}
|
||||
process.stdout.write(line);
|
||||
};
|
||||
|
||||
const main = () => {
|
||||
try {
|
||||
const tag = lastStableTag();
|
||||
if (!tag) {
|
||||
console.log('::warning::no stable v* tag reachable from HEAD; skipping dev build');
|
||||
return setOutput({ skip: true });
|
||||
}
|
||||
const { versionCode: baseCode } = getAndroidVersionInfo(tag.replace(/^v/, ''));
|
||||
const commits = countCommitsSince(tag);
|
||||
const result = computeDevVersionCode({ baseCode, commits });
|
||||
if (result.skip) {
|
||||
console.log(`::warning::${result.reason}`);
|
||||
return setOutput({ skip: true });
|
||||
}
|
||||
const sha = (process.env.GITHUB_SHA || '').slice(0, 8);
|
||||
console.log(`dev versionCode ${result.code} <- ${tag} + ${commits} commits (${sha})`);
|
||||
return setOutput({ skip: false, code: result.code, sha });
|
||||
} catch (err) {
|
||||
// Never break the release-critical build over a dev-versioning hiccup.
|
||||
console.log(`::warning::dev versionCode computation failed: ${err.message}`);
|
||||
return setOutput({ skip: true });
|
||||
}
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { computeDevVersionCode, BAND_SIZE };
|
||||
55
tools/android-dev-version-code.test.js
Normal file
55
tools/android-dev-version-code.test.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { getAndroidVersionInfo } = require('./release-notes');
|
||||
const { computeDevVersionCode, BAND_SIZE } = require('./android-dev-version-code');
|
||||
|
||||
// 18.13.1 -> 1_813_019_000 (release code), next version base 18.13.2 -> 1_813_020_000.
|
||||
const RELEASE_18_13_1 = getAndroidVersionInfo('18.13.1').versionCode;
|
||||
const NEXT_VERSION_BASE = getAndroidVersionInfo('18.13.2').versionCode - 9000;
|
||||
|
||||
test('release code and next-version base match the documented band', () => {
|
||||
assert.equal(RELEASE_18_13_1, 1813019000);
|
||||
assert.equal(NEXT_VERSION_BASE, 1813020000);
|
||||
});
|
||||
|
||||
test('a dev build lands one slot above the release', () => {
|
||||
assert.deepEqual(computeDevVersionCode({ baseCode: RELEASE_18_13_1, commits: 1 }), {
|
||||
skip: false,
|
||||
code: 1813019001,
|
||||
});
|
||||
});
|
||||
|
||||
test('the code increments monotonically with the commit count', () => {
|
||||
const a = computeDevVersionCode({ baseCode: RELEASE_18_13_1, commits: 10 }).code;
|
||||
const b = computeDevVersionCode({ baseCode: RELEASE_18_13_1, commits: 11 }).code;
|
||||
assert.equal(a, 1813019010);
|
||||
assert.ok(b > a);
|
||||
});
|
||||
|
||||
test('the top of the band stays strictly below the next version base', () => {
|
||||
const { code } = computeDevVersionCode({
|
||||
baseCode: RELEASE_18_13_1,
|
||||
commits: BAND_SIZE - 1,
|
||||
});
|
||||
assert.equal(code, 1813019999);
|
||||
assert.ok(code < NEXT_VERSION_BASE, 'dev code must not reach the next version base');
|
||||
});
|
||||
|
||||
test('commits==0 (HEAD is the release commit) skips instead of reusing the release code', () => {
|
||||
const r = computeDevVersionCode({ baseCode: RELEASE_18_13_1, commits: 0 });
|
||||
assert.equal(r.skip, true);
|
||||
});
|
||||
|
||||
test('band exhaustion skips (with a reason) rather than colliding with the next version', () => {
|
||||
const r = computeDevVersionCode({ baseCode: RELEASE_18_13_1, commits: BAND_SIZE });
|
||||
assert.equal(r.skip, true);
|
||||
assert.match(r.reason, /exhausted/);
|
||||
});
|
||||
|
||||
test('invalid inputs throw (so main() degrades to a skip)', () => {
|
||||
assert.throws(() => computeDevVersionCode({ baseCode: 0, commits: 1 }));
|
||||
assert.throws(() => computeDevVersionCode({ baseCode: RELEASE_18_13_1, commits: -1 }));
|
||||
assert.throws(() => computeDevVersionCode({ baseCode: RELEASE_18_13_1, commits: 1.5 }));
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue