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