diff --git a/.github/check_alpha_release.js b/.github/check_alpha_release.js new file mode 100644 index 000000000000..332305a69137 --- /dev/null +++ b/.github/check_alpha_release.js @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +const fs = require("node:fs"); + +module.exports = async function checkAlphaRelease({ github, context, core }) { + const metadata = JSON.parse( + fs.readFileSync("AnkiDroid/build/outputs/release-metadata.json", "utf8"), + ); + if (metadata.releaseType !== "alpha") { + core.info("Skipping scheduled release: main is not on an alpha version."); + return false; + } + + const releases = await github.paginate(github.rest.repos.listReleases, { + ...context.repo, + per_page: 100, + }); + // Use calendar days to check, so last Monday's release doesn't block this release. + const day = 24 * 60 * 60 * 1000; + const today = Math.floor(Date.now() / day); + const recentRelease = releases.find( + release => !release.draft && today - Math.floor(Date.parse(release.published_at) / day) < 7, + ); + if (recentRelease) { + core.info( + `Skipping scheduled release: ${recentRelease.tag_name} was published fewer than seven UTC calendar days ago.`, + ); + return false; + } + + const latestAlpha = releases + .filter(release => !release.draft && /^v\d+\.\d+\.\d+alpha\d+$/.test(release.tag_name)) + .sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0]; + if (!latestAlpha) { + core.info("Skipping scheduled release: no published alpha to compare against."); + return false; + } + + const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({ + ...context.repo, + basehead: `${latestAlpha.tag_name}...${context.sha}`, + }); + if (comparison.status !== "ahead" || comparison.ahead_by === 0) { + core.info( + `Skipping scheduled release: no new commits descending from ${latestAlpha.tag_name}.`, + ); + return false; + } + + core.info( + `Publishing an alpha: ${comparison.ahead_by} new commits since ${latestAlpha.tag_name}.`, + ); + return true; +}; diff --git a/.github/workflows/README.md b/.github/workflows/README.md index a6051a7cbbdf..b8f7926444bf 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -22,4 +22,14 @@ Alternately, you may run the actions on your fork of `Anki-Android`. ## Other Workflows -These are typically run by maintainers. See the [Maintenance guide](https://github.com/ankidroid/Anki-Android/wiki/Maintenance-guide) \ No newline at end of file +These are typically run by maintainers. See the [Maintenance guide](https://github.com/ankidroid/Anki-Android/wiki/Maintenance-guide) + +### Automatic publish + +An automatic alpha publish is performed every Monday at 04:30 UTC. This publish is skipped if: + +* A publish occurred fewer than 7 UTC calendar days ago. +* The app is not in alpha. +* There were no commits since the last release. + +An alpha published on Monday does not block the following Monday's release, regardless of the time it finished. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b95542719c11..2222728ea519 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -88,7 +88,7 @@ jobs: - name: Prettify AnkiDroid javascript code uses: creyD/prettier_action@8c18391fdc98ed0d884c6345f03975edac71b8f0 # v4.6 with: - prettier_options: --check AnkiDroid/**/*.js + prettier_options: --check AnkiDroid/**/*.js .github/check_alpha_release.js dry: True - name: Install Localization diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2adaf6bd1313..38465add834c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,6 +1,9 @@ name: 🚀 Publish on: + schedule: + # Check every Monday (04:30); skip if any release is fewer than seven UTC calendar days old. + - cron: "30 4 * * 1" workflow_dispatch: inputs: public: @@ -22,8 +25,50 @@ concurrency: cancel-in-progress: false jobs: + check_release: + name: 'Check whether a release is due' + if: github.event_name == 'schedule' && github.repository == 'ankidroid/Anki-Android' && github.ref == 'refs/heads/main' + timeout-minutes: 20 + runs-on: ubuntu-latest + outputs: + publish: ${{ steps.check.outputs.result }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Configure JDK + uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + with: + distribution: "jetbrains" + java-version: "21" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + with: + cache-provider: basic + cache-read-only: true + + - name: Write release metadata + run: ./gradlew :AnkiDroid:writeReleaseMetadata --console=plain + + - id: check + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const checkAlphaRelease = require('./.github/check_alpha_release.js'); + return await checkAlphaRelease({ github, context, core }); + release_app: name: 'Release App' + # wait for 'check_release' (success/skipped) + needs: check_release + # Manual releases proceed when the scheduled check is skipped. + if: > + github.repository == 'ankidroid/Anki-Android' && + !cancelled() && + ( + github.event_name == 'workflow_dispatch' || + (success() && needs.check_release.outputs.publish == 'true') + ) timeout-minutes: 120 runs-on: ubuntu-latest steps: diff --git a/AnkiDroid/build.gradle b/AnkiDroid/build.gradle index ca6cdea8e422..1e5b0ced6e03 100644 --- a/AnkiDroid/build.gradle +++ b/AnkiDroid/build.gradle @@ -1,4 +1,5 @@ import com.ichi2.anki.gradle.VersionCodeTasks +import com.ichi2.anki.gradle.WriteReleaseMetadata plugins { // Gradle plugin portal @@ -377,8 +378,17 @@ android { } } -// register `validateVersionCode` with our (potentially new versionCode). +// register release tasks using the finalized version configuration. androidComponents.finalizeDsl { androidDsl -> + // output versionName etc... to `build/outputs/release-metadata.json` for use in CI + tasks.register('writeReleaseMetadata', WriteReleaseMetadata) { + group = 'release' + description = 'Writes the configured release version and type as JSON.' + versionName.set(androidDsl.defaultConfig.versionName) + versionCode.set(androidDsl.defaultConfig.versionCode) + } + + // register `validateVersionCode` with the finalized version code. def validation = VersionCodeTasks.registerValidationTask(project, androidDsl.defaultConfig.versionCode) // handle all 'publish' calls, even if run directly tasks.matching { it.name.startsWith("publish") }.configureEach { diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 6d1e48f82be4..6f76ceb01b9e 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -25,6 +25,8 @@ dependencies { tasks.test { useJUnitPlatform() + // Rerun the path guard if the app build file is moved. + inputs.files("../AnkiDroid/build.gradle") } configure { diff --git a/buildSrc/src/main/kotlin/com/ichi2/anki/gradle/VersionCode.kt b/buildSrc/src/main/kotlin/com/ichi2/anki/gradle/VersionCode.kt index a3e238bb1ff7..8bbc1bf3b4d1 100644 --- a/buildSrc/src/main/kotlin/com/ichi2/anki/gradle/VersionCode.kt +++ b/buildSrc/src/main/kotlin/com/ichi2/anki/gradle/VersionCode.kt @@ -8,8 +8,10 @@ object VersionCode { // Consume whole comments atomically so dots inside them cannot look like chained calls. private const val WHITESPACE_OR_COMMENTS = """(?>\s|//[^\r\n]*|/\*[\s\S]*?\*/)*""" + // Ignore task property access such as versionCode.set(...). // Include chained calls across whitespace/comments so a numeric prefix cannot pass as a literal. - private val assignment = Regex("""^\h*versionCode\b\h*=?\s*([^\r\n]+(?:\R$WHITESPACE_OR_COMMENTS\.[^\r\n]*)*)""", RegexOption.MULTILINE) + private val assignment = + Regex("""^\h*versionCode\b(?!\h*\.)\h*=?\s*([^\r\n]+(?:\R$WHITESPACE_OR_COMMENTS\.[^\r\n]*)*)""", RegexOption.MULTILINE) private val literal = Regex("""([1-9][0-9]*)\h*;?\h*(?://.*)?""") /** diff --git a/buildSrc/src/main/kotlin/com/ichi2/anki/gradle/WriteReleaseMetadata.kt b/buildSrc/src/main/kotlin/com/ichi2/anki/gradle/WriteReleaseMetadata.kt new file mode 100644 index 000000000000..52b5d10250e1 --- /dev/null +++ b/buildSrc/src/main/kotlin/com/ichi2/anki/gradle/WriteReleaseMetadata.kt @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.gradle + +import groovy.json.JsonOutput +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction + +/** + * Exports the configured release version, before any release-script version bump or variant suffix. + * + * Write JSON to a file because Gradle and plugins can also log to stdout. Consumers should read the + * file directly instead of redirecting Gradle output, which may mix those messages with the JSON. + * + * Example output: + * ```json + * { + * "versionName": "2.26.0alpha0", + * "versionCode": 22600100, + * "releaseType": "alpha" + * } + * ``` + */ +@CacheableTask +abstract class WriteReleaseMetadata : DefaultTask() { + @get:Input + abstract val versionName: Property + + @get:Input + abstract val versionCode: Property + + @get:OutputFile + abstract val outputFile: RegularFileProperty + + init { + outputFile.convention(project.layout.buildDirectory.file("outputs/release-metadata.json")) + } + + @TaskAction + fun writeMetadata() { + val code = versionCode.get() + require(code > 0) { "versionCode must be positive: $code" } + // The hundreds digit encodes the release type in AnkiDroid's AbbCCtDD version code. + val releaseType = + when (code / 100 % 10) { + 0 -> "dev" + 1 -> "alpha" + 2 -> "beta" + 3 -> "public" + else -> error("Unknown release type in versionCode: $code") + } + val metadata = + mapOf( + "versionName" to versionName.get(), + "versionCode" to code, + "releaseType" to releaseType, + ) + val destination = outputFile.get().asFile + destination.parentFile.mkdirs() + destination.writeText(JsonOutput.prettyPrint(JsonOutput.toJson(metadata)) + "\n") + } +} diff --git a/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/ReleaseMetadataPathTest.kt b/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/ReleaseMetadataPathTest.kt new file mode 100644 index 000000000000..474381f7af04 --- /dev/null +++ b/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/ReleaseMetadataPathTest.kt @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.gradle + +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File + +class ReleaseMetadataPathTest { + @Test + fun `app build file stays at the path assumed by the publishing script`() { + assertTrue( + File("../AnkiDroid/build.gradle").isFile, + "Update .github/check_alpha_release.js if the AnkiDroid module is moved.", + ) + } +} diff --git a/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/VersionCodeTest.kt b/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/VersionCodeTest.kt index 920fe07c81d8..45149160e65b 100644 --- a/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/VersionCodeTest.kt +++ b/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/VersionCodeTest.kt @@ -20,6 +20,23 @@ class VersionCodeTest { } } + @Test + fun `ignores task property setters when reading the app version code`() { + val script = + """ + android { + defaultConfig { + versionCode = 22600100 + } + } + tasks.register('writeReleaseMetadata', WriteReleaseMetadata) { + versionCode.set(android.defaultConfig.versionCode) + } + """.trimIndent() + + assertEquals(22600100, VersionCode.read(script)) + } + @Test fun `rejects missing ambiguous and unsupported versionCode declarations`() { for (script in listOf( diff --git a/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/WriteReleaseMetadataTest.kt b/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/WriteReleaseMetadataTest.kt new file mode 100644 index 000000000000..cafbb5330c01 --- /dev/null +++ b/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/WriteReleaseMetadataTest.kt @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.gradle + +import groovy.json.JsonSlurper +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource +import java.io.File + +/** Tests for [WriteReleaseMetadata] */ +class WriteReleaseMetadataTest { + @TempDir + lateinit var projectDir: File + + @ParameterizedTest + @CsvSource( + "2.26.0dev, 22600000, dev", + "2.26.0alpha0, 22600100, alpha", + "2.26.0alpha99, 22600199, alpha", + "2.26.0beta2, 22600202, beta", + "2.26.0, 22600300, public", + ) + fun `writes the configured version and release type as JSON`( + name: String, + code: Int, + releaseType: String, + ) { + assertEquals( + mapOf("versionName" to name, "versionCode" to code, "releaseType" to releaseType), + writeMetadata(name, code), + ) + } + + @Test + fun `rejects unknown release types before writing metadata`() { + assertRejectsVersionCode(22600400, IllegalStateException::class.java) + } + + @ParameterizedTest + @ValueSource(ints = [0, -1]) + fun `rejects nonpositive version codes`(code: Int) { + assertRejectsVersionCode(code, IllegalArgumentException::class.java) + } + + private fun writeMetadata( + name: String, + code: Int, + ): Map<*, *> { + val task = metadataTask(name, code) + task.writeMetadata() + return JsonSlurper().parse(task.outputFile.get().asFile) as Map<*, *> + } + + private fun assertRejectsVersionCode( + code: Int, + exceptionType: Class, + ) { + val task = metadataTask("2.26.0dev", code) + val destination = task.outputFile.get().asFile + + assertThrows(exceptionType) { task.writeMetadata() } + assertFalse(destination.exists()) + } + + private fun metadataTask( + name: String, + code: Int, + ): WriteReleaseMetadata { + val project = ProjectBuilder.builder().withProjectDir(projectDir).build() + val task = project.tasks.register("writeReleaseMetadata", WriteReleaseMetadata::class.java).get() + return task.apply { + versionName.set(name) + versionCode.set(code) + } + } +}