Add an auto-publish option to the release dialog
Some checks failed
Build CI / build (push) Has been cancelled
Build CI / static-analysis (push) Has been cancelled
Build CI / android-instrumented-tests (push) Has been cancelled
Build CI / iOS compile & test (push) Has been cancelled
Build CI / iOS UI tests (push) Has been cancelled
PublishInternal / publish-google-play (push) Has been cancelled

The dialog gets an "Auto-publish the GitHub release" checkbox, off by
default. When checked, prepareForRelease appends an `Auto-Publish: true`
trailer to the tag message; set-release-body strips it from the release
body and, once every build job passes, marks the release as the latest
production release, which starts the store publish workflow.

The tag name could not carry the flag: a `+` suffix there reads as a
partial release and would gate off every store. Publishing uses
RELEASE_PAT rather than GITHUB_TOKEN, because GitHub does not start
workflow runs from events triggered by the default token.

The dialog grew a section, so the frame is taller to keep the changelog
editor its original height.
This commit is contained in:
Wavesonics 2026-09-02 00:02:42 -07:00
parent 9fd796c342
commit 405c3b0ae6
No known key found for this signature in database
5 changed files with 134 additions and 2 deletions

View file

@ -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

View file

@ -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

View file

@ -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<Platform>,
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)

View file

@ -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)
}
}

View file

@ -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.