4 Commits
Author SHA1 Message Date
logan 90f1239fba ci: make a release a shipment rather than a merge
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m25s
CI / e2e (pull_request) Successful in 6m13s
release.yml fired on every push to main, so the trigger was "a PR was
merged" and nothing else decided. That is a version per unit of *work*
rather than per *shipment*: eight releases in twenty-two hours, v0.0.1
through v0.3.1, for one session -- each fanning out to four publishers on
a runner with capacity 1, so roughly forty packaging jobs shipped three
issues while ordinary PR CI queued behind them. pacman, Homebrew and
Obtainium see every one.

The push trigger is gone and workflow_dispatch, which was already there
and already worked, is the whole mechanism. Nothing else had to change to
batch releases, because semantic-release already reads every commit since
the last tag: five fixes and two feats become one minor release with all
seven in the notes. Release frequency was only ever how often this file
fired.

This is the rule index-artifact.yml states and is the other instance of:
a job that mutates state which cannot be rebuilt in ten minutes is
triggered deliberately, not by a push. A release here is a tag, a Gitea
release, an Arch package, a Homebrew formula, a signed APK and desktop
assets -- and an Android version going backwards costs the user their
library.

`dry_run` is what makes a manual trigger usable: the point of pulling a
lever by hand is being able to look first, so the input runs
semantic-release --dry-run -- the version and the notes, no tag, no
release, no publishers. Anything but the literal string "true" releases
for real, because a typo in a dispatch box must not silently turn a
shipment into a green no-op.

Two alternatives were considered and rejected, both recorded on the
issue. A `beta` integration branch relocates the trigger rather than
removing one: it needs a second protected branch carrying the same
required checks, and it *adds* a full check + e2e run per batch on the
very runner whose queue is the complaint. A schedule batches without
anyone having to remember, but puts the decision back on a timer, which
is the thing being removed.

Closes #115
2026-08-18 22:25:11 -04:00
logan b3a0814f24 docs: describe the release pipeline where the claims used to be wrong
CLAUDE.md said .releaserc.yml was a config nothing ran and that there
were five workflows; both stop being true with this branch. The CI
section now names release.yml as the entry point and records the four
things in it that are load-bearing, including the two silent failure
modes worth pinning against.

packaging/homebrew/README.md and docs/android-release.md say where a
user would actually look that upgrading from 1.x needs a reinstall --
Homebrew offers nothing silently, and Android refuses outright.
2026-08-17 18:43:17 -04:00
logan df2e9ea777 docs: record what the Android work established and disproved
Build & publish Arch package / arch-package (push) Successful in 2m33s
CI / check (push) Successful in 2m33s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Successful in 6m5s
Section A of plan 016 is closed and B1 is decided, so the three tenses
move together: CLAUDE.md for what mediacontrols now is, the skill for
what to run, NOTES.md for what was measured and when.

The entry worth reading is the one that disproves a claim written here
earlier in the same session. Dropping x86_64 was expected to make
make android-install fail with INSTALL_FAILED_NO_MATCHING_ABIS.
Measured, it installs and launches: Google's google_apis x86_64 images
carry arm64 translation (abilist = x86_64,arm64-v8a), so the loader
maps lib/arm64/libwails.so and runs it. It dies before any of our code
with SIGILL, and the disassembly names the reason exactly --
`mrs x0, ID_AA64ISAR0_EL1`, Go's internal/cpu reading the arm64 feature
register at runtime init, which the translator does not implement. So
no Go binary starts under it, and that is not a property of this app.

Which closes the last plausible shortcut. There are now three distinct
ways this app fails on an x86_64 Android -- seccomp on the x86_64
build, an unimplemented system register on the translated arm64 one,
and a real device still unverified -- and none of them is a bug in it.
A phone remains the only verification path.

Plan 016 also carries the B2 scope, now decided rather than
recommended: option 1's data model with option 2's surface. The phone
gets home, library browse, now-playing-as-a-view, the queue, search and
playlists; it does not get autotag, downloads, Explore or the 93-control
Settings page, and each of those has a reason written beside it. One
rule for the work: no view forks, because a phone template that copies
a view's is two templates to fix every bug in.
2026-08-16 22:26:39 -04:00
logan 0c6ca72cf1 ci(android): publish a signed APK on every version tag
Builds the fat APK and puts it in Gitea's *generic* package registry,
which unlike the repository is readable without credentials -- the
reason an Obtainium client can poll a plain URL with no token and no
public mirror of the source. A versioned copy for history, a fixed
`latest` URL to watch.

**Its own workflow, not a job in ci.yml.** That workflow runs on every
branch push and is the one that gates; this takes tens of minutes on a
cold cache and the runner has capacity 1, so hanging it off the gate
would put every push behind an SDK download.

**Keyed on the tag.** The ljos pipeline this is modelled on computes a
version in CI and cuts the release itself, then gates its Android job
on needs.release.outputs.version with an always() whose absence
silently kills the manual path. This repo has no release automation --
tags are pushed by hand and homebrew-formula.yml already keys on v* --
so the tag is the version and none of that machinery, or its failure
modes, is needed.

**No continue-on-error**, which that pipeline does carry: there the
Android job shares a workflow with a server deploy that must never go
red over a phone build. Here it is standalone and can neither delay nor
redden anything, so a release step that fails silently would be
strictly worse than one that fails visibly.

Four gates before anything is published, each checked against a real
APK: a non-empty artifact, both ABIs present, a versionCode equal to
the one derived from the tag, and -- verified by pointing it at a
deliberately debug-signed build, which it refused -- **not signed with
the debug key**. Android refuses to update an app whose signing
certificate changed and the only remedy is an uninstall that takes the
user's library with it, so the job also refuses to *build* without the
keystore secret rather than falling through to Gradle's debug default.

The keystore is opened with `keytool -list` before Gradle runs, because
Gradle only notices a bad password at :app:validateSigningRelease, a
minute of build time in, and reports it as a missing file. And nothing
pipes into `head`: under pipefail it exits after one line, the producer
takes SIGPIPE and the step fails with 141 having already printed a
perfectly good APK.

Two secrets, not four. keytool has produced PKCS12 by default since
JDK 9 regardless of the .jks extension, and PKCS12 cannot hold a key
password distinct from the store password -- given one it says so and
ignores it. So ANDROID_KEY_PASSWORD defaults to the store password and
the alias to a documented default.

The Wails CLI needs no caching hack here: it is a vendored `go tool`
and the runner already bind-mounts GOCACHE for every job, so it is warm
from ci.yml's own bindings-check. A fourth cache volume for
GRADLE_USER_HOME saves ~700MB a run.
2026-08-16 15:31:18 -04:00