Skip to content
SilverNETGroupSGGWPublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

75 Commits

Folders and files

Repository files navigation

Infra

Infrastructure of SilverNETGroupSGGW:

  • reusable GitHub Actions workflows for the Flutter apps: continuous integration, releases to GitHub, Google Play, App Store Connect and GitHub Pages (this README);
  • the Terraform setup of the apps' store releases (Store setup).
Workflow What it does
flutter-ci.yml Lints and tests the app, checks generated code, builds the release APKs and the web app, optionally the iOS app
flutter-release.yml Builds a GitHub release, attaches the builds to it and publishes them to the stores and Pages; promotes a tested beta without rebuilding it
flutter-pages.yml Serves the newest release's web build at the site root and the newest build of any kind, usually a beta, under /beta/

Each app keeps short caller workflows and implements a few mise tasks (the task contract); everything else lives here, so a fix or a new store requirement is made once.

Using the workflows

Reference the workflows by commit SHA, with the version in a comment, and let Renovate update the SHA (config:recommended does):

uses: SilverNETGroupSGGW/Infra/.github/workflows/flutter-ci.yml@<commit-sha> # v1.0.0

Tags follow semantic versioning. A major version changes inputs, secrets, variables or the task contract incompatibly; the release notes say how to update.

CI

.github/workflows/ci.yml in the app:

name: CI

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:
  ci:
    # Optional: skip while the repository is private, so CI costs no minutes.
    if: ${{ github.event.repository.visibility == 'public' }}
    permissions:
      contents: read # clone the repository
    uses: SilverNETGroupSGGW/Infra/.github/workflows/flutter-ci.yml@<commit-sha> # v1.0.0
    with:
      app-dir: app # the directory with pubspec.yaml; default "."
      codegen: true # if the app commits generated code
Input Default
app-dir . Directory of the Flutter app (the one with pubspec.yaml)
codegen false Run mise run codegen and fail if it changes the app's lib/
android true Build the release APKs (mise run build:apk) and keep them for 7 days
web true Build the web app (mise run build:web)
ios false Build the iOS app without signing (mise run build:ios) on macOS; macOS minutes count ten times on private repositories
macos-runner, xcode-version macos-26, 26.6 Runner and Xcode of the iOS build
mise-version newest release mise version to install

The jobs are called Lint and test, Build Android, Build web and Build iOS (shown as ci / Lint and test and so on) for branch protection.

Releases

.github/workflows/release.yml in the app:

name: Release
run-name: >-
  ${{ github.event_name == 'workflow_dispatch'
  && format('Publish {0} to {1} ({2})', github.ref_name, inputs.track, inputs.platforms)
  || format('Release {0} ({1})', github.ref_name, github.event.action) }}

on:
  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: Store track; beta is open testing
        type: choice
        options: [beta, production]
      platforms:
        type: choice
        options: [all, android, ios]

permissions: {}

# Nothing from the Actions cache reaches a signed build.
cache-mode: none

concurrency:
  group: release-${{ github.ref_name }}
  cancel-in-progress: false
  # Wait in line instead of replacing a waiting run (e.g. a promotion).
  queue: max

jobs:
  release:
    # Required even without Pages or Google Play: GitHub checks what the called
    # jobs ask for when the run starts. Each job gets no more than it needs.
    permissions:
      contents: write # attach the builds to the release
      id-token: write # Google Play (Workload Identity Federation) and Pages
      pages: write # publish the web app
    uses: SilverNETGroupSGGW/Infra/.github/workflows/flutter-release.yml@<commit-sha> # v1.0.0
    with:
      app-dir: app
      android-package: com.example.myapp # turns on Google Play
      bundle-id: com.example.myapp # turns on iOS and App Store Connect
      apple-app-id: "1234567890"
      pages: true
      publish-track: ${{ inputs.track }}
      publish-platforms: ${{ inputs.platforms }}
    secrets:
      ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
      ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
      ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
      ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
      APP_STORE_CONNECT_KEY: ${{ secrets.APP_STORE_CONNECT_KEY }}

Pass the secrets one by one rather than with secrets: inherit, so the workflow only gets what it uses.

Input Default
app-dir . Directory of the Flutter app
artifact-prefix repository name File name prefix of the release assets: <prefix>-<tag>.aab, <prefix>-<tag>-web.zip, ...
android-package Android application ID; turns on Google Play
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
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
environment Environment of the jobs that use signing or store credentials, e.g. one with required reviewers
build-tools flutter,java mise tools the Android and web builds install
macos-runner, xcode-version, mise-version macos-26, 26.6, newest release

The workflow reads these repository (or organization) variables of the app:

Variable
GOOGLE_WORKLOAD_IDENTITY_PROVIDER, GOOGLE_PLAY_SERVICE_ACCOUNT Google Play sign-in (setup)
APP_STORE_CONNECT_KEY_ID, APP_STORE_CONNECT_ISSUER_ID, APPLE_TEAM_ID App Store Connect sign-in and signing (setup)
APP_STORE_USES_NON_EXEMPT_ENCRYPTION Optional export compliance answer, false or true

A store's jobs run when the app passes its store ID (android-package, bundle-id) and the store's service account or key ID variable is set, so organization-wide variables don't turn a store on for every app. A half-done setup (a missing secret, variable or apple-app-id) fails the run before anything is built; with environment, the jobs that use the secrets check them.

How a release flows

  • 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 (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 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. An earlier version still waiting for App Review is withdrawn and replaced, as Google Play does; one that App Review has started stops the run.
  • 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 rebuilt. The store jobs check again that the release is still a release before changing anything. The release's title and build files lose the pre-release label (App 1.2.0 beta 3 becomes App 1.2.0); the tag keeps it.
  • 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 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.
  • Release notes: the release description becomes the store release notes (Google Play: up to 500 characters; App Store: "What's New" in every language).
  • Re-runs continue where a run stopped: an uploaded bundle is not uploaded again, and an unfinished App Store submission is completed.

Don't enable immutable releases in the app repositories: the builds are attached after the release is published.

Security

  • cache-mode: none: nothing from the Actions cache, which other workflows can write, reaches a signed build.
  • Only the publish job can write to the repository, and it runs no build code. The upload key is only in the Android job; the App Store Connect key only in jobs that run no project build code.
  • Google Play uses Workload Identity Federation: no Google key is stored, and the provider can require that the token comes from this workflow (below).
  • Set environment to an environment with required reviewers to approve releases: the Android, Google Play, iOS upload and App Store Connect jobs each wait for an approval when they start. Release runs deploy from their tag, so allow the release tags in the environment's deployment branches and tags (for example the tag rule *). Environment secrets only reach jobs that name the environment, under the names this workflow reads (ANDROID_KEYSTORE_BASE64, ..., APP_STORE_CONNECT_KEY), whatever the caller passes.

Pages

The release workflow deploys Pages itself when pages: true and Pages is enabled for the repository (free plan: public repositories only). To redeploy by hand, add .github/workflows/pages.yml to the app:

name: Deploy web to GitHub Pages

on:
  workflow_dispatch:

permissions: {}

jobs:
  pages:
    permissions:
      contents: read # read the releases and their web builds
      pages: write # publish the site
      id-token: write # authenticate the Pages deployment (OIDC)
    uses: SilverNETGroupSGGW/Infra/.github/workflows/flutter-pages.yml@<commit-sha> # v1.0.0

Enable Pages with "Source: GitHub Actions". Release runs deploy from their tag, so allow the release tags in the github-pages environment:

gh api -X POST -f name='*' -f type=tag \
  repos/{owner}/{repo}/environments/github-pages/deployment-branch-policies

Renovate

.github/renovate.json5 in the app, with non-major updates merged once CI passes:

{
  $schema: 'https://docs.renovatebot.com/renovate-schema.json',
  extends: ['config:recommended'],
  packageRules: [
    {
      // Without bumped ranges, pub only gets updates outside pubspec.yaml's ranges.
      matchManagers: ['pub'],
      matchDepTypes: ['dependencies', 'dev_dependencies'],
      rangeStrategy: 'bump',
    },
    {
      matchUpdateTypes: ['minor', 'patch', 'pin', 'digest'],
      automerge: true,
    },
  ],
}

Renovate counts a skipped check as passed, so a CI caller that skips private repositories must still run on Renovate's pull requests (github.event.pull_request.user.login == 'renovate[bot]').

Task contract

The workflows run these mise tasks of the app (in the repository root's mise.toml; set [task_config] dir to the app directory):

Task Used by Must
check CI Lint and test the app (and scan for secrets, if wanted: the history is fetched in full)
codegen CI, codegen: true Regenerate committed generated code
build:apk CI, release Build per-ABI release APKs (flutter build apk --split-per-abi). Add -P force-version-code-ignoring-abi=true so every APK gets the build number as its version code, like the App Bundle; otherwise an APK from a GitHub release blocks later Play updates
build:appbundle release Build the release App Bundle
build:web CI, release Build the web app and accept --channel beta|prod; prod removes the BETA badge (below)
build:ios CI, ios: true Build the iOS app without code signing

Also:

  • Version: the release workflow writes version: X.Y.Z+B from the tag into pubspec.yaml before it builds, so the build tasks take the version from there (Flutter's default).

  • Tools and outputs: Flutter is the mise tool flutter (the iOS jobs install only it). The builds use Flutter's default output paths (build/app/outputs/flutter-apk/app-<abi>-release.apk, build/app/outputs/bundle/release/app-release.aab, build/web), and the web build keeps <base href="/">, which Pages points at the site's paths.

  • Android signing: the release workflow writes the upload keystore to android-keystore-path and sets KEY_ALIAS, KEY_PASSWORD and STORE_PASSWORD. The app's Gradle release signing reads them; without them (CI, local builds) it should fall back to the debug key.

  • iOS: an Xcode project with the Runner scheme, workspace and app, as flutter create makes it. The release archive is signed ad hoc with APPLE_TEAM_ID and exported with Apple's cloud signing. If ios/Runner/*.entitlements declares push notifications (aps-environment), the workflow fails when the archive or the App Store build lacks them.

  • BETA badge: optional. Wrap it in web/index.html as

    <!-- beta-badge -->
    <p>BETA</p>
    <!-- /beta-badge -->

    so build:web --channel prod and the Pages site root can remove it.

Store setup

Release setup with Terraform

terraform/apps describes the store release setup of every app with the terraform/app-release module: keyless Google Play sign-in in the app's Google Cloud project (its Firebase project), the app repository's release environment (required reviewers, release tags allowed) and the repository variables the workflow reads. Secrets stay out of Terraform; set them with gh secret set. Play Console access is granted by hand (see Google Play below). terraform/apps/firebase.tf also tracks the apps' Firebase projects, apps, Firestore databases, buckets and Hosting sites; their deployments (rules, Hosting releases, Functions) stay with the apps' firebase deploy.

To add an app, add its project to apps of terraform/bootstrap and apply that (it gives the Terraform service account its roles there), then add a module block to terraform/apps/main.tf and merge it to main. The Terraform workflow plans the change and, once a reviewer of the terraform environment has read the plan in the run summary and approved the apply job, applies it. Settings that already exist, such as those set by hand before, get an import block.

Pass environment: release to flutter-release.yml when the app has the release environment: its Google provider then only accepts tokens of this workflow's jobs in that environment. GitHub Free has no environments in private repositories; set environment = null until the repository is public and leave environment out of the workflow.

Terraform in CI

The workflow signs in to Google Cloud as the Terraform service account (Workload Identity Federation, only terraform.yml from main) and to GitHub as the organization's Terraform GitHub App, and keeps the state in HCP Terraform's free tier. The repository is public, so the run summary lists only the changing resources, the logs show no plan or apply values, and the saved plan is encrypted between the plan and apply jobs. No Google Cloud billing account is linked to anything, so nothing can be billed. An administrator sets this up once:

  1. Have the club's HCP Terraform organization KN-Silver (free plan; the bootstrap imports it) and a team API token for the workflow, stored as the TF_HCP_TOKEN secret (Settings → Teams → owners → Team API token).

  2. Create the GitHub App in the organization's settings (Developer settings → GitHub Apps): no webhook, repository permissions Administration and Variables (read and write); install it on the app repositories only and generate a private key.

  3. Apply terraform/bootstrap with your own accounts. It creates the apps workspace in HCP Terraform, the silvernet-infra project with the Terraform service account and its sign-in, its rights on the apps' projects (and their display names), the terraform environment and the workflow's repository variables:

    gcloud auth application-default login --scopes=openid,\
    https://www.googleapis.com/auth/userinfo.email,\
    https://www.googleapis.com/auth/cloud-platform,\
    https://www.googleapis.com/auth/androidpublisher
    export GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/application_default_credentials.json
    export GITHUB_TOKEN=$(gh auth token)
    terraform login
    cd terraform/bootstrap
    terraform init
    terraform apply -var github_app_id=APP_ID \
      -var github_app_installation_id=INSTALLATION_ID
    gh secret set TF_GITHUB_APP_PRIVATE_KEY --repo SilverNETGroupSGGW/Infra \
      < private-key.pem
    gh secret set TF_HCP_TOKEN --repo SilverNETGroupSGGW/Infra

    If the Play Console call fails because the API is not enabled, run gcloud auth application-default set-quota-project silvernet-infra and apply again. Keep the bootstrap state (terraform.tfstate) private.

  4. Run the Terraform workflow (Actions → Terraform → Run workflow).

A Google Cloud organization (Cloud Identity Free on silver.sggw.pl) is optional; the commented-out parts of terraform/bootstrap and terraform/apps show what changes with it.

Google Play

The App Bundle is signed with the app's upload key (the ANDROID_* secrets). The workflow signs in with Workload Identity Federation, set up by terraform/apps. Invite its releases@<project>.iam.gserviceaccount.com service account in Play Console (Users and permissions) with "Release to testing tracks" and "Release to production, exclude devices, and use Play App Signing" for this app only. Store the upload key as secrets of the release environment (without --env release while the app has no environment):

base64 -w0 key.jks | gh secret set ANDROID_KEYSTORE_BASE64 --env release
gh secret set ANDROID_KEY_ALIAS --env release
gh secret set ANDROID_KEY_PASSWORD --env release
gh secret set ANDROID_STORE_PASSWORD --env release

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.

App Store Connect

The workflow archives the app on a GitHub macOS runner and has Apple sign it for distribution (cloud signing), so no certificate is stored. macOS minutes count ten times against the Actions minutes of a private repository.

  1. In App Store Connect, Users and Access → Integrations → App Store Connect API, create a team key with the Admin role (cloud signing needs it; the Account Holder enables API access once). Download the .p8 file, which can only be downloaded once. Every team key reaches all apps of the team; a key per app can still be revoked on its own. The App Store Connect API cannot create API keys, so this step stays manual.
  2. Store the .p8 contents as a secret of the release environment (gh secret set APP_STORE_CONNECT_KEY --env release < AuthKey_KEYID.p8; the repository without an environment), and pass its key ID, the issuer ID and the team ID (Membership details on developer.apple.com) to the app's block in terraform/apps, which sets the variables APP_STORE_CONNECT_KEY_ID, APP_STORE_CONNECT_ISSUER_ID and APPLE_TEAM_ID.
  3. Answer export compliance once per app: add ITSAppUsesNonExemptEncryption to ios/Runner/Info.plist, or set app_store_uses_non_exempt_encryption of the app's block (the variable APP_STORE_USES_NON_EXEMPT_ENCRYPTION) to false or true. This is a legal declaration for the app's owner to make. Without it the iOS jobs stop before building: TestFlight and App Review both need the answer.
  4. Answer the age rating questions in App Information; App Store Connect asks new ones before it accepts updates.

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. 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 version accepts no new builds, so the beta after a release needs a higher version name. Apple reviews one build per version at a time: a beta published while the previous beta of its version is still in Beta App Review fails the App Store Connect job; re-run the job once that review is done.

Development

mise install
mise run lint  # actionlint, zizmor, taplo, rumdl, gitleaks
mise run test  # the store scripts against local mocks of the store APIs
  • scripts/ holds the store clients (Python standard library only). The release workflow checks them out at the commit the caller pinned (job.workflow_sha). tests/ runs them against local mocks: the App Store Connect mock checks every request against a trimmed copy of Apple's OpenAPI description, the Google Play mock checks track updates against Google's Track schema and the other requests with explicit assertions.
  • actionlint 1.7.12 does not know some newer workflow syntax yet; .github/actionlint.yaml lists the ignored messages.
  • Test changes to the workflows in an app before tagging a release here. The store jobs can only be tested with real store accounts: publish a beta of an app first.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages