name: Stable Release # The one button that ships FTStore and FTStore Manager to users. # # Everything that lands on `main` already builds to the rolling dev prereleases # (`dev`, `manager-dev`) — that channel needs no help. This workflow is the # deliberate other half: pick a product and run it. The version is worked out # from the tags — one patch on from the last stable release of that product, or # the xcconfig's own version when there has never been one — so the normal case # is picking a product and pressing the button. Type a version only to jump the # series somewhere it would not go on its own. It stamps the # version, builds on the Mac runner, verifies the binary really reports that # version, publishes a *published* (never draft) release, refreshes the AltStore # manifests that carry the app it just shipped, and leaves `main` sitting on the # new version so the next dev build is ahead of the stable one again. # # Why a dispatch rather than a tag push: FTStore Admin resolves the stable # channel from the releases themselves (`web/src/lib/github/ftstore-app.ts`) and # skips drafts and prereleases outright — a draft release is invisible to every # installed app. Driving the whole thing from one workflow is what keeps the # version inside the IPA, the tag, the manifests and `main` from drifting apart. # # Tag namespaces are load-bearing: the proxy tells the two apps apart by tag # prefix alone (`manager-v*` is the Manager, everything else is the app), so # neither prefix may be reused here or anywhere else. on: workflow_dispatch: inputs: product: description: "What to release" type: choice options: - ftstore - manager - both default: ftstore ftstore_version: description: "FTStore version — leave empty to bump the patch of the last stable release" required: false default: "" manager_version: description: "Manager version — leave empty to bump the patch of the last stable release" required: false default: "" dry_run: description: "Build and verify only — no tag, no release, no commit" type: boolean default: false concurrency: # Never two at once: both products commit the version bump to the same branch. group: stable-release cancel-in-progress: false permissions: contents: write env: # How many stable releases of each product survive a run. Everything older is # deleted once the new one is published — see the `retire` job. # # This number is also how many entries the AltStore manifest's version history # keeps, and the two must stay equal: an entry there carries a download URL # pointing straight at a release asset, so keeping more history than releases # would advertise versions that answer 404. KEEP_RELEASES: 1 jobs: plan: name: Plan # Resolve versions, validate them and reject tags that already exist before # occupying the Mac runner for a build that cannot be released anyway. runs-on: ubuntu-latest outputs: matrix: ${{ steps.plan.outputs.matrix }} steps: - uses: actions/checkout@v5 - name: Resolve products and versions id: plan env: PRODUCT: ${{ inputs.product }} FTSTORE_VERSION: ${{ inputs.ftstore_version }} MANAGER_VERSION: ${{ inputs.manager_version }} run: | set -euo pipefail # A version typed next to a product that is not being released is a # mis-click, not an instruction — ignoring it silently would ship the # version the operator did not mean to ship. case "$PRODUCT" in ftstore) [ -z "$MANAGER_VERSION" ] || { echo "::error::product=ftstore, but manager_version is filled in"; exit 1; } ;; manager) [ -z "$FTSTORE_VERSION" ] || { echo "::error::product=manager, but ftstore_version is filled in"; exit 1; } ;; esac INCLUDE='[]' # Three components, padding what is missing. The xcconfigs carry "1.0" # and "2.8.2"; a release series wants both shaped the same way. normalise() { local IFS=. a b c read -r a b c _ <<<"$1" printf '%s.%s.%s' "${a:-0}" "${b:-0}" "${c:-0}" } bump_patch() { local IFS=. a b c read -r a b c _ <<<"$(normalise "$1")" printf '%s.%s.%s' "$a" "$b" "$((c + 1))" } # Highest version already released under a tag prefix, or empty when # the product has never had a stable release. # # The prefixes keep the two products apart on their own: `v*` cannot # match `manager-v1.0.0`, and neither prefix matches `dev` or # `manager-dev`. The grep then drops anything that is not a plain # numeric version, so a stray tag cannot become the baseline. latest_release() { git ls-remote --tags --refs origin "refs/tags/$1*" 2>/dev/null \ | awk '{print $2}' \ | sed "s|refs/tags/$1||" \ | grep -E '^[0-9]+(\.[0-9]+){0,2}$' \ | sort -V \ | tail -1 } add() { local key="$1" name="$2" dir="$3" ipa="$4" prefix="$5" bundle="$6" requested="$7" local xcconfig="$dir/FTStore.xcconfig" version tag version="$requested" if [ -z "$version" ]; then local latest latest=$(latest_release "$prefix" || true) if [ -n "$latest" ]; then # Normal case: one patch on from whatever shipped last. version=$(bump_patch "$latest") echo "$name: last stable $prefix$latest → $version" else # Never released. Take the version the app already believes it # is — FTSTORE_PROJECT_VERSION is the single source of the # marketing version, the target sets MARKETING_VERSION from it — # and ship exactly that as the first stable release. # # Deliberately NOT a hardcoded 1.0.0. The Manager's xcconfig # says 1.0, so its first release is 1.0.0 and the series runs # 1.0.1, 1.0.2 from there. But FTStore's says 2.8.2, and # starting it at 1.0.0 would publish a stable release *below* # the version every installed copy and the dev channel already # report — an instant fake downgrade for everyone. version=$(normalise "$(grep '^FTSTORE_PROJECT_VERSION' "$xcconfig" | cut -d= -f2 | tr -d '[:space:]')") echo "$name: no stable release yet, starting at $version (from $xcconfig)" fi fi if ! printf '%s' "$version" | grep -Eq '^[0-9]+(\.[0-9]+)*$'; then echo "::error::$name version '$version' is not a plain numeric version (no 'v', no '-dev')" exit 1 fi tag="${prefix}${version}" if git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1; then echo "::error::tag $tag already exists — pick a higher version for $name" exit 1 fi # `prefix` rides along so the retire job can find this product's own # releases without re-deriving the namespace and risking a mismatch. INCLUDE=$(jq -c \ --arg key "$key" --arg name "$name" --arg dir "$dir" --arg ipa "$ipa" \ --arg tag "$tag" --arg bundle "$bundle" --arg version "$version" \ --arg prefix "$prefix" \ '. + [{key:$key,name:$name,dir:$dir,ipa:$ipa,tag:$tag,bundle:$bundle,version:$version,prefix:$prefix}]' \ <<<"$INCLUDE") echo "- **$name** \`$version\` → tag \`$tag\`" >> "$GITHUB_STEP_SUMMARY" } echo "### Stable release plan" >> "$GITHUB_STEP_SUMMARY" case "$PRODUCT" in ftstore|both) add ftstore "FTStore" ios FTStore.ipa v app.ftstore "$FTSTORE_VERSION" ;; esac case "$PRODUCT" in manager|both) add manager "FTStore Manager" manager FTStore-Manager.ipa manager-v app.ftstore.manager "$MANAGER_VERSION" ;; esac if [ "$INCLUDE" = "[]" ]; then echo "::error::nothing selected to release" exit 1 fi echo "matrix={\"include\":$INCLUDE}" >> "$GITHUB_OUTPUT" release: # Without this, GitHub names a matrix job by joining every value in the # include entry: "release (ftstore, FTStore, ios, FTStore.ipa, v2.8.2, # app.ftstore, 2.8.2)". The two that identify it are enough. name: ${{ matrix.name }} ${{ matrix.version }} needs: plan # macOS label required: the bare `self-hosted` label also matches the org's # Linux runners, which have no xcodebuild and fail the build outright. runs-on: [self-hosted, macOS] strategy: fail-fast: false # Serial on purpose: with product=both, each leg commits to the same # branch and the second one has to see the first one's commit. max-parallel: 1 matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} env: DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer VERSION: ${{ matrix.version }} TAG: ${{ matrix.tag }} BRANCH: ${{ github.ref_name }} steps: - name: Checkout uses: actions/checkout@v5 with: ref: ${{ github.ref_name }} fetch-depth: 0 submodules: recursive - name: Cache SwiftPM uses: actions/cache@v5 with: path: ~/Library/Caches/org.swift.swiftpm key: spm-${{ runner.os }}-${{ hashFiles('**/Package.resolved') }} restore-keys: spm-${{ runner.os }}- - name: Stamp marketing version # NOT agvtool: it writes CFBundleShortVersionString into Info.plist, but # the target sets MARKETING_VERSION = $(FTSTORE_PROJECT_VERSION) and # GENERATE_INFOPLIST_FILE = YES, so Xcode regenerates that key from the # build setting and the edit is thrown away. Set the variable the build # actually reads, and fail loudly if it ever stops matching. run: | sed -i '' "s|^FTSTORE_PROJECT_VERSION=.*|FTSTORE_PROJECT_VERSION=${VERSION}|" ${{ matrix.dir }}/FTStore.xcconfig grep -q "^FTSTORE_PROJECT_VERSION=${VERSION}$" ${{ matrix.dir }}/FTStore.xcconfig - name: Stamp build number (best effort) # Records the source commit in CFBundleVersion. Best effort because # agvtool trips over GENERATE_INFOPLIST_FILE=YES ("Cannot find …/YES") # while still doing the write. continue-on-error: true working-directory: ${{ matrix.dir }} run: agvtool new-version -all "${{ github.sha }}" - name: Build ${{ matrix.name }} # `iphoneos`, not the default `all`. # # `all` is iphoneos + maccatalyst, and the Catalyst leg cannot compile: # the app drives Live Activities, and ActivityKit does not exist on Mac # Catalyst. `LiveActivityManager.swift` and `InstallActivityAttributes` # fail with "'ActivityAttributes' is unavailable in Mac Catalyst" — the # iphoneos leg builds first and fine, then the Catalyst leg dies on # roughly forty of those. Guarding all of it behind availability checks # to ship a Catalyst zip nobody consumes is not worth it. # # Nothing was lost by dropping it: the predecessor workflow that ran # bare `make` never executed a single time, so no release has ever # carried a Catalyst zip, and the proxy serves the .ipa alone. # # It also halves the time on the runner, which matters because this is a # self-hosted Mac doing two products in series. # # Both dev workflows already build exactly this target; keep the three # in step, or stable and dev stop being comparable builds. working-directory: ${{ matrix.dir }} run: make iphoneos - name: Verify the built IPA reports ${{ matrix.version }} # The stamping above has silently failed before (MARKETING_VERSION is # generated, not edited). A stable release whose binary disagrees with # its tag hands every installed copy a permanent "update available", so # check the artifact itself rather than trusting the build settings. run: | set -euo pipefail IPA="${{ matrix.dir }}/packages/${{ matrix.ipa }}" # Exact member, not a glob: unzip wildcards cross "/", so a pattern # would also match an embedded extension's Info.plist and -p would # concatenate them. MEMBER=$(unzip -Z1 "$IPA" | grep -m1 -E '^Payload/[^/]+\.app/Info\.plist$') unzip -p "$IPA" "$MEMBER" > "$RUNNER_TEMP/Info.plist" BUILT=$(/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" "$RUNNER_TEMP/Info.plist") echo "IPA reports $BUILT, releasing $VERSION" [ "$BUILT" = "$VERSION" ] || { echo "::error::IPA reports $BUILT but the release is $VERSION"; exit 1; } echo "SIZE=$(stat -f%z "$IPA")" >> "$GITHUB_ENV" echo "DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV" - name: Verify the Live Activity extension shipped and can load # Both products, since the Manager grew an island of its own. They each # embed an appex called FTStoreActivity under their own bundle id, which # is the whole of what this script needs to know. # # Every way the Dynamic Island can be broken is invisible from outside # the build: the IPA installs, the app runs, and the island just never # appears. The script checks the three that have or could happen — # the extension missing, its bundle id drifting out from under the # app's, and the wrong extension point. See its header. run: ios/scripts/verify-live-activity.sh "${{ matrix.dir }}/packages/${{ matrix.ipa }}" - name: Refresh the AltStore manifests # Both manifests are edited by *bundle identifier*, never by index: the # root app-repo.json is the published source, and its one listing is the # Manager rather than the app; manager/ has its own. Whichever file # carries the app being released gets the new version; the others are # left untouched instead of being overwritten with another product's # download URL. env: BUNDLE: ${{ matrix.bundle }} URL: https://github.com/${{ github.repository }}/releases/download/${{ matrix.tag }}/${{ matrix.ipa }} run: | set -euo pipefail for FILE in app-repo.json manager/app-repo.json; do [ -f "$FILE" ] || continue jq -e --arg b "$BUNDLE" 'any(.apps[]?; .bundleIdentifier == $b)' "$FILE" >/dev/null || continue jq --arg b "$BUNDLE" --arg v "$VERSION" --arg d "$DATE" --argjson s "$SIZE" --arg u "$URL" \ --argjson keep "${KEEP_RELEASES}" ' .apps = [ .apps[] | if .bundleIdentifier == $b then .version = $v | .versionDate = $d | .size = $s | .downloadURL = $u # Prepend, dropping any stale entry for this same version (a # re-release), then cut the history to the number of releases # that will still exist after the retire job runs. Every entry # here is a download URL into a release asset, so keeping one # for a release we are about to delete would publish a dead # link into the AltStore source. | .versions = ([{version: $v, date: $d, size: $s, downloadURL: $u}] + [ (.versions // [])[] | select(.version != $v) ])[:$keep] else . end ] ' "$FILE" > "$FILE.tmp" mv "$FILE.tmp" "$FILE" echo "Updated $BUNDLE in $FILE" done - name: Commit the version bump # Committing the bump is what keeps the dev channel ahead of stable: # without it the next `main` build would publish X.Y-dev.N against a # stable X.Y+1 and look like a downgrade. Pushed with GITHUB_TOKEN, so # it deliberately does not re-trigger the dev workflows. if: ${{ !inputs.dry_run }} run: | set -euo pipefail git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add ${{ matrix.dir }}/FTStore.xcconfig app-repo.json manager/app-repo.json if git diff --cached --quiet; then echo "Nothing to commit — ${{ matrix.name }} was already at $VERSION" else git commit -m "chore(release): ${{ matrix.key }} $TAG" # --autostash is load-bearing. The build leaves tracked files dirty: # `agvtool new-version -all` rewrites CURRENT_PROJECT_VERSION in the # xcodeproj, and only the three files staged above are committed. A # plain rebase refuses outright ("cannot pull with rebase: You have # unstaged changes") and takes the release down after the IPA has # already been built and verified. git pull --rebase --autostash origin "$BRANCH" git push origin "HEAD:$BRANCH" fi echo "RELEASE_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV" - name: Publish ${{ matrix.name }} ${{ matrix.tag }} # Published, not draft, and never a prerelease: the proxy's stable # channel filters both out, so a draft release ships to nobody. # make_latest only for the app — "latest" is repo-wide and the README's # /releases/latest/download/FTStore.ipa button rides on it. if: ${{ !inputs.dry_run }} uses: softprops/action-gh-release@v3 with: name: ${{ matrix.name }} ${{ matrix.version }} tag_name: ${{ matrix.tag }} target_commitish: ${{ env.RELEASE_SHA }} body: "Stable release of ${{ matrix.name }} ${{ matrix.version }}. Unsigned — sign it on-device at install." files: ${{ matrix.dir }}/packages/* generate_release_notes: true fail_on_unmatched_files: true draft: false prerelease: false make_latest: ${{ matrix.key == 'ftstore' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Summary run: | if [ "${{ inputs.dry_run }}" = "true" ]; then echo "🧪 Dry run: ${{ matrix.name }} $VERSION built and verified, nothing published." >> "$GITHUB_STEP_SUMMARY" else echo "🚀 [${{ matrix.name }} $VERSION](https://github.com/${{ github.repository }}/releases/tag/$TAG) published." >> "$GITHUB_STEP_SUMMARY" fi retire: name: Retire older ${{ matrix.name }} releases # Only the newest stable release of each product is kept; publishing a new # one deletes the ones it replaces, release and tag together. # # A separate job rather than a step on the Mac runner, for two reasons: it # frees the expensive self-hosted Mac the moment the build is done, and it # gets GNU coreutils — the deletion picks what to keep with `sort -V`, and # the Mac's BSD userland is where that kind of thing quietly behaves # differently. # # `needs: release` is the safety interlock. If any product's build or # publish failed, this job never starts and nothing is deleted. needs: [plan, release] if: ${{ !inputs.dry_run }} runs-on: ubuntu-latest permissions: contents: write strategy: fail-fast: false matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} steps: - name: Delete superseded ${{ matrix.name }} releases env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} PREFIX: ${{ matrix.prefix }} TAG: ${{ matrix.tag }} NAME: ${{ matrix.name }} run: | set -euo pipefail # Strictly this product's own numeric releases, newest first. # # The pattern is anchored at both ends and allows digits and dots # only, which is what keeps every other channel out of reach: `dev` # and `manager-dev` have no numeric tail, `web-v0.1` and `admin-v1.0` # do not start with either prefix, and `v` cannot match # `manager-v1.0.0`. It is the same rule the plan job uses to find the # version baseline, and the two must stay identical — a release this # workflow would not count as a baseline is not one it may delete. TAGS=$( gh release list --repo "$REPO" --limit 200 --json tagName --jq '.[].tagName' \ | grep -E "^${PREFIX}[0-9]+(\.[0-9]+)*$" \ | sed "s|^${PREFIX}||" \ | sort -Vr \ | sed "s|^|${PREFIX}|" \ || true ) if [ -z "$TAGS" ]; then echo "No $PREFIX releases found — nothing to retire." exit 0 fi TOTAL=$(printf '%s\n' "$TAGS" | grep -c . || true) echo "Found $TOTAL $NAME release(s); keeping $KEEP_RELEASES." # The release just published must be the newest one visible. If it is # not, something published after this run started and the list is no # longer this run's to prune — bail rather than delete on a stale view. NEWEST=$(printf '%s\n' "$TAGS" | head -1) if [ "$NEWEST" != "$TAG" ]; then echo "::warning::Newest $PREFIX release is $NEWEST, not the $TAG this run published — skipping cleanup." exit 0 fi if [ "$TOTAL" -le "$KEEP_RELEASES" ]; then echo "Nothing older than $TAG to retire." >> "$GITHUB_STEP_SUMMARY" exit 0 fi echo "### Retired $NAME releases" >> "$GITHUB_STEP_SUMMARY" # `--cleanup-tag` takes the tag with the release. Safe for the version # baseline: the plan job reads the highest remaining tag, and the # newest is exactly what survives. printf '%s\n' "$TAGS" | tail -n +$((KEEP_RELEASES + 1)) | while IFS= read -r OLD; do [ -n "$OLD" ] || continue if gh release delete "$OLD" --repo "$REPO" --yes --cleanup-tag; then echo "Deleted $OLD" echo "- \`$OLD\`" >> "$GITHUB_STEP_SUMMARY" else # A cleanup that cannot finish must not fail a release that already # shipped. Surface it and move on. echo "::warning::Could not delete $OLD" echo "- \`$OLD\` — deletion failed" >> "$GITHUB_STEP_SUMMARY" fi done