diff --git a/.gitea/workflows/material-design-conformance.yml b/.gitea/workflows/material-design-conformance.yml new file mode 100644 index 00000000..18f2890f --- /dev/null +++ b/.gitea/workflows/material-design-conformance.yml @@ -0,0 +1,62 @@ +# Material Design conformance, as a gate rather than a habit. +# +# docs/material-design-conformance.md drove nine classes of defect to zero across eight +# phases. Every one of them is the kind that comes back one call site at a time -- a +# hardcoded colour on a screen somebody was in a hurry on, a `10.dp` typed rather than +# reached for -- and none of them is visible in a diff unless a reviewer is looking for it. +# The budgets in docs/scripts/m3-audit.sh are what look. +# +# Gitea Actions, because the remote is a Gitea instance. The syntax is GitHub Actions'; a +# runner has to be registered against the repository for either job to run at all. +name: Material Design conformance + +on: + push: + branches: [mantra] + pull_request: + +jobs: + # Grep over the source tree. No gradle, no android SDK, no submodules, and no network: + # this job is why the audit is a shell script rather than a gradle plugin, and it should + # stay runnable on a bare container. + budgets: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: false + + - name: Check the conformance budgets + run: docs/scripts/m3-audit.sh --check + + # The assertions that need a compiler. Much heavier than the job above: the composite + # build reaches four levels of submodule and cross-compiles secp256k1's C sources, so a + # cold run is minutes rather than seconds. Split out so a runner can be pointed at + # `budgets` alone where that is all the capacity there is for. + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # The chain is lightning-kmp-app -> experimental/lightning-kmp -> + # experimental/bitcoin-kmp -> experimental/secp256k1-kmp -> native/secp256k1. + # Without every level, gradle fails during configuration with + # "Project with path ':library' not found", which reads like a build script + # error and is not one. + submodules: recursive + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + # :secp256k1-kmp:jni:android is an android library module, so the SDK has to be + # present even for a jvm-only test run -- the failure otherwise is + # "SDK location not found" during configuration. + - uses: android-actions/setup-android@v3 + + - name: Theme, layout and motion tests + run: ./gradlew :composeApp:jvmTest --no-daemon + + - name: Android compilation + run: ./gradlew :composeApp:compileDebugKotlinAndroid --no-daemon diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 2a045ba7..45e0796e 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -221,6 +221,69 @@ room3 { schemaDirectory("$projectDir/schemas") } +/** + * The Material Design conformance audit, as a build task. + * + * `docs/scripts/m3-audit.sh --check` counts what the phases in + * docs/material-design-conformance.md drove to zero -- hardcoded colours, dp literals in + * spacing positions, bare `.clickable`, title case, untriaged `contentDescription = null` + * -- and exits 1 when one of them has come back. It also holds two floors, for the + * adaptive and navigation work, which regress by being *removed*. + * + * Wired into `check` rather than left as a script somebody remembers to run: the whole + * point of a budget is that it is enforced at the moment the number moves, and a number + * that is only checked when a person thinks to look is a number that drifts. + * + * It reads the source tree with grep and needs no gradle, no android SDK and no + * submodules, so it is also the one part of this build that a bare CI runner can do. + */ +val m3Audit = tasks.register("m3Audit") { + group = "verification" + description = "Checks the Material Design conformance budgets in docs/material-design-conformance.md." + + val script = rootProject.layout.projectDirectory.file("docs/scripts/m3-audit.sh").asFile + val uiSources = rootProject.layout.projectDirectory + .dir("composeApp/src/commonMain/kotlin/press/mantra/compose/ui") + val projectDirectory = rootProject.layout.projectDirectory.asFile + + inputs.file(script).withPropertyName("auditScript") + inputs.dir(uiSources).withPropertyName("uiSources") + // No outputs, so this would run every time. A marker file is what makes it + // up-to-date-able, and it is the only thing the task writes. + val marker = layout.buildDirectory.file("m3-audit/passed.txt") + outputs.file(marker) + + doLast { + // Windows has no bash unless somebody installed one. Skipping loudly beats + // failing a build for a reason that has nothing to do with the change under it; + // the CI runner and every developer machine here are unix. + val bash = listOf("/bin/bash", "/usr/bin/bash").firstOrNull { File(it).canExecute() } + if (bash == null) { + logger.warn("m3Audit: no bash found, skipping. Run docs/scripts/m3-audit.sh --check by hand.") + marker.get().asFile.apply { parentFile.mkdirs() }.writeText("skipped: no bash\n") + return@doLast + } + + val result = providers.exec { + commandLine(bash, script.absolutePath, "--check") + workingDir = projectDirectory + isIgnoreExitValue = true + } + val text = result.standardOutput.asText.get() + val exit = result.result.get().exitValue + logger.lifecycle(text) + if (exit != 0) { + throw GradleException( + "Material Design conformance budgets exceeded. See the report above and " + + "docs/material-design-conformance.md." + ) + } + marker.get().asFile.apply { parentFile.mkdirs() }.writeText(text) + } +} + +tasks.named("check") { dependsOn(m3Audit) } + compose.desktop { application { mainClass = "press.mantra.desktop.MainKt"