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
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: CI

on:
pull_request:
push:
branches: [main]

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@v4

- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"

- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4

- name: Run check and assembleDebug
run: ./gradlew check assembleDebug
60 changes: 60 additions & 0 deletions docs/knowledge/codebase/85.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# #85 — GitHub Actions CI (`check` + `assembleDebug`)

Adds `.github/workflows/ci.yml` — a single-job workflow that runs `./gradlew check assembleDebug` on every PR and on every push to `main`. Closes the loop the deterministic-fabric arc ([[83]] Spotless + ktlint, [[84]] Compose Lint Checks + `lint { abortOnError = true }`) was always pointed at: one Gradle invocation now exercises formatting, Android Lint (Compose-specific + AGP-built-in), unit tests, and a debug-variant `assemble`, deterministically, off the developer agent's machine. Last half of the umbrella ticket #72.

## Summary

- Single new file: `.github/workflows/ci.yml`, 35 lines. No Kotlin, Gradle, or `libs.versions.toml` changes.
- Triggers: `pull_request` (default events; **not** `pull_request_target` — that variant runs in the base-repo context with secrets and is unsafe for fork PRs) and `push.branches: [main]`.
- Concurrency: `group: ci-${{ github.ref }}` with `cancel-in-progress: ${{ github.event_name == 'pull_request' }}` — successive pushes to the same PR cancel the prior run, but merged commits on `main` are never cancelled mid-flight.
- Top-level `permissions: contents: read` (least-privilege; no write scopes needed).
- Single `build` job on `ubuntu-latest` with five steps in order: `actions/checkout@v4` → `gradle/actions/wrapper-validation@v4` → `actions/setup-java@v4` (`temurin` / `java-version: "21"`) → `gradle/actions/setup-gradle@v4` (default caching, no `arguments` input) → `run: ./gradlew check assembleDebug`.
- Wrapper validation is a dedicated step rather than the `setup-gradle` flag — the standalone step produces a clearer failure surface in the run log and is what the GitHub-recommended pattern still shows in 2026-Q2.
- First CI run (commit `bdc7d5d`, GHA run `25843254753`) was green in 4m22s on a fresh cache. AC #5 satisfied.

### JDK 21, not JDK 17

The spec's Open Question defaulted to JDK 17 with "bump only if needed." The developer chose `java-version: "21"` from the start, and the first run was green; no rework loop was needed. AGP 9.2.1 requires JDK 17 *minimum* but the AGP team has been publishing all 9.x release notes against JDK 21 since AGP 9.0, and Gradle 9.4.1 itself ships with embedded support for JDK 21 as a daemon JVM. The `foojay-resolver-convention` at `settings.gradle.kts:16` would have auto-provisioned the project's `JavaVersion.VERSION_11` compile target regardless of host JDK — the host JDK only needs to satisfy Gradle + AGP, and JDK 21 is the version both vendors test against today. Future CI tickets (e.g. a hypothetical Kotlin/JVM target bump) should default to whatever the AGP version currently in `libs.versions.toml` is published against — check the AGP release notes, not the AC literal text.

## Files touched

- `.github/workflows/ci.yml` — new, 35 lines. Ends with a trailing newline (Spotless's `format("misc")` block from [[83]] covers `**/*.yml`; the file is now under the formatter gate it itself runs in CI — see Lessons learned).

## Patterns established

- **CI is one Gradle invocation: `./gradlew check assembleDebug`.** No separate `test`, `lint`, `spotlessCheck`, or `assemble` steps. `check` is the umbrella that runs all three deterministic gates (Spotless 7.0.4, Android Lint with `abortOnError = true`, JVM unit tests); `assembleDebug` produces the APK. Combining them in one invocation is faster than two (shared configuration phase, single daemon warmup) and surfaces a single pass/fail in the GHA UI. Future tickets adding a new gate should wire it into `check` (Gradle's lifecycle, not the YAML) so the CI file never needs editing.
- **`pull_request`, never `pull_request_target`.** The latter runs in the base-repo's secrets context for fork PRs and is the standard CI-supply-chain attack surface. Even when this repo eventually needs secrets (e.g. an upload step, Develocity scan publishing), they go on a separate `workflow_run`-triggered downstream workflow, not on `pull_request_target`. This is a hard repo-wide stance, not a per-ticket choice.
- **`permissions: contents: read` at the workflow top level.** Every job in every workflow this repo ever ships starts least-privilege. If a future job needs `pull-requests: write` (e.g. a sticky-comment bot) or `id-token: write` (OIDC), grant it on the *job*, not the workflow.
- **`gradle/actions/wrapper-validation@v4` as a dedicated step.** Yes, `gradle/actions/setup-gradle@v4` also takes a `validate-wrappers: true` flag — using the standalone action instead keeps the security gate visible as its own line in the run log and decouples wrapper validation (a fast, no-network-trust-required step) from Gradle setup (which downloads distributions and warms caches). When `wrapper-validation` fails, you can tell from the GHA UI without scrolling through Gradle output.
- **`actions/setup-java` does not set `cache: gradle` when `gradle/actions/setup-gradle` is also used.** `setup-gradle@v4` owns the Gradle cache (`~/.gradle/caches`, `~/.gradle/wrapper`, the configuration cache); `setup-java`'s `cache: gradle` is a competing implementation that pre-dates `setup-gradle`. Setting both produces stale-cache surprises. Pick `setup-gradle`, leave `setup-java`'s cache input unset.
- **Action versions pinned to `@v4` major-only.** Not SHA-pinned (Dependabot can bump majors but not arbitrary SHAs without churn); not `@v4.x.y` patch-pinned (loses security patches). Major-pin matches GitHub's own stability contract for first-party + Gradle-team actions. If a v5 lands, that's a separate ticket with its own validation pass.

## Lessons learned

- **The workflow file is under Spotless's `format("misc")` glob from [[83]] (`**/*.yml`).** `./gradlew check` on the developer's machine actually formats `.github/workflows/ci.yml` itself — the file the CI runs. There's no chicken-and-egg: Spotless's `endWithNewline` and `trimTrailingWhitespace` are both inert on a well-formed YAML, and the developer ran `./gradlew spotlessApply` locally before commit. If a future workflow file ships malformed, CI catches it via `check` — the gate is self-applying. Don't try to exclude `.github/**` from `format("misc")` "because it's CI config"; the trivial formatter is exactly what you want on YAML files.
- **`cancel-in-progress` must be conditional on `github.event_name`, not unconditional.** The naive `cancel-in-progress: true` would cancel a still-running `main` push when a *second* `main` push lands (e.g. a fast back-to-back merge), masking a regression that only shows up on the second commit. The expression `${{ github.event_name == 'pull_request' }}` evaluates `true` on PR refs (cancel) and `false` on `main` (let it complete). Same shape applies to any future workflow this repo adds.
- **JDK 21 worked on the first push; the spec's "default to JDK 17 and bump if it fails" was over-cautious.** AGP 9.2.1's published JDK floor *is* 17, but the AGP / Kotlin / Gradle trio is co-developed against JDK 21 today, and choosing 17 would have meant a near-EOL toolchain on the CI host while the local developer machines all run JDK 21+. The cost of being wrong (one rework cycle) was symmetric, so going straight to JDK 21 saved an iteration. Future "should we pick the conservative or the current version" decisions on dependencies that are *runtime-tested-by-the-vendor* should bias toward the current version, not the floor.
- **`actions/setup-java@v4` accepts `java-version` as either a quoted string (`"21"`) or unquoted number (`21`); both parse identically.** The committed file uses the quoted form for consistency with multi-version matrices that would mix `"17"` + `"21"` + `"21-ea"`. No semantic difference today — if a matrix appears later, the quoted style stays.
- **`gradle/actions/setup-gradle@v4`'s default cache strategy is correct for a single-module Android project of this size.** No `cache-read-only`, no custom `cache-encryption-key`, no `gradle-home-cache-includes` / `-excludes` — defaults handle `~/.gradle/caches` (build cache + dependency cache), `~/.gradle/wrapper` (distribution), and the configuration cache. Tuning is a Phase 4+ concern if cache hits drop or sizes balloon; tuning prematurely would be cargo-culting from larger projects' YAML files.
- **The first run on a fresh cache took 4m22s end-to-end (`actions/checkout` + wrapper-validation + JDK 21 install + Gradle 9.4.1 distribution download + cold `check assembleDebug`).** Subsequent PR runs are expected to drop into the ~2-3m range once the cache populates. If a future run drifts above 6m without an obvious cause, check the cache-hit rate in the `setup-gradle` step's summary — a cache miss on the Gradle dependency cache is the usual culprit.
- **No `pull_request_target`, no `workflow_dispatch`, no scheduled `cron`, no matrix, no `connectedAndroidTest`, no artifact upload, no build-scan publishing.** Each of those is a deliberate non-inclusion in this ticket (spec's "What the spec deliberately does NOT include"). Future tickets that want any one of them should land it as its own PR with its own justification — not "while we're touching `ci.yml`." The current file's minimalism is itself a feature: every line is load-bearing.

## Patterns deferred (out of scope, named for future tickets)

- **Branch protection rule** requiring the `build` job to pass before merge. Informal "wait for green CI" is the convention today (per ticket Technical Notes); the protection rule is a follow-up — likely once the project transitions out of Phase 0 and has more than one human contributor.
- **`connectedAndroidTest` on an emulator runner.** Needs a separate workflow (or job) running on a macOS or KVM-enabled Linux runner with the Android SDK + an AVD. Out of scope until instrumented tests become load-bearing (today they're parallel coverage on top of unit tests).
- **APK artifact upload (`actions/upload-artifact@v4`).** Debug APK is throwaway at this stage; once a TestFlight-equivalent distribution channel exists (Firebase App Distribution? Play Store internal track?), upload + signing land then.
- **Develocity / build-scan publishing.** Needs a token + a `--scan` flag. Defer until build-time analysis is actually useful (likely Phase 4+ when the build graph grows past a single module).
- **Caching tuning beyond `setup-gradle@v4` defaults.** Investigate only if measured cache-miss rate or run time becomes a problem.

## Links

- Issue: https://github.com/pyrycode/pyrycode-mobile/issues/85
- Spec: `docs/specs/architecture/85-ci-github-actions-check-assemble.md`
- PR: https://github.com/pyrycode/pyrycode-mobile/pull/108
- First green CI run: GHA `25843254753` (`feature/85`, 4m22s, 2026-05-14)
- Split from: #72 (umbrella deterministic-fabric ticket — now fully delivered: [[83]] Spotless + ktlint, [[84]] Compose Lint Checks + `lint { abortOnError = true }`, this ticket the CI wiring)
- Sibling fabric: [[83]] (`./gradlew spotlessCheck` auto-wired into `check`), [[84]] (Android Lint auto-wired into `check`, `abortOnError = true`)
- Related convention: [[instruction-design#Belt-and-Suspenders Means Different Fabric]] — CI is the deterministic safety net to the stochastic developer-agent `./gradlew check` self-run + code-review verification
- GitHub Actions reference: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
- `gradle/actions` reference: https://github.com/gradle/actions
91 changes: 91 additions & 0 deletions docs/specs/architecture/85-ci-github-actions-check-assemble.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Spec: GitHub Actions CI — `check` + `assembleDebug` (#85)

## Files to read first

- `build.gradle.kts` (full, ~30 lines) — Spotless config; **note the `format("misc")` block targets `**/*.yml`**, so the new workflow file is linted by `./gradlew spotlessCheck`. End the YAML with a newline and avoid trailing whitespace, or run `./gradlew spotlessApply` after writing it.
- `app/build.gradle.kts` (lint + test config) — confirms `lintChecks(libs.compose.lint.checks)` and `lint { abortOnError = true }` (or equivalent) are wired; `./gradlew check` should fail on lint errors. The workflow does not need to re-invoke lint separately.
- `gradle/libs.versions.toml` — `agp = "9.2.1"`, `kotlin = "2.2.10"`, ktlint `1.5.0`, spotless `7.0.4`. Used to confirm JDK requirement (see Open questions).
- `gradle/wrapper/gradle-wrapper.properties` — `gradle-9.4.1-bin.zip`. Wrapper is committed; `./gradlew` is the entry point in CI.
- `settings.gradle.kts:16` — `foojay-resolver-convention` is configured, so Gradle will auto-provision any toolchain JDK the build requests. The host JDK in CI only needs to satisfy Gradle/AGP itself, not the project's `JavaVersion.VERSION_11` compile target.

## Context

PRs and pushes to `main` currently rely on the developer agent's self-run of `./gradlew test && lint && assembleDebug` plus a code-review verification. Both are stochastic. Spotless (#83) and Compose Lint Checks (#84) have landed and are wired into `./gradlew check`, so a single deterministic CI invocation now covers formatting, lint, unit tests, and a debug build.

This ticket adds the workflow file and nothing else. No branch protection rule (deferred — informal "wait for green" is acceptable for now).

## Design

### Single file: `.github/workflows/ci.yml`

**Triggers**

- `pull_request` (default events: opened, synchronize, reopened). **NOT** `pull_request_target` — that variant runs in the base-repo context with secrets and is unsafe for fork PRs.
- `push` to `main`.

**Concurrency**

- `concurrency.group: ci-${{ github.ref }}` with `cancel-in-progress: true` on PR refs, so successive pushes to the same PR cancel the prior run. (Don't cancel on `main` pushes — let merged commits complete.)

**Permissions**

- Top-level `permissions: contents: read` (least-privilege). No write permissions are needed.

**Jobs**

A single job `build` running on `ubuntu-latest` with these steps in order:

1. **Checkout** — `actions/checkout@v4`, default fetch depth (1 is fine; we don't need history for `check assembleDebug`).
2. **Gradle Wrapper validation** — `gradle/actions/wrapper-validation@v4` (the modern replacement for the standalone `gradle/wrapper-validation-action`; also exposed via a flag on `setup-gradle@v4`, but a dedicated step is clearer in the log).
3. **Setup JDK** — `actions/setup-java@v4` with `distribution: temurin`, `java-version: 17`. (See Open questions for the JDK 17 vs 21 decision.) Do not set `cache: gradle` here — `setup-gradle` handles caching.
4. **Setup Gradle** — `gradle/actions/setup-gradle@v4`. No `arguments` input; we invoke Gradle explicitly in the next step so the failure surface is clearer. Default caching is fine (caches `~/.gradle/caches`, `~/.gradle/wrapper`, and the configuration cache).
5. **Run `./gradlew check assembleDebug`** — single `run:` step. `check` runs Spotless + lint + unit tests; `assembleDebug` produces the debug APK. Combining them in one Gradle invocation is faster than two (shared configuration, daemon warmup).

**Action versions:** Use `@v4` for `actions/checkout`, `actions/setup-java`, `gradle/actions/setup-gradle`, and `gradle/actions/wrapper-validation`. Developer should verify these are the current stable major at implementation time via `context7` or each action's GitHub repo README; if any has rolled to v5, bump.

### What the spec deliberately does NOT include

- No matrix (single OS / single JDK). Add later only if we need to test multiple JDKs.
- No `connectedAndroidTest` (requires an emulator runner, separate ticket).
- No artifact upload (APK is throwaway at this stage).
- No build scan publishing.
- No `pull_request_target`, no `workflow_dispatch`, no scheduled runs.
- No branch protection rule changes (explicitly out of scope per ticket Technical Notes).

## Failure modes

- **Wrapper validation fails** — someone tampered with `gradle/wrapper/gradle-wrapper.jar`. CI must fail; this is the security gate.
- **Spotless violation** — `./gradlew check` fails the `spotlessCheck` task. CI shows the diff; developer runs `./gradlew spotlessApply` locally and pushes the fix.
- **Lint error** — `./gradlew check` fails the `lint` task (abortOnError). CI surfaces the lint report path; developer fixes.
- **Test failure** — `./gradlew check` fails the `test` task. CI surfaces the failing test class. (Test reports are in `app/build/reports/tests/` but we don't upload them in this ticket; rerun locally for now.)
- **Gradle cache poisoning** — extremely unlikely with `setup-gradle@v4`'s scoped caches; if it happens, push an empty commit or delete the cache via the Actions UI.
- **AGP 9.2.1 / Gradle 9.4.1 requires JDK 21** — covered in Open questions; the fix is a one-line bump to `java-version: 21`.

## Testing strategy

The workflow IS the test. Validation: the PR introducing `ci.yml` must show a green check from this workflow before merge (AC #5).

Local validation before push:

- `./gradlew spotlessApply check assembleDebug` from the repo root must succeed. If `check` passes locally on JDK 17, CI on JDK 17 will pass too (same Gradle wrapper, same toolchain via foojay).
- `./gradlew spotlessCheck` specifically must pass on the new `ci.yml` (it's covered by the `misc` formatter target).

No unit / instrumented tests to write.

## Open questions

1. **JDK 17 vs 21 for the CI host.** AGP 8.x required JDK 17; AGP 9.x's exact minimum at version 9.2.1 should be confirmed at implementation time. Two ways to check:
- Try JDK 17 in the first push. If `./gradlew` fails with a JDK-version error from AGP, bump to `java-version: 21`.
- Or check the AGP 9.2.1 release notes via context7 / Google's developer docs before the first push.

Default to JDK 17 in the initial workflow file; bump only if needed.

2. **`setup-gradle@v4` vs `@v3`.** The ticket body says `@v3`. As of writing, `@v4` is the current major (released early 2025). Developer should use `@v4` unless context7 / the action's README indicates otherwise; treat the ticket's `@v3` as guidance, not a hard pin.

## Out-of-scope follow-ups (do NOT do in this ticket)

- Branch protection rule requiring CI green before merge.
- `connectedAndroidTest` via an emulator runner.
- Build scan publishing to develocity.
- APK artifact upload.
- Caching tuning beyond `setup-gradle@v4` defaults.
Loading