From bfd96fd3d0ecccdee9e1fffd6f7ebf3ac7b22755 Mon Sep 17 00:00:00 2001 From: ThePhaseless Date: Mon, 5 Oct 2026 13:14:45 +0200 Subject: [PATCH] feat: reusable Flutter CI, release and Pages workflows Moves the GitHub Actions setup of SilverTimetable2 here so that every Flutter app of the organization can call it instead of keeping a copy. - flutter-ci.yml: lint and test (`mise run check`), optional generated-code check, release APKs and web build, optional unsigned iOS build. - flutter-release.yml: one GitHub release in, every platform out. The tag is the version (X.Y.Z or X.Y.Z-beta.N, optional "v"); the build number is derived from it. A pre-release becomes a public beta (Play open testing, a TestFlight public link after Beta App Review, Pages /beta/); a release goes to production; unticking "pre-release" promotes the same builds without rebuilding. Store jobs turn on per app (android-package, bundle-id) once the store variables are set, and a half-done setup fails before anything is built. Google Play uses Workload Identity Federation, App Store Connect cloud signing; the store scripts are checked out at the commit the caller pinned (job.workflow_sha). - flutter-pages.yml: newest release at the site root, newest build of any kind under /beta/. - tests/: the store scripts against local mocks that check their requests against trimmed copies of the official API descriptions. Co-Authored-By: Claude Opus 5.5 --- .github/actionlint.yaml | 11 + .github/renovate.json5 | 4 + .github/workflows/ci.yml | 45 + .github/workflows/flutter-ci.yml | 163 + .github/workflows/flutter-pages.yml | 87 + .github/workflows/flutter-release.yml | 661 +++ .gitignore | 8 + .rumdl.toml | 3 + README.md | 434 +- mise.lock | 161 + mise.toml | 44 + scripts/app_store_submit.py | 609 ++ scripts/google_play.py | 180 + tests/__init__.py | 0 tests/specs/README.md | 16 + tests/specs/androidpublisher-v3.json | 116 + tests/specs/app-store-connect-api-4.5.json | 6237 ++++++++++++++++++++ tests/support.py | 205 + tests/test_app_store_submit.py | 600 ++ tests/test_google_play.py | 244 + 20 files changed, 9826 insertions(+), 2 deletions(-) create mode 100644 .github/actionlint.yaml create mode 100644 .github/renovate.json5 create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/flutter-ci.yml create mode 100644 .github/workflows/flutter-pages.yml create mode 100644 .github/workflows/flutter-release.yml create mode 100644 .gitignore create mode 100644 .rumdl.toml create mode 100644 mise.lock create mode 100644 mise.toml create mode 100644 scripts/app_store_submit.py create mode 100644 scripts/google_play.py create mode 100644 tests/__init__.py create mode 100644 tests/specs/README.md create mode 100644 tests/specs/androidpublisher-v3.json create mode 100644 tests/specs/app-store-connect-api-4.5.json create mode 100644 tests/support.py create mode 100644 tests/test_app_store_submit.py create mode 100644 tests/test_google_play.py diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..215674f --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,11 @@ +# actionlint 1.7.12 does not know these newer workflow features yet: +# `cache-mode` (rhysd/actionlint#745), the concurrency `queue` (#657), the +# `$/` reference to a reusable workflow in the same repository (#711) and the +# `job.workflow_*` context properties. +paths: + .github/workflows/**/*.{yml,yaml}: + ignore: + - 'unexpected key "cache-mode" for "(workflow|job)" section' + - 'unexpected key "queue" for "concurrency" section' + - 'reusable workflow call "\$/\.github/workflows/[^"]+" at "uses" is not following the format' + - 'property "workflow_(repository|sha)" is not defined in object type' diff --git a/.github/renovate.json5 b/.github/renovate.json5 new file mode 100644 index 0000000..1119829 --- /dev/null +++ b/.github/renovate.json5 @@ -0,0 +1,4 @@ +{ + $schema: 'https://docs.renovatebot.com/renovate-schema.json', + extends: ['config:recommended'], +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..93cf272 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +# Lints the workflows and runs the store scripts against local mocks of the +# store APIs. + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read # clone the repository + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # gitleaks scans the full history + persist-credentials: false + - uses: jdx/mise-action@7a4e45a543138629540c9a1616d08632b893e492 # v5.0.1 + with: + cache_save: ${{ github.ref_name == github.event.repository.default_branch }} + - run: mise run lint + + test: + name: Store scripts + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read # clone the repository + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: python3 -m unittest discover -s tests -t . -v diff --git a/.github/workflows/flutter-ci.yml b/.github/workflows/flutter-ci.yml new file mode 100644 index 0000000..f049e3b --- /dev/null +++ b/.github/workflows/flutter-ci.yml @@ -0,0 +1,163 @@ +name: Flutter CI + +# Reusable: lint, test and build a Flutter app on every push and pull request. +# The calling repository provides the mise tasks of the task contract (see +# README.md): check, build:apk, build:web, and codegen / build:ios if those +# inputs are on. + +on: + workflow_call: + inputs: + app-dir: + description: Directory of the Flutter app (the one with pubspec.yaml), relative to the repository root. + type: string + default: . + mise-version: + description: The mise version to install; empty (the default) installs the newest release. + type: string + default: "" + codegen: + description: Run `mise run codegen` and fail if it changes anything in the app's lib/ (generated code that was not committed). + type: boolean + default: false + android: + description: Build the release APKs (`mise run build:apk`) and keep them as a workflow artifact. + type: boolean + default: true + web: + description: Build the web app (`mise run build:web`). + type: boolean + default: true + ios: + description: Build the iOS app without code signing (`mise run build:ios`) on macOS. macOS minutes count ten times on private repositories. + type: boolean + default: false + macos-runner: + description: Runner label of the iOS build. + type: string + default: macos-26 + xcode-version: + description: Xcode version of the iOS build, as installed on the runner (/Applications/Xcode_.app). + type: string + default: "26.6" + +permissions: {} + +env: + # The Android build uses the runner's preinstalled Android SDK (licenses + # accepted), so a mise android-sdk tool is never installed here. + MISE_DISABLE_TOOLS: android-sdk + +jobs: + check: + name: Lint and test + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read # clone the repository + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # secret scanners such as gitleaks read the full history + persist-credentials: false + - uses: jdx/mise-action@7a4e45a543138629540c9a1616d08632b893e492 # v5.0.1 + with: + version: ${{ inputs.mise-version }} + # The toolchain entry is large (Flutter is about 1.7 GB): save it from + # the default branch only, so pull requests reuse it. + cache_save: ${{ github.ref_name == github.event.repository.default_branch }} + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.pub-cache + key: pub-${{ runner.os }}-${{ hashFiles(format('{0}/pubspec.lock', inputs.app-dir)) }} + - name: Lint and test + run: mise run check + - if: ${{ inputs.codegen }} + name: Check that generated code is up to date + env: + APP_DIR: ${{ inputs.app-dir }} + run: | + mise run codegen + git diff --exit-code -- "$APP_DIR/lib" + + build-android: + name: Build Android + needs: check + if: ${{ inputs.android }} + runs-on: ubuntu-24.04 + timeout-minutes: 45 + permissions: + contents: read # clone the repository + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: jdx/mise-action@7a4e45a543138629540c9a1616d08632b893e492 # v5.0.1 + with: + version: ${{ inputs.mise-version }} + cache_save: ${{ github.ref_name == github.event.repository.default_branch }} + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.pub-cache + key: pub-${{ runner.os }}-${{ hashFiles(format('{0}/pubspec.lock', inputs.app-dir)) }} + - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + with: + cache-provider: basic + # Without signing secrets the app's release build has to fall back to a + # debug or unsigned key (see README.md, "Task contract"). + - name: Build release APKs + run: mise run build:apk + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: apk + path: ${{ inputs.app-dir }}/build/app/outputs/flutter-apk/*.apk + retention-days: 7 + if-no-files-found: error + + build-web: + name: Build web + needs: check + if: ${{ inputs.web }} + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read # clone the repository + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: jdx/mise-action@7a4e45a543138629540c9a1616d08632b893e492 # v5.0.1 + with: + version: ${{ inputs.mise-version }} + cache_save: ${{ github.ref_name == github.event.repository.default_branch }} + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.pub-cache + key: pub-${{ runner.os }}-${{ hashFiles(format('{0}/pubspec.lock', inputs.app-dir)) }} + - name: Build web + run: mise run build:web + + build-ios: + name: Build iOS + needs: check + if: ${{ inputs.ios }} + runs-on: ${{ inputs.macos-runner }} + timeout-minutes: 60 + permissions: + contents: read # clone the repository + env: + DEVELOPER_DIR: /Applications/Xcode_${{ inputs.xcode-version }}.app/Contents/Developer + # Only Flutter is needed. + MISE_ENABLE_TOOLS: flutter + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: jdx/mise-action@7a4e45a543138629540c9a1616d08632b893e492 # v5.0.1 + with: + version: ${{ inputs.mise-version }} + # The macOS toolchain would be another large cache entry; iOS builds + # are optional and rare. + cache: false + - name: Build iOS without code signing + run: mise run build:ios diff --git a/.github/workflows/flutter-pages.yml b/.github/workflows/flutter-pages.yml new file mode 100644 index 0000000..a4b152f --- /dev/null +++ b/.github/workflows/flutter-pages.yml @@ -0,0 +1,87 @@ +name: Flutter web on GitHub Pages + +# Reusable: serve the newest release's web build at the site root and the +# newest release of any kind, usually a pre-release, under /beta/. It uses the +# --web.zip assets that flutter-release.yml attaches to the +# releases, and drops the BETA badge (see README.md, "Task contract") from the +# root. flutter-release.yml calls it; call it from a workflow_dispatch workflow +# to redeploy by hand. +# +# Enable Pages with "Source: GitHub Actions", then allow the release tags to +# deploy: release runs use their tag as the ref, and the github-pages +# environment only admits the default branch at first (Settings > Environments +# > github-pages > Deployment branches and tags > add the tag rule "*"). + +on: + workflow_call: + +permissions: {} + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Assemble the site + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read # read the releases and their web builds + pages: read # configure-pages reads the site's base path + steps: + - id: pages + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + - name: Download the web builds + env: + GH_TOKEN: ${{ github.token }} + BASE_PATH: ${{ steps.pages.outputs.base_path }} + run: | + # Releases with a web build, newest first, as " ". + gh api --paginate "repos/$GITHUB_REPOSITORY/releases" --jq '.[] + | select((.draft | not) and any(.assets[]; .name | endswith("-web.zip"))) + | "\(.tag_name) \(.prerelease)"' > releases.txt + beta=$(awk 'NR == 1 { print $1 }' releases.txt) + prod=$(awk '$2 == "false" { print $1; exit }' releases.txt) + if [ -z "$beta" ]; then + echo "::error::No release has a web build yet." + exit 1 + fi + # The web builds are made for "/"; point them at their path on the site. + unpack() { + mkdir -p "$2" + gh release download "$1" --repo "$GITHUB_REPOSITORY" \ + --pattern '*-web.zip' --output web.zip --clobber + unzip -q web.zip -d "$2" + rm web.zip + sed -i "s|||" "$2/index.html" + } + if [ -n "$prod" ]; then + unpack "$prod" _site "${BASE_PATH}/" + # A promoted beta was built with the BETA badge; production has none. + sed -i '//d' _site/index.html + else + mkdir _site + echo '' \ + > _site/index.html + fi + unpack "$beta" _site/beta "${BASE_PATH}/beta/" + echo "Production: ${prod:-none}, beta: $beta" >> "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: _site + + deploy: + name: Deploy + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + pages: write # publish the site + id-token: write # authenticate the Pages deployment (OIDC) + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.github/workflows/flutter-release.yml b/.github/workflows/flutter-release.yml new file mode 100644 index 0000000..875d900 --- /dev/null +++ b/.github/workflows/flutter-release.yml @@ -0,0 +1,661 @@ +name: Flutter release + +# Reusable: build a GitHub release of a Flutter app, attach the builds to it and +# publish them to Google Play, App Store Connect and GitHub Pages. +# +# Call it from a workflow that runs `on: release: types: [published, released]` +# (see README.md). The tag sets the version: a pre-release tagged X.Y.Z-beta.N +# is a public beta, a release tagged X.Y.Z goes to production (a leading "v" is +# optional), and turning a beta into a release promotes the beta's builds +# without rebuilding them. The Google Play and App Store jobs run once the app +# passes its store ID and the store variables are set. + +on: + workflow_call: + inputs: + app-dir: + description: Directory of the Flutter app (the one with pubspec.yaml), relative to the repository root. + type: string + default: . + artifact-prefix: + description: File name prefix of the release assets, e.g. `my-app` for my-app-1.2.0.aab. + type: string + default: ${{ github.event.repository.name }} + android-package: + description: Android application ID; turns on Google Play. + type: string + default: "" + android-keystore-path: + description: Where the build expects the upload keystore, relative to app-dir. + type: string + default: android/app/key.jks + bundle-id: + description: iOS bundle ID; turns on the iOS build and App Store Connect. + type: string + default: "" + apple-app-id: + description: The app's numeric Apple ID in App Store Connect (App Information), needed with bundle-id. + type: string + default: "" + play-notes-language: + description: Language of the Google Play release notes; one of the store listing's languages. + type: string + default: en-US + pages: + description: Deploy the web builds to GitHub Pages once Pages is enabled for the repository. + type: boolean + default: false + environment: + description: Deployment environment of the jobs that use signing or store credentials, e.g. one with required reviewers. Empty for none. + type: string + default: "" + build-tools: + description: mise tools that the Android and web builds install (MISE_ENABLE_TOOLS). + type: string + default: flutter,java + mise-version: + description: The mise version to install; empty (the default) installs the newest release. + type: string + default: "" + macos-runner: + description: Runner label of the iOS jobs. + type: string + default: macos-26 + xcode-version: + description: Xcode version of the iOS jobs, as installed on the runner (/Applications/Xcode_.app). + type: string + default: "26.6" + secrets: + ANDROID_KEYSTORE_BASE64: + description: The upload keystore (`base64 -w0 key.jks`). + required: false + ANDROID_KEY_ALIAS: + required: false + ANDROID_KEY_PASSWORD: + required: false + ANDROID_STORE_PASSWORD: + required: false + APP_STORE_CONNECT_KEY: + description: Contents of the App Store Connect API key (.p8), Admin role. + required: false + outputs: + mode: + description: build, promote or skip. + value: ${{ jobs.plan.outputs.mode }} + +permissions: {} + +# Release runs must not restore anything from the Actions cache; the jobs also +# turn their own caches off. +cache-mode: none + +jobs: + plan: + name: Check the release + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: {} + outputs: + mode: ${{ steps.version.outputs.mode }} + channel: ${{ steps.version.outputs.channel }} + tag: ${{ steps.version.outputs.tag }} + version: ${{ steps.version.outputs.version }} + build-number: ${{ steps.version.outputs.build-number }} + steps: + - id: version + name: Read the version from the tag + env: + EVENT: ${{ github.event_name }} + ACTION: ${{ github.event.action }} + TAG: ${{ github.event.release.tag_name }} + PRERELEASE: ${{ github.event.release.prerelease }} + run: | + if [ "$EVENT" != release ]; then + echo "::error::Call this workflow from a workflow that runs on release events." + exit 1 + fi + # [v]X.Y.Z, or [v]X.Y.Z-