Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions .github/workflows/flutter-pages.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: Flutter web on GitHub Pages

# Reusable: serves the newest release at the site root, the newest public beta
# under /beta/. Release runs deploy from their tag: allow the tag rule "*".
# Reusable: serves the newest release at the site root, the newest build of
# any kind under /beta/. Release runs deploy from their tag: allow the tag rule "*".

on:
workflow_call:
Expand All @@ -28,11 +28,10 @@ jobs:
GH_TOKEN: ${{ github.token }}
BASE_PATH: ${{ steps.pages.outputs.base_path }}
run: |
# Releases and public betas (labels beta, rc) with a web build, newest
# first, as "<tag> <prerelease>".
# Releases and pre-releases with a web build, newest first, as
# "<tag> <prerelease>".
gh api --paginate "repos/$GITHUB_REPOSITORY/releases" --jq '.[]
| select((.draft | not) and any(.assets[]; .name | endswith("-web.zip")))
| select((.prerelease | not) or (.tag_name | test("-(beta|rc)[.-]?[1-9][0-9]?$"; "i")))
| "\(.tag_name) \(.prerelease)"' > releases.txt
beta=$(awk 'NR == 1 { print $1 }' releases.txt)
prod=$(awk '$2 == "false" { print $1; exit }' releases.txt)
Expand Down
80 changes: 26 additions & 54 deletions .github/workflows/flutter-release.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: Flutter release

# Reusable: builds a release of a Flutter app and publishes it to the stores
# and Pages; the tag picks the testers (see README.md).
# and Pages; a pre-release goes to open testing, a release to production.

on:
workflow_call:
Expand Down Expand Up @@ -31,12 +31,8 @@ on:
description: API name of the Google Play open testing track; Google names it `beta`, and other names are closed tracks.
type: string
default: beta
play-alpha-track:
description: API name of the Google Play closed testing track that gets the alpha pre-releases.
type: string
default: alpha
publish-track:
description: "Manual runs from a release's tag: the Google Play track to publish to; it also picks the iOS testers."
description: "Manual runs from a release's tag: `production` or the open testing track (play-beta-track)."
type: string
publish-platforms:
description: "Manual runs: `all` (the default), `android` or `ios`."
Expand Down Expand Up @@ -99,7 +95,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read # check the version's other tags
contents: read # check the release's builds and the version's other tags
outputs:
mode: ${{ steps.version.outputs.mode }}
manual: ${{ steps.version.outputs.manual }}
Expand All @@ -124,7 +120,7 @@ jobs:
TRACK: ${{ inputs.publish-track }}
PLATFORMS: ${{ inputs.publish-platforms }}
BETA_TRACK: ${{ inputs.play-beta-track }}
ALPHA_TRACK: ${{ inputs.play-alpha-track }}
PREFIX: ${{ inputs.artifact-prefix }}
GH_TOKEN: ${{ github.token }}
run: |
case "$EVENT/$REF_TYPE" in
Expand All @@ -138,23 +134,17 @@ jobs:
exit 1
;;
esac
# [v]X.Y.Z, or [v]X.Y.Z-<label><N> such as 1.2.0-beta.3 or 1.2.0-rc2.
# [v]X.Y.Z, or [v]X.Y.Z-<label><N> such as 1.2.0-beta.3 or 1.2.0-rc2;
# the label is free text, only N counts.
semver='^v?(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]?)\.(0|[1-9][0-9]?)(-([0-9A-Za-z.-]*[A-Za-z.-])?([1-9][0-9]?))?$'
if [[ ! $TAG =~ $semver ]] || ((BASH_REMATCH[1] > 2099 || BASH_REMATCH[6] > 98)); then
echo "::error::$TAG is not a version tag: X.Y.Z for a release, X.Y.Z-beta.N for a pre-release (optional leading v; X up to 2099, Y, Z up to 99, N from 1 to 98)."
echo "::error::$TAG is not a version tag: X.Y.Z, or X.Y.Z-beta.N for a pre-release (optional leading v; X up to 2099, Y, Z up to 99, N from 1 to 98)."
exit 1
fi
version="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}"
pre=${BASH_REMATCH[4]}
number=${BASH_REMATCH[6]}
# flutter-pages.yml repeats the public labels.
label=${BASH_REMATCH[5]%[.-]}
case "${label,,}" in
beta | rc) testers=beta ;;
alpha) testers=alpha ;;
internal) testers=internal ;;
*) testers= ;;
esac
# A release (99) sorts after its pre-releases:
# 1.2.3-beta.4 builds 1020304, 1.2.3 builds 1020399.
build_number=$((BASH_REMATCH[1] * 1000000 + BASH_REMATCH[2] * 10000 + BASH_REMATCH[3] * 100 + ${BASH_REMATCH[6]:-99}))
Expand All @@ -165,15 +155,9 @@ jobs:
case "$TRACK" in
production) channel=prod ;;
"$BETA_TRACK") channel=beta ;;
"$ALPHA_TRACK") channel=alpha ;;
internal) channel=internal ;;
"")
echo "::error::Pass the publish-track input."
exit 1
;;
*)
channel=beta ios=false
echo "::notice::iOS has no $TRACK track; publish to production, $BETA_TRACK, $ALPHA_TRACK or internal to publish on iOS."
echo "::error::publish-track is production or $BETA_TRACK, not '$TRACK'; publish to other tracks in the store consoles."
exit 1
;;
esac
case "$PLATFORMS" in
Expand All @@ -190,40 +174,29 @@ jobs:
exit 1
fi
else
case "$ACTION/$PRERELEASE/${pre:+pre}" in
published/true/pre)
if [ -z "$testers" ]; then
echo "::error::$TAG has an unknown pre-release label: beta.N or rc.N for a public beta, alpha.N for a private test, internal.N for an internal test. Delete this release and publish it again with such a tag."
exit 1
fi
mode=build channel=$testers
;;
published/false/) mode=build channel=prod ;;
released/*/pre) mode=promote channel=prod ;;
released/*/)
# Published directly as a release: the published run builds it.
# The pre-release checkbox picks the testers, not the tag.
case "$ACTION/$PRERELEASE" in
published/true) mode=build channel=beta play_track=$BETA_TRACK ;;
published/false)
mode=skip channel=prod
echo "::notice::The published run of $TAG builds it. If that run failed because \"pre-release\" was ticked by mistake, delete the release (keep the tag) and publish it again."
;;
published/true/)
echo "::error::A pre-release needs a pre-release tag such as $TAG-beta.1: delete this release (keep the tag) and publish it again, as a release or with a pre-release tag."
exit 1
echo "::notice::GitHub also sends \"released\" for a release; that run publishes $TAG."
;;
published/false/pre)
echo "::error::$TAG is a pre-release tag: delete this release (keep the tag) and publish it again, as a pre-release or as a release tagged ${TAG%%-*}."
exit 1
released/*)
# Sent for a new release and for a pre-release turned into one;
# only the second has a beta's builds to promote.
channel=prod play_track=production
assets=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name')
if grep -qxF -e "$PREFIX-$TAG.aab" -e "$PREFIX-${TAG%%-*}.aab" <<< "$assets"; then
mode=promote
else
mode=build
fi
;;
*)
echo "::error::Unexpected release action $ACTION."
exit 1
;;
esac
case "$mode/$channel" in
build/beta) play_track=$BETA_TRACK ;;
build/alpha) play_track=$ALPHA_TRACK ;;
build/internal) play_track=internal ;;
*) play_track=production ;;
esac
fi
# Pre-releases of a version with the same N share a build number.
if [ "$mode" = build ] && [ -n "$pre" ]; then
Expand All @@ -232,7 +205,7 @@ jobs:
gh api "repos/$GITHUB_REPOSITORY/git/matching-refs/tags/v$version-" --jq "$refs")
for other in $others; do
if [ "$other" != "$TAG" ] && [[ $other =~ $semver ]] && [ "${BASH_REMATCH[6]}" = "$number" ]; then
echo "::error::$other already has build number $build_number: the pre-releases of a version need different numbers (alpha.1, beta.2, beta.3). Delete this release and publish it again with a new tag."
echo "::error::$other already has build number $build_number: the pre-releases of a version need different numbers (beta.1, beta.2, rc.3). Delete this release and publish it again with a new tag."
exit 1
fi
done
Expand Down Expand Up @@ -464,7 +437,7 @@ jobs:
rename:
name: Rename the release
needs: [plan, promote]
if: ${{ needs.plan.outputs.manual != 'true' && needs.promote.result == 'success' }}
if: ${{ needs.plan.outputs.manual != 'true' && needs.plan.outputs.number != '' && needs.promote.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
Expand Down Expand Up @@ -832,7 +805,6 @@ jobs:
if: >-
${{ !cancelled() && inputs.pages && github.event.repository.has_pages
&& needs.plan.outputs.manual != 'true'
&& (needs.plan.outputs.channel == 'prod' || needs.plan.outputs.channel == 'beta')
&& (needs.publish.result == 'success' || needs.promote.result == 'success') }}
permissions:
contents: read # read the releases and their web builds
Expand Down
72 changes: 29 additions & 43 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,17 +88,17 @@ run-name: >-

on:
release:
# published: build a release or pre-release; released: also promote a
# pre-release that was turned into a release.
# published: build a pre-release; released: build a release, or promote a
# pre-release that was turned into one.
types: [published, released]
# Publishes a release's store builds again without building; run it from the
# release's tag.
workflow_dispatch:
inputs:
track:
description: Google Play track (iOS has production, open and closed testing, internal)
description: Store track; beta is open testing
type: choice
options: [beta, production, alpha, internal]
options: [beta, production]
platforms:
type: choice
options: [all, android, ios]
Expand Down Expand Up @@ -150,7 +150,6 @@ workflow only gets what it uses.
| `android-keystore-path` | `android/app/key.jks` | Where the build reads the upload keystore, relative to `app-dir` |
| `bundle-id`, `apple-app-id` | | iOS bundle ID and the app's numeric Apple ID (App Store Connect, App Information); `bundle-id` turns on the iOS build and App Store Connect |
| `play-beta-track` | `beta` | API name of the open testing track; Google names it `beta`, and custom names such as `openBeta` are closed testing tracks |
| `play-alpha-track` | `alpha` | API name of the closed testing track that gets the private tests (alpha pre-releases) |
| `publish-track`, `publish-platforms` | | Manual runs: where to publish the release's builds (below) |
| `play-notes-language` | `en-US` | Language of the Google Play release notes; one of the store listing's languages |
| `pages` | `false` | Deploy the web builds to GitHub Pages once Pages is enabled for the repository |
Expand All @@ -176,37 +175,29 @@ them.

#### How a release flows

- **Version:** the tag is the version: `X.Y.Z` for a release and
`X.Y.Z-<label>.N` for a pre-release, with or without a leading `v`. The
label picks the testers: `beta` or `rc` (`rc.N`, `rc2`) for a public beta,
`alpha` for a private test and `internal` for an internal test; other
labels stop the run before it builds anything. The builds get `X.Y.Z` as their
- **Version:** the tag is the version: `X.Y.Z`, or `X.Y.Z-<label>.N` such as
`1.2.0-beta.3` or `1.2.0-rc2`, with or without a leading `v`. The label is
free text; the tag never picks the testers. The builds get `X.Y.Z` as their
version name and the build number (Android `versionCode`, iOS
`CFBundleVersion`) `X·1000000 + Y·10000 + Z·100 + N`, where a release counts
as `N` = 99: every new version or pre-release gets a higher number, and a
release sorts after its pre-releases. `X` goes up to 2099, `Y` and `Z` up to
99 and `N` from 1 to 98, and the pre-releases of a version need different
numbers across all labels (`alpha.1`, `beta.2`, `rc.3`); the run checks the
numbers across all labels (`beta.1`, `beta.2`, `rc.3`); the run checks the
version's other tags before building. The version in `pubspec.yaml` is only
used by local builds.
- **Beta:** publish a GitHub pre-release with a pre-release tag. The workflow
builds signed per-ABI APKs, an App Bundle and the web app and attaches them
to the release. For a public beta (`beta`, `rc`), Google Play gets the
bundle on the open testing track, TestFlight gets the iOS build in a group
with a public link and sends it to Beta App Review (the link is in the run
summary), and Pages serves it under `/beta/`.
- **Private and internal tests:** an `alpha` pre-release goes to the closed
testing track (`play-alpha-track`) and, after Beta App Review, to the
TestFlight group "Private beta", which has no public link; invite its
testers in App Store Connect. An `internal` pre-release goes to the
`internal` track and TestFlight's internal testers. Pages skips both, though
the GitHub release stays as visible as the repository. Builds are never
copied between tracks, but a Play tester gets the highest build number among
the tracks they joined, production included, and TestFlight's internal
groups with automatic distribution get every build.
- **Production:** publish a release tagged `X.Y.Z`. Google Play gets it on the
production track, App Review gets the iOS build (released once approved),
and Pages serves it at the site root.
- **Beta:** publish a GitHub release with "Set as a pre-release" ticked. The
workflow builds signed per-ABI APKs, an App Bundle and the web app and
attaches them to the release. Google Play gets the bundle on the open
testing track, TestFlight gets the iOS build in a group with a public link
and sends it to Beta App Review (the link is in the run summary), and Pages
serves it under `/beta/`. A Play tester gets the highest build number among
the tracks they joined, production included.
- **Production:** publish a release without ticking "Set as a pre-release".
Google Play gets it on the production track, App Review gets the iOS build
(released once approved), and Pages serves it at the site root.
- **Closed and internal tests** are not automated: upload the builds attached
to a release in Play Console and App Store Connect.
- **Promotion:** untick "Set as a pre-release" on a tested pre-release. The
same Play build moves to production, the same TestFlight build goes to App
Review, and Pages serves it at the root without the BETA badge; nothing is
Expand All @@ -216,12 +207,9 @@ them.
- **Publishing again:** run the release workflow by hand from the release's
tag ("Use workflow from", or
`gh workflow run release.yml --ref v1.2.0-beta.3 -f track=production`). The
builds that the release's run uploaded go to the chosen Google Play track
(`internal`, the closed or open testing track, `production` or another)
without being rebuilt. iOS follows along: App Review for `production`, a
public or private TestFlight beta for the open or closed testing track, the
internal testers for `internal`; other tracks skip iOS.
`platforms` limits it to `android` or `ios`. Pages is not changed.
builds that the release's run uploaded go to `production` (App Review on
iOS) or the open testing track (the public TestFlight beta) without being
rebuilt. `platforms` limits it to `android` or `ios`. Pages is not changed.
- **Screenshots:** with `screenshots-task`, production releases and
promotions run the app's `<task>:android` (Linux, with Noto Color Emoji)
and `<task>:ios` (macOS, with the San Francisco font) mise tasks, which
Expand Down Expand Up @@ -458,11 +446,10 @@ gh secret set ANDROID_KEY_PASSWORD --env release
gh secret set ANDROID_STORE_PASSWORD --env release
```

Public betas go to the open testing track, private tests to the closed
testing track, internal tests to the internal track and releases to
production, all at once, each replacing the release on its track. Open
testing needs its countries or regions chosen once in Play Console (Test and
release → Open testing), closed testing its testers. A staged rollout started
Pre-releases go to the open testing track and releases to production, all
at once, each replacing the release on its track. Open testing needs its
countries or regions chosen once in Play Console (Test and release → Open
testing). A staged rollout started
in Play Console stops the job until it is finished or halted. With managed
publishing on, approved changes still wait for "Publish changes" in Play
Console.
Expand Down Expand Up @@ -495,12 +482,11 @@ count ten times against the Actions minutes of a private repository.
4. Answer the age rating questions in App Information; App Store Connect asks
new ones before it accepts updates.

Public betas go to TestFlight's public testers: the job adds the build to an
Pre-releases go to TestFlight's public testers: the job adds the build to an
external group with a public link (a new "Public beta" group if there is
none), turns on "Automatically notify testers", sends it to Beta App Review
and puts the public link in the run summary; testers get the build once Apple
approves it. Private tests go the same way to the group "Private beta", which
has no public link. Internal tests only get "What to Test".
approves it.
TestFlight test information that is still empty (Beta App Review contact, beta
description, feedback e-mail) is filled from the App Store review details and
description, and the release description becomes "What to Test". A released
Expand Down
Loading