diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c714b0a..80d37f6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,14 +1,26 @@ name: Release -# Cuts a public release, end to end: bumps the version, builds a signed -# Android release APK, publishes it as a GitHub Release with the -# CHANGELOG.md entry for that version as the release notes, AND deploys -# the same version of the web app to GitHub Pages — all in one run, so -# the Android release and the live site are always on the same version. -# Manual-dispatch only: this pushes a version-bump commit to `main`, -# publishes a public GitHub Release, and redeploys the public site, so it -# never runs on its own. See ci.yml for the workflow that runs on every -# PR/push instead (tests only, no publishing). +# Cuts a public release, end to end: builds a signed Android release APK, +# publishes it as a GitHub Release with the CHANGELOG.md entry for that +# version as the release notes, AND deploys the same version of the web +# app to GitHub Pages — all in one run, so the Android release and the +# live site are always on the same version. Manual-dispatch only. +# +# The release version is READ from package.json's "version" field, never +# written by this workflow — `main` requires pull requests (see repo +# Settings -> Branches), and a workflow that never needs to commit +# anything to `main` never has to deal with that at all, unlike a +# workflow that tries to bump-and-push its own version first (see the +# commit that replaced that approach, and docs/STATUS.md's loose ends, +# for what didn't work and why). Same pattern already proven out in +# github.com/Marcogn/ThePatientGamerHelper's release.yml: bump the +# version yourself in a normal PR (and add the matching CHANGELOG.md +# entry) before triggering this workflow — it refuses to overwrite a +# release/tag that already exists, so re-running it before bumping +# anything is a safe no-op failure, not a duplicate release. +# +# See ci.yml for the workflow that runs on every PR/push instead (tests +# only, no publishing). # # Like android-build.yml, this never uploads Android build output via # actions/upload-artifact — the APK here is instead attached directly to @@ -17,15 +29,6 @@ name: Release on: workflow_dispatch: - inputs: - version: - description: >- - Version to release, e.g. 1.0.0 (no "v" prefix). Leave blank to - release the version already in package.json as-is — this is - what the first release (1.0.0) should do, since nothing needs - bumping yet. - required: false - type: string permissions: contents: write @@ -57,45 +60,25 @@ jobs: - run: npm ci - - name: Determine release version + - name: Read release version and refuse to overwrite an existing one id: version + env: + GH_TOKEN: ${{ github.token }} run: | - INPUT_VERSION="${{ inputs.version }}" - if [ -n "$INPUT_VERSION" ]; then - VERSION="$INPUT_VERSION" - else - VERSION=$(node -p "require('./package.json').version") - fi + VERSION=$(node -p "require('./package.json').version") if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "::error::'$VERSION' is not a plain X.Y.Z semantic version." + echo "::error::package.json's version ('$VERSION') is not a plain X.Y.Z semantic version." exit 1 fi - if git ls-remote --exit-code --tags origin "refs/tags/v$VERSION" >/dev/null 2>&1; then - echo "::error::Tag v$VERSION already exists. Pass a new 'version' input (or bump package.json first) and re-run." + if gh release view "v$VERSION" --repo "${{ github.repository }}" >/dev/null 2>&1; then + echo "::error::Release v$VERSION already exists. Bump \"version\" in package.json (and add a matching CHANGELOG.md entry) via a normal PR, merge it, then re-run this workflow." exit 1 fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - name: Sync version into package.json and Android build.gradle - id: bump - run: | - VERSION="${{ steps.version.outputs.version }}" - IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION" - VERSION_CODE=$((MAJOR * 10000 + MINOR * 100 + PATCH)) - - npm version "$VERSION" --no-git-tag-version --allow-same-version - sed -i "s/versionName \".*\"/versionName \"$VERSION\"/" android/app/build.gradle - sed -i "s/versionCode [0-9]*/versionCode $VERSION_CODE/" android/app/build.gradle - - if git diff --quiet; then - echo "changed=false" >> "$GITHUB_OUTPUT" - else - echo "changed=true" >> "$GITHUB_OUTPUT" - fi - - name: Run web test suite run: npm run test @@ -113,6 +96,18 @@ jobs: - name: Sync Android web bundle into Android project run: npx cap sync android + # Only the build in this checkout needs Android's versionName/ + # versionCode to match package.json's version — never committed + # back, since package.json is the only file anyone needs to bump. + - name: Sync Android version from package.json + run: | + VERSION="${{ steps.version.outputs.version }}" + IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION" + VERSION_CODE=$((MAJOR * 10000 + MINOR * 100 + PATCH)) + + sed -i "s/versionName \".*\"/versionName \"$VERSION\"/" android/app/build.gradle + sed -i "s/versionCode [0-9]*/versionCode $VERSION_CODE/" android/app/build.gradle + - name: Ensure signing secrets are present run: | if [ -z "$ANDROID_KEYSTORE_BASE64" ]; then @@ -165,18 +160,6 @@ jobs: working-directory: android run: rm -f release.keystore keystore.properties - # Only publish anything now that both builds have actually succeeded — - # a failed build should never leave a version bump stranded on main, - # a half-published release, or a stale site deploy. - - name: Commit version bump - if: steps.bump.outputs.changed == 'true' - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add package.json package-lock.json android/app/build.gradle - git commit -m "chore(release): v${{ steps.version.outputs.version }}" - git push origin HEAD:main - - name: Extract changelog section id: changelog run: | diff --git a/CLAUDE.md b/CLAUDE.md index 5a83715..5695b7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -207,11 +207,12 @@ for manual completion. The block is still imported. `applicationId`/`namespace` in `android/app/build.gradle`, which must match it. Changing `appId` post-release changes the app's identity on any device that installed it under the old one. -- `package.json`'s `version` field outside of the release workflow (see +- `package.json`'s `version` field without also adding a matching + `## [X.Y.Z]` entry to `CHANGELOG.md` in the same change (see [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) → "Keeping the web and - Android releases in sync"). It drives both the GitHub Release tag and - the Android `versionName`/`versionCode`; bumping it by hand outside - that workflow will desync the two. + Android releases in sync"). The release workflow reads this field + directly — it's the trigger for the next `Release` run, not just a + cosmetic number. ## Common Pitfalls diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 63b2fce..e7eb4e9 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -72,14 +72,17 @@ correctly. ## Keeping the web and Android releases in sync CoverDex ships from one `package.json` version, read by both build -targets. `.github/workflows/release.yml` (manual-dispatch only — see -[`docs/android/BUILD.md`](android/BUILD.md) → "Cutting a public release") -is the single workflow that publishes anything public: given a version, -it bumps `package.json`/`android/app/build.gradle`, runs the test suite, -builds and publishes the signed Android release as a GitHub Release, and -in the same run redeploys GitHub Pages from the same checkout — so a -new Android release and the GitHub Pages deploy always carry the same -version number, published together, with nothing left to drift between -them. `.github/workflows/ci.yml` is the separate, non-publishing workflow -that just validates every PR and push to `main` (tests + a production -build check). +targets. To cut a release: bump `"version"` in `package.json` and add a +matching `## [X.Y.Z]` entry to `CHANGELOG.md`, merge that through a +normal PR, then trigger `.github/workflows/release.yml` (manual-dispatch +only — see [`docs/android/BUILD.md`](android/BUILD.md) → "Cutting a +public release"). It's the single workflow that publishes anything +public: it *reads* that version (never bumps it itself — see "Why this +workflow never writes to `main`" in the doc above for why), runs the +test suite, builds and publishes the signed Android release as a GitHub +Release, and in the same run redeploys GitHub Pages from the same +checkout — so the Android release and the GitHub Pages deploy always +carry the same version number, published together, with nothing left to +drift between them. `.github/workflows/ci.yml` is the separate, +non-publishing workflow that just validates every PR and push to `main` +(tests + a production build check). diff --git a/docs/STATUS.md b/docs/STATUS.md index aeb5bcf..455efd3 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -44,13 +44,13 @@ short version plus what isn't obvious from a feature list. `version` field, shown in Settings → App (`__APP_VERSION__`, injected at build time by `vite.config.ts`). `.github/workflows/release.yml` (manual-dispatch only) is the single workflow that publishes anything - public: given a version, it bumps that field (and Android's - `versionName`/`versionCode`), builds and publishes a signed release APK - as a GitHub Release with `CHANGELOG.md`'s matching section as release - notes, and in the same run redeploys GitHub Pages — see - [`docs/android/BUILD.md`](android/BUILD.md) → "Cutting a public - release" and [`docs/DEVELOPMENT.md`](DEVELOPMENT.md) → "Keeping the web - and Android releases in sync". + public: it *reads* that field (bumping it is a normal PR, not something + the workflow does itself — see "Loose ends" below for why), builds and + publishes a signed release APK as a GitHub Release with `CHANGELOG.md`'s + matching section as release notes, and in the same run redeploys GitHub + Pages — see [`docs/android/BUILD.md`](android/BUILD.md) → "Cutting a + public release" and [`docs/DEVELOPMENT.md`](DEVELOPMENT.md) → "Keeping + the web and Android releases in sync". - Actions workflows were consolidated down to three this session: `ci.yml` (tests + build check on every PR/push to `main`, no publishing — replaces the old `pr-check.yml`), `release.yml` (the @@ -88,28 +88,47 @@ reasoning before re-implementing it; it hasn't changed. ## Loose ends from this session (2026-08-17) -- **`release.yml` has never actually been run.** It's been reviewed - carefully (version validation, tag-exists check, build-before-publish - ordering, signing required) and the repo's other workflows build fine - locally, but the workflow itself — the version bump commit landing on - `main`, the tag/release creation, the changelog extraction, the asset - upload, and the Pages deploy at the end — has not been exercised end to - end in CI. Trigger it once for the real `1.0.0` release and confirm: - the GitHub Release appears with the APK attached and installable, the - live site at `marcogn.github.io/CoverDex` shows the same version in - Settings → App, and the release notes match `CHANGELOG.md`'s `[1.0.0]` - section. -- **`CHANGELOG.md` needs a new `## [X.Y.Z]` entry before every release - after `1.0.0`.** The workflow falls back to a generic "see CHANGELOG.md - / README.md" note if it can't find a matching heading — better than - failing the release, but not a substitute for real notes. +- **`release.yml` still hasn't completed a successful end-to-end run.** + Three real attempts against `1.0.0` so far, each catching a real + problem: (1) failed on "Decode release keystore" — the + `ANDROID_KEYSTORE_BASE64` secret wasn't valid base64, fixed by + re-setting it via `gh secret set`, and the step now fails with an + actionable error instead of a bare `base64: invalid input`; (2) got + past the Android build but then failed pushing the version bump + straight to `main` (`GH006: Protected branch update failed`, "Require + a pull request before merging"); (3) the fix for that — opening and + auto-merging a small PR — worked but was more machinery than needed. + Comparing against `ThePatientGamerHelper`'s already-working + `release.yml` (same kind of branch protection on its `main`) showed the + actual fix: don't bump the version *in* the release workflow at all. + `release.yml` now only **reads** `package.json`'s version and refuses + to run if that version's release already exists — the maintainer bumps + `package.json` and adds the `CHANGELOG.md` entry via a normal PR first, + same as any other change, so the workflow never needs to write to + `main` and branch protection never comes up. See + `docs/android/BUILD.md` → "Why this workflow never writes to `main`". + **Before the next trigger**, `package.json`'s `"version"` needs to + already say `1.0.0` on `main` (it does, untouched since the project's + start) and `CHANGELOG.md` needs its `[1.0.0]` entry (it does). Trigger + it and confirm: the GitHub Release appears with the APK attached and + installable, the live site at `marcogn.github.io/CoverDex` shows the + same version in Settings → App, and the release notes match + `CHANGELOG.md`'s `[1.0.0]` section. +- **`CHANGELOG.md` needs a new `## [X.Y.Z]` entry, and `package.json`'s + `"version"` needs bumping to match, via a normal PR before every + release after `1.0.0`.** `release.yml` refuses to run at all if the + version it reads has already been released (no silent duplicate/ + overwritten release), and falls back to a generic "see CHANGELOG.md / + README.md" note if it can't find a matching `CHANGELOG.md` heading for + the version it did find — better than failing the release outright, + but not a substitute for real notes. - **GitHub Pages no longer redeploys on every push to `main`** — only - `release.yml` deploys it now (see "What's implemented" above). If a - docs-only or urgent web fix needs to go live without a full Android - release, either trigger `release.yml` anyway (it's a no-op version bump - if `package.json` is unchanged, still rebuilds and redeploys Pages) or - do a one-off manual deploy per `docs/DEVELOPMENT.md` → "Manual - deployment". + `release.yml` deploys it now (see "What's implemented" above), and + since that workflow now refuses to run for an already-released + version, it can no longer double as a "just redeploy Pages" trick + either. For a docs-only or urgent web fix that needs to go live without + a full Android release, use the one-off manual deploy in + `docs/DEVELOPMENT.md` → "Manual deployment" instead. ## Loose ends from the previous session (2026-08-04) diff --git a/docs/android/BUILD.md b/docs/android/BUILD.md index baab5a1..e367046 100644 --- a/docs/android/BUILD.md +++ b/docs/android/BUILD.md @@ -181,37 +181,66 @@ reachable there either. `.github/workflows/release.yml` is the one workflow that publishes anywhere public — everything above (Firebase App Distribution) only ever reaches invited testers. It's manual-only (**Actions → Release → Run -workflow**) and, given a version like `1.0.0` (or a blank input, which -reuses whatever version is already in `package.json` — the right choice -for the first release): - -1. Validates the version and checks a `vX.Y.Z` tag doesn't already exist. -2. Writes that version into `package.json`, `package-lock.json`, and - `android/app/build.gradle`'s `versionName`/`versionCode` (derived - deterministically from the semver: `major*10000 + minor*100 + patch`). +workflow**), takes no inputs, and: + +1. **Reads** the release version straight from `package.json`'s + `"version"` field — it never writes or bumps it. To cut a new + release: bump `"version"` in `package.json` yourself, add a matching + `## [X.Y.Z]` entry to [`CHANGELOG.md`](../../CHANGELOG.md), merge that + through a normal PR like any other change, then trigger this workflow. + This is the same pattern already proven out in + [`ThePatientGamerHelper`](https://github.com/Marcogn/ThePatientGamerHelper)'s + `release.yml`: the workflow that *publishes* a release should never + also be the one *deciding* the version, because publishing needs to + run non-interactively and version bumps are exactly the kind of + change `main`'s branch protection ("Require a pull request before + merging") exists to gate. A workflow that only ever reads already- + merged files never runs into that at all — see "Why this workflow + never writes to `main`" below for what didn't work first. +2. Refuses to run if a GitHub Release tagged `vX.Y.Z` already exists — + re-running it before bumping `package.json` is a safe no-op failure, + not a duplicate or overwritten release. 3. Runs the web test suite, builds the plain web app (for GitHub Pages) - and the Android bundle, and builds a **signed** release APK — signing - is required here (unlike `android-build.yml`'s optional signing for - internal testing), since an unsigned APK can't be installed at all and - this release is public. -4. Only once both builds have actually succeeded, it publishes - everything: commits the version bump to `main` and pushes it, publishes - a GitHub Release tagged `vX.Y.Z` with the matching - [`CHANGELOG.md`](../../CHANGELOG.md) section (`## [X.Y.Z]` up to the - next version heading) as the release notes and the signed APK attached - directly (as a plain file, not zipped, not through - `actions/upload-artifact`), and redeploys GitHub Pages from the same - build — all in the same run, so the Android release and the live site - always carry the same version. See + and the Android bundle, patches `android/app/build.gradle`'s + `versionName`/`versionCode` to match (derived deterministically from + the semver: `major*10000 + minor*100 + patch` — this edit is never + committed, it only affects the build in this checkout), and builds a + **signed** release APK — signing is required here (unlike + `android-build.yml`'s optional signing for internal testing), since an + unsigned APK can't be installed at all and this release is public. +4. Only once that build has actually succeeded, it publishes everything: + a GitHub Release tagged `vX.Y.Z` with the matching `CHANGELOG.md` + section (`## [X.Y.Z]` up to the next version heading) as the release + notes and the signed APK attached directly (as a plain file, not + zipped, not through `actions/upload-artifact`), and redeploys GitHub + Pages from the same build — all in the same run, so the Android + release and the live site always carry the same version. See [`docs/DEVELOPMENT.md`](../DEVELOPMENT.md) → "Keeping the web and Android releases in sync". Requires the same `ANDROID_KEYSTORE_BASE64`/`ANDROID_KEYSTORE_PASSWORD`/ `ANDROID_KEY_ALIAS`/`ANDROID_KEY_PASSWORD` secrets as `android-build.yml` (see above) — no Firebase secrets needed, this workflow never touches -Firebase. Before running it for a version after `1.0.0`, add a new -`## [X.Y.Z]` entry to `CHANGELOG.md` first so the release has real notes -instead of the generic fallback text. +Firebase. + +### Why this workflow never writes to `main` + +The first two real attempts at cutting `1.0.0` both had `release.yml` +bump `package.json`/`android/app/build.gradle` itself and then either +`git push` the commit straight to `main` (rejected outright: `GH006: +Protected branch update failed for refs/heads/main`, since `main` +requires pull requests) or open-and-auto-merge a small PR for it (worked, +but added real complexity — a `pull-requests: write` permission, two +extra repo settings to get right, an `if ! ... then warn` fallback for +when auto-merge isn't configured or a status check is still pending). +Comparing against a sibling project's already-working release pipeline +showed a simpler way out: `release.yml` doesn't need to be able to write +to `main` at all if it never tries to. Bumping the version is now a +completely ordinary, human-reviewed PR like any other change to this +repo — `release.yml` only ever reads what's already there, so it never +touches branch protection in the first place. No bypass list, no PAT, no +special repo settings beyond the signing secrets every release needs +anyway. ## Running the Espresso smoke test locally