Commit Graph
16 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5 90bb3538c6 feat(release): build a dev channel so testing stops requiring a release
test-go / test (push) Successful in 1m11s
release / Build signed APK (releases and dev) (push) Successful in 5m0s
test-go / integration (push) Successful in 5m55s
release / Build + push container image (push) Successful in 1m52s
release / Verify release artifacts (tag releases only) (push) Skipped
There was no test channel at all. release.yml ran only on main and tags, so
no :dev image existed and no APK was produced outside a release — the only
way to get a build onto a phone was to ship one, which made `main` the
staging area by default.

A push to dev now builds a signed APK, bundles it, and publishes :dev.

Signed with the SAME key as release builds, deliberately. A differently
signed APK cannot install over the stable app, so anyone moving between
channels would have to uninstall and lose their local data. Same key means
both directions work.

:dev is published ALONE, with no per-commit tag. A rolling channel is
rolling by definition; a commit-addressable image for it would be a rollback
target nobody ever pulls, kept forever. Recovery on dev is to fix forward,
and that is a deliberate trade rather than an omission.

The channel is derived from the REF, not the commit, which is why it is
computed in the workflow and not in ci/version.sh. The same commit built on
dev and on main reports the same version NAME and differs only in the
channel field — that separation is the entire point of keeping the three
values apart.

What this repo deliberately does NOT get: a cross-repo dispatch to refresh
the channel when its bundled APK is rebuilt. That mechanism exists elsewhere
in the family because the app and server live in separate repos, and a
channel that can only be refreshed by an unrelated commit is not a channel.
Minstrel is a monorepo — one push builds the APK and the image in the same
run from the same commit, so the channel cannot go stale against its own
artifact. The requirement is met structurally; copying the mechanism would
add a moving part to fix a problem that does not exist here.

Two guards, for the two ways this wiring can fail quietly:

A dev push must never move :latest. That would ship untested code to every
stable operator on their next pull, with the build green and the image
perfectly valid — just the wrong audience. Nothing else in the suite would
notice.

The two bundling paths must stay mutually exclusive. The rebundle step is
now gated to main specifically, not to "not a tag": under the looser
condition a dev push would run BOTH steps, staging its fresh APK and then
overwriting it with the previous release's. The image still builds, the
sidecar still parses, and the channel whose whole job is being current
quietly serves stale art.

Both falsified against the regressions they name before committing.

Scribe task #3819, milestone #390.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 22:47:42 -04:00
bvandeusenandClaude Opus 5 eaf4654c0a test(release): make the version derivation executable, and guard it on dev
test-go / test (push) Successful in 1m10s
test-go / integration (push) Canceled after 5m10s
Steps 1 and 2 of this milestone shipped with no CI coverage at all, and the
reason generalises: release.yml triggers only on main and tags, so nothing
inside it is exercised until a release is already running. That is the worst
place in the repo to be unguarded, because the failure mode is silence — a
version nobody can compare looks exactly like being up to date, and nobody
reports an update they were never offered.

The fix is not a test that reads YAML. The derivation moved into
ci/version.sh, so it can be RUN, and internal/server/release_version_test.go
runs it on every push. release.yml now calls the same script, so the thing
that ships and the thing under test are one artifact rather than two copies
that agree until they don't.

test-go.yml gains 'ci/**' and '.gitea/workflows/release.yml' in its paths.
Without that the guard exists but never fires on the changes it protects,
which is the same nothing it replaces.

What is pinned, and why each one:

  - HHMM is zero-padded. A build at 00:42 must emit "0042"; a stripped
    leading zero shifts the segment two orders of magnitude and reverses
    comparisons against every other build that day. It only bites for a
    tenth of the day, so it will not be found by chance.
  - The name derives from the COMMIT and the code from the BUILD. Asserted
    by holding one clock and moving the other: the name must not move, the
    code must.
  - The code clears 1895, the highest versionCode the retired commit-count
    scheme shipped. Below that Android refuses the upgrade as a downgrade
    and the channel becomes a one-way door.
  - The tag is the name with a `v`, never chosen.
  - release.yml still calls the script, and does not derive a commit count
    again. This pins the WIRING: without it every other assertion keeps
    passing while the shipped path silently drifts out of coverage.

The script rejects unusable clocks rather than emitting something plausible,
and those rejections are tested — a guard that cannot fail is worse than
none, because it reads as coverage.

Falsified before committing rather than after: ran the script against good
and broken inputs and watched all three failure paths fire; verified every
asserted value by executing it rather than by reading it; and checked the
two workflow predicates catch their regressions while staying immune to a
comment that merely names the old formula.

Step 5 of 5 — Scribe task #3812, milestone #390.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 22:11:51 -04:00
bvandeusenandClaude Opus 5 9f3e0b8cd3 feat(version): sidecar and /api/client/version carry name, code and channel
test-go / test (push) Successful in 1m25s
test-go / integration (push) Successful in 5m52s
The client compares names while Android installs by versionCode, and the
wire had no way to close that gap: the sidecar was one positional line and
the endpoint returned a name only. This is the plumbing that makes the
ordering key decidable by the client at all.

The sidecar is now JSON rather than a grown positional string. That shape
was chosen against a specific failure: the obvious growth path was
"<name> <code>", which a first-space split silently mangles the moment a
third field appears — the code stops parsing as an integer and the reader
falls back to name comparison WITHOUT erroring. JSON cannot mistake a new
field for an old one.

code is a POINTER on both sides, and omitempty on the wire. Absent has to
stay distinguishable from zero: a build published before ordering keys were
recorded genuinely has no code, and zero would claim it is infinitely old
rather than unknown.

A malformed sidecar now fails loudly instead of serving a blank version.
If an unreadable file produced an empty name, every client would compare
against nothing, conclude it was current, and go quiet — "I cannot read
this" and "there is nothing newer" would return the same answer, which is
the failure mode nobody reports because nobody is offered anything to
report.

The non-tag :latest path no longer RECONSTRUCTS the bundled APK's version.
android-release now publishes the sidecar as a release asset beside the
APK, and the image build downloads it. The old reconstruction duplicated a
derivation formula across two files, and could only ever recover the name —
the ordering key is build-time minutes and exists nowhere once that build
ends. Releases predating the sidecar report their name with a null code,
which is the honest answer rather than a guessed one.

image-release also drops to a shallow checkout: it needed full history and
tags only to re-derive versions from the tagged commit, and now touches git
for nothing. MINSTREL_VERSION comes from GITHUB_REF.

Two things checked rather than assumed. The Android Json sets
ignoreUnknownKeys, so the added fields cannot break already-installed apps.
It also sets coerceInputValues, which will silently turn a null code into 0
if step 4 declares the field non-nullable — recorded on task #3811, because
reading the field declaration alone would never reveal it.

Also fixes a stale comment block describing "the Flutter client", deleted
in v2026.08.18.

Step 3 of 5 — Scribe task #3810, milestone #390.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 21:51:41 -04:00
bvandeusenandClaude Opus 5 e46c6bcccf docs(release): tags become vYYYY.MM.DD.HHMM, and stop telling people to move them
The tag is now the artifact's own version name with a `v` in front, so
`v2026.09.10.1432` and `2026.09.10.1432` are one string. Nothing has to
reconcile what the tag claims against what the APK reports, and minting one
is arithmetic on the tagged commit's timestamp rather than a lookup.

The substantive change is the prose. release.yml's header instructed the
reader to `git push -f origin vYYYY.MM.DD` on a same-day re-cut. That is
the operation the family rulebook forbids outright, and it has incidents
behind it — moving a same-day tag forward once took a published release
down with it. Anyone who had installed from that tag was holding something
it no longer pointed at.

With HHMM there is nothing left for mutability to buy: every tag is unique
by construction, so a second release the same day is not a collision to
resolve, just another tag.

The old instruction is recorded as retired rather than deleted. Someone who
remembers it should learn it was withdrawn and why, not find it silently
absent and assume they misremembered.

README contradicted itself inside one sentence — "immutable per-day release
tags ... a same-day re-cut moves the tag forward" — and now says which it
is, plus a note that pre-2026-09-10 tags keep the old shape and still work.

Transition wrinkle, deliberately left for step 3: the non-tag :latest path
reconstructs the bundled APK's name from the latest release's commit
timestamp, which for the one existing old-shape release yields
2026.09.09.1828 while that APK actually declares 2026.09.09.1895. It fails
SAFE — 1828 compares lower, so no false update is offered — and it
self-corrects at the first new-scheme release. Step 3 removes the
reconstruction entirely by having the sidecar carry recorded values instead
of derived ones.

Step 2 of 5 — Scribe task #3809, milestone #390.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 21:43:33 -04:00
bvandeusenandClaude Opus 5 bfdaed9365 fix(release): derive versionCode from build time, versionName from commit time
android / Build + lint + test (push) Successful in 4m19s
versionCode was `git rev-list --count HEAD`, and build.gradle.kts called it
"monotonic forever". It is not, and that claim was sitting directly above
the bug it denied.

A commit count runs ahead on `dev`. So a dev build carried a HIGHER code
than the `main` release meant to supersede it, and Android refuses that
install as a downgrade — a channel you can enter and cannot leave without
uninstalling and losing local data.

Two clocks now, and the split is deliberate even though it reads like an
inconsistency:

The NAME answers "is this the same code?", so it derives from COMMIT time
and reads identically on every lane building this source. A dev build and
a main build of one commit must report the same string. Build time cannot
do that — it prints two numbers for one thing.

The ORDERING KEY answers "may this be installed over that?", so it must be
monotonic BY CONSTRUCTION: minutes since 2020-01-01. Commit time fails
here for the mirror-image reason — rebuild an older commit and it goes
DOWN, which on a phone is a refused install rather than a confusing label.

The non-tag :latest path reconstructed the bundled APK's name with the old
formula, so it is moved to the same commit-timestamp derivation. That
duplication is temporary: once the tag becomes `v<version-name>` it
collapses to `${TAG#v}` with nothing left to keep in step.

Verified locally by running the derivations rather than reasoning about
them: HEAD yields 2026.09.09.1828; the key yields 3519456 against ~1895
from the old scheme, inside int32 with ~4000 years of headroom; a commit
at 00:42 UTC yields "0042", not "42". The workflow now asserts the emitted
shape too — a malformed name builds, signs and publishes happily and only
surfaces as an update nobody is offered, which nobody reports.

That local check is the only verification this commit gets. release.yml
triggers on main and tags only, so nothing on `dev` executes the new
derivation; CI here proves the Gradle file still parses and nothing else.

Also confirms the migration constraint recorded in milestone #390: this
commit would name a release 2026.09.09.1828, which is LOWER than the
installed 2026.09.09.1895 under name comparison. The first new-scheme
release must be cut on a later calendar day, or existing installs will
never be offered it.

Step 1 of 5 — Scribe task #3808, milestone #390.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 21:37:30 -04:00
bvandeusen a254cb2273 ci(release): close the verify blind spot, check preconditions before the build
Auditing the gating turned up two problems.

verify-release only checked the APK. Because it runs with `always()`, it
runs even when image-release FAILED — so android succeeding while the image
push died would have reported "verified" on a release with no immutable
:vYYYY.MM.DD image. That is exactly half of what was missing when
v2026.08.07 had to be re-cut, so the guard would have caught the incident we
had and waved through its mirror image. Now checks the image too, via
docker manifest inspect.

"Attach APK to gitea Release" resolves the release by tag and fails if it is
absent — but it is the LAST step, so a bare `git push origin vX` built an
APK for several minutes before discovering it had nowhere to put it. Same
check now runs immediately after version computation: seconds, not minutes.
Releases created through the API create tag and release together and pass it.

The rest of the gating audits clean, and one part is worth not "fixing":
image-release's `if: !failure() && !cancelled()` looks odd next to
`needs: [android-release]` but is correct. On main pushes android-release is
SKIPPED, and a skipped dependency is not success() — so the obvious
`if: success()` would silently stop main from ever publishing :latest.
Steps 4/5 vs 6 are mutually exclusive on the tag context, and every image
step gates on the Dockerfile+go.mod guard.

Validated: YAML parses, and `bash -n` over every run: block in all three
jobs is clean.
2026-08-07 08:27:09 -04:00
bvandeusen e368b82f0a ci(release): fail loudly when a tag release ends up without its APK
v2026.08.07 had to be re-cut, and the tag build's android-release job
never started — no job log was written at all, so all eight steps reported
`failure` with none executed and image-release showed `skipped`.

The run was red, but the release PAGE rendered fine and main's own push
build had already moved :latest, so the code was deployable and nothing
looked obviously wrong. What was actually missing — the attached APK and
the immutable :vYYYY.MM.DD image — is easy to skim past, and I nearly did.

This cannot prevent that. The cause was a runner failing to launch a
container, not anything in this file, and it did not reproduce on an
unchanged re-run. What this does is make the CONSEQUENCE legible: an
incomplete release now fails with a named error instead of eight mystery
step failures, and the message says to re-run the run rather than delete
and re-create the tag.

`if: always()` is load-bearing — the job has to report precisely when the
jobs above did not succeed.

Correcting the record while here: I first blamed this on the workflow's
`cancel-in-progress` concurrency block. That was wrong. Cancellation needs
a NEWER run in the same group, and there was exactly one run on the tag
ref (total_count 632 -> 633 on release creation); the main-push runs sit
in a different group. Plausible mechanism, unchecked precondition.

Validated locally: YAML parses, `bash -n` clean, and the asset-parsing
logic unit-checked against a release with an APK, one with no assets, and
one with a non-APK asset.
2026-08-07 08:23:11 -04:00
bvandeusenandClaude Opus 5 fa0827f668 ci: pin the download mirror to v6, not v5 — match on @actions/artifact
The previous pin matched the two actions by their own version numbers, which
is meaningless: upload-artifact and download-artifact release on unrelated
cadences. upload v5 bundles @actions/artifact ^4.0.0; download v5 bundles
^2.3.2. "v5 and v5" was in fact a mismatched pair.

download v6 is the tag that puts ^4.0.0 on both sides — and ^4.0.0 is the
library major just proven against this instance by the upload side
(thoughtsync run 3094: two artifacts listed, downloaded and extracted
intact). ^2.3.2 has never been exercised here.

Not v7: that major is a runner requirement rather than a feature change. It
moves to runs.using: node24 and upstream requires runner >= 2.327.1 for it,
which act_runner does not claim to satisfy. Everything pinned stays node20.

ci-requirements.md now carries the version/runtime table and the reasoning,
so the next person matches on the library instead of the tag number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:37:43 -04:00
bvandeusenandClaude Opus 5 52d53e0044 ci: swap artifact upload+download to the mirrored actions (issue 2270)
android / Build + lint + test (push) Successful in 4m30s
android.yml and release.yml uploaded via actions/upload-artifact@v3, which
reports success while Gitea stores the result in a format its v4-only
artifact API will never serve back — 72 artifacts on this repo are on disk,
have valid DB rows, and are invisible to every retrieval path. Green jobs
producing nothing retrievable.

release.yml is a producer/consumer pair: android-release uploads
minstrel-apk and image-release downloads it to bundle into the container.
Swapping only the upload would have left download-artifact@v3 reading the
v1/v3 listing and finding nothing, so mirror the download side too —
bvandeusen/download-artifact, pull mirror of forgejo/download-artifact,
pinned at its v5 tag to match the upload pin's major.

Not actions/{upload,download}-artifact@v4: isGhes() throws on the hostname
before opening a connection, so no server-side change reaches it.

Upload steps also set if-no-files-found: error — image-release hard-depends
on minstrel-apk existing, so an empty upload must fail where it happens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:28:10 -04:00
bvandeusenandClaude Opus 4.8 2c5c477a0d ci(release): bundle the latest release APK into non-tag :latest builds
Every main push moves :latest, but main builds don't build an APK — so the
in-app update channel silently vanished from :latest until the next tag.

Now the image-release job, on non-tag builds, pulls the most-recent
release's signed APK from the gitea API and reconstructs its exact
versionName (${TAG#v}.$(git rev-list --count TAG) — the same formula
android-release bakes in) for the version sidecar. No rebuild, just
rebundle; tag builds still bundle their own freshly-built APK. Checkout
gains fetch-depth:0 + fetch-tags so the commit count resolves. Degrades to
an empty client/ (404 update channel) — never a wrong version — if no
release / APK asset / tag count can be resolved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:54:54 -04:00
bvandeusen 9f0af9c24b feat(android): CI-injected versionName with commit-count iteration
android / Build + lint + test (push) Successful in 4m12s
The APK was shipping with a hardcoded versionName="0.1.0-native"
and versionCode=1 — so the About card never reflected the actual
release, and two same-day re-cuts of the per-day mutable tag
v2026.06.02 looked identical to the update-banner comparator.
Operator wants the iteration restored on the APK side (the docker
tag stays plain per the earlier intentional change).

Scheme:
- Per release: versionName = "${tag}.${commit_count}", e.g.
  "2026.06.02.142", where commit_count = `git rev-list --count HEAD`.
  Monotonic across the project lifetime, deterministic, no manual
  counter to maintain.
- versionCode = commit_count. Monotonic, fits in Int forever (we're
  not hitting 2.1B commits).
- Local / debug / dev builds fall back to versionName="dev" /
  versionCode=1 so the About card reads honestly.

build.gradle.kts:
- defaultConfig reads MINSTREL_VERSION_NAME / MINSTREL_VERSION_CODE
  Gradle properties via project.findProperty with the dev fallbacks.

.gitea/workflows/release.yml:
- android-release: checkout with fetch-depth: 0 (the default shallow
  clone would return 1 for `git rev-list --count HEAD`); new
  Compute release version step exports name + code as step outputs;
  assembleRelease passes them via -P; new job-level outputs propagate
  them to the downstream image-release job.
- image-release: Stage bundled APK + version sidecar pulls the
  computed version_name from needs.android-release.outputs and
  writes it into client/minstrel.apk.version, so the server's
  /api/client/version reports the exact string baked into the APK.
  Without this the sidecar would say "v2026.06.02" while the
  installed APK has "2026.06.02.142" — isVersionNewer would call
  the bundled APK older and the update banner would thrash.

The existing isVersionNewer comparator already handles the
4-component shape ("2026.06.02.142" > "2026.06.02.141"), so no
client-side logic changes are needed.
2026-06-02 14:07:57 -04:00
bvandeusen 31ad650329 ci: chain APK build → image build via needs (fix race)
android / Build + lint + test (push) Successful in 4m54s
Previous design: android.yml's release job and release.yml ran in
parallel on every tag push. release.yml polled the gitea release-
download URL for up to 15 min waiting for the APK to appear. In
practice the polling step was completing in 11s — either following a
gitea redirect to a 200 page and writing HTML into the bundled APK
file, or returning empty content silently. Either way the resulting
image shipped without a working APK and the web Settings page's
in-app-update section never rendered.

Fix the race architecturally:

- Move the signed-release-build + Attach-APK steps out of android.yml
  and into a new android-release job in release.yml.
- release.yml's image-release job declares needs: [android-release],
  so on tag pushes the image cannot start building until the APK is
  guaranteed-attached.
- Pass the APK between jobs via actions/upload-artifact@v3 +
  download-artifact@v3 (the v2 backend isn't supported on Gitea).
  This removes the polling loop entirely — the image-release job just
  downloads the artifact, renames to minstrel.apk, writes the
  .version sidecar, and continues to docker buildx.
- For main pushes android-release is skipped via its if: condition.
  image-release uses  so it
  still runs (the skipped predecessor doesn't poison the chain) and
  the download/stage steps gate themselves on the tag context. Main
  images ship without an APK by design, same as before.

android.yml is now testing-only: lint + detekt + unit tests on every
push, debug APK artifact on main. Independent of release CI as
requested.
2026-06-01 18:30:53 -04:00
bvandeusen 90bfcaa388 ci: drop pull_request triggers + add cancel-in-progress concurrency
test-web / test (push) Successful in 37s
android / Build + lint + test (push) Successful in 4m13s
test-go / integration (push) Successful in 13m25s
test-go / test (push) Successful in 35s
android / Build signed release APK (push) Has been skipped
Every commit on dev that's part of an open PR fires each workflow
TWICE: once for the push event and once for the pull_request event
(observable in run #43 + #44 for the same SHA in android.yml, same
shape across test-web.yml and test-go.yml). The two runs check the
exact same SHA — pure waste.

Drop the pull_request trigger from test-web.yml, test-go.yml, and
android.yml. The dev push already exercises every check that a PR
would re-run. This repo has no fork PRs to cover, which is the only
case pull_request would catch that push doesn't.

Add a concurrency group keyed on workflow + ref with
cancel-in-progress: true to every workflow including release.yml.
Rapid re-pushes (or force-moving the per-day tag) now cancel the
older in-flight run instead of stacking.
2026-06-01 14:39:09 -04:00
bvandeusen 8db488b8ab ci: switch tag scheme to per-day mutable vYYYY.MM.DD
test-go / test (pull_request) Successful in 29s
android / Build + lint + test (push) Successful in 4m57s
test-go / integration (pull_request) Successful in 13m14s
test-web / test (pull_request) Successful in 42s
android / Build + lint + test (pull_request) Successful in 5m19s
android / Build signed release APK (push) Has been skipped
android / Build signed release APK (pull_request) Has been skipped
Drops the trailing patch digit from the CalVer model. Same-day
re-releases force-move the tag (and overwrite both the docker image
and the release asset of the same name). :latest is now updated by
every main push AND every tag push, so it always reflects the
newest blessed image without a separate cut step.

Comment-only changes — the workflow glob is already 'v*', so the
existing pipeline accepts the new format with no code changes.
2026-06-01 14:35:02 -04:00
bvandeusen 906eb80ce5 ci: publish :latest from main + native APK as minstrel-<tag>.apk
test-web / test (pull_request) Successful in 32s
android / Build signed release APK (push) Has been skipped
test-go / test (pull_request) Successful in 33s
android / Build + lint + test (push) Successful in 4m43s
android / Build + lint + test (pull_request) Successful in 5m9s
android / Build signed release APK (pull_request) Has been skipped
test-go / integration (pull_request) Successful in 12m14s
Two related cutover changes so the rolling stable channel works end
to end after the Flutter sunset.

release.yml:
- Main pushes now tag both :main and :latest. Main is the protected
  post-PR-merge branch and the team's stable channel; pinning to
  :latest gets the newest main automatically while :vX.Y.Z stays
  available for pinned consumers.

android.yml:
- Asset attach renamed from minstrel-android-<tag>.apk to
  minstrel-<tag>.apk (M8 phase 14.4 cutover). Without this, the
  server image's bundled in-app-update fetcher in release.yml polls
  a filename that no workflow produces, so /api/client/version
  returns 404 and the web UI's MobileAppDownload silently renders
  nothing. With this rename the existing fetcher resolves to the
  native APK with no further plumbing.

No code paths change; both fixes are workflow rewires.
2026-06-01 14:30:07 -04:00
bvandeusen 3cf829752b chore(ci): migrate workflows to gitea + consolidate keystore secret
flutter / analyze-test-build (push) Failing after 2s
test-go / test (push) Successful in 33s
android / Build + lint + test (push) Failing after 41s
test-web / test (push) Successful in 41s
android / Build signed release APK (push) Has been skipped
test-go / integration (push) Successful in 11m16s
- rename .forgejo/workflows/ to .gitea/workflows/ (git mv preserves
  history); Gitea Actions reads either path, but the directory now
  matches the active platform
- collapse ANDROID_STORE_PASSWORD + ANDROID_KEY_PASSWORD into a
  single ANDROID_KEYSTORE_PASSWORD secret (PKCS12 keystores require
  the two passwords to be identical, so the duplication carried no
  information); build.gradle.kts still reads two env vars to stay
  format-agnostic, both now sourced from the same secret in CI
- ignore android/*.keystore, android/*.keystore.*, android/*.jks,
  android/keystore.properties so the regenerated signing material
  never reaches a remote on accident
- update prose references from Forgejo to Gitea in CLAUDE.md and
  the docs; the historical "migrated from Forgejo" note in CLAUDE.md
  is kept intentionally; forgejo/upload-artifact@v3 action refs are
  left untouched (canonical artifact action, resolves cleanly under
  Gitea Actions)

Operator side: ANDROID_KEY_ALIAS, ANDROID_KEYSTORE_PASSWORD, and
ANDROID_KEYSTORE_B64 secrets registered in Gitea before this lands.
2026-05-31 23:05:03 -04:00