diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index ee350080d2..221f875a34 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -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: | diff --git a/package.json b/package.json index 0abcf6cb97..9c08d1ea17 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tools/android-dev-version-code.js b/tools/android-dev-version-code.js new file mode 100644 index 0000000000..66ff1489dd --- /dev/null +++ b/tools/android-dev-version-code.js @@ -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 = + . 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 }; diff --git a/tools/android-dev-version-code.test.js b/tools/android-dev-version-code.test.js new file mode 100644 index 0000000000..0b8db64a10 --- /dev/null +++ b/tools/android-dev-version-code.test.js @@ -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 })); +});