Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .github/check_alpha_release.js
Original file line number Diff line number Diff line change
@@ -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;
};
12 changes: 11 additions & 1 deletion .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
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.
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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:
Expand Down
12 changes: 11 additions & 1 deletion AnkiDroid/build.gradle
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import com.ichi2.anki.gradle.VersionCodeTasks
import com.ichi2.anki.gradle.WriteReleaseMetadata

plugins {
// Gradle plugin portal
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions buildSrc/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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<KtlintExtension> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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*(?://.*)?""")

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>

@get:Input
abstract val versionCode: Property<Int>

@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")
}
}
Original file line number Diff line number Diff line change
@@ -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.",
)
}
}
17 changes: 17 additions & 0 deletions buildSrc/src/test/kotlin/com/ichi2/anki/gradle/VersionCodeTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<out RuntimeException>,
) {
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)
}
}
}
Loading