diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 0000000..b5a0381 --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -0,0 +1,100 @@ +--- +name: release +description: Bump versions on a release branch, build a signed APK, tag, and publish a GitHub release +disable-model-invocation: true +argument-hint: " (e.g. v0.2.0)" +--- + +Release process for Worn. Version: $ARGUMENTS + +Worn is a Kotlin Multiplatform app, so a release bumps **both** platforms in the same commit even +though only the Android APK is published as a release asset. + +`main` is protected — nothing is committed or pushed to it directly. The version bump goes on a +release branch and reaches `main` through a pull request; the tag is then created on the resulting +merge commit (this is how `v0.1.0` was released: branch `chore/version-0.1.0`, PR #21, tag on the +merge commit). + +## Steps + +1. **Validate version argument**: Ensure a version was provided (e.g. `v0.2.0`). It must start with + `v` followed by semver. Abort if missing or malformed. Derive the numeric version by stripping + the `v` prefix (e.g. `v0.2.0` -> `0.2.0`). Abort if the tag already exists (`git tag -l`). + +2. **Pre-flight checks**: + - Ensure the working tree is clean (`git status`). Abort if there are uncommitted changes. + - Ensure you are on `main` and in sync with `origin/main` (`git fetch origin && git status`). + - Run `./gradlew detekt`. Abort if it reports issues. + - Run `./gradlew :shared:allTests`. Abort if tests fail. + +3. **Create the release branch**: `git switch -c chore/version-` off `main`. + +4. **Bump versions (one commit, both platforms)**: + - `composeApp/build.gradle.kts` — set `versionName` to the numeric version and increment + `versionCode` by 1. + - `iosApp/Configuration/Config.xcconfig` — set `MARKETING_VERSION` to the numeric version and + `CURRENT_PROJECT_VERSION` to the **same number as the new Android `versionCode`**, keeping the + two build numbers in lockstep. This xcconfig is the base configuration for both project-level + build configs, so it covers the `iosApp` and `WornShareExtension` targets; the Info.plists + carry no version keys (`GENERATE_INFOPLIST_FILE = YES`). + - `Config.xcconfig` is tracked in git and its `TEAM_ID` is intentionally empty. Change only the + two version lines, then check `git diff` shows nothing else — a locally filled `TEAM_ID` must + never be committed. + - Commit: `chore: bump version to `. + +5. **Open the pull request**: + - `git push -u origin chore/version-`. + - `gh pr create` targeting `main`, title `chore: bump version to `, body filled + in from `.github/pull_request_template.md` (Summary, Changes, Test plan, Checklist). + - Print the PR URL. + +6. **Wait for the PR to merge**: `main` requires the `check` CI job to pass (`enforce_admins` is on, + so there is no bypass), but requires no approving review — the PR can be merged as soon as CI is + green. Watch it with `gh pr checks --watch`, then merge it, or use `gh pr merge --auto` if the + user asks. Do not proceed until `gh pr view --json state` reports `MERGED`. Then + `git switch main && git pull origin main`. + +7. **Build signed APK** (from merged `main`, so the artifact matches the commit being tagged): + - Confirm `local.properties` defines all four signing constants: `KEYSTORE_FILE`, + `KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD`. Check that the **keys are present** only — + never read, print, or echo their values. Abort with a clear message if any is missing, because + the build would otherwise silently produce an unsigned APK. + - Run `./gradlew clean :composeApp:assembleRelease`. + - Verify the APK exists at `composeApp/build/outputs/apk/release/composeApp-release.apk`. + - Verify it is actually signed: `apksigner verify ` (or `jarsigner -verify`). Abort if the + APK is unsigned. + - Copy it to `Worn-.apk` in the repo root for upload (`*.apk` is gitignored). + +8. **Create git tag** on the merge commit now at the tip of `main`: + - `git tag -a -m "Release "`. + - `git push origin ` (tags push fine; only branch pushes to `main` are blocked). + +9. **Generate changelog**: + - Find the previous tag: `git describe --tags --abbrev=0 HEAD~1` (if no previous tag exists, use + all commits). + - List commits since the previous tag: `git log ..HEAD --oneline --no-merges`. + - Write a short changelog as a bullet-point list summarizing the user-facing changes (group + related commits, skip chore/CI-only commits — including this release's own version bump — and + keep each bullet to one sentence in English). + - Show the changelog to the user for approval before proceeding. + +10. **Create GitHub release**: + - Use `gh release create ` with the signed APK (`Worn-.apk`) attached. + - Title: `Worn `. + - Use the approved changelog as the release body (pass via `--notes`). + - Mark as latest release. + +11. **Clean up and summarize**: Delete the temporary `Worn-.apk` and the merged + release branch (`git branch -d chore/version-`), then print the release URL, + the new Android `versionCode`/`versionName`, and the iOS + `MARKETING_VERSION`/`CURRENT_PROJECT_VERSION`. + +## Important + +- Abort immediately if any step fails. +- Never commit or push directly to `main` — it is protected. The version bump always goes through + the release branch and its PR. +- Ask the user for confirmation before pushing the tag and creating the release. +- Never skip detekt, the tests, or the APK signature verification. +- Never print, log, or commit any value from `local.properties`, and never commit a real `TEAM_ID`. + Refer to the signing constants by name only. diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index e3be54a..1217741 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -1,8 +1,19 @@ +import java.util.Properties + plugins { alias(libs.plugins.androidApplication) alias(libs.plugins.composeCompiler) } +// Read through providers.fileContents so the configuration cache tracks local.properties as an +// input. A plain File.inputStream() read at configuration time is untracked, so editing the file +// would leave a stale cached configuration behind. +val localProperties = Properties().apply { + providers.fileContents( + rootProject.layout.projectDirectory.file("local.properties") + ).asText.orNull?.let { load(it.reader()) } +} + android { namespace = "com.github.worn" compileSdk = libs.versions.android.compileSdk.get().toInt() @@ -19,8 +30,21 @@ android { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } + signingConfigs { + // Absent on CI and on machines without a keystore, where release builds stay unsigned. + val keystoreFile = localProperties["KEYSTORE_FILE"] as? String + if (keystoreFile != null) { + create("release") { + storeFile = file(keystoreFile) + storePassword = localProperties["KEYSTORE_PASSWORD"] as String + keyAlias = localProperties["KEY_ALIAS"] as String + keyPassword = localProperties["KEY_PASSWORD"] as String + } + } + } buildTypes { getByName("release") { + signingConfigs.findByName("release")?.let { signingConfig = it } isMinifyEnabled = true isShrinkResources = true proguardFiles(