Skip to content

Build once, upload per flavor, and record what actually shipped - #568

Open
andiwand wants to merge 2 commits into
mainfrom
tag-what-a-release-uploaded
Open

Build once, upload per flavor, and record what actually shipped#568
andiwand wants to merge 2 commits into
mainfrom
tag-what-a-release-uploaded

Conversation

@andiwand

@andiwand andiwand commented Aug 4, 2026

Copy link
Copy Markdown
Member

Aligned with the same change in OpenDocument.ios#135 — the two apps now describe one release design.

A version tag had to be pushed before the run it named, which is a promise the run can fail to keep. v4.9.0 is the case in point: its tag push run failed, and the upload that shipped came from a dispatched run instead. So the tag stops being the trigger and becomes the record.

That much was the original scope. What changed since is everything downstream of one observation: Lite and Pro are the same app. The flavor switches ads and tracking off and nothing else, so any change worth shipping to one is worth shipping to the other.

What changes

No input chooses a flavor. The uploads choice is gone; inputs are version and dry_run, the same two iOS takes. One run builds both flavors from one checkout, so a version's build tag names a single commit — which is what the v* tag and F-Droid then build from.

Three jobs: buildupload (per flavor) → record. The split is what makes a half uploaded release repairable. If Lite's upload fails alone, "Re-run failed jobs" retries just that job against the bundle already built, signed and verify-signed.sh'd. No rebuild, so the retry cannot come out different from what its other half uploaded. Re-running all jobs is the wrong button and fails fast on the build tag rather than six minutes in at Play.

That also separates the credentials: the keystore never enters an upload job, and the Play service account key never enters the build job.

One build tag, build/<version>, written by record once both flavors are up. Not per flavor — there is only one commit to name. A half uploaded release gets no tag at all, which is the honest answer: nothing yet could be published from it.

The v* tag is written neither by hand nor by the workflow. record drafts a GitHub release at the built commit carrying the Pro APK. A draft creates no tag; publishing it creates one there:

gh release edit v4.14.0 --draft=false

That wait is not cosmetic. F-Droid tracks this repository with UpdateCheckMode: Tags and AutoUpdateMode: Version, so a v* tag is what makes F-Droid build and ship. One written when the bundle merely reached the internal track would push a version to F-Droid users that Google may never release. --target is the SHA rather than a branch for the same family of reason: a branch name resolves when the draft is published, days later.

attach-apk.yml is deleted. Every check it made is now structural — no cross-run artifact fetch, no 90-day expiry check, no "the release must exist already", and no version-tag-vs-build-tag drift check, because publishing the draft is the tag.

The release body is the CHANGELOG.md section for the version, with GitHub's generated pull request list appended below it (--generate-notes appends to --notes-file). A version with no section is refused in the run's first seconds, before anything is built — new .github/scripts/changelog-section.py. That gives the changelog a second consumer, so it cannot silently rot.

resolve-version.py takes --dry-run instead of --uploads, so both repositories now run the same script with the same arguments.

The trade

A half uploaded release leaves no tag, so the pre-flight check cannot catch a fresh re-dispatch of a half-uploaded version — that fails at Play instead. The supported repair is a re-run; past the ~30 days GitHub offers re-runs for, the answer is a new patch version for both flavors. Accepted deliberately: it buys one tag family instead of two in a repository F-Droid scans by tag.

Testing

No end-to-end run is possible — a real dispatch uploads to Play. actionlint is clean (shellcheck included), resolve-version.py and changelog-section.py were run across all their cases including missing and empty sections, and a dry_run dispatch exercises the build, the signing, both pre-flight checks and the archiving — everything except the upload and record jobs, which are skipped by design.

Load-bearing GitHub behaviours worth confirming before the first real release: that "Re-run failed jobs" re-runs a dependent job skipped because its needs failed, that artifacts from attempt 1 download in attempt 2, and that F-Droid's tag scanner ignores the new build/… tags — if not, that is a one-line UpdateCheckMode: Tags ^v[0-9.]+$ change in fdroiddata, not here.

Separately and not addressed here: F-Droid's metadata currently reads CurrentVersion: '4.3' / CurrentVersionCode: 199 against an app at 4.13 / 41300, so it looks badly behind.

🤖 Generated with Claude Code

andiwand and others added 2 commits August 4, 2026 08:12
A tag had to be pushed before the run it named, which is a promise the
run can fail to keep. v4.9.0 is the case in point: its tag push run
failed and the upload came from a dispatched run instead. The same
commit that time, but nothing made it so - a dispatch off main builds
whatever main is at, and a release uploaded from the
fix-play-store-upload branch has no tag naming it anywhere.

So the tag stops being the trigger and becomes the record. release.yml
is dispatch-only, and writes build/<flavor>/<version> at the commit it
uploaded, as each lane lands rather than once at the end: the half
uploaded release is the case the record exists for, and a lite failure
must not take pro's tag down with it. Per flavor for the same reason -
the two halves get finished from different commits.

The plain v* tag stays a human decision, taken once the release is
actually live, and no longer builds anything. What it does run is
attach-apk.yml, which is the old attach job moved out: it fetches the
pro apk from the run that uploaded it and puts it on the github
release. Attaching had to move anyway, since it keyed off a trigger tag
that no longer exists, and it is better placed here - it now attaches
the apk that went to play rather than one built alongside it, and it
refuses a version tag that does not sit on the commit
build/pro/<version> names.

resolve-version.py loses its tag arm; the input is the only source.
CHANGELOG.md gets the matching rule: the heading is cut when the
version is dispatched, not when it is tagged, because a version code
can only be uploaded once and no later commit can ever ship under it.

Untested end to end, since a real run uploads to the play store. Both
workflows parse, the run blocks are shellcheck clean, the tag round
trip and the flavor loop were exercised in a scratch repo, and
resolve-version.py was run across all its cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RsdTta67FZ4KeNfPPw5BVL
Follows the same change in OpenDocument.ios. The release workflow was one job
that built both flavors and uploaded them in a loop, with an `uploads` input to
pick a flavor when one half failed. That input is gone, and so is the loop.

Both flavors always go out together now. Lite and pro are the same app with ads
and tracking switched off, so anything worth rebuilding one for is worth
rebuilding the other for - and once one run builds both from one checkout, a
version's build tag names a single commit. That is what the v* tag and f-droid
build from, so it is worth more than the flexibility it costs.

Build, upload and record are three jobs. A half uploaded release is repaired
with "Re-run failed jobs", which retries only the failed upload against the
bundle already built, signed and verified - no rebuild, so the retry cannot come
out different from what its other half uploaded.

The v* tag is no longer written by hand. `record` drafts a github release at the
built commit carrying the pro apk; a draft creates no tag, and publishing it
creates one there. That wait matters: f-droid tracks this repository with
UpdateCheckMode: Tags, so a v* tag is what makes it build and ship, and one
written when the bundle merely reached the internal track would push a version
to f-droid users that google may never release.

attach-apk.yml is deleted - every check it made is now structural. No cross-run
artifact fetch, no 90 day expiry, no version-tag-vs-build-tag drift check.

The release body is the version's CHANGELOG.md section with the generated pull
request list below it, and a version with no section is refused before anything
is built rather than after both flavors are on the store.

resolve-version.py takes --dry-run instead of --uploads, so both repositories
now run the same script with the same arguments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nhfz8qVwtcDrJ6wdkrrcJr
@andiwand andiwand changed the title Tag what a release uploaded instead of what it was meant to upload Build once, upload per flavor, and record what actually shipped Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant