diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8dc336b..1e5aa299 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,9 +6,169 @@ on: tags: [ 'v*' ] pull_request: branches: [ master ] + workflow_dispatch: + inputs: + version: + description: 'Version to release, e.g. v3.0.1 — a single trailing letter (v3.0.1a) publishes a pre-release' + required: true + type: string jobs: + # Single source of truth for "is this a release, and which version is it". + # + # On workflow_dispatch this also bumps app/build.gradle, commits it to master + # and tags that commit, so the release jobs below build the tagged bump commit + # within this same run. It has to happen in one run: a tag pushed with + # GITHUB_TOKEN cannot start a new workflow run, so there is no second, + # tag-triggered run to fall back on. Manually pushing a v* tag still works + # exactly as before, tests and all. + release-context: + runs-on: ubuntu-latest + permissions: + contents: write # push the version bump commit and the release tag + outputs: + release: ${{ steps.context.outputs.release }} + tag: ${{ steps.context.outputs.tag }} + run_tests: ${{ steps.context.outputs.run_tests }} + + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + # A dispatch can be started from any ref, but releases always come off + # master. fetch-tags so the "already released?" check below can see them. + ref: ${{ github.event_name == 'workflow_dispatch' && 'master' || github.ref }} + fetch-depth: 0 + fetch-tags: true + + - name: Validate requested version + if: github.event_name == 'workflow_dispatch' + env: + TAG: ${{ inputs.version }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + # The checkout below swaps in master's *contents*, but GitHub already + # picked this workflow's definition from the ref chosen in the Run + # workflow UI. Releasing from anywhere else would run that ref's + # release logic — with contents:write — against master's code. + if [ "$GITHUB_REF" != "refs/heads/master" ]; then + echo "::error::Releases must be dispatched from master, not ${GITHUB_REF#refs/heads/}." + exit 1 + fi + if [[ ! "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)[a-z]?$ ]]; then + echo "::error::'$TAG' is not a valid version. Expected vMAJOR.MINOR.PATCH, optionally with one trailing lowercase letter for a beta — e.g. v3.0.1 or v3.0.1a." + exit 1 + fi + # Refuse if a GitHub Release already exists for this version, whether or + # not its tag does. A release outlives a deleted tag, and either path + # would then hand it to action-gh-release, which overwrites by default + # and would replace already-published artefacts with a fresh build. + # Only 404 proves absence; anything inconclusive refuses. + release_state="$(curl -sS -o /dev/null -w '%{http_code}' \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/releases/tags/$TAG" || echo 000)" + case "$release_state" in + 404) ;; + 200) + echo "::error::A GitHub Release already exists for $TAG. Delete it (and the tag, if you want to re-cut the same version) before re-running, or choose a version that has not been released." + exit 1 ;; + *) + echo "::error::Could not determine whether $TAG already has a published release (HTTP $release_state). Refusing rather than risk overwriting published artefacts." + exit 1 ;; + esac + + # An existing tag means a previous run bumped and pushed but failed + # later on; that is resumable. Judge it by what the tag *contains*, + # not by where master is now — master may have moved on since, and + # the release job checks out the tag rather than the branch anyway. + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + # A tag can be created from anywhere. Releases come off master, and + # resuming skips the tests, so an unreachable tag would publish a commit + # that was never on master and was never tested. + if ! git merge-base --is-ancestor "refs/tags/$TAG^{commit}" HEAD; then + echo "::error::Tag $TAG is not reachable from master — releases must come from master." + exit 1 + fi + # Only the newest release is resumable. Re-running an older one would + # reuse the current appVersionCode and drag the release attachments + # back to a superseded build. + newest="$(git for-each-ref --merged HEAD --sort=-creatordate \ + --format='%(refname:strip=2)' 'refs/tags/v*' \ + | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+[a-z]?$' | head -1)" + if [ "$newest" != "$TAG" ]; then + echo "::error::Tag $TAG is not the most recently cut release ($newest) — only the latest release can be resumed. Delete the tag and its GitHub Release, then re-dispatch, or cut a new version." + exit 1 + fi + if ! git show "$TAG:app/build.gradle" 2>/dev/null \ + | grep -qxF "def baseVersionName = \"${TAG#v}\""; then + echo "::error::Tag $TAG already exists but its commit does not set baseVersionName to ${TAG#v} — pick a version that has not been released." + exit 1 + fi + echo "::notice::Tag $TAG already exists and carries version ${TAG#v}; resuming a previous release run without re-tagging." + echo "RELEASE_RESUME=true" >> "$GITHUB_ENV" + else + echo "RELEASE_RESUME=false" >> "$GITHUB_ENV" + fi + + - name: Bump version, commit and tag + if: github.event_name == 'workflow_dispatch' && env.RELEASE_RESUME != 'true' + env: + TAG: ${{ inputs.version }} + run: | + set -euo pipefail + version="${TAG#v}" + + # versionCode must increase monotonically or Android refuses the upgrade. + current_code="$(grep -oP '^def appVersionCode = \K[0-9]+' app/build.gradle)" + sed -i -E "s/^def appVersionCode = [0-9]+$/def appVersionCode = $((current_code + 1))/" app/build.gradle + sed -i -E "s/^def baseVersionName = \".*\"$/def baseVersionName = \"${version}\"/" app/build.gradle + + # Fail loudly rather than releasing an unbumped build. + grep -qE "^def baseVersionName = \"${version}\"\$" app/build.gradle || { + echo "::error::Failed to set baseVersionName to ${version} in app/build.gradle." + exit 1 + } + grep -qE "^def appVersionCode = $((current_code + 1))\$" app/build.gradle || { + echo "::error::Failed to increment appVersionCode to $((current_code + 1)) in app/build.gradle." + exit 1 + } + grep -E '^def (appVersionCode|baseVersionName) ' app/build.gradle + + # Attribute the bump to the repo owner, matching every other hand-made + # commit here, rather than to the Actions bot. + git config user.name 'Robin Jacobs' + git config user.email 'RobinJ1995@users.noreply.github.com' + git add app/build.gradle + # Re-releasing a version already present in the file is a no-op commit-wise; + # the tag still needs to land on master's tip. + git diff --cached --quiet || git commit -m "$TAG" + git tag -a "$TAG" -m "$TAG" + # --atomic: either both refs land or neither does. A half-push would + # leave master bumped but unreleased, and the GITHUB_TOKEN branch push + # starts no CI run that would make that visible. + git push --atomic origin HEAD:master "refs/tags/$TAG" + + - name: Resolve release context + id: context + env: + TAG: ${{ inputs.version }} + run: | + set -euo pipefail + if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then + # Nothing about the code changed, so the tests that already passed on + # master still hold — go straight to building the release. + printf 'release=true\ntag=%s\nrun_tests=false\n' "$TAG" >> "$GITHUB_OUTPUT" + elif [ "$GITHUB_REF_TYPE" = "tag" ]; then + printf 'release=true\ntag=%s\nrun_tests=true\n' "$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT" + else + printf 'release=false\ntag=\nrun_tests=true\n' >> "$GITHUB_OUTPUT" + fi + unit-tests: + needs: release-context + if: needs.release-context.outputs.run_tests == 'true' runs-on: ubuntu-latest steps: @@ -44,10 +204,15 @@ jobs: name: unit-test-coverage path: app/build/reports/jacoco/jacocoTestReport/ - # Build signed APK + AAB on version tags (v*), gated on the unit tests. + # Build signed APK + AAB for a release, gated on the unit tests. + # + # The status functions matter: on a dispatch run unit-tests is *skipped*, and a + # skipped dependency would skip this job too under the implicit success(). + # !cancelled() && !failure() drops that implicit check while still refusing to + # release if unit-tests actually ran and failed. release: - if: startsWith(github.ref, 'refs/tags/v') - needs: unit-tests + if: ${{ !cancelled() && !failure() && needs.release-context.outputs.release == 'true' }} + needs: [ release-context, unit-tests ] runs-on: ubuntu-latest permissions: contents: write # create/attach the GitHub Release on tags @@ -55,6 +220,10 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + # On a dispatch the triggering SHA predates the version bump, so build + # the tag release-context just created rather than the default checkout. + ref: refs/tags/${{ needs.release-context.outputs.tag }} - name: Set up JDK 21 uses: actions/setup-java@v5 @@ -99,7 +268,9 @@ jobs: echo "acra_password=${ACRA_PASSWORD}" >> secret.properties - name: Set release artifact version - run: echo "RELEASE_VERSION=$GITHUB_REF_NAME" >> "$GITHUB_ENV" + env: + TAG: ${{ needs.release-context.outputs.tag }} + run: echo "RELEASE_VERSION=$TAG" >> "$GITHUB_ENV" - name: Build signed APK and AAB run: ./gradlew assembleRelease bundleRelease @@ -149,7 +320,7 @@ jobs: - name: Detect release type id: release_metadata run: | - if [[ "$GITHUB_REF_NAME" =~ [[:alpha:]]$ ]]; then + if [[ "$RELEASE_VERSION" =~ [[:alpha:]]$ ]]; then echo "prerelease=true" >> "$GITHUB_OUTPUT" { echo "body<.apk`, `DistroHopper-.aab`, and `DistroHopper--paranoia.apk`, where `` is the full version tag, including its leading `v`. +- Releases can also be cut from GitHub: run the **CI** workflow manually + (Actions → CI → Run workflow) and give it a version such as `v3.0.1` or + `v3.0.1a`. The `release-context` job validates the format, refuses a version + whose tag already exists but does not carry that version (a tag whose commit + already sets `baseVersionName` to it is a previous run that pushed but failed + later, and the release resumes from it — judged by what the tag holds, so a + resume still works once `master` has moved on past it, and the release builds + the tagged commit rather than the branch tip; the tag must also be reachable + from `master` and be the newest release, so a resume cannot reuse a superseded + build's `appVersionCode`; and no GitHub Release may exist for the tag yet — a + release that outlived a failed run, or its deleted tag, must be deleted before + retrying, since the release action overwrites its attachments by default), bumps `baseVersionName` and + increments + `appVersionCode` in `app/build.gradle`, commits that to `master`, tags the + commit (authored as `Robin Jacobs ` — the + same identity as hand-made commits here, and `git config user.*` sets author, + committer and tagger alike; the *push* is still authenticated by + `GITHUB_TOKEN`, so the pusher remains `github-actions[bot]` and the commit is + unsigned), and the release job builds from that tag in the same run. Unit + tests are skipped on this path — the code being released is master's already-tested + tip, and the bump commit touches only the version constants. Pushing a tag by + hand still runs the tests first, unchanged. Note that if branch protection is + ever enabled on `master`, `github-actions[bot]` needs a bypass entry or the + bump commit cannot be pushed. - Tagged CI releases build the normal signed APK/AAB plus a best-effort signed `-paranoia` APK (`./gradlew clean assembleRelease -PparanoiaBuild=true`): it appends `-paranoia` to `versionName`, forces `BuildConfig.ACRA_CONFIGURED`