From 2390ad0fabd521951d04d2cce9c8693b7c060b5a Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Fri, 24 Apr 2026 17:57:29 +0100 Subject: [PATCH] ci(release): support patch releases from release/* branches - prepare-release: add `branch` input; validate branch/bump combination; skip Weblate merge, mobile build, and APK asset when not on main; point checkout, release target, and tag at the selected branch; backport the archived-versions.json entry to main via PR. - build-mobile: gate Android release build and iOS TestFlight upload on `environment == production` instead of the branch name, so patch releases still produce production artifacts if ever re-enabled. - docker: build on pushes to release/**; restrict retag-from-main jobs to PRs and main-branch pushes. - docs-build: build on pushes to release/**; include release/** in the pre-job force-branches list. --- .github/workflows/build-mobile.yml | 12 +-- .github/workflows/docker.yml | 8 +- .github/workflows/docs-build.yml | 4 +- .github/workflows/prepare-release.yml | 111 +++++++++++++++++++++++--- 4 files changed, 113 insertions(+), 22 deletions(-) diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 72e8b10aeb..56334b3cfe 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -143,9 +143,9 @@ jobs: ALIAS: ${{ secrets.ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }} - IS_MAIN: ${{ github.ref == 'refs/heads/main' }} + IS_RELEASE: ${{ inputs.environment == 'production' || github.ref == 'refs/heads/main' }} run: | - if [[ $IS_MAIN == 'true' ]]; then + if [[ $IS_RELEASE == 'true' ]]; then flutter build apk --release flutter build apk --release --split-per-abi --target-platform android-arm,android-arm64,android-x64 else @@ -268,20 +268,20 @@ jobs: APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} ENVIRONMENT: ${{ inputs.environment || 'development' }} BUNDLE_ID_SUFFIX: ${{ inputs.environment == 'production' && '' || 'development' }} - GITHUB_REF: ${{ github.ref }} + IS_RELEASE: ${{ inputs.environment == 'production' || github.ref == 'refs/heads/main' }} FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 120 FASTLANE_XCODEBUILD_SETTINGS_RETRIES: 6 working-directory: ./mobile/ios run: | - # Only upload to TestFlight on main branch - if [[ "$GITHUB_REF" == "refs/heads/main" ]]; then + # Upload to TestFlight on main or when explicitly invoked as a production release. + if [[ "$IS_RELEASE" == "true" ]]; then if [[ "$ENVIRONMENT" == "development" ]]; then bundle exec fastlane gha_testflight_dev else bundle exec fastlane gha_release_prod fi else - # Build only, no TestFlight upload for non-main branches + # Build only, no TestFlight upload bundle exec fastlane gha_build_only fi diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 84509103be..7a1d72ed15 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -3,7 +3,7 @@ name: Docker on: workflow_dispatch: push: - branches: [main] + branches: [main, 'release/**'] pull_request: release: types: [published] @@ -53,7 +53,8 @@ jobs: permissions: contents: read packages: write - if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == false && !github.event.pull_request.head.repo.fork }} + # Retag sources from the :main image, so only retag for PRs and main-branch pushes. + if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == false && !github.event.pull_request.head.repo.fork && (github.event_name == 'pull_request' || github.ref == 'refs/heads/main') }} runs-on: ubuntu-latest strategy: matrix: @@ -83,7 +84,8 @@ jobs: permissions: contents: read packages: write - if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == false && !github.event.pull_request.head.repo.fork }} + # Retag sources from the :main image, so only retag for PRs and main-branch pushes. + if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == false && !github.event.pull_request.head.repo.fork && (github.event_name == 'pull_request' || github.ref == 'refs/heads/main') }} runs-on: ubuntu-latest strategy: matrix: diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index 0ccebfb363..974ac8e4ce 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -1,7 +1,7 @@ name: Docs build on: push: - branches: [main] + branches: [main, 'release/**'] pull_request: release: types: [published] @@ -39,7 +39,7 @@ jobs: force-filters: | - '.github/workflows/docs-build.yml' force-events: 'release' - force-branches: 'main' + force-branches: 'main,release/**' build: name: Docs Build diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 2aa028b22e..c4f7764e34 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -3,8 +3,13 @@ name: Prepare new release on: workflow_dispatch: inputs: + branch: + description: 'Branch to release from (must be main or release/*)' + required: true + default: 'main' + type: string serverBump: - description: 'Bump server version' + description: 'Bump server version (only patch allowed on release/* branches)' required: true default: 'false' type: choice @@ -29,10 +34,31 @@ concurrency: permissions: {} jobs: + validate_inputs: + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Validate branch and bump combination + env: + BRANCH: ${{ inputs.branch }} + SERVER_BUMP: ${{ inputs.serverBump }} + run: | + set -euo pipefail + if [[ "$BRANCH" != "main" && "$BRANCH" != release/* ]]; then + echo "::error::branch must be 'main' or start with 'release/' (got '$BRANCH')" + exit 1 + fi + if [[ "$BRANCH" != "main" && "$SERVER_BUMP" != "false" && "$SERVER_BUMP" != "patch" ]]; then + echo "::error::only 'patch' (or 'false') serverBump is allowed on '$BRANCH'" + exit 1 + fi + merge_translations: + needs: [validate_inputs] uses: ./.github/workflows/merge-translations.yml with: - skip: ${{ inputs.skipTranslations }} + # Weblate tracks main only, so skip translations when releasing from a release/* branch. + skip: ${{ inputs.skipTranslations || inputs.branch != 'main' }} permissions: pull-requests: write secrets: @@ -60,7 +86,7 @@ jobs: with: token: ${{ steps.generate-token.outputs.token }} persist-credentials: true - ref: main + ref: ${{ inputs.branch }} - name: Install uv uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 @@ -94,6 +120,10 @@ jobs: push: true build_mobile: + # Mobile build numbers are monotonic per store; releasing from a release/* branch + # would collide with build numbers already shipped from main. Skip mobile on patch + # releases — handle mobile patches on main instead. + if: ${{ inputs.branch == 'main' }} uses: ./.github/workflows/build-mobile.yml needs: bump_version permissions: @@ -118,6 +148,8 @@ jobs: prepare_release: runs-on: ubuntu-latest needs: [build_mobile, bump_version] + # Run even when build_mobile is skipped (patch release from release/* branch). + if: ${{ always() && needs.bump_version.result == 'success' && (needs.build_mobile.result == 'success' || needs.build_mobile.result == 'skipped') }} permissions: actions: read # To download the app artifact # No content permissions are needed because it uses the app-token @@ -134,26 +166,83 @@ jobs: with: token: ${{ steps.generate-token.outputs.token }} persist-credentials: false + ref: ${{ needs.bump_version.outputs.ref }} - name: Download APK + if: ${{ needs.build_mobile.result == 'success' }} uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: release-apk-signed github-token: ${{ steps.generate-token.outputs.token }} + - name: Assemble release assets + id: assets + env: + HAS_APK: ${{ needs.build_mobile.result == 'success' }} + run: | + { + echo 'files<> "$GITHUB_OUTPUT" + - name: Create draft release uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: draft: true tag_name: ${{ needs.bump_version.outputs.version }} + target_commitish: ${{ inputs.branch }} token: ${{ steps.generate-token.outputs.token }} generate_release_notes: true body_path: misc/release/notes.tmpl - files: | - docker/docker-compose.yml - docker/docker-compose.rootless.yml - docker/example.env - docker/hwaccel.ml.yml - docker/hwaccel.transcoding.yml - docker/prometheus.yml - *.apk + files: ${{ steps.assets.outputs.files }} + + backport_archived_versions: + # When releasing from a release/* branch, the archived-versions.json update + # lives on that branch only. Open a PR to mirror the new entry onto main so + # main's docs keep a complete archive list. + if: ${{ inputs.branch != 'main' && needs.bump_version.result == 'success' }} + runs-on: ubuntu-latest + needs: [bump_version, prepare_release] + permissions: {} # uses the app token + steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + with: + app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} + private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} + + - name: Checkout main + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + token: ${{ steps.generate-token.outputs.token }} + persist-credentials: false + ref: main + + - name: Update archived versions on main + env: + VERSION: ${{ needs.bump_version.outputs.version }} + run: ./misc/release/archive-version.js "${VERSION#v}" + + - name: Open backport PR + uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 + with: + token: ${{ steps.generate-token.outputs.token }} + branch: backport/archived-versions-${{ needs.bump_version.outputs.version }} + base: main + commit-message: 'chore(docs): archive ${{ needs.bump_version.outputs.version }}' + title: 'chore(docs): archive ${{ needs.bump_version.outputs.version }}' + body: | + Backports the `archived-versions.json` entry for ${{ needs.bump_version.outputs.version }}, + released from `${{ inputs.branch }}`, so main's docs archive list stays complete. + add-paths: docs/static/archived-versions.json + delete-branch: true