diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml deleted file mode 100644 index 3927cf086..000000000 --- a/.github/workflows/npm-publish.yml +++ /dev/null @@ -1,354 +0,0 @@ -name: Publish to npm - -on: - push: - tags: - - 'v*' - -# One run per tag — a single tag push must not spawn duplicate publishes. -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - # Skip beta/alpha/rc tags — only publish stable releases - check-stable: - name: Check Stable Release - runs-on: ubuntu-latest - outputs: - is-stable: ${{ steps.check.outputs.is-stable }} - steps: - - name: Check if stable tag - id: check - run: | - TAG="${{ github.ref_name }}" - if echo "$TAG" | grep -qE '-(alpha|beta|rc|test)[.0-9]*$'; then - echo "is-stable=false" >> "$GITHUB_OUTPUT" - echo "Skipping npm publish for pre-release tag: $TAG" - else - echo "is-stable=true" >> "$GITHUB_OUTPUT" - echo "Stable release: $TAG" - fi - - # macOS ARM64 — build, sign, notarize - build-macos-arm64: - name: Build (macOS arm64) - needs: check-stable - if: needs.check-stable.outputs.is-stable == 'true' - runs-on: macos-latest - env: - APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable - with: - targets: aarch64-apple-darwin - - - name: Cache cargo - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: macos-cargo-aarch64-apple-darwin-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - macos-cargo-aarch64-apple-darwin- - - - name: Build - run: cargo build --release --target aarch64-apple-darwin - - - name: Import signing certificate - if: env.APPLE_CERTIFICATE_P12 != '' - env: - CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} - CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - run: | - KEYCHAIN_PATH=$RUNNER_TEMP/signing.keychain-db - KEYCHAIN_PASSWORD=$(openssl rand -base64 32) - - security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" - security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - - echo "$CERTIFICATE_P12" | base64 --decode > "$RUNNER_TEMP/certificate.p12" - security import "$RUNNER_TEMP/certificate.p12" \ - -P "$CERTIFICATE_PASSWORD" \ - -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" - security list-keychain -d user -s "$KEYCHAIN_PATH" - - - name: Sign binary - if: env.APPLE_CERTIFICATE_P12 != '' - env: - DEVELOPER_ID: ${{ secrets.APPLE_DEVELOPER_ID }} - run: | - codesign --deep --force --verify --verbose \ - --sign "$DEVELOPER_ID" \ - --options runtime \ - target/aarch64-apple-darwin/release/actual - - codesign --verify --verbose target/aarch64-apple-darwin/release/actual - - - name: Notarize - if: env.APPLE_CERTIFICATE_P12 != '' - env: - APPLE_ID: ${{ secrets.APPLE_NOTARIZATION_APPLE_ID }} - APPLE_TEAM_ID: ${{ secrets.APPLE_NOTARIZATION_TEAM_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }} - run: | - ditto -c -k --keepParent \ - target/aarch64-apple-darwin/release/actual \ - "$RUNNER_TEMP/actual.zip" - - xcrun notarytool submit "$RUNNER_TEMP/actual.zip" \ - --apple-id "$APPLE_ID" \ - --team-id "$APPLE_TEAM_ID" \ - --password "$APPLE_PASSWORD" \ - --wait - - - name: Upload artifact - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: actual-darwin-arm64 - path: target/aarch64-apple-darwin/release/actual - - # macOS x86_64 — build, sign, notarize - build-macos-x64: - name: Build (macOS x64) - needs: check-stable - if: needs.check-stable.outputs.is-stable == 'true' - runs-on: macos-latest - env: - APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable - with: - targets: x86_64-apple-darwin - - - name: Cache cargo - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: macos-cargo-x86_64-apple-darwin-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - macos-cargo-x86_64-apple-darwin- - - - name: Build - run: cargo build --release --target x86_64-apple-darwin - - - name: Import signing certificate - if: env.APPLE_CERTIFICATE_P12 != '' - env: - CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} - CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - run: | - KEYCHAIN_PATH=$RUNNER_TEMP/signing.keychain-db - KEYCHAIN_PASSWORD=$(openssl rand -base64 32) - - security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" - security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - - echo "$CERTIFICATE_P12" | base64 --decode > "$RUNNER_TEMP/certificate.p12" - security import "$RUNNER_TEMP/certificate.p12" \ - -P "$CERTIFICATE_PASSWORD" \ - -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" - security list-keychain -d user -s "$KEYCHAIN_PATH" - - - name: Sign binary - if: env.APPLE_CERTIFICATE_P12 != '' - env: - DEVELOPER_ID: ${{ secrets.APPLE_DEVELOPER_ID }} - run: | - codesign --deep --force --verify --verbose \ - --sign "$DEVELOPER_ID" \ - --options runtime \ - target/x86_64-apple-darwin/release/actual - - codesign --verify --verbose target/x86_64-apple-darwin/release/actual - - - name: Notarize - if: env.APPLE_CERTIFICATE_P12 != '' - env: - APPLE_ID: ${{ secrets.APPLE_NOTARIZATION_APPLE_ID }} - APPLE_TEAM_ID: ${{ secrets.APPLE_NOTARIZATION_TEAM_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }} - run: | - ditto -c -k --keepParent \ - target/x86_64-apple-darwin/release/actual \ - "$RUNNER_TEMP/actual.zip" - - xcrun notarytool submit "$RUNNER_TEMP/actual.zip" \ - --apple-id "$APPLE_ID" \ - --team-id "$APPLE_TEAM_ID" \ - --password "$APPLE_PASSWORD" \ - --wait - - - name: Upload artifact - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: actual-darwin-x64 - path: target/x86_64-apple-darwin/release/actual - - # Linux x86_64 - build-linux-x64: - name: Build (Linux x64) - needs: check-stable - if: needs.check-stable.outputs.is-stable == 'true' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable - with: - targets: x86_64-unknown-linux-gnu - - - name: Cache cargo - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: linux-cargo-x86_64-unknown-linux-gnu-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - linux-cargo-x86_64-unknown-linux-gnu- - - - name: Build - run: cargo build --release --target x86_64-unknown-linux-gnu - - - name: Upload artifact - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: actual-linux-x64 - path: target/x86_64-unknown-linux-gnu/release/actual - - # Linux ARM64 (cross-compile) - build-linux-arm64: - name: Build (Linux arm64) - needs: check-stable - if: needs.check-stable.outputs.is-stable == 'true' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable - with: - targets: aarch64-unknown-linux-gnu - - - name: Install cross-compilation tools - run: | - sudo apt-get update - sudo apt-get install -y gcc-aarch64-linux-gnu - - - name: Cache cargo - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: linux-cargo-aarch64-unknown-linux-gnu-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - linux-cargo-aarch64-unknown-linux-gnu- - - - name: Build - run: cargo build --release --target aarch64-unknown-linux-gnu - env: - CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc - - - name: Upload artifact - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: actual-linux-arm64 - path: target/aarch64-unknown-linux-gnu/release/actual - - # Publish all npm packages - publish-npm: - name: Publish to npm - needs: - - check-stable - - build-macos-arm64 - - build-macos-x64 - - build-linux-x64 - - build-linux-arm64 - if: needs.check-stable.outputs.is-stable == 'true' - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write # mint the OIDC token for npm trusted publishing - steps: - - name: Checkout npm scaffolding - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - repository: actual-software/actual-releases - token: ${{ secrets.TAP_GITHUB_TOKEN }} - - - name: Set up Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 - with: - node-version: '20' - registry-url: 'https://registry.npmjs.org' - - - name: Upgrade npm (trusted publishing needs >= 11.5.1) - # Pin to npm 11.x: >= 11.5.1 adds OIDC trusted publishing and still supports - # Node 20. npm@latest is now npm 12, which requires Node >= 22 and breaks here. - run: npm install -g 'npm@^11.5.1' - - - name: Download all binaries - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - path: artifacts/ - - - name: Sync npm versions from tag - run: | - VERSION="${{ github.ref_name }}" - bash scripts/sync-npm-version.sh "${VERSION#v}" - - - name: Place binaries into platform packages - run: | - cp artifacts/actual-darwin-arm64/actual npm/actual-darwin-arm64/bin/actual - chmod +x npm/actual-darwin-arm64/bin/actual - - cp artifacts/actual-darwin-x64/actual npm/actual-darwin-x64/bin/actual - chmod +x npm/actual-darwin-x64/bin/actual - - cp artifacts/actual-linux-x64/actual npm/actual-linux-x64/bin/actual - chmod +x npm/actual-linux-x64/bin/actual - - cp artifacts/actual-linux-arm64/actual npm/actual-linux-arm64/bin/actual - chmod +x npm/actual-linux-arm64/bin/actual - - # Auth is via OIDC trusted publishing (id-token: write above) — no NPM_TOKEN. - - name: Publish platform packages - run: | - for pkg in actual-darwin-arm64 actual-darwin-x64 actual-linux-x64 actual-linux-arm64; do - echo "Publishing @actualai/$pkg..." - (cd npm/$pkg && npm publish --access public) - done - - - name: Publish root package - run: | - echo "Publishing @actualai/actual..." - cd npm/actual && npm publish --access public diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f1946e25..d269dd5f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,14 @@ on: push: tags: - 'v*' + # A pull request that changes the release pipeline runs it as a dry run: the + # same build, SHA256SUMS and npm packages, all checked, and nothing published. + pull_request: + branches: [main] + paths: + - '.github/workflows/release.yml' + - 'crates/release-sign/**' + - 'release-keys/**' # One run per tag — a single tag push must not spawn duplicate releases. concurrency: @@ -11,7 +19,7 @@ concurrency: cancel-in-progress: true permissions: - contents: write + contents: read jobs: check-stable: @@ -19,12 +27,14 @@ jobs: runs-on: ubuntu-latest outputs: is-stable: ${{ steps.check.outputs.is-stable }} + publish: ${{ steps.mode.outputs.publish }} + version: ${{ steps.mode.outputs.version }} steps: - name: Check if stable tag id: check run: | TAG="${{ github.ref_name }}" - if echo "$TAG" | grep -qE '-(alpha|beta|rc|test)[.0-9]*$'; then + if echo "$TAG" | grep -qE -- '-(alpha|beta|rc|test)[.0-9]*$'; then echo "is-stable=false" >> "$GITHUB_OUTPUT" echo "Skipping release for pre-release tag: $TAG" else @@ -32,6 +42,24 @@ jobs: echo "Stable release: $TAG" fi + - name: Publish or dry run + id: mode + run: | + if [ "$GITHUB_EVENT_NAME" = push ] && [ "${GITHUB_REF#refs/tags/v}" != "$GITHUB_REF" ]; then + { + echo "publish=true" + echo "version=${GITHUB_REF_NAME#v}" + } >> "$GITHUB_OUTPUT" + else + # npm publish --dry-run still refuses a version the registry already + # holds, so a dry run packages a version that can never be published. + { + echo "publish=false" + echo "version=0.0.0-dry-run.$GITHUB_RUN_ID" + } >> "$GITHUB_OUTPUT" + echo "Dry run: everything is built and checked, nothing is published" + fi + build-macos-arm64: name: Build (macOS arm64) needs: check-stable @@ -243,6 +271,7 @@ jobs: targets: aarch64-unknown-linux-gnu - name: Install cross-compilation tools + timeout-minutes: 10 run: | sudo apt-get update sudo apt-get install -y gcc-aarch64-linux-gnu @@ -271,8 +300,8 @@ jobs: name: actual-linux-arm64 path: target/aarch64-unknown-linux-gnu/release/actual - package-and-release: - name: Package & Release + package: + name: Package needs: [check-stable, build-macos-arm64, build-macos-x64, build-linux-x64, build-linux-arm64] if: needs.check-stable.outputs.is-stable == 'true' runs-on: ubuntu-latest @@ -284,7 +313,6 @@ jobs: - name: Create tarballs run: | - VERSION="${{ github.ref_name }}" mkdir -p tarballs # Make all binaries executable @@ -299,45 +327,64 @@ jobs: tar -czf tarballs/actual-linux-x64.tar.gz -C artifacts/actual-linux-x64 actual tar -czf tarballs/actual-linux-arm64.tar.gz -C artifacts/actual-linux-arm64 actual - - name: Create private GitHub Release + # The one SHA256SUMS: both release pages, the Homebrew formula, the npm + # check below and the release signature all start from this file. + - name: Write SHA256SUMS + working-directory: tarballs + run: | + sha256sum actual-darwin-arm64.tar.gz actual-darwin-x64.tar.gz \ + actual-linux-x64.tar.gz actual-linux-arm64.tar.gz > SHA256SUMS + cat SHA256SUMS + + - name: Upload release assets + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: release-assets + path: tarballs/ + if-no-files-found: error + + github-release: + name: GitHub Releases + needs: [check-stable, package] + if: needs.check-stable.outputs.publish == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download release assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-assets + path: dist/ + + - name: Check the tarballs against SHA256SUMS + working-directory: dist + run: sha256sum --check --strict SHA256SUMS + + - name: Create GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - VERSION="${{ github.ref_name }}" - gh release create "$VERSION" \ - --repo "${{ github.repository }}" \ - --title "$VERSION" \ + gh release create "$GITHUB_REF_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + --title "$GITHUB_REF_NAME" \ --generate-notes \ - tarballs/*.tar.gz + dist/*.tar.gz dist/SHA256SUMS - - name: Mirror tarballs to public releases repo + - name: Mirror to public releases repo env: GH_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} run: | - VERSION="${{ github.ref_name }}" - gh release create "$VERSION" \ + gh release create "$GITHUB_REF_NAME" \ --repo actual-software/actual-releases \ - --title "$VERSION" \ - --notes "Release assets for actual-cli $VERSION. Source: ${{ github.server_url }}/${{ github.repository }}" \ - tarballs/*.tar.gz - - - name: Compute SHA256s - id: sha256 - run: | - echo "darwin_arm64=$(shasum -a 256 tarballs/actual-darwin-arm64.tar.gz | awk '{print $1}')" >> "$GITHUB_OUTPUT" - echo "darwin_x64=$(shasum -a 256 tarballs/actual-darwin-x64.tar.gz | awk '{print $1}')" >> "$GITHUB_OUTPUT" - echo "linux_x64=$(shasum -a 256 tarballs/actual-linux-x64.tar.gz | awk '{print $1}')" >> "$GITHUB_OUTPUT" - echo "linux_arm64=$(shasum -a 256 tarballs/actual-linux-arm64.tar.gz | awk '{print $1}')" >> "$GITHUB_OUTPUT" - - outputs: - sha256_darwin_arm64: ${{ steps.sha256.outputs.darwin_arm64 }} - sha256_darwin_x64: ${{ steps.sha256.outputs.darwin_x64 }} - sha256_linux_x64: ${{ steps.sha256.outputs.linux_x64 }} - sha256_linux_arm64: ${{ steps.sha256.outputs.linux_arm64 }} + --title "$GITHUB_REF_NAME" \ + --notes "Release assets for actual-cli $GITHUB_REF_NAME. Source: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY" \ + dist/*.tar.gz dist/SHA256SUMS update-homebrew-tap: name: Update Homebrew Tap - needs: package-and-release + needs: [check-stable, github-release] + if: needs.check-stable.outputs.publish == 'true' runs-on: ubuntu-latest steps: - name: Checkout tap repo @@ -346,6 +393,24 @@ jobs: repository: actual-software/homebrew-actual token: ${{ secrets.TAP_GITHUB_TOKEN }} + - name: Download release assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-assets + path: ${{ runner.temp }}/dist + + - name: Read SHA256s from SHA256SUMS + id: sha256 + run: | + for platform in darwin-arm64 darwin-x64 linux-x64 linux-arm64; do + sha=$(awk -v f="actual-$platform.tar.gz" '$2 == f { print $1 }' "$RUNNER_TEMP/dist/SHA256SUMS") + if ! [[ "$sha" =~ ^[0-9a-f]{64}$ ]]; then + echo "::error::SHA256SUMS has no line for actual-$platform.tar.gz" + exit 1 + fi + echo "${platform//-/_}=$sha" >> "$GITHUB_OUTPUT" + done + - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 @@ -357,10 +422,10 @@ jobs: template/actual.rb \ Formula/actual.rb \ --version "${VERSION#v}" \ - --sha-darwin-arm64 "${{ needs.package-and-release.outputs.sha256_darwin_arm64 }}" \ - --sha-darwin-x64 "${{ needs.package-and-release.outputs.sha256_darwin_x64 }}" \ - --sha-linux-x64 "${{ needs.package-and-release.outputs.sha256_linux_x64 }}" \ - --sha-linux-arm64 "${{ needs.package-and-release.outputs.sha256_linux_arm64 }}" + --sha-darwin-arm64 "${{ steps.sha256.outputs.darwin_arm64 }}" \ + --sha-darwin-x64 "${{ steps.sha256.outputs.darwin_x64 }}" \ + --sha-linux-x64 "${{ steps.sha256.outputs.linux_x64 }}" \ + --sha-linux-arm64 "${{ steps.sha256.outputs.linux_arm64 }}" - name: Commit and push to tap env: @@ -371,3 +436,176 @@ jobs: git add Formula/actual.rb git diff --staged --quiet || git commit -m "actual $VERSION" git push + + # Builds the five npm packages from the release tarballs and checks them. + # It runs on pull requests too, so it holds no id-token: npm exchanges an + # OIDC token for a publish token even on `npm publish --dry-run`. + npm-packages: + name: npm Packages + needs: [check-stable, package] + if: needs.check-stable.outputs.is-stable == 'true' + runs-on: ubuntu-latest + env: + VERSION: ${{ needs.check-stable.outputs.version }} + steps: + - name: Checkout npm scaffolding + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: actual-software/actual-releases + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + + - name: Upgrade npm (trusted publishing needs >= 11.5.1) + # Pin to npm 11.x: >= 11.5.1 adds OIDC trusted publishing and still supports + # Node 20. npm@latest is now npm 12, which requires Node >= 22 and breaks here. + run: npm install -g 'npm@^11.5.1' + + - name: Download release assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-assets + path: ${{ runner.temp }}/dist + + - name: Check the tarballs against SHA256SUMS + working-directory: ${{ runner.temp }}/dist + run: sha256sum --check --strict SHA256SUMS + + - name: Sync npm versions + run: bash scripts/sync-npm-version.sh "$VERSION" + + - name: Place binaries into platform packages + run: | + for platform in darwin-arm64 darwin-x64 linux-x64 linux-arm64; do + mkdir -p "$RUNNER_TEMP/release-bin/$platform" + tar -xzf "$RUNNER_TEMP/dist/actual-$platform.tar.gz" -C "$RUNNER_TEMP/release-bin/$platform" + cp "$RUNNER_TEMP/release-bin/$platform/actual" "npm/actual-$platform/bin/actual" + chmod +x "npm/actual-$platform/bin/actual" + done + + - name: Pack the packages + run: | + for pkg in actual-darwin-arm64 actual-darwin-x64 actual-linux-x64 actual-linux-arm64 actual; do + mkdir -p "$RUNNER_TEMP/npm-packages/$pkg" + (cd "npm/$pkg" && npm pack --pack-destination "$RUNNER_TEMP/npm-packages/$pkg") + done + + - name: Compare the packed binaries with the release tarballs + run: | + for platform in darwin-arm64 darwin-x64 linux-x64 linux-arm64; do + unpacked="$RUNNER_TEMP/npm-unpacked/$platform" + mkdir -p "$unpacked" + tar -xzf "$RUNNER_TEMP/npm-packages/actual-$platform/actualai-actual-$platform-$VERSION.tgz" -C "$unpacked" + if ! test -x "$unpacked/package/bin/actual"; then + echo "::error::@actualai/actual-$platform packs bin/actual without the executable bit" + exit 1 + fi + if ! cmp "$unpacked/package/bin/actual" "$RUNNER_TEMP/release-bin/$platform/actual"; then + echo "::error::@actualai/actual-$platform packs a different binary than actual-$platform.tar.gz" + exit 1 + fi + echo "@actualai/actual-$platform carries the binary from actual-$platform.tar.gz, byte for byte" + done + + - name: npm publish --dry-run + if: needs.check-stable.outputs.publish != 'true' + run: | + for pkg in actual-darwin-arm64 actual-darwin-x64 actual-linux-x64 actual-linux-arm64 actual; do + (cd "npm/$pkg" && npm publish --dry-run --tag dry-run --access public) + done + + - name: Upload npm packages + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: npm-packages + path: ${{ runner.temp }}/npm-packages/ + if-no-files-found: error + + publish-npm: + name: Publish to npm + needs: [check-stable, npm-packages] + if: needs.check-stable.outputs.publish == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # mint the OIDC token for npm trusted publishing + env: + VERSION: ${{ needs.check-stable.outputs.version }} + steps: + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + + - name: Upgrade npm (trusted publishing needs >= 11.5.1) + run: npm install -g 'npm@^11.5.1' + + - name: Download npm packages + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: npm-packages + path: ${{ runner.temp }}/npm-packages + + - name: Unpack the checked packages + run: | + for pkg in actual-darwin-arm64 actual-darwin-x64 actual-linux-x64 actual-linux-arm64 actual; do + mkdir -p "npm/$pkg" + tar -xzf "$RUNNER_TEMP/npm-packages/$pkg/actualai-$pkg-$VERSION.tgz" -C "npm/$pkg" --strip-components=1 + done + + # Auth is via OIDC trusted publishing (id-token: write above) — no NPM_TOKEN. + - name: Publish platform packages + run: | + for pkg in actual-darwin-arm64 actual-darwin-x64 actual-linux-x64 actual-linux-arm64; do + echo "Publishing @actualai/$pkg..." + (cd npm/$pkg && npm publish --access public) + done + + - name: Publish root package + run: | + echo "Publishing @actualai/actual..." + cd npm/actual && npm publish --access public + + signing-tool: + name: Signing Tool + if: github.event_name == 'pull_request' + runs-on: macos-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + with: + components: clippy, rustfmt + + - name: Cache cargo + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: macos-cargo-release-sign-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + macos-cargo-release-sign- + + - name: Install minisign + env: + HOMEBREW_NO_AUTO_UPDATE: '1' + run: brew install minisign + + - name: Check formatting + run: cargo fmt -p release-sign --check + + - name: Run clippy + run: cargo clippy -p release-sign --all-targets -- -D warnings + + - name: Run tests, including the stock minisign check + run: cargo test -p release-sign -- --include-ignored diff --git a/Cargo.lock b/Cargo.lock index 9c2dd94f3..197d4db3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2139,6 +2139,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "minisign-verify" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3036,6 +3042,24 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +[[package]] +name = "release-sign" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "blake2", + "clap", + "minisign-verify", + "mockito", + "reqwest", + "ring", + "serde", + "serde_json", + "sha2", + "tempfile", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -3044,6 +3068,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", + "futures-channel", "futures-core", "futures-util", "http", diff --git a/Cargo.toml b/Cargo.toml index addce55a3..ce31dca88 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = [".", "crates/tui-test"] +members = [".", "crates/tui-test", "crates/release-sign"] [package] name = "actual-cli" diff --git a/README.md b/README.md index be6d74112..f518e4b01 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,23 @@ cargo install --git https://github.com/actual-software/actual-cli.git +
+Check a downloaded release + +Releases after v0.9.0 carry `SHA256SUMS`, which lists every archive, and `SHA256SUMS.minisig`, a [minisign](https://jedisct1.github.io/minisign/) signature over that list. Check the signature first: + +```bash +curl -fsSLO https://github.com/actual-software/actual-releases/releases/latest/download/SHA256SUMS +curl -fsSLO https://github.com/actual-software/actual-releases/releases/latest/download/SHA256SUMS.minisig +curl -fsSL -o actual-release.pub https://raw.githubusercontent.com/actual-software/actual-cli/main/release-keys/primary.pub +minisign -Vm SHA256SUMS -p actual-release.pub +shasum -a 256 --check --ignore-missing SHA256SUMS +``` + +`minisign` then prints the signed trusted comment, which should name the tag of the release you downloaded, because a signature from an older release still verifies. If it reports a different key id, check against the spare key in [Release signing](docs/RELEASE_SIGNING.md). + +
+ ## Use Actual.ai Skill + CLI Once the [Actual.ai Skill](https://github.com/actual-software/actual-skill) is installed, ask your agent in plain language. The skill manages the CLI and picks the right command for each request. For example: diff --git a/crates/release-sign/Cargo.toml b/crates/release-sign/Cargo.toml new file mode 100644 index 000000000..9da000ab2 --- /dev/null +++ b/crates/release-sign/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "release-sign" +version = "0.1.0" +edition = "2021" +description = "Signs a published actual-cli release's SHA256SUMS with the org's Cloud KMS key" +license = "MIT" +publish = false + +[[bin]] +name = "release-sign" +path = "src/main.rs" + +[dependencies] +anyhow = "1" +base64 = "0.22" +blake2 = "0.10" +clap = { version = "4", features = ["derive", "env"] } +minisign-verify = "0.3" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +tempfile = "3" + +[dev-dependencies] +mockito = "1" +ring = "0.17" diff --git a/crates/release-sign/src/github.rs b/crates/release-sign/src/github.rs new file mode 100644 index 000000000..7c4dccc29 --- /dev/null +++ b/crates/release-sign/src/github.rs @@ -0,0 +1,272 @@ +//! Reading and writing GitHub releases through the signer's own `gh` login. + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{ensure, Context, Result}; +use serde::Deserialize; + +use crate::spawn::spawn_with_etxtbsy_retry; + +/// A workflow run, with the fields that tie it to a tag. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct Run { + pub id: u64, + pub event: String, + pub path: String, + pub head_branch: Option, + pub head_sha: String, + pub status: String, + pub conclusion: Option, +} + +pub trait ReleaseHost { + /// The commit a tag points at. + fn tag_commit(&self, repo: &str, tag: &str) -> Result; + /// The release workflow's runs for a tag push. + fn release_runs(&self, repo: &str, workflow: &str, tag: &str) -> Result>; + fn download_run_artifact( + &self, + repo: &str, + run_id: u64, + artifact: &str, + dir: &Path, + ) -> Result<()>; + /// Downloads every asset of a release into `dir`. + fn download_release(&self, repo: &str, tag: &str, dir: &Path) -> Result<()>; + /// Uploads `file` to a release, replacing an asset of the same name. + fn upload_release_asset(&self, repo: &str, tag: &str, file: &Path) -> Result<()>; +} + +pub struct Gh { + program: PathBuf, +} + +#[derive(Deserialize)] +struct Runs { + workflow_runs: Vec, +} + +impl Gh { + pub fn new(program: impl Into) -> Self { + Self { + program: program.into(), + } + } + + fn run(&self, args: I) -> Result> + where + I: IntoIterator, + S: AsRef, + { + let args: Vec<_> = args.into_iter().map(|a| a.as_ref().to_owned()).collect(); + let shown = args + .iter() + .map(|a| a.to_string_lossy()) + .collect::>() + .join(" "); + let output = spawn_with_etxtbsy_retry(|| Command::new(&self.program).args(&args).output()) + .with_context(|| { + format!( + "running {} (is the GitHub CLI installed?)", + self.program.display() + ) + })?; + ensure!( + output.status.success(), + "`gh {shown}` failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + Ok(output.stdout) + } +} + +impl ReleaseHost for Gh { + fn tag_commit(&self, repo: &str, tag: &str) -> Result { + let out = self.run([ + "api".to_owned(), + format!("repos/{repo}/commits/refs/tags/{tag}"), + "--jq".to_owned(), + ".sha".to_owned(), + ])?; + let sha = String::from_utf8(out)?.trim().to_owned(); + ensure!( + sha.len() == 40 && sha.bytes().all(|b| b.is_ascii_hexdigit()), + "GitHub didn't return a commit for tag {tag} in {repo}" + ); + Ok(sha) + } + + fn release_runs(&self, repo: &str, workflow: &str, tag: &str) -> Result> { + let out = self.run([ + "api".to_owned(), + "--method".to_owned(), + "GET".to_owned(), + format!("repos/{repo}/actions/workflows/{workflow}/runs"), + "-f".to_owned(), + "event=push".to_owned(), + "-f".to_owned(), + format!("branch={tag}"), + "-f".to_owned(), + "per_page=100".to_owned(), + ])?; + let runs: Runs = serde_json::from_slice(&out).context("reading the workflow runs")?; + Ok(runs.workflow_runs) + } + + fn download_run_artifact( + &self, + repo: &str, + run_id: u64, + artifact: &str, + dir: &Path, + ) -> Result<()> { + self.run([ + OsStr::new("run"), + OsStr::new("download"), + OsStr::new(&run_id.to_string()), + OsStr::new("--repo"), + OsStr::new(repo), + OsStr::new("--name"), + OsStr::new(artifact), + OsStr::new("--dir"), + dir.as_os_str(), + ])?; + Ok(()) + } + + fn download_release(&self, repo: &str, tag: &str, dir: &Path) -> Result<()> { + self.run([ + OsStr::new("release"), + OsStr::new("download"), + OsStr::new(tag), + OsStr::new("--repo"), + OsStr::new(repo), + OsStr::new("--dir"), + dir.as_os_str(), + ])?; + Ok(()) + } + + fn upload_release_asset(&self, repo: &str, tag: &str, file: &Path) -> Result<()> { + self.run([ + OsStr::new("release"), + OsStr::new("upload"), + OsStr::new(tag), + file.as_os_str(), + OsStr::new("--repo"), + OsStr::new(repo), + OsStr::new("--clobber"), + ])?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::PermissionsExt; + + use super::*; + + /// A `gh` that records its arguments and answers from `script`. + fn fake_gh(script: &str) -> (tempfile::TempDir, Gh, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join("calls"); + let path = dir.path().join("gh"); + std::fs::write( + &path, + format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\n{script}\n", + log.display() + ), + ) + .unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + let gh = Gh::new(&path); + (dir, gh, log) + } + + fn calls(log: &Path) -> String { + std::fs::read_to_string(log).unwrap() + } + + const SHA: &str = "768473505d1c4bfd566cb0a5339e42b23ccfdd64"; + + #[test] + fn resolves_a_tag_to_its_commit() { + let (_dir, gh, log) = fake_gh(&format!("echo {SHA}")); + assert_eq!(gh.tag_commit("o/r", "v1.2.3").unwrap(), SHA); + assert_eq!( + calls(&log), + "api repos/o/r/commits/refs/tags/v1.2.3 --jq .sha\n" + ); + } + + #[test] + fn refuses_a_tag_lookup_that_returns_no_commit() { + let (_dir, gh, _log) = fake_gh("echo null"); + assert!(gh.tag_commit("o/r", "v1.2.3").is_err()); + } + + #[test] + fn lists_the_release_runs_for_a_tag() { + let body = format!( + r#"{{"total_count":1,"workflow_runs":[{{"id":39998172755,"name":"Release","event":"push","path":".github/workflows/release.yml","head_branch":"v1.2.3","head_sha":"{SHA}","status":"completed","conclusion":"success"}}]}}"# + ); + let (_dir, gh, log) = fake_gh(&format!("echo '{body}'")); + let runs = gh.release_runs("o/r", "release.yml", "v1.2.3").unwrap(); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].id, 39998172755); + assert_eq!(runs[0].head_branch.as_deref(), Some("v1.2.3")); + assert_eq!( + calls(&log), + "api --method GET repos/o/r/actions/workflows/release.yml/runs -f event=push -f branch=v1.2.3 -f per_page=100\n" + ); + } + + #[test] + fn downloads_and_uploads_with_the_expected_arguments() { + let (dir, gh, log) = fake_gh("exit 0"); + let target = dir.path().join("out"); + gh.download_run_artifact("o/r", 42, "release-assets", &target) + .unwrap(); + gh.download_release("o/m", "v1.2.3", &target).unwrap(); + gh.upload_release_asset("o/m", "v1.2.3", &target.join("SHA256SUMS.minisig")) + .unwrap(); + let t = target.display(); + assert_eq!( + calls(&log), + format!( + "run download 42 --repo o/r --name release-assets --dir {t}\n\ + release download v1.2.3 --repo o/m --dir {t}\n\ + release upload v1.2.3 {t}/SHA256SUMS.minisig --repo o/m --clobber\n" + ) + ); + } + + #[test] + fn surfaces_gh_errors() { + let (_dir, gh, _log) = fake_gh("echo 'HTTP 404: Not Found' >&2; exit 1"); + let err = gh + .download_release("o/m", "v9.9.9", Path::new("/tmp")) + .unwrap_err() + .to_string(); + assert!( + err.contains("gh release download v9.9.9") && err.contains("404"), + "{err}" + ); + let missing = Gh::new("/nonexistent/gh"); + assert!(missing + .tag_commit("o/r", "v1") + .unwrap_err() + .to_string() + .contains("GitHub CLI")); + } + + #[test] + fn refuses_unreadable_run_listings() { + let (_dir, gh, _log) = fake_gh("echo '{}'"); + assert!(gh.release_runs("o/r", "release.yml", "v1").is_err()); + } +} diff --git a/crates/release-sign/src/keys.rs b/crates/release-sign/src/keys.rs new file mode 100644 index 000000000..6db616050 --- /dev/null +++ b/crates/release-sign/src/keys.rs @@ -0,0 +1,83 @@ +//! The public keys committed in `release-keys/`, built into the tool so it +//! checks every signature against exactly what the repository publishes. + +use anyhow::{Context, Result}; + +use crate::minisign::{PublicKey, TrustedKey}; + +const COMMITTED: [(&str, &str); 2] = [ + ("primary", include_str!("../../../release-keys/primary.pub")), + ("spare", include_str!("../../../release-keys/spare.pub")), +]; + +pub fn committed() -> Result> { + parse_all(&COMMITTED) +} + +fn parse_all(files: &[(&str, &str)]) -> Result> { + files + .iter() + .map(|(name, text)| { + let key = PublicKey::parse(text).with_context(|| { + format!("release-keys/{name}.pub can't be used (the one-time setup of the signing keys, in the team's internal docs, writes it)") + })?; + Ok(TrustedKey { + name: (*name).to_owned(), + key, + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_placeholder_key_names_the_file_and_the_setup() { + let err = parse_all(&[("primary", "untrusted comment: placeholder\n")]).unwrap_err(); + let text = format!("{err:#}"); + assert!( + text.contains("release-keys/primary.pub") + && text.contains( + "the one-time setup of the signing keys, in the team's internal docs" + ), + "{text}" + ); + } + + #[test] + fn real_keys_parse_in_order() { + let primary = PublicKey { + key_id: [1; 8], + key: [2; 32], + }; + let spare = PublicKey { + key_id: [3; 8], + key: [4; 32], + }; + let files = [ + ("primary", primary.to_file_contents()), + ("spare", spare.to_file_contents()), + ]; + let borrowed: Vec<(&str, &str)> = files.iter().map(|(n, t)| (*n, t.as_str())).collect(); + let keys = parse_all(&borrowed).unwrap(); + assert_eq!(keys[0].name, "primary"); + assert_eq!(keys[0].key, primary); + assert_eq!(keys[1].key, spare); + } + + #[test] + fn the_committed_files_are_placeholders_or_real_keys() { + for (name, text) in COMMITTED { + let first = text.lines().next().unwrap_or_default(); + assert!( + first.starts_with("untrusted comment:"), + "release-keys/{name}.pub" + ); + if text.lines().count() > 1 { + PublicKey::parse(text).unwrap(); + } + } + } +} diff --git a/crates/release-sign/src/kms.rs b/crates/release-sign/src/kms.rs new file mode 100644 index 000000000..6577f50cb --- /dev/null +++ b/crates/release-sign/src/kms.rs @@ -0,0 +1,341 @@ +//! Signing with an Ed25519 key held in Cloud KMS, through the REST API and the +//! signer's own `gcloud` login. + +use std::path::Path; +use std::process::Command; +use std::time::Duration; + +use anyhow::{bail, ensure, Context, Result}; +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine as _; +use serde::Deserialize; + +use crate::minisign::Signer; +use crate::spawn::spawn_with_etxtbsy_retry; + +const ENDPOINT: &str = "https://cloudkms.googleapis.com"; + +pub struct KmsSigner { + client: reqwest::blocking::Client, + endpoint: String, + key_version: String, + access_token: String, +} + +#[derive(Deserialize)] +struct SignResponse { + signature: String, + name: String, +} + +#[derive(Deserialize)] +struct ErrorResponse { + error: ErrorBody, +} + +#[derive(Deserialize)] +struct ErrorBody { + message: String, +} + +impl KmsSigner { + pub fn new(key_version: &str, access_token: String) -> Result { + Self::with_endpoint(ENDPOINT, key_version, access_token) + } + + fn with_endpoint(endpoint: &str, key_version: &str, access_token: String) -> Result { + check_key_version_name(key_version)?; + Ok(Self { + client: reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(60)) + .build()?, + endpoint: endpoint.to_owned(), + key_version: key_version.to_owned(), + access_token, + }) + } +} + +impl Signer for KmsSigner { + fn sign(&self, message: &[u8]) -> Result<[u8; 64]> { + // Ed25519 keys take the message itself in `data`, never a `digest`. + let response = self + .client + .post(format!( + "{}/v1/{}:asymmetricSign", + self.endpoint, self.key_version + )) + .bearer_auth(&self.access_token) + .json(&serde_json::json!({ "data": BASE64.encode(message) })) + .send() + .context("calling Cloud KMS")?; + let status = response.status(); + let body = response.bytes().context("reading the Cloud KMS response")?; + if !status.is_success() { + let detail = serde_json::from_slice::(&body) + .map(|e| e.error.message) + .unwrap_or_else(|_| String::from_utf8_lossy(&body).into_owned()); + bail!("Cloud KMS refused to sign ({status}): {detail}"); + } + let signed: SignResponse = + serde_json::from_slice(&body).context("reading the Cloud KMS response")?; + ensure!( + signed.name == self.key_version, + "Cloud KMS answered for {} instead of {}", + signed.name, + self.key_version + ); + let signature = BASE64 + .decode(&signed.signature) + .context("the Cloud KMS signature isn't base64")?; + signature.try_into().map_err(|s: Vec| { + anyhow::anyhow!( + "Cloud KMS returned a {}-byte signature; an Ed25519 key returns 64", + s.len() + ) + }) + } +} + +/// Requires the full resource name of one key version, so a signature is never made with whichever version is primary. +fn check_key_version_name(name: &str) -> Result<()> { + let parts: Vec<&str> = name.split('/').collect(); + let shape = [ + "projects", + "", + "locations", + "", + "keyRings", + "", + "cryptoKeys", + "", + "cryptoKeyVersions", + ]; + let well_formed = parts.len() == 10 + && parts.iter().all(|p| { + !p.is_empty() + && p.bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')) + }) + && shape + .iter() + .zip(&parts) + .all(|(want, got)| want.is_empty() || want == got) + && parts[9].bytes().all(|b| b.is_ascii_digit()); + ensure!( + well_formed, + "--kms-key takes a key version's full name, projects//locations//keyRings//cryptoKeys//cryptoKeyVersions/, not `{name}`" + ); + Ok(()) +} + +/// An access token for the signer's own `gcloud` login. +pub fn gcloud_access_token(gcloud: &Path) -> Result { + let output = spawn_with_etxtbsy_retry(|| { + Command::new(gcloud) + .args(["auth", "print-access-token"]) + .output() + }) + .with_context(|| { + format!( + "running {} (is the Google Cloud CLI installed?)", + gcloud.display() + ) + })?; + ensure!( + output.status.success(), + "`gcloud auth print-access-token` failed; run `gcloud auth login` with your work account first: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + let token = String::from_utf8(output.stdout)?.trim().to_owned(); + ensure!( + !token.is_empty(), + "`gcloud auth print-access-token` printed nothing" + ); + Ok(token) +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::PermissionsExt; + use std::sync::Arc; + + use ring::signature::{Ed25519KeyPair, KeyPair}; + + use super::*; + use crate::minisign::{encode_for_trusted_key, sign, PublicKey, TrustedKey}; + + const KEY_VERSION: &str = + "projects/p/locations/global/keyRings/release/cryptoKeys/release-signing-primary/cryptoKeyVersions/1"; + const SIGN_PATH: &str = + "/v1/projects/p/locations/global/keyRings/release/cryptoKeys/release-signing-primary/cryptoKeyVersions/1:asymmetricSign"; + + /// A stand-in for Cloud KMS that signs `data` with a local key, the way the real endpoint does. + fn fake_kms(server: &mut mockito::Server) -> (mockito::Mock, PublicKey) { + let rng = ring::rand::SystemRandom::new(); + let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); + let pair = Arc::new(Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap()); + let public = PublicKey { + key_id: [3; 8], + key: pair.public_key().as_ref().try_into().unwrap(), + }; + let mock = server + .mock("POST", SIGN_PATH) + .match_header("authorization", "Bearer test-token") + .match_header("content-type", "application/json") + .with_status(200) + .with_body_from_request(move |request| { + let body: serde_json::Value = + serde_json::from_slice(request.body().unwrap()).unwrap(); + assert!( + body.get("digest").is_none(), + "Ed25519 keys sign data, not a digest" + ); + let data = BASE64.decode(body["data"].as_str().unwrap()).unwrap(); + serde_json::json!({ + "signature": BASE64.encode(pair.sign(&data)), + "name": KEY_VERSION, + "protectionLevel": "SOFTWARE", + }) + .to_string() + .into_bytes() + }) + .expect(2) + .create(); + (mock, public) + } + + #[test] + fn signs_a_minisign_signature_through_the_kms_api() { + let mut server = mockito::Server::new(); + let (mock, public) = fake_kms(&mut server); + let signer = + KmsSigner::with_endpoint(&server.url(), KEY_VERSION, "test-token".into()).unwrap(); + let data = b"some SHA256SUMS\n"; + let signature = sign(&signer, data, "timestamp:1\tfile:SHA256SUMS\ttag:v1.0.0").unwrap(); + let keys = [TrustedKey { + name: "primary".into(), + key: public, + }]; + let (key, _) = encode_for_trusted_key(&signature, data, &keys).unwrap(); + assert_eq!(key.name, "primary"); + mock.assert(); + } + + #[test] + fn reports_why_kms_refused() { + let mut server = mockito::Server::new(); + server + .mock("POST", SIGN_PATH) + .with_status(403) + .with_body(r#"{"error":{"code":403,"message":"Permission 'cloudkms.cryptoKeyVersions.useToSign' denied","status":"PERMISSION_DENIED"}}"#) + .create(); + let signer = + KmsSigner::with_endpoint(&server.url(), KEY_VERSION, "test-token".into()).unwrap(); + let err = signer.sign(b"x").unwrap_err().to_string(); + assert!(err.contains("403") && err.contains("useToSign"), "{err}"); + } + + #[test] + fn reports_a_refusal_without_a_json_body() { + let mut server = mockito::Server::new(); + server + .mock("POST", SIGN_PATH) + .with_status(502) + .with_body("bad gateway") + .create(); + let signer = KmsSigner::with_endpoint(&server.url(), KEY_VERSION, "t".into()).unwrap(); + let err = signer.sign(b"x").unwrap_err().to_string(); + assert!(err.contains("502") && err.contains("bad gateway"), "{err}"); + } + + #[test] + fn refuses_a_response_for_another_key_or_of_the_wrong_size() { + for (body, expected) in [ + ( + serde_json::json!({ "signature": BASE64.encode([0u8; 64]), "name": "projects/p/locations/global/keyRings/release/cryptoKeys/other/cryptoKeyVersions/1" }), + "instead of", + ), + ( + serde_json::json!({ "signature": BASE64.encode([0u8; 63]), "name": KEY_VERSION }), + "63-byte signature", + ), + ( + serde_json::json!({ "signature": "!!", "name": KEY_VERSION }), + "isn't base64", + ), + ( + serde_json::json!({ "name": KEY_VERSION }), + "reading the Cloud KMS response", + ), + ] { + let mut server = mockito::Server::new(); + server + .mock("POST", SIGN_PATH) + .with_status(200) + .with_body(body.to_string()) + .create(); + let signer = KmsSigner::with_endpoint(&server.url(), KEY_VERSION, "t".into()).unwrap(); + let err = signer.sign(b"x").unwrap_err(); + assert!(format!("{err:#}").contains(expected), "{err:#}"); + } + } + + #[test] + fn reports_an_unreachable_endpoint() { + let signer = + KmsSigner::with_endpoint("http://127.0.0.1:9", KEY_VERSION, "t".into()).unwrap(); + assert!(signer + .sign(b"x") + .unwrap_err() + .to_string() + .contains("calling Cloud KMS")); + } + + #[test] + fn needs_one_key_version_by_its_full_name() { + assert!(KmsSigner::new(KEY_VERSION, "t".into()).is_ok()); + for name in [ + "projects/p/locations/global/keyRings/release/cryptoKeys/release-signing-primary", + "projects/p/locations/global/keyRings/release/cryptoKeys/k/cryptoKeyVersions/latest", + "projects/p/locations/global/keyRings/release/cryptoKeys/k/cryptoKeyVersions/1/extra", + "projects//locations/global/keyRings/release/cryptoKeys/k/cryptoKeyVersions/1", + "folders/p/locations/global/keyRings/release/cryptoKeys/k/cryptoKeyVersions/1", + "projects/p/locations/global/keyRings/r?x=1/cryptoKeys/k/cryptoKeyVersions/1", + ] { + assert!(KmsSigner::new(name, "t".into()).is_err(), "accepted {name}"); + } + } + + fn fake_gcloud(script: &str) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("gcloud"); + std::fs::write(&path, format!("#!/bin/sh\n{script}\n")).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + dir + } + + #[test] + fn reads_the_access_token_from_gcloud() { + let dir = fake_gcloud(r#"[ "$*" = "auth print-access-token" ] && echo ya29.token"#); + assert_eq!( + gcloud_access_token(&dir.path().join("gcloud")).unwrap(), + "ya29.token" + ); + } + + #[test] + fn explains_a_missing_or_logged_out_gcloud() { + let dir = fake_gcloud("echo 'ERROR: no credentialed accounts' >&2; exit 1"); + let err = gcloud_access_token(&dir.path().join("gcloud")) + .unwrap_err() + .to_string(); + assert!( + err.contains("gcloud auth login") && err.contains("no credentialed accounts"), + "{err}" + ); + let empty = fake_gcloud("exit 0"); + assert!(gcloud_access_token(&empty.path().join("gcloud")).is_err()); + assert!(gcloud_access_token(&dir.path().join("missing")).is_err()); + } +} diff --git a/crates/release-sign/src/main.rs b/crates/release-sign/src/main.rs new file mode 100644 index 000000000..615ebec93 --- /dev/null +++ b/crates/release-sign/src/main.rs @@ -0,0 +1,292 @@ +//! Signs a published actual-cli release's SHA256SUMS with the org's Cloud KMS +//! key. docs/RELEASE_SIGNING.md covers when to run it, and the one-time setup +//! of the signing keys is in the team's internal docs. + +mod github; +mod keys; +mod kms; +mod minisign; +mod release; +mod spawn; +mod sums; +#[cfg(test)] +mod test_support; + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; + +use crate::minisign::{Signer, TrustedKey}; + +#[derive(Parser)] +#[command( + name = "release-sign", + about = "Sign actual-cli releases with the org's Cloud KMS key (see docs/RELEASE_SIGNING.md)" +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Check a published release against the run that built it, then sign its SHA256SUMS and upload the signature to both release pages + Release { + /// The release tag, such as v1.2.3 + tag: String, + /// The Cloud KMS key version to sign with, by its full resource name + #[arg(long, env = "RELEASE_SIGN_KMS_KEY", value_name = "KEY_VERSION")] + kms_key: String, + /// The release run to check against, when the tag's workflow ran more than once + #[arg(long, value_name = "RUN_ID")] + run: Option, + /// Check and sign, but keep SHA256SUMS.minisig on this machine + #[arg(long)] + no_upload: bool, + /// Where to write SHA256SUMS.minisig + #[arg(long, default_value = ".", value_name = "DIR")] + out_dir: PathBuf, + }, + /// Sign a local file into FILE.minisig, as the setup's test step does + SignFile { + file: PathBuf, + /// The Cloud KMS key version to sign with, by its full resource name + #[arg(long, env = "RELEASE_SIGN_KMS_KEY", value_name = "KEY_VERSION")] + kms_key: String, + }, + /// Print the minisign public key for a Cloud KMS Ed25519 key exported as PEM + PublicKey { + /// The PEM file `gcloud kms keys versions get-public-key` wrote + #[arg(long)] + pem: PathBuf, + /// The key id as 16 hex digits; random when omitted + #[arg(long)] + key_id: Option, + }, +} + +fn main() -> ExitCode { + match run(Cli::parse()) { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("error: {err:#}"); + ExitCode::FAILURE + } + } +} + +fn run(cli: Cli) -> Result<()> { + match cli.command { + Command::Release { + tag, + kms_key, + run, + no_upload, + out_dir, + } => { + let keys = keys::committed()?; + release::check_tag(&tag)?; + let signer = kms_signer(&kms_key)?; + let work = tempfile::tempdir()?; + let options = release::Options { + tag, + run_id: run, + upload: !no_upload, + out_dir, + timestamp: now()?, + }; + let signed = release::sign_release( + &github::Gh::new("gh"), + &signer, + &keys, + work.path(), + &options, + )?; + let summary = format!( + "Signed {} (built by run {}) with the {} key", + options.tag, signed.run_id, signed.key_name + ); + if signed.uploaded_to.is_empty() { + println!( + "{summary} and wrote {} without uploading it.", + signed.signature_path.display() + ); + } else { + println!( + "{summary}; {} is on the release pages of {}.", + release::SIGNATURE_FILE, + signed.uploaded_to.join(" and ") + ); + } + } + Command::SignFile { file, kms_key } => { + let keys = keys::committed()?; + let signer = kms_signer(&kms_key)?; + let out = sign_file(&signer, &keys, &file, now()?)?; + println!("Wrote {}.", out.display()); + } + Command::PublicKey { pem, key_id } => { + let pem = + fs::read_to_string(&pem).with_context(|| format!("reading {}", pem.display()))?; + print!("{}", public_key(&pem, key_id.as_deref())?); + } + } + Ok(()) +} + +fn kms_signer(kms_key: &str) -> Result { + kms::KmsSigner::new(kms_key, kms::gcloud_access_token(Path::new("gcloud"))?) +} + +fn now() -> Result { + Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs()) +} + +fn sign_file( + signer: &dyn Signer, + keys: &[TrustedKey], + file: &Path, + timestamp: u64, +) -> Result { + let data = fs::read(file).with_context(|| format!("reading {}", file.display()))?; + let name = file + .file_name() + .with_context(|| format!("{} isn't a file", file.display()))? + .to_string_lossy() + .into_owned(); + let signature = minisign::sign( + signer, + &data, + &format!("timestamp:{timestamp}\tfile:{name}"), + )?; + let (key, text) = minisign::encode_for_trusted_key(&signature, &data, keys)?; + let out = file.with_file_name(format!("{name}.minisig")); + fs::write(&out, text).with_context(|| format!("writing {}", out.display()))?; + eprintln!( + "Signed with the {} key (key id {}); the signature verifies.", + key.name, + key.key.key_id_hex() + ); + Ok(out) +} + +fn public_key(pem: &str, key_id: Option<&str>) -> Result { + let key = minisign::ed25519_key_from_pem(pem)?; + let key_id = match key_id { + Some(hex) => minisign::parse_key_id(hex)?, + None => minisign::random_key_id()?, + }; + Ok(minisign::PublicKey { key_id, key }.to_file_contents()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{release_key, LocalSigner}; + + const RFC_8410_PEM: &str = + "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAGb9ECWmEzf6FQbrBZ9w7lshQhqowtrbLDFw4rXAxZuE=\n-----END PUBLIC KEY-----\n"; + + #[test] + fn prints_a_minisign_public_key_for_a_kms_pem() { + let text = public_key(RFC_8410_PEM, Some("0807060504030201")).unwrap(); + let key = minisign::PublicKey::parse(&text).unwrap(); + assert_eq!(key.key_id, [1, 2, 3, 4, 5, 6, 7, 8]); + assert_eq!( + key.key, + minisign::ed25519_key_from_pem(RFC_8410_PEM).unwrap() + ); + let random = public_key(RFC_8410_PEM, None).unwrap(); + assert_ne!( + minisign::PublicKey::parse(&random).unwrap().key_id, + key.key_id + ); + assert!(public_key(RFC_8410_PEM, Some("xyz")).is_err()); + } + + #[test] + fn signs_a_local_file_next_to_it() { + let signer = LocalSigner::generate(); + let keys = [release_key("primary", &signer, [9; 8])]; + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("signing-test.txt"); + fs::write(&file, "test\n").unwrap(); + let out = sign_file(&signer, &keys, &file, 1_792_800_000).unwrap(); + assert_eq!(out, dir.path().join("signing-test.txt.minisig")); + let comment = + minisign::verify(&fs::read_to_string(&out).unwrap(), b"test\n", &keys[0].key).unwrap(); + assert_eq!(comment, "timestamp:1792800000\tfile:signing-test.txt"); + assert!(sign_file(&signer, &keys, &dir.path().join("missing"), 1).is_err()); + } + + #[test] + fn parses_the_three_commands() { + let cli = Cli::try_parse_from([ + "release-sign", + "release", + "v1.2.3", + "--kms-key", + "projects/p/locations/l/keyRings/r/cryptoKeys/k/cryptoKeyVersions/1", + "--no-upload", + ]) + .unwrap(); + assert!(matches!( + cli.command, + Command::Release { + no_upload: true, + run: None, + .. + } + )); + let cli = Cli::try_parse_from(["release-sign", "public-key", "--pem", "k.pem"]).unwrap(); + assert!(matches!( + cli.command, + Command::PublicKey { key_id: None, .. } + )); + let cli = + Cli::try_parse_from(["release-sign", "sign-file", "f", "--kms-key", "x"]).unwrap(); + assert!(matches!(cli.command, Command::SignFile { .. })); + } + + #[test] + fn the_public_key_command_reads_its_pem_file() { + let dir = tempfile::tempdir().unwrap(); + let pem = dir.path().join("primary.pem"); + fs::write(&pem, RFC_8410_PEM).unwrap(); + let ok = Cli { + command: Command::PublicKey { pem, key_id: None }, + }; + run(ok).unwrap(); + let missing = Cli { + command: Command::PublicKey { + pem: dir.path().join("absent.pem"), + key_id: None, + }, + }; + assert!(run(missing).is_err()); + } + + /// Placeholder keys stop it first, real keys stop it at the tag; neither reaches gcloud. + #[test] + fn release_stops_before_gcloud_on_a_bad_tag() { + let release = Cli { + command: Command::Release { + tag: "main".into(), + kms_key: "projects/p/locations/l/keyRings/r/cryptoKeys/k/cryptoKeyVersions/1" + .into(), + run: None, + no_upload: true, + out_dir: ".".into(), + }, + }; + let err = format!("{:#}", run(release).unwrap_err()); + assert!( + err.contains("release-keys/primary.pub") || err.contains("isn't a release tag"), + "{err}" + ); + } +} diff --git a/crates/release-sign/src/minisign.rs b/crates/release-sign/src/minisign.rs new file mode 100644 index 000000000..f0b3a6047 --- /dev/null +++ b/crates/release-sign/src/minisign.rs @@ -0,0 +1,397 @@ +//! The minisign public key and signature formats, written for a key that never +//! leaves Cloud KMS. Format reference: . +//! +//! A prehashed (`ED`) minisign signature holds two Ed25519 signatures: one over +//! the BLAKE2b-512 digest of the file, and a global one over that signature +//! followed by the trusted comment. KMS signs each message as given (PureEdDSA), +//! so signing a file takes two calls to the key. + +use std::io::Read; + +use anyhow::{bail, ensure, Context, Result}; +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine as _; +use blake2::{Blake2b512, Digest}; + +/// Something that holds an Ed25519 private key and signs the exact bytes it is given. +pub trait Signer { + fn sign(&self, message: &[u8]) -> Result<[u8; 64]>; +} + +/// A minisign public key: the key id we picked, and the Ed25519 key. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PublicKey { + pub key_id: [u8; 8], + pub key: [u8; 32], +} + +impl PublicKey { + /// Parses the two-line `minisign.pub` format. + pub fn parse(text: &str) -> Result { + let mut lines = text.lines(); + let comment = lines.next().unwrap_or_default(); + ensure!( + comment.starts_with("untrusted comment:"), + "a minisign public key starts with an `untrusted comment:` line" + ); + let Some(encoded) = lines.next() else { + bail!("no key yet, only the comment line"); + }; + let bytes = BASE64 + .decode(encoded.trim()) + .context("the key line isn't base64")?; + ensure!( + bytes.len() == 42 && bytes.starts_with(b"Ed"), + "the key line doesn't hold an Ed25519 minisign key" + ); + Ok(Self { + key_id: bytes[2..10].try_into()?, + key: bytes[10..42].try_into()?, + }) + } + + /// The key id as `minisign` prints it. + pub fn key_id_hex(&self) -> String { + format!("{:016X}", u64::from_le_bytes(self.key_id)) + } + + /// The `minisign.pub` file for this key, in the layout `minisign -G` writes. + pub fn to_file_contents(self) -> String { + let mut bytes = Vec::with_capacity(42); + bytes.extend_from_slice(b"Ed"); + bytes.extend_from_slice(&self.key_id); + bytes.extend_from_slice(&self.key); + format!( + "untrusted comment: minisign public key {}\n{}\n", + self.key_id_hex(), + BASE64.encode(bytes) + ) + } +} + +/// A public key the repository trusts, by the name its file carries. +#[derive(Clone, Debug)] +pub struct TrustedKey { + pub name: String, + pub key: PublicKey, +} + +/// The two signatures of a prehashed minisign signature, before a key id is attached. +pub struct Signature { + signature: [u8; 64], + trusted_comment: String, + global_signature: [u8; 64], +} + +/// Signs `data` with two calls to `signer`, the way `minisign -S` does with a local key. +pub fn sign(signer: &dyn Signer, data: &[u8], trusted_comment: &str) -> Result { + ensure!( + !trusted_comment.contains(['\n', '\r']), + "a trusted comment must fit on one line" + ); + let signature = signer.sign(&Blake2b512::digest(data))?; + let mut global = signature.to_vec(); + global.extend_from_slice(trusted_comment.as_bytes()); + let global_signature = signer.sign(&global)?; + Ok(Signature { + signature, + trusted_comment: trusted_comment.to_owned(), + global_signature, + }) +} + +impl Signature { + /// The `.minisig` file text for this signature under `key_id`. + pub fn encode(&self, key_id: [u8; 8], untrusted_comment: &str) -> String { + let mut line = Vec::with_capacity(74); + line.extend_from_slice(b"ED"); + line.extend_from_slice(&key_id); + line.extend_from_slice(&self.signature); + format!( + "untrusted comment: {untrusted_comment}\n{}\ntrusted comment: {}\n{}\n", + BASE64.encode(line), + self.trusted_comment, + BASE64.encode(self.global_signature) + ) + } +} + +/// Finds the trusted key that made `signature` and returns it with the `.minisig` text. +/// +/// The key id isn't covered by either signature, so it can be attached after +/// signing. Each candidate is checked with the minisign-verify crate, an +/// implementation independent of the code above. +pub fn encode_for_trusted_key<'a>( + signature: &Signature, + data: &[u8], + keys: &'a [TrustedKey], +) -> Result<(&'a TrustedKey, String)> { + for candidate in keys { + let comment = format!( + "signature from the actual-cli {} release key", + candidate.name + ); + let text = signature.encode(candidate.key.key_id, &comment); + if verify(&text, data, &candidate.key).is_ok() { + return Ok((candidate, text)); + } + } + let names: Vec<&str> = keys.iter().map(|k| k.name.as_str()).collect(); + bail!( + "the KMS key's signature doesn't verify against the {} key in release-keys/, so it isn't a key this repository trusts", + names.join(" or ") + ) +} + +/// Checks a `.minisig` text over `data` with the minisign-verify crate and returns its trusted comment. +pub fn verify(text: &str, data: &[u8], key: &PublicKey) -> Result { + let public_key = minisign_verify::PublicKey::decode(&key.to_file_contents())?; + let signature = minisign_verify::Signature::decode(text)?; + public_key.verify(data, &signature, false)?; + Ok(signature.trusted_comment().to_owned()) +} + +const ED25519_SPKI_PREFIX: [u8; 12] = [ + 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, +]; + +/// The raw Ed25519 key in a `PUBLIC KEY` PEM, the format Cloud KMS exports. +pub fn ed25519_key_from_pem(pem: &str) -> Result<[u8; 32]> { + let lines: Vec<&str> = pem + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .collect(); + ensure!( + lines.len() >= 3 + && lines[0] == "-----BEGIN PUBLIC KEY-----" + && lines[lines.len() - 1] == "-----END PUBLIC KEY-----", + "expected a single `PUBLIC KEY` PEM block" + ); + let der = BASE64 + .decode(lines[1..lines.len() - 1].concat()) + .context("the PEM body isn't base64")?; + ensure!( + der.len() == 44 && der[..12] == ED25519_SPKI_PREFIX, + "the PEM doesn't hold an Ed25519 public key" + ); + Ok(der[12..].try_into()?) +} + +/// Eight random bytes for a new key id. +pub fn random_key_id() -> Result<[u8; 8]> { + let mut id = [0u8; 8]; + std::fs::File::open("/dev/urandom") + .and_then(|mut f| f.read_exact(&mut id)) + .context("reading /dev/urandom")?; + Ok(id) +} + +/// Parses a key id written as 16 hex digits, the way `minisign` prints one. +pub fn parse_key_id(hex: &str) -> Result<[u8; 8]> { + ensure!( + hex.len() == 16 && hex.chars().all(|c| c.is_ascii_hexdigit()), + "a key id is 16 hex digits" + ); + Ok(u64::from_str_radix(hex, 16)?.to_le_bytes()) +} + +#[cfg(test)] +mod tests { + use std::process::Command; + + use super::*; + use crate::test_support::{release_key, LocalSigner}; + + const DATA: &[u8] = b"b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c actual-darwin-arm64.tar.gz\n"; + const COMMENT: &str = "timestamp:1792800000\tfile:SHA256SUMS\ttag:v1.2.3"; + + #[test] + fn public_key_file_round_trips() { + let key = PublicKey { + key_id: [1, 2, 3, 4, 5, 6, 7, 8], + key: [9; 32], + }; + let text = key.to_file_contents(); + assert!(text.starts_with("untrusted comment: minisign public key 0807060504030201\n")); + assert_eq!(PublicKey::parse(&text).unwrap(), key); + } + + #[test] + fn public_key_parse_rejects_what_it_cannot_use() { + let placeholder = "untrusted comment: placeholder until the setup exports the key\n"; + assert!(PublicKey::parse(placeholder) + .unwrap_err() + .to_string() + .contains("no key yet")); + assert!( + PublicKey::parse("RWQBAgMEBQYHCAkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJ\n").is_err() + ); + assert!(PublicKey::parse("untrusted comment: x\nnot base64!\n").is_err()); + // A signature line is base64 too, but it isn't a key. + let wrong_length = format!("untrusted comment: x\n{}\n", BASE64.encode([0u8; 74])); + assert!(PublicKey::parse(&wrong_length).is_err()); + let wrong_algorithm = format!("untrusted comment: x\n{}\n", BASE64.encode([0u8; 42])); + assert!(PublicKey::parse(&wrong_algorithm).is_err()); + } + + #[test] + fn signature_verifies_with_minisign_verify_and_names_the_key() { + let signer = LocalSigner::generate(); + let keys = [release_key("primary", &signer, [7; 8])]; + let signature = sign(&signer, DATA, COMMENT).unwrap(); + let (key, text) = encode_for_trusted_key(&signature, DATA, &keys).unwrap(); + assert_eq!(key.name, "primary"); + assert_eq!(verify(&text, DATA, &key.key).unwrap(), COMMENT); + assert!(text + .starts_with("untrusted comment: signature from the actual-cli primary release key\n")); + assert_eq!(text.lines().count(), 4); + assert!(text.ends_with('\n')); + } + + #[test] + fn signature_fails_for_changed_data_or_comment() { + let signer = LocalSigner::generate(); + let key = release_key("primary", &signer, [7; 8]).key; + let text = sign(&signer, DATA, COMMENT) + .unwrap() + .encode(key.key_id, "x"); + assert!(verify(&text, b"something else\n", &key).is_err()); + let retagged = text.replace("tag:v1.2.3", "tag:v9.9.9"); + assert!(verify(&retagged, DATA, &key).is_err()); + } + + #[test] + fn the_spare_key_is_found_when_the_primary_did_not_sign() { + let primary = LocalSigner::generate(); + let spare = LocalSigner::generate(); + let keys = [ + release_key("primary", &primary, [1; 8]), + release_key("spare", &spare, [2; 8]), + ]; + let signature = sign(&spare, DATA, COMMENT).unwrap(); + let (key, text) = encode_for_trusted_key(&signature, DATA, &keys).unwrap(); + assert_eq!(key.name, "spare"); + assert!(verify(&text, DATA, &keys[1].key).is_ok()); + assert!(verify(&text, DATA, &keys[0].key).is_err()); + } + + #[test] + fn a_key_the_repository_does_not_trust_is_refused() { + let committed = LocalSigner::generate(); + let other = LocalSigner::generate(); + let keys = [release_key("primary", &committed, [1; 8])]; + let signature = sign(&other, DATA, COMMENT).unwrap(); + let err = encode_for_trusted_key(&signature, DATA, &keys).unwrap_err(); + assert!(err + .to_string() + .contains("doesn't verify against the primary key")); + } + + #[test] + fn a_trusted_comment_must_be_one_line() { + let signer = LocalSigner::generate(); + assert!(sign(&signer, DATA, "two\nlines").is_err()); + assert!(sign(&signer, DATA, "carriage\rreturn").is_err()); + assert_eq!(signer.calls(), 0); + } + + #[test] + fn signing_asks_the_key_twice() { + let signer = LocalSigner::generate(); + sign(&signer, DATA, COMMENT).unwrap(); + assert_eq!(signer.calls(), 2); + } + + #[test] + fn reads_the_ed25519_key_from_a_pem() { + // The Ed25519 example from RFC 8410, section 10.1. + let pem = "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAGb9ECWmEzf6FQbrBZ9w7lshQhqowtrbLDFw4rXAxZuE=\n-----END PUBLIC KEY-----\n"; + let key = ed25519_key_from_pem(pem).unwrap(); + assert_eq!(key[..4], [0x19, 0xbf, 0x44, 0x09]); + assert_eq!(key[28..], [0x70, 0x31, 0x66, 0xe1]); + } + + #[test] + fn rejects_a_pem_that_is_not_one_ed25519_public_key() { + let p256 = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEp9pP02JNe4KxHFXDP0BmBXxex9gY\nacs72hOnBbmch6927vTJV1iLgGsM1DSXp0TkBLGUG08NTRiG5WKB1WSvQg==\n-----END PUBLIC KEY-----\n"; + assert!(ed25519_key_from_pem(p256) + .unwrap_err() + .to_string() + .contains("doesn't hold an Ed25519")); + // Right length, but the algorithm is Ed448 (1.3.101.113). + let mut ed448_oid = ED25519_SPKI_PREFIX.to_vec(); + ed448_oid[8] = 0x71; + ed448_oid.extend_from_slice(&[0; 32]); + let pem = format!( + "-----BEGIN PUBLIC KEY-----\n{}\n-----END PUBLIC KEY-----\n", + BASE64.encode(ed448_oid) + ); + assert!(ed25519_key_from_pem(&pem).is_err()); + // A valid Ed25519 body under another label. + let relabelled = "-----BEGIN CERTIFICATE-----\nMCowBQYDK2VwAyEAGb9ECWmEzf6FQbrBZ9w7lshQhqowtrbLDFw4rXAxZuE=\n-----END CERTIFICATE-----\n"; + assert!(ed25519_key_from_pem(relabelled).is_err()); + assert!(ed25519_key_from_pem( + "-----BEGIN PUBLIC KEY-----\n!!!\n-----END PUBLIC KEY-----\n" + ) + .is_err()); + assert!(ed25519_key_from_pem("").is_err()); + } + + #[test] + fn key_ids_parse_from_hex_and_come_out_random() { + let id = parse_key_id("0807060504030201").unwrap(); + assert_eq!(id, [1, 2, 3, 4, 5, 6, 7, 8]); + assert!(parse_key_id("123").is_err()); + assert!(parse_key_id("zz07060504030201").is_err()); + assert_ne!(random_key_id().unwrap(), random_key_id().unwrap()); + } + + /// Stock `minisign -V` is the check a user runs, so the output has to pass it too. + #[test] + #[ignore = "needs the minisign CLI on PATH; CI runs it in the release workflow's signing-tool job"] + fn stock_minisign_accepts_the_signature() { + let signer = LocalSigner::generate(); + let key = release_key("primary", &signer, [0x5a; 8]).key; + let text = sign(&signer, DATA, COMMENT) + .unwrap() + .encode(key.key_id, "x"); + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("SHA256SUMS"); + let public_key = dir.path().join("primary.pub"); + std::fs::write(&file, DATA).unwrap(); + std::fs::write(&public_key, key.to_file_contents()).unwrap(); + std::fs::write(dir.path().join("SHA256SUMS.minisig"), &text).unwrap(); + + let run = |file: &std::path::Path| { + Command::new("minisign") + .arg("-Vm") + .arg(file) + .arg("-p") + .arg(&public_key) + .output() + .expect("minisign runs") + }; + let accepted = run(&file); + let stdout = String::from_utf8_lossy(&accepted.stdout); + assert!( + accepted.status.success(), + "minisign -V refused: {stdout}{}", + String::from_utf8_lossy(&accepted.stderr) + ); + assert!( + stdout.contains(&format!("Trusted comment: {COMMENT}")), + "{stdout}" + ); + + // The same check has to fail once the file changes. + let tampered = dir.path().join("tampered"); + std::fs::write(&tampered, b"something else\n").unwrap(); + std::fs::copy( + dir.path().join("SHA256SUMS.minisig"), + dir.path().join("tampered.minisig"), + ) + .unwrap(); + assert!(!run(&tampered).status.success()); + } +} diff --git a/crates/release-sign/src/release.rs b/crates/release-sign/src/release.rs new file mode 100644 index 000000000..b1a860174 --- /dev/null +++ b/crates/release-sign/src/release.rs @@ -0,0 +1,423 @@ +//! Signing a published release: check that both release pages carry what the +//! release run built, then sign SHA256SUMS and upload the signature to both. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, ensure, Context, Result}; + +use crate::github::{ReleaseHost, Run}; +use crate::minisign::{self, Signer, TrustedKey}; +use crate::sums; + +pub const SOURCE_REPO: &str = "actual-software/actual-cli"; +pub const MIRROR_REPO: &str = "actual-software/actual-releases"; +pub const WORKFLOW: &str = "release.yml"; +pub const ARTIFACT: &str = "release-assets"; +pub const SIGNATURE_FILE: &str = "SHA256SUMS.minisig"; + +pub struct Options { + pub tag: String, + /// Picks one run when a tag has several. + pub run_id: Option, + pub upload: bool, + /// Where SHA256SUMS.minisig is written. + pub out_dir: PathBuf, + /// Seconds since the Unix epoch, recorded in the trusted comment. + pub timestamp: u64, +} + +#[derive(Debug)] +pub struct Signed { + pub run_id: u64, + pub key_name: String, + pub signature_path: PathBuf, + pub uploaded_to: Vec<&'static str>, +} + +/// The trusted comment on every release signature. Self-update reads the tag from it. +pub fn trusted_comment(timestamp: u64, tag: &str) -> String { + format!("timestamp:{timestamp}\tfile:{}\ttag:{tag}", sums::FILE_NAME) +} + +/// A release tag: `v`, then a version made of letters, digits, `.`, `-` and `+`. +pub fn check_tag(tag: &str) -> Result<()> { + let version = tag.strip_prefix('v').unwrap_or_default(); + ensure!( + version.starts_with(|c: char| c.is_ascii_digit()) + && version + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'+')), + "`{tag}` isn't a release tag such as v1.2.3" + ); + Ok(()) +} + +pub fn sign_release( + host: &dyn ReleaseHost, + signer: &dyn Signer, + keys: &[TrustedKey], + work_dir: &Path, + options: &Options, +) -> Result { + let tag = options.tag.as_str(); + check_tag(tag)?; + ensure!( + !keys.is_empty(), + "no trusted public keys to check the signature with" + ); + + let commit = host.tag_commit(SOURCE_REPO, tag)?; + let run = pick_run( + host.release_runs(SOURCE_REPO, WORKFLOW, tag)?, + tag, + &commit, + options.run_id, + )?; + eprintln!( + "{tag} is {commit}; its release run is {} ({}).", + run.id, + describe(&run) + ); + + let run_dir = work_dir.join("run"); + fs::create_dir_all(&run_dir)?; + host.download_run_artifact(SOURCE_REPO, run.id, ARTIFACT, &run_dir) + .with_context(|| format!("downloading the {ARTIFACT} artifact of run {}", run.id))?; + let built = fs::read(run_dir.join(sums::FILE_NAME)) + .with_context(|| format!("the {ARTIFACT} artifact has no {}", sums::FILE_NAME))?; + let entries = sums::parse(std::str::from_utf8(&built).context("SHA256SUMS isn't UTF-8")?)?; + sums::check_dir(&run_dir, &entries, &[sums::FILE_NAME]) + .with_context(|| format!("checking the {ARTIFACT} artifact of run {}", run.id))?; + + for (index, repo) in [SOURCE_REPO, MIRROR_REPO].into_iter().enumerate() { + let page_dir = work_dir.join(format!("page-{index}")); + fs::create_dir_all(&page_dir)?; + host.download_release(repo, tag, &page_dir) + .with_context(|| format!("downloading the {tag} release from {repo}"))?; + let published = fs::read(page_dir.join(sums::FILE_NAME)) + .with_context(|| format!("the {tag} release on {repo} has no {}", sums::FILE_NAME))?; + ensure!( + published == built, + "the {} on the {tag} release in {repo} differs from the one run {} built", + sums::FILE_NAME, + run.id + ); + sums::check_dir(&page_dir, &entries, &[sums::FILE_NAME, SIGNATURE_FILE]) + .with_context(|| format!("checking the {tag} release in {repo}"))?; + if page_dir.join(SIGNATURE_FILE).exists() { + eprintln!("{repo}: already has a {SIGNATURE_FILE}; the new one replaces it."); + } + eprintln!( + "{repo}: {} matches run {}, and its {} tarballs match {}.", + sums::FILE_NAME, + run.id, + entries.len(), + sums::FILE_NAME + ); + } + + let signature = minisign::sign(signer, &built, &trusted_comment(options.timestamp, tag))?; + let (key, text) = minisign::encode_for_trusted_key(&signature, &built, keys)?; + eprintln!( + "Signed with the {} key (key id {}); the signature verifies.", + key.name, + key.key.key_id_hex() + ); + + fs::create_dir_all(&options.out_dir)?; + let signature_path = options.out_dir.join(SIGNATURE_FILE); + fs::write(&signature_path, &text) + .with_context(|| format!("writing {}", signature_path.display()))?; + + let mut uploaded_to = Vec::new(); + if options.upload { + for repo in [SOURCE_REPO, MIRROR_REPO] { + host.upload_release_asset(repo, tag, &signature_path) + .with_context(|| { + format!("uploading {SIGNATURE_FILE} to the {tag} release in {repo}") + })?; + eprintln!("{repo}: uploaded {SIGNATURE_FILE}."); + uploaded_to.push(repo); + } + } + Ok(Signed { + run_id: run.id, + key_name: key.name.clone(), + signature_path, + uploaded_to, + }) +} + +/// The one push run of the release workflow that built `tag` at `commit`. +fn pick_run(runs: Vec, tag: &str, commit: &str, wanted: Option) -> Result { + let mut matching: Vec = runs + .into_iter() + .filter(|run| { + run.event == "push" + && run.path == format!(".github/workflows/{WORKFLOW}") + && run.head_branch.as_deref() == Some(tag) + && run.head_sha == commit + }) + .collect(); + if let Some(id) = wanted { + matching.retain(|run| run.id == id); + ensure!( + !matching.is_empty(), + "run {id} isn't a {WORKFLOW} run for the push of {tag} at {commit}" + ); + } + match matching.len() { + 0 => bail!("no {WORKFLOW} run built {tag} at {commit}"), + 1 => Ok(matching.remove(0)), + _ => { + let ids: Vec = matching.iter().map(|run| run.id.to_string()).collect(); + bail!( + "{WORKFLOW} ran more than once for {tag} ({}); pass --run with the one that published the release", + ids.join(", ") + ) + } + } +} + +fn describe(run: &Run) -> String { + match &run.conclusion { + Some(conclusion) => format!("{}, {conclusion}", run.status), + None => run.status.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{release_key, FakeHost, LocalSigner, COMMIT, TAG}; + + fn options(dir: &Path, upload: bool) -> Options { + Options { + tag: TAG.into(), + run_id: None, + upload, + out_dir: dir.join("out"), + timestamp: 1_792_800_000, + } + } + + fn sign_with( + host: &FakeHost, + signer: &LocalSigner, + keys: &[TrustedKey], + upload: bool, + ) -> Result { + let work = tempfile::tempdir().unwrap(); + sign_release( + host, + signer, + keys, + work.path(), + &options(&host.root(), upload), + ) + } + + #[test] + fn signs_a_consistent_release_and_uploads_to_both_pages() { + let host = FakeHost::consistent_release(); + let signer = LocalSigner::generate(); + let keys = [release_key("primary", &signer, [1; 8])]; + let signed = sign_with(&host, &signer, &keys, true).unwrap(); + + assert_eq!(signed.key_name, "primary"); + assert_eq!(signed.uploaded_to, [SOURCE_REPO, MIRROR_REPO]); + assert_eq!(host.uploads(), [SOURCE_REPO, MIRROR_REPO]); + let text = fs::read_to_string(&signed.signature_path).unwrap(); + let comment = minisign::verify(&text, &host.sums(), &keys[0].key).unwrap(); + assert_eq!(comment, "timestamp:1792800000\tfile:SHA256SUMS\ttag:v1.2.3"); + } + + #[test] + fn without_upload_it_only_writes_the_signature() { + let host = FakeHost::consistent_release(); + let signer = LocalSigner::generate(); + let keys = [release_key("primary", &signer, [1; 8])]; + let signed = sign_with(&host, &signer, &keys, false).unwrap(); + assert!(signed.signature_path.exists()); + assert!(signed.uploaded_to.is_empty()); + assert!(host.uploads().is_empty()); + } + + #[test] + fn a_page_whose_sums_differ_from_the_run_is_not_signed() { + let host = FakeHost::consistent_release(); + host.edit_page(MIRROR_REPO, "SHA256SUMS", |sums| sums.replacen('a', "b", 1)); + let signer = LocalSigner::generate(); + let keys = [release_key("primary", &signer, [1; 8])]; + let err = sign_with(&host, &signer, &keys, true).unwrap_err(); + assert!( + format!("{err:#}").contains("differs from the one run"), + "{err:#}" + ); + assert_eq!(signer.calls(), 0); + assert!(host.uploads().is_empty()); + } + + #[test] + fn a_swapped_tarball_on_either_page_is_not_signed() { + for repo in [SOURCE_REPO, MIRROR_REPO] { + let host = FakeHost::consistent_release(); + host.edit_page(repo, "actual-linux-x64.tar.gz", |_| "tampered".into()); + let signer = LocalSigner::generate(); + let keys = [release_key("primary", &signer, [1; 8])]; + let err = sign_with(&host, &signer, &keys, true).unwrap_err(); + assert!( + format!("{err:#}").contains("actual-linux-x64.tar.gz has SHA-256"), + "{err:#}" + ); + assert_eq!(signer.calls(), 0); + assert!(host.uploads().is_empty()); + } + } + + #[test] + fn an_extra_or_missing_asset_is_not_signed() { + let host = FakeHost::consistent_release(); + host.add_to_page(SOURCE_REPO, "actual-windows-x64.zip", "unlisted"); + let signer = LocalSigner::generate(); + let keys = [release_key("primary", &signer, [1; 8])]; + let err = format!("{:#}", sign_with(&host, &signer, &keys, true).unwrap_err()); + assert!(err.contains("actual-windows-x64.zip"), "{err}"); + + let host = FakeHost::consistent_release(); + host.remove_from_page(MIRROR_REPO, "actual-darwin-x64.tar.gz"); + let err = format!("{:#}", sign_with(&host, &signer, &keys, true).unwrap_err()); + assert!(err.contains("actual-darwin-x64.tar.gz is listed"), "{err}"); + assert_eq!(signer.calls(), 0); + } + + #[test] + fn an_existing_signature_on_a_page_is_replaced() { + let host = FakeHost::consistent_release(); + host.add_to_page(MIRROR_REPO, SIGNATURE_FILE, "an older signature"); + let signer = LocalSigner::generate(); + let keys = [release_key("primary", &signer, [1; 8])]; + sign_with(&host, &signer, &keys, true).unwrap(); + assert_eq!(host.uploads(), [SOURCE_REPO, MIRROR_REPO]); + } + + #[test] + fn a_tampered_run_artifact_is_not_signed() { + let host = FakeHost::consistent_release(); + host.edit_artifact("actual-darwin-arm64.tar.gz", |_| "tampered".into()); + let signer = LocalSigner::generate(); + let keys = [release_key("primary", &signer, [1; 8])]; + let err = format!("{:#}", sign_with(&host, &signer, &keys, true).unwrap_err()); + assert!( + err.contains("checking the release-assets artifact"), + "{err}" + ); + } + + #[test] + fn a_key_the_repository_does_not_trust_uploads_nothing() { + let host = FakeHost::consistent_release(); + let committed = LocalSigner::generate(); + let other = LocalSigner::generate(); + let keys = [release_key("primary", &committed, [1; 8])]; + let err = sign_with(&host, &other, &keys, true).unwrap_err(); + assert!(err.to_string().contains("doesn't verify"), "{err}"); + assert!(host.uploads().is_empty()); + } + + #[test] + fn a_failed_upload_is_reported() { + let host = FakeHost::consistent_release(); + host.fail_uploads_to(MIRROR_REPO); + let signer = LocalSigner::generate(); + let keys = [release_key("primary", &signer, [1; 8])]; + let err = format!("{:#}", sign_with(&host, &signer, &keys, true).unwrap_err()); + assert!(err.contains("uploading SHA256SUMS.minisig to the v1.2.3 release in actual-software/actual-releases"), "{err}"); + assert_eq!(host.uploads(), [SOURCE_REPO]); + } + + fn run(id: u64) -> Run { + Run { + id, + event: "push".into(), + path: ".github/workflows/release.yml".into(), + head_branch: Some(TAG.into()), + head_sha: COMMIT.into(), + status: "completed".into(), + conclusion: Some("success".into()), + } + } + + #[test] + fn picks_the_one_run_that_built_the_tag() { + let other_commit = Run { + head_sha: "0".repeat(40), + ..run(1) + }; + let other_workflow = Run { + path: ".github/workflows/npm-publish.yml".into(), + ..run(2) + }; + let not_a_push = Run { + event: "workflow_dispatch".into(), + ..run(3) + }; + let other_tag = Run { + head_branch: Some("v1.2.4".into()), + ..run(4) + }; + let runs = vec![other_commit, other_workflow, not_a_push, other_tag, run(5)]; + assert_eq!(pick_run(runs, TAG, COMMIT, None).unwrap().id, 5); + } + + #[test] + fn needs_a_choice_when_a_tag_ran_twice() { + let err = pick_run(vec![run(5), run(6)], TAG, COMMIT, None).unwrap_err(); + assert!(err.to_string().contains("5, 6"), "{err}"); + assert_eq!( + pick_run(vec![run(5), run(6)], TAG, COMMIT, Some(6)) + .unwrap() + .id, + 6 + ); + assert!(pick_run(vec![run(5)], TAG, COMMIT, Some(7)).is_err()); + assert!(pick_run(vec![], TAG, COMMIT, None).is_err()); + } + + #[test] + fn needs_a_release_tag() { + for good in ["v1.2.3", "v0.10.0", "v1.2.3-rc.1", "v1.2.3+build.5"] { + check_tag(good).unwrap(); + } + for bad in ["1.2.3", "v", "vx", "v1.2.3\tx", "v1/2", "main", "v1.2.3 "] { + assert!(check_tag(bad).is_err(), "accepted {bad:?}"); + } + let host = FakeHost::consistent_release(); + let signer = LocalSigner::generate(); + let keys = [release_key("primary", &signer, [1; 8])]; + let work = tempfile::tempdir().unwrap(); + let mut bad = options(work.path(), true); + bad.tag = "main".into(); + assert!(sign_release(&host, &signer, &keys, work.path(), &bad).is_err()); + assert!(sign_release( + &host, + &signer, + &[], + work.path(), + &options(work.path(), true) + ) + .is_err()); + } + + #[test] + fn describes_runs_with_and_without_a_conclusion() { + assert_eq!(describe(&run(1)), "completed, success"); + let running = Run { + status: "in_progress".into(), + conclusion: None, + ..run(1) + }; + assert_eq!(describe(&running), "in_progress"); + } +} diff --git a/crates/release-sign/src/spawn.rs b/crates/release-sign/src/spawn.rs new file mode 100644 index 000000000..6dd9fa318 --- /dev/null +++ b/crates/release-sign/src/spawn.rs @@ -0,0 +1,74 @@ +//! Starting the signer's `gh` and `gcloud`, retrying while Linux reports them busy. + +use std::io; +use std::thread; +use std::time::Duration; + +/// Retries `attempt` briefly while starting the program fails with ETXTBSY. +/// +/// Linux won't exec a file that any process has open for writing, and a program written a +/// moment ago can still be open in a child another thread forked mid-write. Parallel tests +/// that write a fake `gh` or `gcloud` hit this, as could a just-updated install. +pub fn spawn_with_etxtbsy_retry(mut attempt: impl FnMut() -> io::Result) -> io::Result { + const ATTEMPTS: u32 = 5; + let mut tries = 1; + loop { + match attempt() { + Err(e) if e.kind() == io::ErrorKind::ExecutableFileBusy && tries < ATTEMPTS => { + thread::sleep(Duration::from_millis(u64::from(20 * tries))); + tries += 1; + } + result => return result, + } + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::io::Error; + + use super::*; + + const ETXTBSY: i32 = 26; + const ENOENT: i32 = 2; + + #[test] + fn retries_a_busy_program_until_it_starts() { + let calls = Cell::new(0); + let started = spawn_with_etxtbsy_retry(|| { + calls.set(calls.get() + 1); + if calls.get() < 3 { + Err(Error::from_raw_os_error(ETXTBSY)) + } else { + Ok("started") + } + }); + assert_eq!(started.unwrap(), "started"); + assert_eq!(calls.get(), 3); + } + + #[test] + fn gives_up_after_five_busy_attempts() { + let calls = Cell::new(0); + let err = spawn_with_etxtbsy_retry(|| -> io::Result<()> { + calls.set(calls.get() + 1); + Err(Error::from_raw_os_error(ETXTBSY)) + }) + .unwrap_err(); + assert_eq!(err.raw_os_error(), Some(ETXTBSY)); + assert_eq!(calls.get(), 5); + } + + #[test] + fn returns_any_other_error_at_once() { + let calls = Cell::new(0); + let err = spawn_with_etxtbsy_retry(|| -> io::Result<()> { + calls.set(calls.get() + 1); + Err(Error::from_raw_os_error(ENOENT)) + }) + .unwrap_err(); + assert_eq!(err.raw_os_error(), Some(ENOENT)); + assert_eq!(calls.get(), 1); + } +} diff --git a/crates/release-sign/src/sums.rs b/crates/release-sign/src/sums.rs new file mode 100644 index 000000000..7f4f01c39 --- /dev/null +++ b/crates/release-sign/src/sums.rs @@ -0,0 +1,247 @@ +//! SHA256SUMS in the format `sha256sum` writes: a lowercase hex digest, two +//! spaces and a file name, one line per file. + +use std::collections::BTreeSet; +use std::fs; +use std::io::Read; +use std::path::Path; + +use anyhow::{bail, ensure, Context, Result}; +use sha2::{Digest, Sha256}; + +pub const FILE_NAME: &str = "SHA256SUMS"; + +#[derive(Debug, PartialEq, Eq)] +pub struct Entry { + pub sha256: String, + pub name: String, +} + +/// Parses a SHA256SUMS file, refusing anything `sha256sum` wouldn't have written for plain files. +pub fn parse(text: &str) -> Result> { + ensure!( + !text.is_empty() && text.ends_with('\n') && !text.contains('\r'), + "{FILE_NAME} is empty, doesn't end with a newline, or has Windows line endings" + ); + let mut entries = Vec::new(); + let mut names = BTreeSet::new(); + for (index, line) in text.lines().enumerate() { + let line_no = index + 1; + let Some((sha256, name)) = line.split_once(" ") else { + bail!("{FILE_NAME} line {line_no} isn't ` `"); + }; + ensure!( + sha256.len() == 64 + && sha256 + .bytes() + .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')), + "{FILE_NAME} line {line_no} doesn't start with a lowercase hex SHA-256" + ); + ensure!( + is_plain_file_name(name), + "{FILE_NAME} line {line_no} names `{name}`, which isn't a plain file name" + ); + ensure!( + names.insert(name.to_owned()), + "{FILE_NAME} lists `{name}` twice" + ); + entries.push(Entry { + sha256: sha256.to_owned(), + name: name.to_owned(), + }); + } + Ok(entries) +} + +fn is_plain_file_name(name: &str) -> bool { + !name.is_empty() + && !name.starts_with('.') + && name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) +} + +/// Checks that `dir` holds every file `entries` lists, with the listed digest, +/// and nothing else apart from the names in `also_allowed`. +pub fn check_dir(dir: &Path, entries: &[Entry], also_allowed: &[&str]) -> Result<()> { + let mut present = BTreeSet::new(); + for item in fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))? { + let item = item?; + ensure!( + item.file_type()?.is_file(), + "{} holds `{}`, which isn't a file", + dir.display(), + item.file_name().to_string_lossy() + ); + present.insert(item.file_name().to_string_lossy().into_owned()); + } + let listed: BTreeSet<&str> = entries.iter().map(|e| e.name.as_str()).collect(); + let unexpected: Vec<&str> = present + .iter() + .map(String::as_str) + .filter(|name| !listed.contains(name) && !also_allowed.contains(name)) + .collect(); + ensure!( + unexpected.is_empty(), + "found files {FILE_NAME} doesn't list: {}", + unexpected.join(", ") + ); + for entry in entries { + ensure!( + present.contains(&entry.name), + "{} is listed in {FILE_NAME} but missing", + entry.name + ); + let actual = sha256_file(&dir.join(&entry.name))?; + ensure!( + actual == entry.sha256, + "{} has SHA-256 {actual}, but {FILE_NAME} lists {}", + entry.name, + entry.sha256 + ); + } + Ok(()) +} + +fn sha256_file(path: &Path) -> Result { + let mut file = fs::File::open(path).with_context(|| format!("opening {}", path.display()))?; + let mut hasher = Sha256::new(); + let mut buf = vec![0u8; 1 << 16]; + loop { + let n = file.read(&mut buf)?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +#[cfg(test)] +mod tests { + use super::*; + + const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + const ABC_SHA256: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + + fn sums_for(files: &[(&str, &str)]) -> String { + files + .iter() + .map(|(sha, name)| format!("{sha} {name}\n")) + .collect() + } + + #[test] + fn parses_what_sha256sum_writes() { + let text = sums_for(&[ + (ABC_SHA256, "actual-linux-x64.tar.gz"), + (EMPTY_SHA256, "empty"), + ]); + let entries = parse(&text).unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].name, "actual-linux-x64.tar.gz"); + assert_eq!(entries[0].sha256, ABC_SHA256); + } + + #[test] + fn refuses_anything_else() { + for (text, why) in [ + (String::new(), "empty"), + (format!("{ABC_SHA256} a"), "no trailing newline"), + (format!("{ABC_SHA256} a\n"), "one space"), + (format!("{ABC_SHA256} *a\n"), "binary-mode marker"), + ( + format!("{} a\n", ABC_SHA256.to_uppercase()), + "uppercase digest", + ), + (format!("{} a\n", &ABC_SHA256[1..]), "short digest"), + (format!("{ABC_SHA256} ../a\n"), "path"), + (format!("{ABC_SHA256} .hidden\n"), "dot file"), + (format!("{ABC_SHA256} a b\n"), "space in name"), + (format!("{ABC_SHA256} a\n\n"), "blank line"), + (format!("{ABC_SHA256} a\r\n"), "carriage return"), + ( + sums_for(&[(ABC_SHA256, "a"), (EMPTY_SHA256, "a")]), + "duplicate", + ), + ] { + assert!(parse(&text).is_err(), "accepted a SHA256SUMS with {why}"); + } + } + + fn dir_with(files: &[(&str, &[u8])]) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + for (name, content) in files { + fs::write(dir.path().join(name), content).unwrap(); + } + dir + } + + #[test] + fn a_directory_matching_its_sums_passes() { + let entries = parse(&sums_for(&[ + (ABC_SHA256, "a.tar.gz"), + (EMPTY_SHA256, "b.tar.gz"), + ])) + .unwrap(); + let dir = dir_with(&[ + ("a.tar.gz", b"abc"), + ("b.tar.gz", b""), + ("SHA256SUMS", b"x"), + ]); + check_dir(dir.path(), &entries, &[FILE_NAME]).unwrap(); + } + + #[test] + fn a_changed_file_fails() { + let entries = parse(&sums_for(&[(ABC_SHA256, "a.tar.gz")])).unwrap(); + let dir = dir_with(&[("a.tar.gz", b"abd")]); + let err = check_dir(dir.path(), &entries, &[]) + .unwrap_err() + .to_string(); + assert!(err.contains("a.tar.gz has SHA-256"), "{err}"); + } + + #[test] + fn a_missing_file_fails() { + let entries = parse(&sums_for(&[ + (ABC_SHA256, "a.tar.gz"), + (EMPTY_SHA256, "b.tar.gz"), + ])) + .unwrap(); + let dir = dir_with(&[("a.tar.gz", b"abc")]); + let err = check_dir(dir.path(), &entries, &[]) + .unwrap_err() + .to_string(); + assert!(err.contains("b.tar.gz is listed"), "{err}"); + } + + #[test] + fn an_unlisted_file_fails_unless_allowed() { + let entries = parse(&sums_for(&[(ABC_SHA256, "a.tar.gz")])).unwrap(); + let dir = dir_with(&[("a.tar.gz", b"abc"), ("extra.tar.gz", b"")]); + let err = check_dir(dir.path(), &entries, &[]) + .unwrap_err() + .to_string(); + assert!(err.contains("extra.tar.gz"), "{err}"); + check_dir(dir.path(), &entries, &["extra.tar.gz"]).unwrap(); + } + + #[test] + fn a_subdirectory_fails() { + let entries = parse(&sums_for(&[(ABC_SHA256, "a.tar.gz")])).unwrap(); + let dir = dir_with(&[("a.tar.gz", b"abc")]); + fs::create_dir(dir.path().join("nested")).unwrap(); + assert!(check_dir(dir.path(), &entries, &[]).is_err()); + } + + #[test] + fn hashes_a_file_larger_than_one_read() { + let content = vec![b'x'; (1 << 16) + 3]; + let dir = dir_with(&[("big", &content)]); + let expected = format!("{:x}", Sha256::digest(&content)); + assert_eq!(sha256_file(&dir.path().join("big")).unwrap(), expected); + assert!(sha256_file(&dir.path().join("absent")).is_err()); + assert!(check_dir(&dir.path().join("absent"), &[], &[]).is_err()); + } +} diff --git a/crates/release-sign/src/test_support.rs b/crates/release-sign/src/test_support.rs new file mode 100644 index 000000000..f06f57851 --- /dev/null +++ b/crates/release-sign/src/test_support.rs @@ -0,0 +1,207 @@ +//! A local Ed25519 key in place of Cloud KMS, and a release host backed by directories. + +use std::cell::RefCell; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use anyhow::{bail, Result}; +use ring::signature::{Ed25519KeyPair, KeyPair}; +use sha2::{Digest, Sha256}; + +use crate::github::{ReleaseHost, Run}; +use crate::minisign::{PublicKey, Signer, TrustedKey}; +use crate::release::{ARTIFACT, MIRROR_REPO, SOURCE_REPO}; + +pub const TAG: &str = "v1.2.3"; +pub const COMMIT: &str = "768473505d1c4bfd566cb0a5339e42b23ccfdd64"; +pub const RUN_ID: u64 = 39998172755; + +pub struct LocalSigner { + pair: Ed25519KeyPair, + calls: AtomicUsize, +} + +impl LocalSigner { + pub fn generate() -> Self { + let rng = ring::rand::SystemRandom::new(); + let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); + Self { + pair: Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(), + calls: AtomicUsize::new(0), + } + } + + pub fn public_key(&self) -> [u8; 32] { + self.pair.public_key().as_ref().try_into().unwrap() + } + + pub fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } +} + +impl Signer for LocalSigner { + fn sign(&self, message: &[u8]) -> Result<[u8; 64]> { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(self.pair.sign(message).as_ref().try_into()?) + } +} + +pub fn release_key(name: &str, signer: &LocalSigner, key_id: [u8; 8]) -> TrustedKey { + TrustedKey { + name: name.into(), + key: PublicKey { + key_id, + key: signer.public_key(), + }, + } +} + +/// A release run's artifact and the two release pages, as directories under one temp dir. +pub struct FakeHost { + root: tempfile::TempDir, + uploads: RefCell>, + failing_repo: RefCell>, +} + +const TARBALLS: [&str; 4] = [ + "actual-darwin-arm64.tar.gz", + "actual-darwin-x64.tar.gz", + "actual-linux-x64.tar.gz", + "actual-linux-arm64.tar.gz", +]; + +impl FakeHost { + /// Both pages carry exactly what the run built. + pub fn consistent_release() -> Self { + let host = Self { + root: tempfile::tempdir().unwrap(), + uploads: RefCell::new(Vec::new()), + failing_repo: RefCell::new(None), + }; + let mut sums = String::new(); + for name in TARBALLS { + let content = format!("the {name} binary"); + sums.push_str(&format!( + "{:x} {name}\n", + Sha256::digest(content.as_bytes()) + )); + for dir in host.all_dirs() { + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join(name), &content).unwrap(); + } + } + for dir in host.all_dirs() { + fs::write(dir.join("SHA256SUMS"), &sums).unwrap(); + } + host + } + + pub fn root(&self) -> PathBuf { + self.root.path().to_path_buf() + } + + pub fn sums(&self) -> Vec { + fs::read(self.artifact_dir().join("SHA256SUMS")).unwrap() + } + + pub fn uploads(&self) -> Vec { + self.uploads.borrow().clone() + } + + pub fn edit_page(&self, repo: &str, name: &str, edit: impl Fn(String) -> String) { + edit_file(&self.page_dir(repo).join(name), edit); + } + + pub fn edit_artifact(&self, name: &str, edit: impl Fn(String) -> String) { + edit_file(&self.artifact_dir().join(name), edit); + } + + pub fn add_to_page(&self, repo: &str, name: &str, content: &str) { + fs::write(self.page_dir(repo).join(name), content).unwrap(); + } + + pub fn remove_from_page(&self, repo: &str, name: &str) { + fs::remove_file(self.page_dir(repo).join(name)).unwrap(); + } + + pub fn fail_uploads_to(&self, repo: &str) { + *self.failing_repo.borrow_mut() = Some(repo.into()); + } + + fn artifact_dir(&self) -> PathBuf { + self.root.path().join("artifact") + } + + fn page_dir(&self, repo: &str) -> PathBuf { + self.root.path().join("pages").join(repo.replace('/', "_")) + } + + fn all_dirs(&self) -> [PathBuf; 3] { + [ + self.artifact_dir(), + self.page_dir(SOURCE_REPO), + self.page_dir(MIRROR_REPO), + ] + } +} + +fn edit_file(path: &Path, edit: impl Fn(String) -> String) { + let text = fs::read_to_string(path).unwrap(); + fs::write(path, edit(text)).unwrap(); +} + +fn copy_dir(from: &Path, to: &Path) -> Result<()> { + for item in fs::read_dir(from)? { + let item = item?; + fs::copy(item.path(), to.join(item.file_name()))?; + } + Ok(()) +} + +impl ReleaseHost for FakeHost { + fn tag_commit(&self, repo: &str, tag: &str) -> Result { + assert_eq!((repo, tag), (SOURCE_REPO, TAG)); + Ok(COMMIT.into()) + } + + fn release_runs(&self, repo: &str, workflow: &str, tag: &str) -> Result> { + assert_eq!((repo, workflow, tag), (SOURCE_REPO, "release.yml", TAG)); + Ok(vec![Run { + id: RUN_ID, + event: "push".into(), + path: ".github/workflows/release.yml".into(), + head_branch: Some(TAG.into()), + head_sha: COMMIT.into(), + status: "completed".into(), + conclusion: Some("success".into()), + }]) + } + + fn download_run_artifact( + &self, + repo: &str, + run_id: u64, + artifact: &str, + dir: &Path, + ) -> Result<()> { + assert_eq!((repo, run_id, artifact), (SOURCE_REPO, RUN_ID, ARTIFACT)); + copy_dir(&self.artifact_dir(), dir) + } + + fn download_release(&self, repo: &str, tag: &str, dir: &Path) -> Result<()> { + assert_eq!(tag, TAG); + copy_dir(&self.page_dir(repo), dir) + } + + fn upload_release_asset(&self, repo: &str, tag: &str, file: &Path) -> Result<()> { + assert_eq!(tag, TAG); + assert!(file.ends_with("SHA256SUMS.minisig")); + if self.failing_repo.borrow().as_deref() == Some(repo) { + bail!("HTTP 403: Resource not accessible"); + } + self.uploads.borrow_mut().push(repo.into()); + Ok(()) + } +} diff --git a/docs/CLI_DISTRIBUTION.md b/docs/CLI_DISTRIBUTION.md index ee688c9fe..417f84bd6 100644 --- a/docs/CLI_DISTRIBUTION.md +++ b/docs/CLI_DISTRIBUTION.md @@ -6,16 +6,11 @@ How the `actual` binary is built and published to Homebrew, npm, and GitHub Rele ## Overview -A release is triggered by pushing a `v*` tag to `main`. Pre-release tags (suffixed with `-alpha`, `-beta`, `-rc`, or `-test`) are detected and skipped by all release workflows — only clean semver tags (e.g. `v0.2.0`) produce published artifacts. +A release is triggered by pushing a `v*` tag to `main`. Pre-release tags (suffixed with `-alpha`, `-beta`, `-rc`, or `-test`) are detected and skipped by the release workflow — only clean semver tags (e.g. `v0.2.0`) produce published artifacts. -The two release workflows are: +One workflow, `.github/workflows/release.yml`, builds the binaries once per tag and feeds every channel from that build: the two GitHub Releases, the Homebrew tap and the five npm packages. It also writes `SHA256SUMS` for the four tarballs, which a member of the signing group signs after the release (see [RELEASE_SIGNING.md](RELEASE_SIGNING.md)). -| Workflow | File | What it does | -|----------|------|-------------| -| Release | `.github/workflows/release.yml` | Builds binaries, creates GitHub Releases, updates Homebrew tap | -| npm Publish | `.github/workflows/npm-publish.yml` | Builds binaries, publishes to npm | - -Both workflows build from the same tag but run independently. +A pull request that changes `release.yml`, the signing tool in `crates/release-sign` or the public keys in `release-keys/` runs the workflow as a dry run: the same build, `SHA256SUMS` and npm packages, all checked, with every publishing job skipped. --- @@ -34,7 +29,7 @@ git tag v0.2.0 git push origin v0.2.0 ``` -Both workflows fire automatically on tag push. +The release workflow fires on the tag push. Once it has published both GitHub Releases, a member of the signing group signs the release's `SHA256SUMS` with `cargo run -p release-sign -- release --kms-key `, as [RELEASE_SIGNING.md](RELEASE_SIGNING.md) describes. **Pre-release tag** (does not publish): ```bash @@ -46,7 +41,7 @@ git push origin v0.2.0-beta.1 ## Build matrix -Both workflows build the same four targets in parallel: +The release workflow builds the four targets once per tag, in parallel: | Target | Runner | Signing | |--------|--------|---------| @@ -65,7 +60,7 @@ All builds use `cargo build --release` (LTO, stripped, single codegen unit — s ## macOS signing and notarization -Both workflows have steps that sign the macOS binaries with an Apple Developer ID certificate and notarize them with `xcrun notarytool`, which would let Gatekeeper accept them without a quarantine prompt. The steps have been in both workflows since v0.1.0, but they run only when `APPLE_CERTIFICATE_P12` is set in the repo secrets, and no release has had it. The runs for v0.2.0 through v0.8.0 show the import, sign, and notarize steps skipped in both macOS jobs of both workflows. GitHub no longer keeps step records for the v0.1.x runs, and their binaries carry no Developer ID signature either. Every published arm64 binary carries only the linker's ad hoc signature (`codesign -dv` reports `Signature=adhoc`), every x64 binary is unsigned (`codesign -dv` reports `code object is not signed at all`), and Gatekeeper rejects both (`spctl --assess --type execute` reports `rejected`). +The macOS build jobs have steps that sign the binaries with an Apple Developer ID certificate and notarize them with `xcrun notarytool`, which would let Gatekeeper accept them without a quarantine prompt. The steps have been in the release builds since v0.1.0, but they run only when `APPLE_CERTIFICATE_P12` is set in the repo secrets, and no release has had it. The runs for v0.2.0 through v0.8.0 show the import, sign, and notarize steps skipped in both macOS jobs of both workflows. GitHub no longer keeps step records for the v0.1.x runs, and their binaries carry no Developer ID signature either. Every published arm64 binary carries only the linker's ad hoc signature (`codesign -dv` reports `Signature=adhoc`), every x64 binary is unsigned (`codesign -dv` reports `code object is not signed at all`), and Gatekeeper rejects both (`spctl --assess --type execute` reports `rejected`). Until the binaries are signed, each channel handles Gatekeeper its own way: @@ -95,22 +90,26 @@ The `release.yml` workflow creates two GitHub Releases from the same artifacts, `actual-releases` is the download source for the Homebrew formula and for manual installs. It holds no CLI source, only the release assets and the npm package scaffolding. -**Artifact filenames** (each is a `.tar.gz` containing a single `actual` binary): +**Artifact filenames** (each `.tar.gz` contains a single `actual` binary): ``` actual-darwin-arm64.tar.gz actual-darwin-x64.tar.gz actual-linux-x64.tar.gz actual-linux-arm64.tar.gz +SHA256SUMS +SHA256SUMS.minisig ``` +`SHA256SUMS` lists the four tarballs in `sha256sum` format and ships with the release. `SHA256SUMS.minisig` appears once a member of the signing group has signed the release; [RELEASE_SIGNING.md](RELEASE_SIGNING.md) covers checking a download against it. + --- ## Homebrew tap After the GitHub Release is created, `release.yml` automatically updates the formula in `actual-software/homebrew-actual`. -The update script (`scripts/update-formula.py` in the tap repo) takes the new version and SHA256 hashes computed from the release tarballs and rewrites `Formula/actual.rb` from `template/actual.rb`. +The update script (`scripts/update-formula.py` in the tap repo) takes the new version and the tarballs' SHA256 hashes, read from the release's `SHA256SUMS`, and rewrites `Formula/actual.rb` from `template/actual.rb`. The formula is committed directly to `main` on the tap repo. Homebrew picks it up immediately when users run `brew upgrade` or `brew install`. @@ -125,7 +124,7 @@ brew install actual-software/actual/actual ## npm packages -The `npm-publish.yml` workflow publishes five packages to the `@actualai` scope on the public npm registry: +The release workflow publishes five packages to the `@actualai` scope on the public npm registry: | Package | Contents | |---------|---------| @@ -135,14 +134,14 @@ The `npm-publish.yml` workflow publishes five packages to the `@actualai` scope | `@actualai/actual-linux-x64` | Linux x64 binary | | `@actualai/actual-linux-arm64` | Linux arm64 binary | -The npm package scaffolding (package.json files, install scripts) lives in `actual-software/actual-releases` under `npm/`. Versions are synced from the git tag via `scripts/sync-npm-version.sh` before publishing. +The npm package scaffolding (package.json files, install scripts) lives in `actual-software/actual-releases` under `npm/`. Versions are synced from the git tag via `scripts/sync-npm-version.sh`, and each platform package takes its binary from the release tarball. Before anything is published, the `npm Packages` job packs all five and checks that each platform package carries its tarball's binary, byte for byte. The `Publish to npm` job then publishes exactly those packed packages. **npm install (user-facing):** ```bash npm install -g @actualai/actual ``` -**Credentials:** the publish job authenticates through npm trusted publishing (GitHub OIDC, from the job's `id-token: write` permission, with npm 11.5.1 or later), so it uses no npm token, and npm records a provenance attestation for each package it publishes. Apart from the optional Apple signing secrets, the workflow needs only `TAP_GITHUB_TOKEN`, to check out `actual-software/actual-releases`. +**Credentials:** the publish job authenticates through npm trusted publishing (GitHub OIDC, from the job's `id-token: write` permission, with npm 11.5.1 or later), so it uses no npm token, and npm records a provenance attestation for each package it publishes. npm checks the workflow's file name, so `release.yml` has to be a trusted publisher on all five packages, with direct `npm publish` allowed. Only the publish job holds `id-token: write`: npm exchanges the OIDC token even for `npm publish --dry-run`, so the job that packs and checks the packages, which also runs on pull requests, goes without it. The scaffolding checkout uses the workflow's own token, since `actual-software/actual-releases` is public. --- @@ -150,8 +149,9 @@ npm install -g @actualai/actual | # | Decision | Rationale | |---|----------|-----------| -| 1 | Separate `release.yml` and `npm-publish.yml` workflows | Keeps concerns isolated; a failed npm publish doesn't block a GitHub Release | +| 1 | One release workflow and one build per tag (through v0.9.0, `npm-publish.yml` built its own binaries) | Every channel ships the same binaries. The npm and GitHub Release jobs don't depend on each other, so a failed npm publish still doesn't block a GitHub Release | | 2 | Pre-release tag detection via suffix pattern | Allows staging releases (`-beta`, `-rc`) without publishing to users | | 3 | Tarballs contain a single `actual` binary at root (no subdirectory) | Simplifies Homebrew formula and manual install — `tar xzf` drops the binary directly into the current directory | | 4 | Release assets mirrored to `actual-software/actual-releases` | Gave Homebrew, manual installs, and the npm scaffolding a public home while this repo was private. This repo is public now and its releases carry the same assets, but the formula and the README still download from `actual-releases` | | 5 | Signing conditional on `APPLE_CERTIFICATE_P12` presence | Forks and contributors can still build and test release artifacts without Apple credentials. No release run has had the secret, so no release has been Developer ID signed yet | +| 6 | `SHA256SUMS` signed with minisign, by an org key in Google Cloud KMS, by hand after each release | Anyone can check a download with stock `minisign -V`, the key can't be exported, and nothing in GitHub can make a signature. See [RELEASE_SIGNING.md](RELEASE_SIGNING.md) | diff --git a/docs/RELEASE_SIGNING.md b/docs/RELEASE_SIGNING.md new file mode 100644 index 000000000..9b7be339a --- /dev/null +++ b/docs/RELEASE_SIGNING.md @@ -0,0 +1,62 @@ +# Release signing + +Every release carries a `SHA256SUMS` file that lists the four tarballs, plus `SHA256SUMS.minisig`, a signature over it. Both release pages get them: [actual-cli](https://github.com/actual-software/actual-cli/releases) and the [actual-releases](https://github.com/actual-software/actual-releases/releases) mirror. The signing key is an Ed25519 key in Google Cloud KMS that nobody can view or export, and a member of the signing group signs each release by hand once it's out. Nothing in GitHub can sign. + +The public keys live in [`release-keys/`](../release-keys). Releases are signed with `primary.pub`'s key; `spare.pub` takes over if the primary is ever retired. + +--- + +## Checking a download + +You'll need [minisign](https://jedisct1.github.io/minisign/) (`brew install minisign`, or your package manager). Fetch the tarball, `SHA256SUMS`, `SHA256SUMS.minisig` and the public key, then check the signature first and the checksum second: + +```bash +TAG=v0.10.0 +base=https://github.com/actual-software/actual-releases/releases/download/$TAG +for f in SHA256SUMS SHA256SUMS.minisig actual-darwin-arm64.tar.gz; do curl -fsSLO "$base/$f"; done +curl -fsSL -o actual-release.pub https://raw.githubusercontent.com/actual-software/actual-cli/main/release-keys/primary.pub + +minisign -Vm SHA256SUMS -p actual-release.pub +shasum -a 256 --check --ignore-missing SHA256SUMS +``` + +If the signature's good, `minisign` prints `Signature and comment signature verified` and then the trusted comment. Make sure the comment names the tag you downloaded. An older release's signature still verifies. And if `minisign` reports a different key id, the spare key signed the release, so check against `spare.pub` instead. + +## The trusted comment + +The trusted comment is signed too. On every release signature it has exactly three tab-separated fields, in this order, and changing any of them breaks the signature: + +``` +timestamp: file:SHA256SUMS tag: +``` + +`tag:` holds the full tag, such as `tag:v0.10.0`. Code that reads the tag back should split the comment on tabs and compare the `tag:` field exactly. Searching it for a version string isn't enough. + +## Signing a release + +Once the release workflow has published both release pages, one member of the signing group runs `release-sign` from a checkout of `main` (any member will do). Beyond a Rust toolchain, it only needs your own logins: + +- `gcloud auth login` with your work account. Membership in the signing group is what lets that account sign with the primary key. +- `gh auth login` with write access to actual-cli and actual-releases, for the uploads. + +```bash +cargo run -p release-sign -- release v0.10.0 --kms-key "projects/$PROJECT/locations/$LOCATION/keyRings/$KEY_RING/cryptoKeys/release-signing-primary/cryptoKeyVersions/1" +``` + +Maintainers will find the values of `$PROJECT`, `$LOCATION` and `$KEY_RING` in the team's internal docs. `--kms-key` can also come from `RELEASE_SIGN_KMS_KEY`. Before it asks KMS for anything, the tool checks three things: + +1. that the tag resolves to a commit, and that exactly one push run of `release.yml` built that commit for that tag; +2. that the `SHA256SUMS` in that run's `release-assets` artifact matches every tarball the run built; +3. that the `SHA256SUMS` on each release page is byte for byte the run's copy, that every tarball on the page matches it, and that the page holds nothing else apart from an earlier `SHA256SUMS.minisig`. + +Only then does it sign `SHA256SUMS`. It verifies the signature against the key in `release-keys/` it came from, writes `SHA256SUMS.minisig`, and uploads it to both release pages (replacing an earlier one, if there is one). + +If a check fails, stop. It means something on a release page isn't what the release workflow built, and that needs explaining before anyone signs it. `--no-upload` runs every check and signs, but keeps the signature on your machine, and `--run ` picks the run when a tag's workflow ran more than once. Running the command again is safe; it's also how to finish when the upload to the second page failed. + +Sign while the run's artifacts still exist. They're kept for the repository's artifact retention period, 90 days unless someone has changed it. + +Maintainers will find the one-time setup of the signing keys, the audit of who signed what, and the steps for rotating to the spare key in the team's internal docs. + +## How the signature is made + +A minisign signature in the prehashed format holds two Ed25519 signatures: one over the BLAKE2b-512 digest of `SHA256SUMS`, and one over that signature followed by the trusted comment. Cloud KMS signs the exact bytes it's given (PureEdDSA), so `release-sign` makes two `asymmetricSign` calls and assembles the file. The 8-byte key id isn't covered by either signature: the tool attaches the id of whichever committed key the signature verifies against, checking it with the [minisign-verify](https://crates.io/crates/minisign-verify) crate. The tool's tests make the same file with a local Ed25519 key and check it with stock `minisign -V` and with minisign-verify. No test calls Cloud KMS. diff --git a/release-keys/primary.pub b/release-keys/primary.pub new file mode 100644 index 000000000..4a98f9b25 --- /dev/null +++ b/release-keys/primary.pub @@ -0,0 +1,2 @@ +untrusted comment: minisign public key FAFF02944334CC75 +RWR1zDRDlAL/+jYFCiSON5XrQqKNuSqyYlTL0UHTC90HDfVmC/A9p0JJ diff --git a/release-keys/spare.pub b/release-keys/spare.pub new file mode 100644 index 000000000..c18de2bf4 --- /dev/null +++ b/release-keys/spare.pub @@ -0,0 +1,2 @@ +untrusted comment: minisign public key 97EACF8B05C0855F +RWRfhcAFi8/ql+E9xAXv3vEbOEePxwpg1IX4TMJF/QjOUfVFIFicOAO5