Skip to content

Build once, upload per app, and record what actually shipped - #135

Merged
andiwand merged 3 commits into
mainfrom
release-workflow-records-what-it-built
Aug 6, 2026
Merged

Build once, upload per app, and record what actually shipped#135
andiwand merged 3 commits into
mainfrom
release-workflow-records-what-it-built

Conversation

@andiwand

@andiwand andiwand commented Aug 3, 2026

Copy link
Copy Markdown
Member

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

A version tag had to be pushed before the build it named, which is a promise the release cannot keep: a version often takes more than one build to clear review, and the second one gets built from a different commit. Tag 1.37 is the case in point — it points at 6e97420, three commits behind what was actually submitted. 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: Pro and Lite are the same app. The target 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 an app. The flavor choice and the fromJSON matrix expression it fed are gone; inputs are version and dry_run, the same two droid takes.

Three jobs: buildupload (per app) → record. The old matrix was pinned to max-parallel: 1, so one checkout, one Xcode select, one ruby setup, one keychain import and one version resolve all ran twice, on two macOS runners. Now they run once.

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 .ipa already built and signed — build number included, since it is baked in at archive time. The upload jobs need no keychain and no provisioning profile; the archive is already signed.

Pro and Lite share a build number. One above the highest either app has, resolved once and given to both builds. So one (version, build) pair names one commit in both listings, and the build tag loses its per-flavor component — build/<version>/<build>.

Resolving once is required, not just tidier: querying again after Pro's upload would hand Lite a higher number and undo the very thing sharing one is for. App Store Connect only requires the number to increase, not to be contiguous, so whichever app is behind simply skips ahead once.

The version tag is written neither by hand nor by the workflow. record drafts a GitHub release named <version> — bare, no v, matching all 40-odd existing tags — pointing at the built commit. A draft creates no tag; publishing it creates one there:

gh release edit 1.38 --draft=false

--target is the SHA rather than a branch: a branch name resolves when the draft is published, days later. A version that needs a second build to clear review re-points the same draft rather than making another.

Fastfile splits deploy into build_ipa and upload_ipa, with buildPro / uploadPro / resolveBuildNumber lanes for the workflow to call. upload_ipa takes the .ipa from build/ rather than making one. deployPro and deployLite still build and upload in one go, so a hand run off a laptop is unchanged, and ODR_BUILD_NUMBER falls back to asking App Store Connect when unset.

The release body is the CHANGELOG.md section for the version, with GitHub's generated pull request list appended below it. A version with no section is refused in the run's first seconds, before anything is built — new .github/scripts/changelog-section.py, which also strips the Keep a Changelog link definitions at the foot of the file. That makes the "cut the heading at submission" rule load-bearing rather than a convention.

No build tag is checked up front, unlike droid: a second build of one version is normal here, so there is nothing to refuse.

Testing

No end-to-end run is possible — a real dispatch uploads to App Store Connect. actionlint is clean, ruby -c and bundle exec fastlane lanes both pass on the Fastfile (all seven lanes resolve, no collisions with action names), and resolve-version.py and changelog-section.py were run across all their cases including the undated ## [Unreleased] heading, a missing version and the oldest section, where the link definitions would otherwise leak into the body.

A dry_run dispatch exercises the build, the signing, the build-number query and the changelog check — 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, and that artifacts from attempt 1 download in attempt 2.

Two things deliberately left alone, as before: the existing mis-pointed 1.37 tag, and the 1.36/1.37 changelog attribution.

🤖 Generated with Claude Code

andiwand and others added 2 commits August 4, 2026 00:17
A version tag had to be pushed before the build it named, which is a
promise the release cannot keep: a version often takes more than one
build to clear review, and the second one is built from a different
commit. Tag 1.37 is the case in point - it points at 6e97420, three
commits behind what was actually submitted, and the build that shipped
has no name in git at all.

So the tag stops being the trigger and becomes the record. The workflow
is dispatched by hand with the version it should build, and writes
build/<flavor>/<version>/<build> at the commit it built once the upload
goes through. Per flavor, because Pro and Lite count their builds
separately: one commit uploaded to both apps is two builds with two
different numbers. The plain version tag is left to a human, once the
release is actually live.

The build number was only ever known inside the lane, so the Fastfile
hands it back through GITHUB_OUTPUT. Dropping the tag trigger also
matters on its own: the receipt tag would have matched '[0-9]*' and
started another release.

resolve-version.py loses its tag arm, and the version input is now the
only source. Nobody bumps a version in the tree - a commit on main is
not a release, and the 0.0.0 in project.pbxproj says so.

CHANGELOG.md gets the matching rule: the heading is cut at submission
rather than at a tag, on main, in the same pull request that writes the
store copy, and a fix that goes up as a second build of the same version
belongs under the heading already cut rather than back under Unreleased.
That is the case #134 fell into.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HcHeDGMHcFChp6oHiaiEDV
Follows the same change in OpenDocument.droid. The release workflow was a matrix
over a `flavor` input, pinned to max-parallel 1, so one checkout, one Xcode
select, one ruby setup, one keychain import and one version resolve all ran
twice on two macOS runners.

Both apps always go out together now, and nothing chooses one. Pro and Lite are
the same app with ads and tracking switched off, so anything worth rebuilding one
for is worth rebuilding the other for.

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 .ipa
already built and signed - build number included, since it is baked in at archive
time. That is what makes the next part possible.

Pro and Lite now share a build number: one above the highest either app has,
resolved once and given to both builds. So one (version, build) pair names one
commit in both listings, and the build tag loses its per-flavor component. Asking
App Store Connect once rather than once per app is required, not just tidier -
querying again after Pro's upload would hand Lite a higher number.

The Fastfile splits `deploy` into build_ipa and upload_ipa, with buildPro /
uploadPro / resolveBuildNumber lanes for the workflow. deployPro and deployLite
still build and upload in one go, so a hand run off a laptop is unchanged.

The version tag is no longer written by hand. `record` drafts a github release at
the built commit; a draft creates no tag, and publishing it creates one there. A
version that needs a second build to clear review re-points the same draft.

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 apps are uploaded.

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 built instead of what it was meant to build Build once, upload per app, and record what actually shipped Aug 5, 2026
The release design is explained three times over - once in the workflow
header, once in the README, once in each script - and each telling had
grown to the length of an essay. Cut the repetition and the reflow-only
churn, keeping the reasons that are not visible from the code: why the
build number is resolved once, why record re-derives the version, why a
draft rather than a tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016UQrTtiKcM16vduYq1hRgX
@andiwand
andiwand merged commit 9c26ed7 into main Aug 6, 2026
@andiwand
andiwand deleted the release-workflow-records-what-it-built branch August 6, 2026 18:26
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