-
Notifications
You must be signed in to change notification settings - Fork 25
Allow cutting releases from the GitHub Actions UI #113
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9567f47
35e7c65
fed30c2
13a860d
26d34c9
07ea5df
d1ca1f6
2d5b3ae
e325e7a
be38962
ec72b1b
8ee2d35
e368317
0d944a4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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." | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the latest matching tag already has a successfully published release, this path still labels it a failed-run resume and proceeds to rebuild and upload the same attachment names. The release action's AGENTS.md reference: AGENTS.md:L37-L41 Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed, and fixed in This is the one failure mode in the resume path where the damage is not recoverable by re-running:
I went with "any published release refuses" rather than trying to detect a partial upload. Distinguishing complete from incomplete means hardcoding the expected asset names in the guard, which then goes stale the moment the attachment list changes — and guessing wrong in the permissive direction is exactly the overwrite this is meant to prevent. The cost is that a run which died between creating the release and finishing its uploads needs the release deleted by hand before the retry; that is a visible, reversible step, unlike a silent overwrite. Resume therefore now covers the case it was built for: the tag is pushed, the release job fails before publishing. Verified both branches with a stubbed API — The same guard is in all four repos. Generated by Claude Code |
||
| echo "RELEASE_RESUME=true" >> "$GITHUB_ENV" | ||
| else | ||
| echo "RELEASE_RESUME=false" >> "$GITHUB_ENV" | ||
|
Comment on lines
+111
to
+112
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an unused but older version is dispatched—for example, Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This one resolved itself when I changed how "newest" is computed, in You're right that the two paths disagreed: the fresh path accepted any unused version (downgrades are a deliberate decision here), while the resume path picked the highest version as newest. A Rather than add a newest-version check to the fresh path — which would have rejected the downgrades the owner explicitly wants — the resume path now orders candidates by tag creation date rather than version magnitude. A downgrade is by definition the most recently cut release, so it resumes normally, and the two paths agree without either giving up its intended behaviour. Verified: with The out-of-order publishing you mention at the end is real and unchanged — a downgrade moves the mutable image and release aliases backward. That is the accepted cost of allowing downgrades, decided before this PR. Generated by Claude Code |
||
| 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If the declaration later gains an inline comment, such as AGENTS.md reference: AGENTS.md:L37-L39 Useful? React with 👍 / 👎. |
||
| 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" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a dispatch is started while the latest AGENTS.md reference: AGENTS.md:L43-L45 Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct on the mechanics, and deliberate. The dispatch run does not consult This was weighed before implementing. The alternative considered was to require a successful run for the exact pre-bump SHA (query the Actions API for a green Two things bound the blast radius: the version input is validated and an existing tag is refused, so a mistaken run cannot overwrite a published release; and a bad release can be deleted and re-cut, since the tag is the only durable artefact. Leaving as-is. If the assumption turns out to bite in practice, the fix is small and self-contained — a step in Generated by Claude Code
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. no. |
||
| 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,17 +204,26 @@ 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 | ||
|
|
||
| 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<<RELEASE_BODY" | ||
|
|
@@ -163,9 +334,11 @@ jobs: | |
| fi | ||
|
|
||
| - name: Attach to GitHub Release | ||
| if: startsWith(github.ref, 'refs/tags/') | ||
| uses: softprops/action-gh-release@v2 | ||
| with: | ||
| # Explicit: on a dispatch run github.ref is a branch, so the tag cannot | ||
| # be inferred from it. | ||
| tag_name: ${{ needs.release-context.outputs.tag }} | ||
| prerelease: ${{ steps.release_metadata.outputs.prerelease }} | ||
| append_body: true | ||
| body: ${{ steps.release_metadata.outputs.body }} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,6 +32,30 @@ codebase — older Java alongside newer Kotlin. | |
| Release attachments are named `DistroHopper-<version>.apk`, | ||
| `DistroHopper-<version>.aab`, and `DistroHopper-<version>-paranoia.apk`, | ||
| where `<version>` 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the requested tag already points at the current AGENTS.md reference: AGENTS.md:L3-L8 Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fair — the docs described the refusal and not the recovery path added in the same change. Fixed in The resume condition itself was wrong. Comparing the tag against
Generated by Claude Code |
||
| 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 <RobinJ1995@users.noreply.github.com>` — 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` | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If a GitHub Release remains after its Git tag is deleted, this condition bypasses the release API check and treats the version as new; notably, the superseded-tag error on line 82 explicitly tells the operator to “Delete the tag and re-dispatch.” The workflow then recreates the tag and
softprops/action-gh-releasefinds the old release, whose documentedoverwrite_filesdefault istrue, replacing the already-published signed APK/AAB assets. This is fresh evidence beyond the previously fixed completed-release case because the final check is scoped only to tags that still exist; check for an existing release on both paths and remove the tag-only deletion advice.AGENTS.md reference: AGENTS.md:L28-L34
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correct, and fixed in
e368317and0d944a4.This one was self-inflicted: the guard sat inside the tag-exists branch, so it only ran on a resume — while the superseded-tag error I wrote told the operator to delete the tag. Following my own advice landed on the fresh path, which recreated the tag and handed the surviving release to
action-gh-release, overwriting exactly the artefacts the guard exists to protect.The check now runs before either path, so a release is refused whether or not its tag still exists, and the advice says to delete the release too.
0d944a4documents the prerequisite inAGENTS.md, sincecreate-releaseruns before the attachment jobs and the ordinary failure therefore leaves a release behind.Verified all four combinations against a stubbed API:
Generated by Claude Code