diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3881e601..f30757b8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,6 +29,7 @@ env: GIT_COMMIT_DART_DEFINE: --dart-define=GIT_COMMIT=${{ github.sha }} SENTRY_ENV_DART_DEFINE: --dart-define=SENTRY_ENVIRONMENT=github DONATIONS_DART_DEFINE: --dart-define=ENABLE_DONATIONS=true + TRUSTED_BUILD_CACHE_VERSION: trusted-build-v1 LINUX_APT_PACKAGES: > clang cmake meson ninja-build pkg-config nasm libgtk-3-dev libevdev-dev liblzma-dev libstdc++-12-dev libasound2-dev libass-dev libfreetype-dev libfontconfig-dev libfribidi-dev @@ -39,7 +40,21 @@ env: ruby ruby-dev rubygems build-essential rpm libarchive-tools imagemagick libcurl4-openssl-dev jobs: + validate-trusted-ref: + name: Validate trusted build ref + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Require the protected main branch + shell: bash + run: | + if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then + echo "Release builds may only run from refs/heads/main." >&2 + exit 1 + fi + build-android: + needs: validate-trusted-ref if: ${{ inputs.build_android }} runs-on: ubuntu-latest permissions: @@ -47,38 +62,39 @@ jobs: attestations: write contents: read steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false - name: Setup Java - uses: actions/setup-java@v5 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 with: distribution: "temurin" java-version: "17" - name: Setup Flutter - uses: subosito/flutter-action@v2 + uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" flutter-version: "3.44.0" cache: true + cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:" pub-cache: false - name: Cache Pub dependencies - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: | ~/.pub-cache - key: ${{ runner.os }}-${{ runner.arch }}-pub-v2-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }} + key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-pub-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }} - name: Cache Gradle - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: | ~/.gradle/caches ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- + key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - name: Install dependencies run: flutter pub get --enforce-lockfile --no-example @@ -114,7 +130,7 @@ jobs: tar -czf plezy-android-x86_64.tar.gz -C build/app/outputs/flutter-apk app-x86_64-release.apk --transform 's/app-x86_64-release.apk/plezy.apk/' - name: Attest APKs - uses: actions/attest-build-provenance@v4 + uses: actions/attest-build-provenance@78e6cbd37d0ac1a40113c04f2037dacf1ea3f12e # v4 with: subject-path: | plezy-android-arm64-v8a.tar.gz @@ -122,7 +138,7 @@ jobs: plezy-android-x86_64.tar.gz - name: Upload APKs - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: android-apk path: | @@ -131,6 +147,7 @@ jobs: plezy-android-x86_64.tar.gz build-ios: + needs: validate-trusted-ref if: ${{ inputs.build_ios }} runs-on: macos-26 permissions: @@ -138,33 +155,34 @@ jobs: attestations: write contents: read steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false - name: Setup Flutter - uses: subosito/flutter-action@v2 + uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" flutter-version: "3.44.0" cache: true + cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:" pub-cache: false - name: Cache Pub dependencies - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: | ~/.pub-cache - key: ${{ runner.os }}-${{ runner.arch }}-pub-v2-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }} + key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-pub-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }} - name: Cache CocoaPods - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: | ios/Pods ~/Library/Caches/CocoaPods ~/.cocoapods - key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }} - restore-keys: | - ${{ runner.os }}-pods- + key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-${{ runner.os }}-ios-pods-${{ hashFiles('**/Podfile.lock') }} - name: Install dependencies run: flutter pub get --enforce-lockfile --no-example @@ -186,17 +204,18 @@ jobs: zip -r plezy-ios.ipa Payload - name: Attest IPA - uses: actions/attest-build-provenance@v4 + uses: actions/attest-build-provenance@78e6cbd37d0ac1a40113c04f2037dacf1ea3f12e # v4 with: subject-path: plezy-ios.ipa - name: Upload IPA - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ios-ipa path: plezy-ios.ipa build-macos: + needs: validate-trusted-ref if: ${{ inputs.build_macos }} runs-on: macos-26 permissions: @@ -204,33 +223,34 @@ jobs: attestations: write contents: read steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false - name: Setup Flutter - uses: subosito/flutter-action@v2 + uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" flutter-version: "3.44.0" cache: true + cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:" pub-cache: false - name: Cache Pub dependencies - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: | ~/.pub-cache - key: ${{ runner.os }}-${{ runner.arch }}-pub-v2-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }} + key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-pub-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }} - name: Cache CocoaPods - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: | macos/Pods ~/Library/Caches/CocoaPods ~/.cocoapods - key: ${{ runner.os }}-macos-pods-${{ hashFiles('**/Podfile.lock') }} - restore-keys: | - ${{ runner.os }}-macos-pods- + key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-${{ runner.os }}-macos-pods-${{ hashFiles('**/Podfile.lock') }} - name: Install dependencies run: flutter pub get --enforce-lockfile --no-example @@ -355,7 +375,7 @@ jobs: echo "MACOS_DMG_SIZE=$(stat -f%z plezy-macos.dmg)" >> $GITHUB_ENV - name: Attest macOS DMG - uses: actions/attest-build-provenance@v4 + uses: actions/attest-build-provenance@78e6cbd37d0ac1a40113c04f2037dacf1ea3f12e # v4 with: subject-path: plezy-macos.dmg @@ -365,7 +385,7 @@ jobs: echo "${{ env.MACOS_DMG_SIZE }}" > macos-dmg-size.txt - name: Upload macOS DMG - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: macos-dmg path: | @@ -374,6 +394,7 @@ jobs: macos-dmg-size.txt build-windows: + needs: validate-trusted-ref name: Build Windows (${{ matrix.arch }}) if: ${{ inputs.build_windows }} runs-on: ${{ matrix.runner }} @@ -394,14 +415,16 @@ jobs: build/windows/arm64/_deps build/windows/arm64/mpv-dev-arm64 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false - name: Cache Windows native dependencies id: windows-native-cache - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ matrix.native_cache_path }} - key: windows-native-${{ matrix.arch }}-${{ hashFiles('windows/CMakeLists.txt') }} + key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-windows-native-${{ matrix.arch }}-${{ hashFiles('windows/CMakeLists.txt') }} - name: Install 7-Zip if: matrix.arch == 'arm64' && steps.windows-native-cache.outputs.cache-hit != 'true' @@ -410,11 +433,12 @@ jobs: - name: Setup Flutter if: matrix.flutter_setup == 'action' - uses: subosito/flutter-action@v2 + uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" flutter-version: "3.44.0" cache: true + cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:" pub-cache: false - name: Set up Flutter 3.44.0 (git tag) @@ -427,16 +451,19 @@ jobs: shell: pwsh run: | $root = "$env:RUNNER_TEMP\flutter" - git clone --depth 1 --branch 3.44.0 https://github.com/flutter/flutter.git $root + git init $root + git -C $root remote add origin https://github.com/flutter/flutter.git + git -C $root fetch --depth 1 origin 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + git -C $root checkout --detach FETCH_HEAD "$root\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 & "$root\bin\flutter.bat" --version - name: Cache Pub dependencies - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: | ~\AppData\Local\Pub\Cache - key: ${{ runner.os }}-${{ runner.arch }}-pub-v2-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }} + key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-pub-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }} - name: Install dependencies shell: pwsh @@ -461,7 +488,7 @@ jobs: run: .\scripts\upload-symbols.ps1 windows-${{ matrix.arch }} - name: Upload ${{ matrix.arch }} build - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: windows-${{ matrix.arch }}-build path: build/windows/${{ matrix.arch }}/runner/Release/ @@ -475,19 +502,21 @@ jobs: attestations: write contents: read steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false - name: Setup Dart - uses: dart-lang/setup-dart@v1 + uses: dart-lang/setup-dart@65eb853c7ba17dde3be364c3d2858773e7144260 # v1 - name: Download x64 build - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: windows-x64-build path: build-x64 - name: Download arm64 build - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: windows-arm64-build path: build-arm64 @@ -509,8 +538,8 @@ jobs: shell: pwsh run: | mkdir _signer | Out-Null - @{name="signer"; environment=@{sdk=">=3.0.0 <4.0.0"}; dependencies=@{cryptography="^2.7.0"}} | ConvertTo-Json -Depth 3 | Out-File _signer/pubspec.yaml - Invoke-WebRequest -Uri "https://raw.githubusercontent.com/edde746/auto_updater/main/packages/auto_updater/bin/sign_update.dart" -OutFile _signer/sign.dart + @{name="signer"; environment=@{sdk=">=3.0.0 <4.0.0"}; dependencies=@{cryptography="2.9.0"}} | ConvertTo-Json -Depth 3 | Out-File _signer/pubspec.yaml + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/edde746/auto_updater/9e150f71e17495b7361aedbe6df22e89ad52c254/packages/auto_updater/bin/sign_update.dart" -OutFile _signer/sign.dart Push-Location _signer dart pub get Set-Content -Path ed25519_key.pem -Value $env:SPARKLE_PRIVATE_KEY -Encoding ascii -NoNewline @@ -522,7 +551,7 @@ jobs: Set-Content -Path win-installer-size.txt -Value (Get-Item plezy-windows-installer.exe).Length.ToString() -Encoding ascii -NoNewline - name: Attest Windows artifacts - uses: actions/attest-build-provenance@v4 + uses: actions/attest-build-provenance@78e6cbd37d0ac1a40113c04f2037dacf1ea3f12e # v4 with: subject-path: | plezy-windows-x64-portable.7z @@ -530,19 +559,19 @@ jobs: plezy-windows-installer.exe - name: Upload x64 portable - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: windows-x64-portable path: plezy-windows-x64-portable.7z - name: Upload arm64 portable - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: windows-arm64-portable path: plezy-windows-arm64-portable.7z - name: Upload installer - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: windows-installer path: | @@ -551,6 +580,7 @@ jobs: win-installer-size.txt build-linux: + needs: validate-trusted-ref name: Build Linux (${{ matrix.arch }}) if: ${{ inputs.build_linux }} runs-on: ${{ matrix.runner }} @@ -571,28 +601,31 @@ jobs: flutter_channel: master pkg_config_arch: aarch64-linux-gnu steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false - name: Setup Flutter - uses: subosito/flutter-action@v2 + uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: ${{ matrix.flutter_channel }} flutter-version: "3.44.0" cache: true + cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:" pub-cache: false - name: Cache Pub dependencies - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: | ~/.pub-cache - key: ${{ runner.os }}-${{ runner.arch }}-pub-v2-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }} + key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-pub-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }} - name: Cache APT packages - uses: awalsh128/cache-apt-pkgs-action@latest + uses: awalsh128/cache-apt-pkgs-action@2153a1bf62a0ad7830c24ccdf1d588bedc2834a7 # latest with: packages: ${{ env.LINUX_APT_PACKAGES }} - version: 1.1 + version: trusted-build-v1.1 - name: Install Linux dependencies shell: bash @@ -613,10 +646,10 @@ jobs: - name: Cache libmpv build id: libmpv-cache - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: libmpv-prefix - key: libmpv-${{ runner.arch }}-${{ hashFiles('linux/packaging/build-libmpv.sh') }} + key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-libmpv-${{ runner.arch }}-${{ hashFiles('linux/packaging/build-libmpv.sh') }} - name: Build libmpv if: steps.libmpv-cache.outputs.cache-hit != 'true' @@ -625,7 +658,7 @@ jobs: - name: Install fpm shell: bash - run: sudo gem install fpm + run: sudo gem install fpm --version 1.17.0 --no-document - name: Install dependencies shell: bash @@ -702,7 +735,7 @@ jobs: tar -czf "$GITHUB_WORKSPACE/plezy-linux-${{ matrix.arch }}.tar.gz" * - name: Attest Linux ${{ matrix.arch }} artifacts - uses: actions/attest-build-provenance@v4 + uses: actions/attest-build-provenance@78e6cbd37d0ac1a40113c04f2037dacf1ea3f12e # v4 with: subject-path: | plezy-linux-${{ matrix.arch }}.tar.gz @@ -711,7 +744,7 @@ jobs: plezy-linux-${{ matrix.arch }}.pkg.tar.zst - name: Upload Linux ${{ matrix.arch }} artifacts - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: linux-${{ matrix.arch }} path: | @@ -721,16 +754,17 @@ jobs: plezy-linux-${{ matrix.arch }}.pkg.tar.zst create-release: - needs: [build-android, build-ios, build-macos, build-windows, package-windows, build-linux] - if: ${{ always() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && inputs.build_android && inputs.build_ios && inputs.build_macos && inputs.build_windows && inputs.build_linux }} + needs: [validate-trusted-ref, build-android, build-ios, build-macos, build-windows, package-windows, build-linux] + if: ${{ always() && needs.validate-trusted-ref.result == 'success' && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && inputs.build_android && inputs.build_ios && inputs.build_macos && inputs.build_windows && inputs.build_linux }} runs-on: ubuntu-latest permissions: contents: write steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: sparse-checkout: pubspec.yaml sparse-checkout-cone-mode: false + persist-credentials: false - name: Read version from pubspec.yaml id: version @@ -742,56 +776,56 @@ jobs: - name: Download Android artifacts if: ${{ inputs.build_android }} - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: android-apk path: artifacts/android-apk - name: Download iOS artifact if: ${{ inputs.build_ios }} - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: ios-ipa path: artifacts/ios-ipa - name: Download macOS artifact if: ${{ inputs.build_macos }} - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: macos-dmg path: artifacts/macos-dmg - name: Download Windows x64 artifact if: ${{ inputs.build_windows }} - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: windows-x64-portable path: artifacts/windows-x64-portable - name: Download Windows arm64 artifact if: ${{ inputs.build_windows }} - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: windows-arm64-portable path: artifacts/windows-arm64-portable - name: Download Windows installer artifact if: ${{ inputs.build_windows }} - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: windows-installer path: artifacts/windows-installer - name: Download Linux x64 artifacts if: ${{ inputs.build_linux }} - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: linux-x64 path: artifacts/linux-x64 - name: Download Linux arm64 artifacts if: ${{ inputs.build_linux }} - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: linux-arm64 path: artifacts/linux-arm64 @@ -899,7 +933,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Create Release - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@c12583777ecdfd3be55c69cf75464299dc01057e # v3 with: files: ${{ steps.release-files.outputs.files }} draft: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d7c94c01..2e0c7566 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,9 +17,17 @@ jobs: runs-on: ubuntu-latest steps: + - name: Require the protected main branch + run: | + if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then + echo "Release automation may only run from refs/heads/main." >&2 + exit 1 + fi + - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: + ref: main fetch-depth: 0 - name: Validate version format diff --git a/.github/workflows/update-packages.yml b/.github/workflows/update-packages.yml index 5221d1f9..8dab9a08 100644 --- a/.github/workflows/update-packages.yml +++ b/.github/workflows/update-packages.yml @@ -18,6 +18,14 @@ jobs: outputs: tag: ${{ steps.release.outputs.tag }} steps: + - name: Require the default branch for manual runs + if: github.event_name == 'workflow_dispatch' + run: | + if [[ "$GITHUB_REF" != "refs/heads/${{ github.event.repository.default_branch }}" ]]; then + echo "Manual package updates may only run from the default branch." >&2 + exit 1 + fi + - name: Resolve published release tag id: release env: @@ -60,7 +68,7 @@ jobs: env: RELEASE_TAG: ${{ needs.resolve-release.outputs.tag }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ github.event.repository.default_branch }} fetch-depth: 0 @@ -95,7 +103,7 @@ jobs: needs: resolve-release runs-on: windows-latest steps: - - uses: vedantmgoyal9/winget-releaser@v2 + - uses: vedantmgoyal9/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2 with: identifier: edde746.Plezy installers-regex: 'plezy-windows-installer\.exe$' @@ -110,7 +118,9 @@ jobs: env: RELEASE_TAG: ${{ needs.resolve-release.outputs.tag }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ github.event.repository.default_branch }} - name: Download appcast.xml from release run: | diff --git a/scripts/check_build_workflow.py b/scripts/check_build_workflow.py index 272e003b..7c0791d0 100644 --- a/scripts/check_build_workflow.py +++ b/scripts/check_build_workflow.py @@ -75,7 +75,7 @@ require( for expected in ( "if: matrix.flutter_setup == 'action'", "if: matrix.flutter_setup == 'git'", - "git clone --depth 1 --branch 3.44.0", + "git -C $root fetch --depth 1 origin 559ffa3f75e7402d65a8def9c28389a9b2e6fe42", "flutter pub get --enforce-lockfile --no-example", "--dart-define=SENTRY_DIST=github-windows-${{ matrix.arch }}", "--split-debug-info=debug-info/windows-${{ matrix.arch }}", @@ -161,9 +161,9 @@ for artifact in ( release = job("create-release") require( - "needs: [build-android, build-ios, build-macos, build-windows, package-windows, build-linux]" + "needs: [validate-trusted-ref, build-android, build-ios, build-macos, build-windows, package-windows, build-linux]" in release, - "release dependencies must include both architecture matrices and Windows packaging", + "release dependencies must include the trust gate, both architecture matrices, and Windows packaging", ) for artifact in ( "android-apk", @@ -203,6 +203,64 @@ require( "untagged draft creation must not inspect or block on published releases", ) +trusted_ref = job("validate-trusted-ref") +require("permissions: {}" in trusted_ref, "trusted-ref validation must have no token permissions") +require( + '"$GITHUB_REF" != "refs/heads/main"' in trusted_ref, + "trusted-ref validation must reject non-main refs", +) +for protected_job in ( + "build-android", + "build-ios", + "build-macos", + "build-windows", + "build-linux", +): + require( + "needs: validate-trusted-ref" in job(protected_job), + f"{protected_job} must depend on trusted-ref validation", + ) + +require( + "TRUSTED_BUILD_CACHE_VERSION: trusted-build-v1" in text, + "build caches must use a dedicated trusted namespace", +) +require("restore-keys:" not in text, "privileged build caches must not use prefix fallback") +cache_keys = re.findall(r"(?m)^ key: (.+)$", text) +require(bool(cache_keys), "build workflow must define cache keys") +for cache_key in cache_keys: + require( + "TRUSTED_BUILD_CACHE_VERSION" in cache_key, + f"cache key is outside the trusted build namespace: {cache_key}", + ) +require( + text.count("cache-key:") == text.count("cache: true"), + "every Flutter SDK cache must define its trusted cache key", +) + +action_refs = re.findall(r"(?m)^\s*(?:-\s+)?uses:\s+([^\s@]+)@([^\s#]+)", text) +require(bool(action_refs), "build workflow must use pinned actions") +for action, ref in action_refs: + require( + re.fullmatch(r"[0-9a-f]{40}", ref) is not None, + f"action {action} must be pinned to a full commit SHA", + ) + +checkout_count = sum(action == "actions/checkout" for action, _ in action_refs) +require( + text.count("persist-credentials: false") == checkout_count, + "every build checkout must discard GitHub credentials", +) +require( + "raw.githubusercontent.com/edde746/auto_updater/9e150f71e17495b7361aedbe6df22e89ad52c254/" + in text, + "Windows signing helper must remain pinned to the locked auto_updater commit", +) +require( + 'dependencies=@{cryptography="2.9.0"}' in text, + "Windows signing dependency must remain exact", +) + if errors: for error in errors: print(f"ERROR: {error}", file=sys.stderr) diff --git a/scripts/check_workflow_security.py b/scripts/check_workflow_security.py new file mode 100755 index 00000000..9dab7bed --- /dev/null +++ b/scripts/check_workflow_security.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Enforce trust-boundary invariants across GitHub Actions workflows.""" + +from pathlib import Path +import re +import sys + + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOWS = ROOT / ".github" / "workflows" +FULL_SHA = re.compile(r"[0-9a-f]{40}") +PR_TRIGGER = re.compile(r"(?m)^ pull_request:\s*$") + + +def _active_text(text: str) -> str: + return "\n".join( + "" if line.lstrip().startswith("#") else line for line in text.splitlines() + ) + + +def check_workflow(path: Path, text: str) -> list[str]: + errors: list[str] = [] + active = _active_text(text) + + for dangerous_trigger in ("pull_request_target:", "workflow_run:"): + if dangerous_trigger in active: + errors.append(f"{path}: unaudited privileged trigger {dangerous_trigger[:-1]}") + + checkout_count = 0 + for line_number, line in enumerate(active.splitlines(), start=1): + match = re.match(r"^\s*(?:-\s+)?uses:\s+(.+?)\s*$", line) + if match is None: + continue + reference = match.group(1).split(" #", maxsplit=1)[0].strip() + if reference.startswith("./"): + continue + action, separator, ref = reference.rpartition("@") + if not separator or not action or FULL_SHA.fullmatch(ref) is None: + errors.append( + f"{path}:{line_number}: external action must use a full commit SHA: {reference}" + ) + if action == "actions/checkout": + checkout_count += 1 + + if re.search( + r"https://raw\.githubusercontent\.com/[^/\s]+/[^/\s]+/(?:main|master)/", + active, + ): + errors.append(f"{path}: raw GitHub downloads must use an immutable commit") + + if PR_TRIGGER.search(active): + if "secrets." in active: + errors.append(f"{path}: pull-request workflow must not reference repository secrets") + if re.search(r"(?m)^\s+[a-zA-Z0-9_-]+:\s+write\s*$", active): + errors.append(f"{path}: pull-request workflow must not request write permissions") + if active.count("persist-credentials: false") != checkout_count: + errors.append( + f"{path}: every pull-request checkout must discard GitHub credentials" + ) + + return errors + + +def main() -> int: + errors: list[str] = [] + for path in sorted(WORKFLOWS.glob("*.yml")): + errors.extend(check_workflow(path.relative_to(ROOT), path.read_text(encoding="utf-8"))) + + if errors: + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + print("workflow security checks passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci_checks.sh b/scripts/ci_checks.sh index 183883be..f34e9408 100755 --- a/scripts/ci_checks.sh +++ b/scripts/ci_checks.sh @@ -83,6 +83,8 @@ fi # 4. Workflow and script regression guards section "workflow and script guards" if python3 scripts/check_build_workflow.py && + python3 scripts/check_workflow_security.py && + python3 scripts/test_check_workflow_security.py && python3 scripts/check_update_packages_workflow.py && python3 scripts/test_pubspec_version.py && python3 scripts/test_clean_translations.py && diff --git a/scripts/test_check_workflow_security.py b/scripts/test_check_workflow_security.py new file mode 100755 index 00000000..7bf742f0 --- /dev/null +++ b/scripts/test_check_workflow_security.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import unittest + +from check_workflow_security import check_workflow + + +SAFE_SHA = "a" * 40 + + +class WorkflowSecurityTests(unittest.TestCase): + def check(self, text: str) -> list[str]: + return check_workflow(Path(".github/workflows/test.yml"), text) + + def test_accepts_read_only_pull_request_workflow_with_pinned_action(self) -> None: + errors = self.check( + f"""name: Test +on: + pull_request: +jobs: + test: + permissions: + contents: read + steps: + - uses: actions/checkout@{SAFE_SHA} # v7 + with: + persist-credentials: false +""" + ) + self.assertEqual(errors, []) + + def test_rejects_mutable_action_reference(self) -> None: + errors = self.check("jobs:\n test:\n steps:\n - uses: actions/checkout@v7\n") + self.assertTrue(any("full commit SHA" in error for error in errors)) + + def test_rejects_secrets_in_pull_request_workflow(self) -> None: + errors = self.check( + "on:\n pull_request:\njobs:\n test:\n env:\n TOKEN: ${{ secrets.TOKEN }}\n" + ) + self.assertTrue(any("must not reference repository secrets" in error for error in errors)) + + def test_rejects_write_permission_in_pull_request_workflow(self) -> None: + errors = self.check( + "on:\n pull_request:\njobs:\n test:\n permissions:\n contents: write\n" + ) + self.assertTrue(any("must not request write permissions" in error for error in errors)) + + def test_rejects_privileged_untrusted_trigger(self) -> None: + errors = self.check("on:\n pull_request_target:\n") + self.assertTrue(any("unaudited privileged trigger" in error for error in errors)) + + def test_rejects_mutable_raw_github_download(self) -> None: + errors = self.check( + "jobs:\n test:\n steps:\n - run: curl https://raw.githubusercontent.com/o/r/main/tool.sh\n" + ) + self.assertTrue(any("immutable commit" in error for error in errors)) + + +if __name__ == "__main__": + unittest.main()