Every check and release runs on GitHub Actions, from .github/workflows/. A change reaches main only through a pull request whose checks pass, and only a verified commit on main publishes anything.
Workflow overview
| Workflow | File | Trigger | Description |
|---|---|---|---|
| Build Check | ci.yml |
Manual, push to main, every PR, weekly |
Audit dependencies, lint/format, type-check, Rstest tests, check bundles/web host/docs, validate site links/metadata, compile/lint Android, verify the APK bundle and sounds, build iOS and run its XCUITest smoke tests; calls Build Android after main pushes pass |
| CodeQL | codeql.yml |
Push/PR to main, weekly, manual |
JavaScript/TypeScript, Actions, Python, and Kotlin security analysis; Swift (full Xcode build) weekly and on demand |
| Deploy Web | deploy.yml |
Push to main, manual |
Build the Lynx game and the Astro docs/ site, validate it, and deploy it to GitHub Pages |
| Build Android | build-android.yml |
Called by Build Check on main, manual from main |
Read-only APK build followed by a separate build-prerelease publisher |
| Build iOS | build-ios.yml |
Manual | Unsigned archive |
| Release | release.yml |
v* tags on main, manual from main |
Full release pipeline with all artifacts |
Shared steps live in composite actions: .github/actions/setup-js installs Node.js 24, the Bun version pinned by each package’s packageManager field, restores Bun’s package cache, and runs a frozen install; .github/actions/setup-android installs JDK 21, the Android SDK platform, and Gradle caching. The release APK and unsigned iOS archive are reusable workflows (reusable-android-apk.yml, reusable-ios-archive.yml) shared by the build and release pipelines. Every job declares timeout-minutes, and only publishing jobs receive contents: write.
Build check
Runs on manual dispatch, pushes to main, every pull request (including stacked ones), and weekly. Pull-request runs cancel superseded runs; main runs queue so every verified commit can publish. Validates the codebase compiles and builds:
bun install --frozen-lockfile— install dependenciesbun audit --audit-level=high— fail on high or critical advisories; the weekly run reports every severitybun run lint— Biome lint and formatting checkbun run check— TypeScript type-checking (app, tests, web host, configs)bun run test— Rstest unit and component testsbun run assets:check— the site’s sprite copies matchassets/bun run buildandbun run build:web-host— build Lynx/web bundles and the development host- Upload bundles as artifact (14-day retention)
- Compile and lint the Android debug host in a read-only job without signing secrets, then verify the APK contains the current bundle and every sound
- Build the iOS host and run its UI smoke tests on a simulator (see Build iOS)
- Build the Lynx game for the site (
bun run build:site), install the frozen docs lockfile, audit it, build the site, and check every generated page’s local links, fragments, unique IDs, canonical/OG URL, and spacing around links
Successful builds and static checks do not establish device behavior or accessibility conformance.
Deploy Web
Deploys the Astro site in docs/ to GitHub Pages on pushes to main or manual dispatch from main. The home page plays the real Lynx build, so the workflow installs both packages: it runs bun run build:site at the root (the bundle and the Lynx web runtime into docs/public/play/), then builds the site with Node.js 24, runs npm run check:site, and publishes docs/out/ in a separate job that alone holds the Pages and OIDC permissions. Only the optional public analytics ID is read from secrets. It no longer uses the shared docs workflow from jonathanperis/.github, which builds docs/ alone.
Build Android
The main production workflow. Build Check calls it only after its build, android, and docs jobs pass for a push to main, so an unverified commit never publishes:
- Version computation — from the short SHA (
0.0.0-a1b2c3d) - Build Lynx bundle —
bun run build - Stage bundle — Gradle copies the current
dist/main.lynx.bundleinto generated APK assets - Decode keystore when configured — from
KEYSTORE_BASE64secret - Gradle build and package verification —
./gradlew assembleRelease, signed only when the signing env vars are present; verify that the APK contains the exact current bundle - Artifact handoff — upload the versioned APK from the read-only build job
- Immutable publication — a separate write-enabled job creates
build/<version>at the verified commit, uploads the APK to a draft, then publishes it as a prerelease that is never marked “Latest”
The signing step learns only whether KEYSTORE_BASE64 is configured (secrets cannot be read in step conditions), decodes the keystore when present, and deletes it after the Gradle build.
Android signing secrets
| Secret | Purpose |
|---|---|
KEYSTORE_BASE64 |
Base64-encoded release keystore |
KEYSTORE_PASSWORD |
Keystore password |
KEY_ALIAS |
Key alias name |
KEY_PASSWORD |
Key password |
Android artifact matrix
| Artifact | Trigger/path | Signing/status |
|---|---|---|
| Local debug APK | cd android && ./gradlew assembleDebug after the root bundle build |
Debug-signed by Android tooling |
| CI main-build APK | build-android.yml after Build Check passes on main, or manual dispatch from main |
Release build; signed only when keystore secrets are configured |
| Tagged release APK | release.yml on v* tags |
Attached to the GitHub Release; signed when KEYSTORE_BASE64, KEYSTORE_PASSWORD, KEY_ALIAS, and KEY_PASSWORD are present |
Versioning
| Trigger | Version Name | Version Code | Release Type |
|---|---|---|---|
Push to main |
0.0.0-<sha> |
Epoch-based | Automated build prerelease (build/<version> tag) |
Tag v1.2.3 |
1.2.3 |
Epoch-based | Full release, marked “Latest” |
Tag v1.2.3-rc.1 |
1.2.3-rc.1 |
Epoch-based | Prerelease |
Manual release from main |
0.0.0-<sha> (v0.0.0-<sha> tag) |
Epoch-based | Prerelease |
Build iOS
Build Check’s ios job (macOS) runs on pull requests, the weekly run, and manual runs; pushes to main skip it because every commit there already passed it in its pull request, and macOS runners are scarce. It builds the bundle, installs the locked pods with pod install --deployment, builds the app, and runs the XCUITest smoke tests on the newest available iPhone simulator; the result bundle is uploaded when it fails. build-ios.yml (manual) and the release pipeline produce an unsigned archive through reusable-ios-archive.yml. Paid membership is not needed for either.
The workflows always disable code signing; Apple secrets are not read. Signed device distribution would require additional workflow implementation as well as Apple signing assets.
Release
Triggered by version tags (v*) or manual dispatch from main. The tagged commit must be on main; the pipeline audits, lints, type-checks, and tests before building. This is the sole publisher for versioned releases, avoiding concurrent publishers racing an immutable release. Builds Lynx bundles, the Android APK, and the unsigned iOS archive; a failed build blocks publication. The publishing job uploads all available assets to a draft, then publishes the immutable release. Subsequent corrections require a new release instead of replacing published assets.