diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 7698b15b1..a52d61d9d 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -595,6 +595,16 @@ jobs: run: | TAG_NAME=${GITHUB_REF#refs/tags/} TAG_MESSAGE=$(git tag -n90 --format='%(contents)' "$TAG_NAME" ) + + # prepareForRelease appends this trailer when the release dialog's + # auto-publish box is checked. Strip it so it stays out of the body. + AUTO_PUBLISH=false + if printf '%s\n' "$TAG_MESSAGE" | grep -qxF 'Auto-Publish: true'; then + AUTO_PUBLISH=true + fi + TAG_MESSAGE=$(printf '%s\n' "$TAG_MESSAGE" | grep -vxF 'Auto-Publish: true' || true) + echo "auto_publish=$AUTO_PUBLISH" >> $GITHUB_ENV + echo "Auto-publish: $AUTO_PUBLISH" echo "Tag: $TAG_NAME" echo -e "Tag message:\n$TAG_MESSAGE" @@ -612,3 +622,19 @@ jobs: prerelease: true allowUpdates: true body: ${{ env.tag_message }} + + # Flipping the draft fires `release: published`, which starts + # publish-release.yml. The token must not be GITHUB_TOKEN: GitHub suppresses + # workflow runs triggered by it, so no store would ever publish. + - name: Publish release + if: env.auto_publish == 'true' + env: + GH_TOKEN: ${{ secrets.RELEASE_PAT }} + REPO: ${{ github.repository }} + TAG_NAME: ${{ github.ref_name }} + run: | + if [ -z "$GH_TOKEN" ]; then + echo "::error::Auto-publish was requested but the RELEASE_PAT secret is not set." + exit 1 + fi + gh release edit "$TAG_NAME" --repo "$REPO" --draft=false --prerelease=false --latest diff --git a/build.gradle.kts b/build.gradle.kts index 8188b6a49..e225e21a4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -334,7 +334,7 @@ tasks.register("prepareForRelease") { // Tag the merge commit explicitly; the main tree stays on develop. val tagMessageFile = File(project.rootDir, "build/release-tag-message.txt") tagMessageFile.parentFile.mkdirs() - tagMessageFile.writeText(releaseInfo.changeLog) + tagMessageFile.writeText(releaseInfo.tagMessage) git("tag", "-a", releaseInfo.tag, "-F", tagMessageFile.absolutePath, "release") // Push the branches and only this release's tag. Pushing --tags would try diff --git a/buildSrc/src/main/java/changelog.kt b/buildSrc/src/main/java/changelog.kt index 128ba6a7a..75c97efef 100644 --- a/buildSrc/src/main/java/changelog.kt +++ b/buildSrc/src/main/java/changelog.kt @@ -80,15 +80,20 @@ fun writeBakedChangelog(entry: String, bakedFile: File) { println("Baked changelog written to ${bakedFile.path}") } +/** Tag-message trailer that tells CI to publish the release instead of leaving it a draft. */ +const val AUTO_PUBLISH_TRAILER = "Auto-Publish: true" + /** * @param changeLog The full release notes: CHANGELOG.md and the GitHub release. * @param storeChangeLog The app-only subset every store listing carries. + * @param autoPublish Publish the GitHub release once every CI build job passes. */ data class ReleaseInfo( val semVar: SemVar, val changeLog: String, val storeChangeLog: String, val platforms: Set, + val autoPublish: Boolean = false, ) { init { // Fail fast at construction so we never get halfway through prepareForRelease @@ -99,6 +104,11 @@ data class ReleaseInfo( /** The git tag for this release: `vX.Y.Z` for full, `vX.Y.Z+token+token` for partial. */ val tag: String get() = "v$semVar${tagSuffix(platforms)}" + + // The trailer is the only channel that reaches CI per-release: the tag name already + // encodes platform scope, and a `+` suffix there would read as a partial release. + /** The annotated-tag message: the notes, plus the auto-publish trailer when asked for. */ + val tagMessage: String get() = if (autoPublish) "$changeLog\n\n$AUTO_PUBLISH_TRAILER" else changeLog } /** @@ -151,7 +161,8 @@ fun configureRelease(currentSemVarStr: String, lastReleaseChangelog: String? = n SwingUtilities.invokeAndWait { val frame = JFrame() frame.defaultCloseOperation = JFrame.DISPOSE_ON_CLOSE - frame.setSize(620, 900) + // The fixed sections take the height they need; this leaves the changelog editor ~250px. + frame.setSize(620, 1080) fun refreshTitle() { frame.title = "Prepare Release — $curSemVar → $newSemVar" @@ -374,6 +385,18 @@ fun configureRelease(currentSemVarStr: String, lastReleaseChangelog: String? = n // ============= Section: Will push tag ============= val tagSection = section("Will push tag").apply { add(tagLabel) } + // ============= Section: After the build ============= + val autoPublishBox = JCheckBox("Auto-publish the GitHub release").apply { + alignmentX = Component.LEFT_ALIGNMENT + } + val autoPublishSection = section("After the build").apply { + add(autoPublishBox) + add( + tabHint("Off leaves a draft. Publishing starts the store workflow.") + .apply { alignmentX = Component.LEFT_ALIGNMENT }, + ) + } + // ============= Section: Changelog ============= val changelogSection = section("Changelog") @@ -467,6 +490,7 @@ fun configureRelease(currentSemVarStr: String, lastReleaseChangelog: String? = n changeLog = changeLog.text, storeChangeLog = storeNotes.text, platforms = currentPlatforms(), + autoPublish = autoPublishBox.isSelected, ) frame.dispose() } @@ -486,6 +510,8 @@ fun configureRelease(currentSemVarStr: String, lastReleaseChangelog: String? = n add(Box.createRigidArea(Dimension(0, 8))) add(tagSection) add(Box.createRigidArea(Dimension(0, 8))) + add(autoPublishSection) + add(Box.createRigidArea(Dimension(0, 8))) } val root = JPanel(BorderLayout()).apply { border = BorderFactory.createEmptyBorder(16, 20, 16, 20) diff --git a/buildSrc/src/test/java/ReleaseInfoTest.kt b/buildSrc/src/test/java/ReleaseInfoTest.kt new file mode 100644 index 000000000..17dcfb29c --- /dev/null +++ b/buildSrc/src/test/java/ReleaseInfoTest.kt @@ -0,0 +1,65 @@ +package com.darkrockstudios.build + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ReleaseInfoTest { + + private fun releaseInfo(autoPublish: Boolean) = ReleaseInfo( + semVar = SemVar(1, 2, 3), + changeLog = "Added a thing\n- detail one", + storeChangeLog = "Added a thing", + platforms = Platform.ALL, + autoPublish = autoPublish, + ) + + @Test + fun `Auto-publish is off by default`() { + assertFalse( + ReleaseInfo( + semVar = SemVar(1, 2, 3), + changeLog = "notes", + storeChangeLog = "notes", + platforms = Platform.ALL, + ).autoPublish + ) + } + + @Test + fun `Tag message is the changelog alone when auto-publish is off`() { + assertEquals("Added a thing\n- detail one", releaseInfo(autoPublish = false).tagMessage) + } + + @Test + fun `Tag message appends the trailer as its own last line when auto-publish is on`() { + val lines = releaseInfo(autoPublish = true).tagMessage.lines() + assertEquals(AUTO_PUBLISH_TRAILER, lines.last()) + // The blank line keeps the trailer out of the last changelog paragraph, so + // set-release-body strips it without leaving a dangling line in the body. + assertEquals("", lines[lines.size - 2]) + } + + // set-release-body greps for this exact whole line; a reword here silently stops + // CI from publishing. + @Test + fun `Trailer wire format is stable`() { + assertEquals("Auto-Publish: true", AUTO_PUBLISH_TRAILER) + } + + @Test + fun `Dropping the trailer restores the plain changelog`() { + val stripped = releaseInfo(autoPublish = true).tagMessage + .lines() + .filterNot { it == AUTO_PUBLISH_TRAILER } + .joinToString("\n") + .trimEnd() + assertEquals(releaseInfo(autoPublish = false).tagMessage, stripped) + } + + @Test + fun `Trailer never leaks into a release that did not ask for it`() { + assertTrue(AUTO_PUBLISH_TRAILER !in releaseInfo(autoPublish = false).tagMessage) + } +} diff --git a/docs/HOW-TO-RELEASE.md b/docs/HOW-TO-RELEASE.md index a8d3ef4df..56e6a0fc5 100644 --- a/docs/HOW-TO-RELEASE.md +++ b/docs/HOW-TO-RELEASE.md @@ -3,6 +3,8 @@ - Make sure your local repository is in a clean state, nothing outstanding - Change branch to `develop` - When `develop` is ready to release, run: `./gradlew prepareForRelease` + - Leave **Auto-publish the GitHub release** unchecked to publish by hand (see + [Auto-publishing](#auto-publishing)) - This will prepare your repo by doing the following: - Increment app version `app` in `libs.versions.toml` - Add new changelog in `fastlane\metadata\android\en-US\changelogs` called `n.txt` where `n` is @@ -24,6 +26,19 @@ to [hammer.ink](https://hammer.ink), and notify the **Discord** channel of a new release - All done! +## Auto-publishing + +The release dialog's **Auto-publish the GitHub release** checkbox (off by default) does the +manual publish steps above for you. When it is checked `prepareForRelease` appends an +`Auto-Publish: true` trailer to the tag message; once every build job passes, `set-release-body` +strips the trailer from the release body and marks the release as the latest production release, +firing the same `publish` action. + +This needs a `RELEASE_PAT` repository secret: a fine-grained PAT scoped to this repo with +**Contents: read and write**. GitHub does not start workflow runs from events triggered by the +default `GITHUB_TOKEN`, so publishing with that token would leave every store untouched. The step +fails with a clear error when the secret is missing. + ## Partial (single-store) releases Use this when one store needs a hotfix and you don't want to ship the whole matrix.