FTStore Manager puts an app on a device through the same three stages FTStore does, and had nothing to show for any of it once the screen was off. It has a Live Activity now: the same widget the app carries, embedded as its own extension under its own identifier, naming the app, filling a bar while the bytes arrive, adding the rate and the time left, then Preparing and Installing. It is the app's island with the pause button taken out. This download manager has no pause and no screen to offer one from, so there are no intents to compile into the extension and no paused state in the payload. The island is read-only on every version rather than only on the older ones. It ends when the installer answers, which is the part the app had to learn the hard way. Open launches what was just installed and leaves the sheet standing behind it, so ending on dismissal left the island describing an install that had finished, until somebody came back and closed a receipt they were done with. Dismissal still ends it, which is what covers a flow that never gets an answer at all. A dropped connection is now one of those answers. It used to remove the download and tell nobody, leaving the flow spinning with no way to find out; that was merely invisible before and would now be an island counting bytes that stopped arriving. None of this is visible from a build: an IPA whose extension is missing, misnamed or misfiled installs, runs, and simply shows no island. Both of the Manager's release workflows therefore run the same check the app's do against the built IPA, and the ad-hoc signing step seals the nested extension. Two products now build an extension of the same name out of projects that share their identifiers, so the separate derived data paths carry more weight than before, and the notes that explain them say so.
471 lines
23 KiB
YAML
471 lines
23 KiB
YAML
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
|