diff --git a/.github/workflows/stable-release.yml b/.github/workflows/stable-release.yml new file mode 100644 index 00000000..6c9f9461 --- /dev/null +++ b/.github/workflows/stable-release.yml @@ -0,0 +1,481 @@ +name: Stable Release + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: Existing stable release tag, for example v2.2.0 + required: true + type: string + +concurrency: + group: stable-release-${{ github.event.release.tag_name || inputs.tag }} + cancel-in-progress: false + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.release.outputs.tag }} + version: ${{ steps.release.outputs.version }} + commit: ${{ steps.release.outputs.commit }} + steps: + - name: Resolve release tag + id: tag + shell: bash + env: + EVENT_TAG: ${{ github.event.release.tag_name }} + INPUT_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + tag=${EVENT_TAG:-$INPUT_TAG} + test -n "$tag" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@v4 + with: + ref: ${{ steps.tag.outputs.tag }} + fetch-depth: 0 + + - name: Validate stable release identity + id: release + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ steps.tag.outputs.tag }} + run: | + set -euo pipefail + release_json=$(gh release view "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --json isDraft,isPrerelease,tagName) + test "$(jq -r '.isDraft' <<< "$release_json")" = false + test "$(jq -r '.isPrerelease' <<< "$release_json")" = false + test "$(jq -r '.tagName' <<< "$release_json")" = "$RELEASE_TAG" + + version=$(node scripts/ci/verify-release-version.mjs "$RELEASE_TAG") + commit=$(git rev-parse HEAD) + { + echo "tag=$RELEASE_TAG" + echo "version=$version" + echo "commit=$commit" + } >> "$GITHUB_OUTPUT" + + desktop: + needs: prepare + strategy: + fail-fast: false + matrix: + include: + - platform: windows-latest + target_args: "" + portable: true + portable_name: windows-x64 + - platform: macos-latest + target_args: --target x86_64-apple-darwin + portable: false + - platform: macos-latest + target_args: --target aarch64-apple-darwin + portable: false + - platform: ubuntu-22.04 + target_args: --bundles appimage,deb,rpm + portable: true + portable_name: linux-x64 + - platform: ubuntu-22.04-arm + target_args: --bundles deb,rpm + portable: true + portable_name: linux-arm64 + + runs-on: ${{ matrix.platform }} + env: + TAURITAVERN_BUILD_BRANCH: main + TAURITAVERN_BUILD_REVISION: ${{ needs.prepare.outputs.commit }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.tag }} + + - name: Install Linux dependencies + if: startsWith(matrix.platform, 'ubuntu-22.04') + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libwebkit2gtk-4.1-dev librsvg2-dev patchelf rpm \ + gstreamer1.0-plugins-base gstreamer1.0-plugins-good \ + gstreamer1.0-alsa gstreamer1.0-pulseaudio + if apt-cache show libayatana-appindicator3-dev >/dev/null 2>&1; then + sudo apt-get install -y libayatana-appindicator3-dev + else + sudo apt-get install -y libappindicator3-dev + fi + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} + + - uses: swatinem/rust-cache@v2 + with: + workspaces: "src-tauri -> target" + + - uses: pnpm/action-setup@v4 + with: + version: 10 + run_install: false + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Setup macOS signing and notarization + if: matrix.platform == 'macos-latest' + shell: bash + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + run: ./scripts/ci/setup-macos-signing.sh + + - name: Build desktop bundles + uses: tauri-apps/tauri-action@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + APPLE_API_ISSUER: ${{ matrix.platform == 'macos-latest' && secrets.APPLE_API_ISSUER || '' }} + APPLE_API_KEY: ${{ matrix.platform == 'macos-latest' && secrets.APPLE_API_KEY || '' }} + with: + projectPath: src-tauri/crates/tauritavern + tauriScript: node ../../../scripts/tauri-app.mjs + args: ${{ matrix.target_args }} + uploadWorkflowArtifacts: true + uploadUpdaterJson: false + uploadUpdaterSignatures: false + workflowArtifactNamePattern: 'TauriTavern-${{ needs.prepare.outputs.version }}-[platform]-[arch]-[bundle]' + + - name: Build portable binary + if: matrix.portable + run: node scripts/build-portable.mjs --skip-web-build --output-dir portable + + - name: Upload portable binary + if: matrix.portable + uses: actions/upload-artifact@v4 + with: + name: TauriTavern-${{ needs.prepare.outputs.version }}-${{ matrix.portable_name }}-portable + path: portable/* + if-no-files-found: error + + mobile: + needs: prepare + strategy: + fail-fast: false + matrix: + include: + - mobile: android + platform: ubuntu-latest + args: --verbose --apk --split-per-abi --target aarch64 armv7 + - mobile: ios + platform: macos-latest + args: --verbose + + runs-on: ${{ matrix.platform }} + env: + TAURITAVERN_BUILD_BRANCH: main + TAURITAVERN_BUILD_REVISION: ${{ needs.prepare.outputs.commit }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.tag }} + + - name: Install Android host dependencies + if: matrix.mobile == 'android' + run: | + sudo apt-get update + sudo apt-get install -y \ + libglib2.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev \ + libayatana-appindicator3-dev + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.mobile == 'android' && 'aarch64-linux-android,armv7-linux-androideabi' || 'aarch64-apple-ios' }} + + - name: Setup JDK + if: matrix.mobile == 'android' + uses: actions/setup-java@v5 + with: + java-version: "21" + distribution: temurin + cache: gradle + cache-dependency-path: | + src-tauri/crates/tauritavern/gen/android/**/*.gradle* + src-tauri/crates/tauritavern/gen/android/**/gradle-wrapper.properties + + - name: Setup Android SDK + if: matrix.mobile == 'android' + uses: android-actions/setup-android@v3 + + - name: Setup Android NDK + if: matrix.mobile == 'android' + id: setup-ndk + uses: nttld/setup-ndk@v1 + with: + ndk-version: r29 + link-to-sdk: true + + - uses: swatinem/rust-cache@v2 + with: + workspaces: "src-tauri -> target" + + - uses: pnpm/action-setup@v4 + with: + version: 10 + run_install: false + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Setup Android signing + if: matrix.mobile == 'android' + shell: bash + run: | + cd src-tauri/crates/tauritavern/gen/android + echo "keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}" > keystore.properties + echo "password=${{ secrets.ANDROID_KEY_PASSWORD }}" >> keystore.properties + base64 -d <<< "${{ secrets.ANDROID_KEY_BASE64 }}" > "$RUNNER_TEMP/keystore.jks" + echo "storeFile=$RUNNER_TEMP/keystore.jks" >> keystore.properties + + - name: Setup App Store Connect API key + if: matrix.mobile == 'ios' + shell: bash + env: + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + run: | + : "${APPLE_API_ISSUER:?Missing APPLE_API_ISSUER secret}" + : "${APPLE_API_KEY:?Missing APPLE_API_KEY secret}" + : "${APPLE_API_KEY_P8:?Missing APPLE_API_KEY_P8 secret}" + key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY}.p8" + umask 077 + printf '%s' "$APPLE_API_KEY_P8" > "$key_path" + echo "APPLE_API_KEY_PATH=$key_path" >> "$GITHUB_ENV" + + - name: Build mobile bundle + uses: tauri-apps/tauri-action@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NDK_HOME: ${{ matrix.mobile == 'android' && steps.setup-ndk.outputs.ndk-path || '' }} + APPLE_API_ISSUER: ${{ matrix.mobile == 'ios' && secrets.APPLE_API_ISSUER || '' }} + APPLE_API_KEY: ${{ matrix.mobile == 'ios' && secrets.APPLE_API_KEY || '' }} + with: + projectPath: src-tauri/crates/tauritavern + tauriScript: node ../../../scripts/tauri-app.mjs + mobile: ${{ matrix.mobile }} + args: ${{ matrix.args }} + uploadWorkflowArtifacts: true + uploadPlainBinary: true + uploadUpdaterJson: false + uploadUpdaterSignatures: false + workflowArtifactNamePattern: 'TauriTavern-${{ needs.prepare.outputs.version }}-[platform]-[arch]-[bundle]' + + publish-release: + needs: [prepare, desktop, mobile] + runs-on: ubuntu-latest + permissions: + actions: read + contents: write + env: + ASSET_PREFIX: TauriTavern-${{ needs.prepare.outputs.version }}- + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + steps: + - uses: actions/download-artifact@v4 + with: + pattern: 'TauriTavern-${{ needs.prepare.outputs.version }}-*' + path: dist + + - name: Validate and collect release assets + shell: bash + run: | + set -euo pipefail + mkdir release-assets + for artifact in dist/"${ASSET_PREFIX}"*; do + base=$(basename "$artifact") + [[ "$base" == *-app ]] && continue + mapfile -t files < <(find "$artifact" -type f) + test "${#files[@]}" -eq 1 || { + echo "Expected one file in $artifact, found ${#files[@]}" >&2 + exit 1 + } + case "${files[0]}" in + *.AppImage) ext=.AppImage ;; + *.dmg) ext=.dmg ;; + *.msi) ext=.msi ;; + *.exe) ext=.exe ;; + *.deb) ext=.deb ;; + *.rpm) ext=.rpm ;; + *.apk) ext=.apk ;; + *.aab) ext=.aab ;; + *.ipa) ext=.ipa ;; + *) + [[ "$base" == *-portable ]] || { + echo "Unknown stable artifact type: ${files[0]}" >&2 + exit 1 + } + ext=.bin + ;; + esac + cp "${files[0]}" "release-assets/${base}${ext}" + done + mapfile -t assets < <(find release-assets -type f -name "${ASSET_PREFIX}*" | sort) + printf '%s\n' "${assets[@]}" + test "${#assets[@]}" -eq 15 || { + echo "Expected 15 stable assets, found ${#assets[@]}" >&2 + exit 1 + } + + - name: Upload assets without changing release notes + shell: bash + run: | + set -euo pipefail + find release-assets -type f -name "${ASSET_PREFIX}*" -print0 \ + | xargs -0 gh release upload "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --clobber + + publish-package-repositories: + needs: [prepare, publish-release] + continue-on-error: true + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + AWS_EC2_METADATA_DISABLED: "true" + R2_ENDPOINT: https://feb4df7b9167cb2a5c75b327584225b5.r2.cloudflarestorage.com + PACKAGES_R2_BUCKET: tauritavern-packages + RELEASE_VERSION: ${{ needs.prepare.outputs.version }} + EXPECTED_RPM_RELEASE: "1" + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + RELEASE_COMMIT: ${{ needs.prepare.outputs.commit }} + LINUX_REPOSITORY_SIGNING_FPR: D609D0B174E0073BB3980A1BEDC6CEF924B6C529 + RELEASE_ASSETS_DIR: dist + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.tag }} + + - uses: actions/download-artifact@v4 + with: + pattern: 'TauriTavern-${{ needs.prepare.outputs.version }}-*' + path: dist + + - name: Install repository tools + run: | + sudo apt-get update + sudo apt-get install -y \ + apt-utils awscli createrepo-c gnupg2 jq rpm + + - name: Import repository signing subkey + id: signing + shell: bash + env: + GPG_PRIVATE_KEY_BASE64: ${{ secrets.LINUX_REPOSITORY_GPG_PRIVATE_KEY_BASE64 }} + GPG_PASSPHRASE: ${{ secrets.LINUX_REPOSITORY_GPG_PASSPHRASE }} + run: | + set -euo pipefail + : "${GPG_PRIVATE_KEY_BASE64:?Missing LINUX_REPOSITORY_GPG_PRIVATE_KEY_BASE64 secret}" + key_file="$RUNNER_TEMP/tauritavern-repository-private-key.asc" + passphrase_file="$RUNNER_TEMP/tauritavern-repository-passphrase" + umask 077 + printf '%s' "$GPG_PRIVATE_KEY_BASE64" | base64 --decode > "$key_file" + printf '%s' "$GPG_PASSPHRASE" > "$passphrase_file" + gpg --batch --import "$key_file" + gpg --batch --with-colons --with-subkey-fingerprint \ + --list-secret-keys "$LINUX_REPOSITORY_SIGNING_FPR" \ + | awk -F: -v expected="$LINUX_REPOSITORY_SIGNING_FPR" \ + '$1 == "fpr" && $10 == expected { found = 1 } END { exit !found }' + echo "passphrase_file=$passphrase_file" >> "$GITHUB_OUTPUT" + + - name: Publish signed APT and RPM repositories + env: + LINUX_REPOSITORY_GPG_PASSPHRASE_FILE: ${{ steps.signing.outputs.passphrase_file }} + run: ./scripts/ci/publish-linux-repositories.sh + + - name: Verify public repository pointers + run: | + curl -fsSL https://packages.tauritavern.com/apt/dists/stable/InRelease \ + | gpg --batch --verify + curl -fsSL \ + https://packages.tauritavern.com/rpm/fedora/stable/x86_64/repodata/repomd.xml \ + >/dev/null + curl -fsSL \ + https://packages.tauritavern.com/rpm/opensuse/16.0/x86_64/repodata/repomd.xml \ + >/dev/null + + publish-nix-cache: + needs: [prepare, publish-release] + continue-on-error: true + strategy: + fail-fast: false + matrix: + include: + - system: x86_64-linux + runner: ubuntu-22.04 + - system: aarch64-linux + runner: ubuntu-22.04-arm + runs-on: ${{ matrix.runner }} + permissions: + contents: read + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + AWS_EC2_METADATA_DISABLED: "true" + R2_ENDPOINT: https://feb4df7b9167cb2a5c75b327584225b5.r2.cloudflarestorage.com + NIX_R2_BUCKET: tauritavern-nix-cache + NIX_CACHE_URL: https://nix-cache.tauritavern.com + NIX_CACHE_PUBLIC_KEY: nix-cache.tauritavern.com-1:mOl/sCsfndubNIhnLODjA7GPqk1qw5iknbayZLRn92U= + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.tag }} + + - uses: cachix/install-nix-action@v31 + + - name: Build release flake + run: >- + nix + --accept-flake-config + build + .#tauritavern + --print-build-logs + + - name: Sign and publish runtime and dependency paths + env: + NIX_CACHE_PRIVATE_KEY_BASE64: ${{ secrets.NIX_CACHE_PRIVATE_KEY_BASE64 }} + run: | + cargo_deps=$(nix eval --raw \ + ".#packages.${{ matrix.system }}.tauritavern.cargoDeps.outPath") + pnpm_deps=$(nix eval --raw \ + ".#packages.${{ matrix.system }}.tauritavern.pnpmDeps.outPath") + ./scripts/ci/publish-nix-cache.sh "$cargo_deps" "$pnpm_deps" + + - name: Verify public Nix cache + run: curl -fsSL https://nix-cache.tauritavern.com/nix-cache-info diff --git a/scripts/ci/publish-linux-repositories.sh b/scripts/ci/publish-linux-repositories.sh new file mode 100755 index 00000000..a95457fd --- /dev/null +++ b/scripts/ci/publish-linux-repositories.sh @@ -0,0 +1,330 @@ +#!/usr/bin/env bash + +set -euo pipefail + +: "${RELEASE_ASSETS_DIR:?Missing RELEASE_ASSETS_DIR}" +: "${RELEASE_VERSION:?Missing RELEASE_VERSION}" +: "${RELEASE_TAG:?Missing RELEASE_TAG}" +: "${RELEASE_COMMIT:?Missing RELEASE_COMMIT}" +: "${R2_ENDPOINT:?Missing R2_ENDPOINT}" +: "${PACKAGES_R2_BUCKET:?Missing PACKAGES_R2_BUCKET}" +: "${LINUX_REPOSITORY_SIGNING_FPR:?Missing LINUX_REPOSITORY_SIGNING_FPR}" +: "${LINUX_REPOSITORY_GPG_PASSPHRASE_FILE:?Missing LINUX_REPOSITORY_GPG_PASSPHRASE_FILE}" + +REPOSITORY_CHANNEL=${REPOSITORY_CHANNEL:-stable} +EXPECTED_DEB_VERSION=${EXPECTED_DEB_VERSION:-$RELEASE_VERSION} +EXPECTED_RPM_RELEASE=${EXPECTED_RPM_RELEASE:-} + +case "$REPOSITORY_CHANNEL" in + stable) + apt_pool_path=pool/main/t/tauri-tavern + manifest_name=repository-manifest.json + rpm_repository_paths=( + rpm/fedora/stable/x86_64 + rpm/fedora/stable/aarch64 + rpm/opensuse/16.0/x86_64 + rpm/opensuse/16.0/aarch64 + ) + ;; + canary) + apt_pool_path=pool/canary/main/t/tauri-tavern + manifest_name=repository-manifest-canary.json + rpm_repository_paths=( + rpm/fedora/canary/x86_64 + rpm/fedora/canary/aarch64 + rpm/opensuse/16.0/canary/x86_64 + rpm/opensuse/16.0/canary/aarch64 + ) + ;; + *) + echo "Unsupported repository channel: $REPOSITORY_CHANNEL" >&2 + exit 1 + ;; +esac + +for required_command in \ + apt-ftparchive aws createrepo_c dpkg-deb gpg gzip jq rpm rpmsign; do + command -v "$required_command" >/dev/null 2>&1 || { + echo "Missing required command: $required_command" >&2 + exit 1 + } +done + +repository_dir=$(mktemp -d "${RUNNER_TEMP:-/tmp}/tauritavern-repository.XXXXXX") +trap 'rm -rf "$repository_dir"' EXIT + +aws_r2() { + aws --endpoint-url "$R2_ENDPOINT" s3 "$@" +} + +sync_existing_packages() { + mkdir -p "$repository_dir/apt/$apt_pool_path" + aws_r2 sync \ + "s3://$PACKAGES_R2_BUCKET/apt/$apt_pool_path" \ + "$repository_dir/apt/$apt_pool_path" + + for repository_path in "${rpm_repository_paths[@]}"; do + mkdir -p "$repository_dir/$repository_path/packages" + aws_r2 sync \ + "s3://$PACKAGES_R2_BUCKET/$repository_path/packages" \ + "$repository_dir/$repository_path/packages" + done +} + +stage_deb_packages() { + mapfile -d '' deb_assets < <(find "$RELEASE_ASSETS_DIR" -type f -name '*.deb' -print0) + test "${#deb_assets[@]}" -eq 2 || { + echo "Expected two DEB assets, found ${#deb_assets[@]}" >&2 + exit 1 + } + + for asset in "${deb_assets[@]}"; do + package=$(dpkg-deb -f "$asset" Package) + version=$(dpkg-deb -f "$asset" Version) + architecture=$(dpkg-deb -f "$asset" Architecture) + test "$package" = "tauri-tavern" || { + echo "Unexpected DEB package name in $asset: $package" >&2 + exit 1 + } + test "$version" = "$EXPECTED_DEB_VERSION" || { + echo "Unexpected DEB version in $asset: $version" >&2 + exit 1 + } + case "$architecture" in + amd64 | arm64) ;; + *) + echo "Unexpected DEB architecture in $asset: $architecture" >&2 + exit 1 + ;; + esac + install -m 0644 "$asset" \ + "$repository_dir/apt/$apt_pool_path/tauri-tavern_${version}_${architecture}.deb" + done +} + +stage_rpm_packages() { + mapfile -d '' rpm_assets < <(find "$RELEASE_ASSETS_DIR" -type f -name '*.rpm' -print0) + test "${#rpm_assets[@]}" -eq 2 || { + echo "Expected two RPM assets, found ${#rpm_assets[@]}" >&2 + exit 1 + } + + rpm_signature_db="$repository_dir/rpm-signature-db" + signing_public_key="$repository_dir/repository-signing-public-key.asc" + mkdir -p "$rpm_signature_db" + gpg --batch --armor --export "$LINUX_REPOSITORY_SIGNING_FPR" >"$signing_public_key" + rpm --dbpath "$rpm_signature_db" --initdb + rpm --dbpath "$rpm_signature_db" --import "$signing_public_key" + + for asset in "${rpm_assets[@]}"; do + package=$(rpm --dbpath "$rpm_signature_db" -qp --queryformat '%{NAME}' "$asset") + version=$(rpm --dbpath "$rpm_signature_db" -qp --queryformat '%{VERSION}' "$asset") + release=$(rpm --dbpath "$rpm_signature_db" -qp --queryformat '%{RELEASE}' "$asset") + architecture=$(rpm --dbpath "$rpm_signature_db" -qp --queryformat '%{ARCH}' "$asset") + test "$package" = "tauri-tavern" || { + echo "Unexpected RPM package name in $asset: $package" >&2 + exit 1 + } + test "$version" = "$RELEASE_VERSION" || { + echo "Unexpected RPM version in $asset: $version" >&2 + exit 1 + } + if [ -n "$EXPECTED_RPM_RELEASE" ] && [ "$release" != "$EXPECTED_RPM_RELEASE" ]; then + echo "Unexpected RPM release in $asset: $release" >&2 + exit 1 + fi + case "$architecture" in + x86_64 | aarch64) ;; + *) + echo "Unexpected RPM architecture in $asset: $architecture" >&2 + exit 1 + ;; + esac + + signed_rpm="$repository_dir/tauri-tavern-${version}-${release}.${architecture}.rpm" + install -m 0644 "$asset" "$signed_rpm" + rpmsign \ + --dbpath "$rpm_signature_db" \ + --define "_gpg_name ${LINUX_REPOSITORY_SIGNING_FPR}!" \ + --define "_gpg_sign_cmd_extra_args --batch --pinentry-mode loopback --passphrase-file $LINUX_REPOSITORY_GPG_PASSPHRASE_FILE" \ + --addsign \ + "$signed_rpm" + signature_check=$(rpm --dbpath "$rpm_signature_db" --checksig "$signed_rpm") + echo "$signature_check" + grep -q 'digests signatures OK$' <<<"$signature_check" + + for distribution_path in "${rpm_repository_paths[@]}"; do + [[ "$distribution_path" == */"$architecture" ]] || continue + install -m 0644 "$signed_rpm" \ + "$repository_dir/$distribution_path/packages/$(basename "$signed_rpm")" + done + done +} + +generate_apt_metadata() { + apt_root="$repository_dir/apt" + for architecture in amd64 arm64; do + binary_dir="$apt_root/dists/$REPOSITORY_CHANNEL/main/binary-$architecture" + mkdir -p "$binary_dir" + ( + cd "$apt_root" + apt-ftparchive -a "$architecture" packages "$apt_pool_path" \ + >"dists/$REPOSITORY_CHANNEL/main/binary-$architecture/Packages" + ) + gzip -n -9 -c "$binary_dir/Packages" >"$binary_dir/Packages.gz" + done + + ( + cd "$apt_root" + apt-ftparchive \ + -o APT::FTPArchive::Release::Origin=TauriTavern \ + -o APT::FTPArchive::Release::Label=TauriTavern \ + -o APT::FTPArchive::Release::Suite="$REPOSITORY_CHANNEL" \ + -o APT::FTPArchive::Release::Codename="$REPOSITORY_CHANNEL" \ + -o APT::FTPArchive::Release::Architectures='amd64 arm64' \ + -o APT::FTPArchive::Release::Components=main \ + -o APT::FTPArchive::Release::Description="TauriTavern $REPOSITORY_CHANNEL repository" \ + release "dists/$REPOSITORY_CHANNEL" >"dists/$REPOSITORY_CHANNEL/Release" + ) + + gpg \ + --batch \ + --yes \ + --pinentry-mode loopback \ + --passphrase-file "$LINUX_REPOSITORY_GPG_PASSPHRASE_FILE" \ + --local-user "${LINUX_REPOSITORY_SIGNING_FPR}!" \ + --armor \ + --detach-sign \ + --output "$apt_root/dists/$REPOSITORY_CHANNEL/Release.gpg" \ + "$apt_root/dists/$REPOSITORY_CHANNEL/Release" + gpg \ + --batch \ + --yes \ + --pinentry-mode loopback \ + --passphrase-file "$LINUX_REPOSITORY_GPG_PASSPHRASE_FILE" \ + --local-user "${LINUX_REPOSITORY_SIGNING_FPR}!" \ + --clearsign \ + --output "$apt_root/dists/$REPOSITORY_CHANNEL/InRelease" \ + "$apt_root/dists/$REPOSITORY_CHANNEL/Release" +} + +generate_rpm_metadata() { + for repository_path in "${rpm_repository_paths[@]}"; do + repository_root="$repository_dir/$repository_path" + createrepo_c --update "$repository_root" + gpg \ + --batch \ + --yes \ + --pinentry-mode loopback \ + --passphrase-file "$LINUX_REPOSITORY_GPG_PASSPHRASE_FILE" \ + --local-user "${LINUX_REPOSITORY_SIGNING_FPR}!" \ + --armor \ + --detach-sign \ + --output "$repository_root/repodata/repomd.xml.asc" \ + "$repository_root/repodata/repomd.xml" + done +} + +write_manifest() { + jq -n \ + --arg version "$RELEASE_VERSION" \ + --arg tag "$RELEASE_TAG" \ + --arg commit "$RELEASE_COMMIT" \ + --arg channel "$REPOSITORY_CHANNEL" \ + --arg deb_version "$EXPECTED_DEB_VERSION" \ + --arg rpm_release "$EXPECTED_RPM_RELEASE" \ + --arg published_at "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \ + '{ + schema_version: 1, + channel: $channel, + version: $version, + deb_version: $deb_version, + rpm_release: (if $rpm_release == "" then null else $rpm_release end), + tag: $tag, + commit: $commit, + published_at: $published_at + }' >"$repository_dir/$manifest_name" +} + +verify_repository_metadata() { + gpg --batch --verify \ + "$repository_dir/apt/dists/$REPOSITORY_CHANNEL/Release.gpg" \ + "$repository_dir/apt/dists/$REPOSITORY_CHANNEL/Release" + gpg --batch --verify "$repository_dir/apt/dists/$REPOSITORY_CHANNEL/InRelease" + + for repository_path in "${rpm_repository_paths[@]}"; do + repository_root="$repository_dir/$repository_path" + gpg --batch --verify \ + "$repository_root/repodata/repomd.xml.asc" \ + "$repository_root/repodata/repomd.xml" + done +} + +upload_apt_repository() { + aws_r2 sync \ + "$repository_dir/apt/pool" \ + "s3://$PACKAGES_R2_BUCKET/apt/pool" \ + --cache-control 'public, max-age=31536000, immutable' + aws_r2 sync \ + "$repository_dir/apt/dists" \ + "s3://$PACKAGES_R2_BUCKET/apt/dists" \ + --exclude '*/Release' \ + --exclude '*/Release.gpg' \ + --exclude '*/InRelease' \ + --cache-control 'no-cache' + for release_file in Release Release.gpg InRelease; do + aws_r2 cp \ + "$repository_dir/apt/dists/$REPOSITORY_CHANNEL/$release_file" \ + "s3://$PACKAGES_R2_BUCKET/apt/dists/$REPOSITORY_CHANNEL/$release_file" \ + --cache-control 'no-cache' + done +} + +upload_rpm_repository() { + for repository_path in "${rpm_repository_paths[@]}"; do + repository_root="$repository_dir/$repository_path" + destination="s3://$PACKAGES_R2_BUCKET/$repository_path" + aws_r2 sync \ + "$repository_root/packages" \ + "$destination/packages" \ + --cache-control 'public, max-age=31536000, immutable' + aws_r2 sync \ + "$repository_root/repodata" \ + "$destination/repodata" \ + --exclude 'repomd.xml' \ + --exclude 'repomd.xml.asc' \ + --cache-control 'public, max-age=31536000, immutable' + aws_r2 cp \ + "$repository_root/repodata/repomd.xml.asc" \ + "$destination/repodata/repomd.xml.asc" \ + --cache-control 'no-cache' + aws_r2 cp \ + "$repository_root/repodata/repomd.xml" \ + "$destination/repodata/repomd.xml" \ + --cache-control 'no-cache' + done +} + +sync_existing_packages +stage_deb_packages +stage_rpm_packages +generate_apt_metadata +generate_rpm_metadata +write_manifest +verify_repository_metadata + +if [ "${TAURITAVERN_REPOSITORY_PREPARE_ONLY:-0}" = "1" ]; then + echo "Prepared and verified TauriTavern $RELEASE_VERSION repository metadata" + exit 0 +fi + +# Immutable payloads and ordinary metadata are uploaded before the signed +# release pointers, so a failed upload leaves clients on the previous snapshot. +upload_apt_repository +upload_rpm_repository +aws_r2 cp \ + "$repository_dir/$manifest_name" \ + "s3://$PACKAGES_R2_BUCKET/$manifest_name" \ + --cache-control 'no-cache' + +echo "Published TauriTavern $RELEASE_VERSION ($REPOSITORY_CHANNEL) to $PACKAGES_R2_BUCKET" diff --git a/scripts/ci/publish-nix-cache.sh b/scripts/ci/publish-nix-cache.sh new file mode 100755 index 00000000..25317a69 --- /dev/null +++ b/scripts/ci/publish-nix-cache.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash + +set -euo pipefail + +: "${NIX_CACHE_PRIVATE_KEY_BASE64:?Missing NIX_CACHE_PRIVATE_KEY_BASE64}" +: "${NIX_CACHE_PUBLIC_KEY:?Missing NIX_CACHE_PUBLIC_KEY}" +: "${R2_ENDPOINT:?Missing R2_ENDPOINT}" +: "${NIX_R2_BUCKET:?Missing NIX_R2_BUCKET}" + +NIX_STORE_PATH=${NIX_STORE_PATH:-./result} +NIX_CACHE_URL=${NIX_CACHE_URL:-} +NIX_CACHE_URL=${NIX_CACHE_URL%/} + +for required_command in aws base64 curl nix; do + command -v "$required_command" >/dev/null 2>&1 || { + echo "Missing required command: $required_command" >&2 + exit 1 + } +done + +work_dir=$(mktemp -d "${RUNNER_TEMP:-/tmp}/tauritavern-nix-cache.XXXXXX") +trap 'rm -rf "$work_dir"' EXIT +key_file="$work_dir/private-key" +cache_dir="$work_dir/cache" +mkdir -p "$cache_dir" + +umask 077 +printf '%s' "$NIX_CACHE_PRIVATE_KEY_BASE64" | base64 --decode >"$key_file" +actual_public_key=$(nix key convert-secret-to-public <"$key_file") +test "$actual_public_key" = "$NIX_CACHE_PUBLIC_KEY" || { + echo "Nix cache private key does not match NIX_CACHE_PUBLIC_KEY" >&2 + exit 1 +} + +store_roots=("$NIX_STORE_PATH") +for extra_path in "$@"; do + nix path-info "$extra_path" >/dev/null + store_name=$(basename "$extra_path") + store_hash=${store_name%%-*} + [[ "$store_hash" =~ ^[0-9a-df-np-sv-z]{32}$ ]] || { + echo "Invalid Nix store path: $extra_path" >&2 + exit 1 + } + : "${NIX_CACHE_URL:?Missing NIX_CACHE_URL for additional store paths}" + response_code=$(curl \ + --silent \ + --show-error \ + --location \ + --retry 3 \ + --output /dev/null \ + --write-out '%{http_code}' \ + "$NIX_CACHE_URL/$store_hash.narinfo") + case "$response_code" in + 200) + echo "Already cached: $extra_path" + ;; + 404) + store_roots+=("$extra_path") + ;; + *) + echo "Unable to query $extra_path in the public cache: HTTP $response_code" >&2 + exit 1 + ;; + esac +done + +nix path-info --recursive "${store_roots[@]}" | + sort -u | + nix store sign --stdin --key-file "$key_file" +nix path-info --sigs "${store_roots[@]}" +nix copy --to "file://$cache_dir" "${store_roots[@]}" + +aws_r2() { + aws --endpoint-url "$R2_ENDPOINT" s3 "$@" +} + +aws_r2 sync \ + "$cache_dir/nar" \ + "s3://$NIX_R2_BUCKET/nar" \ + --cache-control 'public, max-age=31536000, immutable' +aws_r2 sync \ + "$cache_dir" \ + "s3://$NIX_R2_BUCKET" \ + --exclude '*' \ + --include '*.narinfo' \ + --cache-control 'public, max-age=31536000, immutable' +aws_r2 cp \ + "$cache_dir/nix-cache-info" \ + "s3://$NIX_R2_BUCKET/nix-cache-info" \ + --cache-control 'no-cache' diff --git a/scripts/ci/rewrite-deb-version.sh b/scripts/ci/rewrite-deb-version.sh new file mode 100755 index 00000000..53d351a5 --- /dev/null +++ b/scripts/ci/rewrite-deb-version.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [ "$#" -ne 2 ]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +package_path=$1 +target_version=$2 + +command -v dpkg-deb >/dev/null 2>&1 || { + echo "Missing required command: dpkg-deb" >&2 + exit 1 +} +test -f "$package_path" || { + echo "DEB package not found: $package_path" >&2 + exit 1 +} +dpkg --validate-version "$target_version" + +work_dir=$(mktemp -d "${RUNNER_TEMP:-/tmp}/tauritavern-deb.XXXXXX") +trap 'rm -rf "$work_dir"' EXIT + +package_root="$work_dir/package" +rebuilt_package="$work_dir/rebuilt.deb" +dpkg-deb --raw-extract "$package_path" "$package_root" + +control_file="$package_root/DEBIAN/control" +test -f "$control_file" || { + echo "Missing DEBIAN/control in $package_path" >&2 + exit 1 +} + +awk -v version="$target_version" ' + BEGIN { replaced = 0 } + /^Version: / { + print "Version: " version + replaced = 1 + next + } + { print } + END { + if (!replaced) { + exit 1 + } + } +' "$control_file" >"$control_file.new" +mv "$control_file.new" "$control_file" + +dpkg-deb --build --root-owner-group "$package_root" "$rebuilt_package" +test "$(dpkg-deb -f "$rebuilt_package" Version)" = "$target_version" +install -m 0644 "$rebuilt_package" "$package_path" diff --git a/scripts/ci/verify-release-version.mjs b/scripts/ci/verify-release-version.mjs new file mode 100755 index 00000000..47677354 --- /dev/null +++ b/scripts/ci/verify-release-version.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node + +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const repositoryRoot = resolve(import.meta.dirname, '../..'); +const requestedTag = process.argv[2] ?? ''; +const version = requestedTag.startsWith('v') ? requestedTag.slice(1) : requestedTag; + +if (!/^[0-9]+\.[0-9]+\.[0-9]+$/.test(version)) { + throw new Error(`Stable release tag must be vMAJOR.MINOR.PATCH, received: ${requestedTag || '(empty)'}`); +} + +async function read(path) { + return readFile(resolve(repositoryRoot, path), 'utf8'); +} + +function expectMatch(path, source, pattern) { + const match = source.match(pattern); + if (!match) { + throw new Error(`Unable to locate the release version in ${path}`); + } + if (match[1] !== version) { + throw new Error(`${path} declares ${match[1]}, but the release tag declares ${version}`); + } +} + +const packageJsonPath = 'package.json'; +const tauriConfigPath = 'src-tauri/crates/tauritavern/tauri.conf.json'; +const cargoManifestPath = 'src-tauri/crates/tauritavern/Cargo.toml'; +const cargoLockPath = 'src-tauri/Cargo.lock'; +const nixPackagePath = 'nix/package.nix'; + +const [ + packageJsonSource, + tauriConfigSource, + cargoManifestSource, + cargoLockSource, + nixPackageSource, +] = await Promise.all([ + read(packageJsonPath), + read(tauriConfigPath), + read(cargoManifestPath), + read(cargoLockPath), + read(nixPackagePath), +]); + +const packageJson = JSON.parse(packageJsonSource); +const tauriConfig = JSON.parse(tauriConfigSource); + +if (packageJson.version !== version) { + throw new Error(`${packageJsonPath} declares ${packageJson.version}, but the release tag declares ${version}`); +} +if (tauriConfig.version !== version) { + throw new Error(`${tauriConfigPath} declares ${tauriConfig.version}, but the release tag declares ${version}`); +} + +expectMatch(cargoManifestPath, cargoManifestSource, /^\[package\]\s+name = "tauritavern"\s+version = "([^"]+)"/m); +expectMatch(cargoLockPath, cargoLockSource, /\[\[package\]\]\s+name = "tauritavern"\s+version = "([^"]+)"/m); +expectMatch(nixPackagePath, nixPackageSource, /^\s*version = "([^"]+)";$/m); + +process.stdout.write(`${version}\n`); diff --git a/tests/release-version.test.mjs b/tests/release-version.test.mjs new file mode 100644 index 00000000..82a70e46 --- /dev/null +++ b/tests/release-version.test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +const verifier = resolve('scripts/ci/verify-release-version.mjs'); +const currentVersion = JSON.parse(readFileSync('package.json', 'utf8')).version; + +test('stable release verifier accepts the synchronized project version', () => { + const result = spawnSync(process.execPath, [verifier, `v${currentVersion}`], { + encoding: 'utf8', + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), currentVersion); +}); + +test('stable release verifier rejects a mismatched tag', () => { + const result = spawnSync(process.execPath, [verifier, 'v99.99.99'], { + encoding: 'utf8', + }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /package\.json declares/); +}); + +test('stable release verifier rejects prerelease tags', () => { + const result = spawnSync(process.execPath, [verifier, `v${currentVersion}-rc.1`], { + encoding: 'utf8', + }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /vMAJOR\.MINOR\.PATCH/); +}); diff --git a/tests/stable-release-workflow.test.mjs b/tests/stable-release-workflow.test.mjs new file mode 100644 index 00000000..9d0504af --- /dev/null +++ b/tests/stable-release-workflow.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import YAML from 'yaml'; + +const workflowPath = '.github/workflows/stable-release.yml'; +const workflowSource = readFileSync(workflowPath, 'utf8'); +const workflow = YAML.parse(workflowSource); + +test('stable release workflow starts from a published release or an explicit tag', () => { + assert.deepEqual(workflow.on.release.types, ['published']); + assert.equal(workflow.on.workflow_dispatch.inputs.tag.required, true); +}); + +test('stable release workflow preserves manually written release notes', () => { + assert.doesNotMatch(workflowSource, /codex|release edit|notes-file/i); + assert.match(workflowSource, /Upload assets without changing release notes/); +}); + +test('stable release workflow publishes release assets before optional repositories', () => { + assert.deepEqual(workflow.jobs['publish-release'].needs, ['prepare', 'desktop', 'mobile']); + + for (const jobName of ['publish-package-repositories', 'publish-nix-cache']) { + const job = workflow.jobs[jobName]; + assert.deepEqual(job.needs, ['prepare', 'publish-release']); + assert.equal(job['continue-on-error'], true); + } +}); + +test('stable release workflow keeps repository credentials in GitHub secrets', () => { + assert.match(workflowSource, /secrets\.R2_ACCESS_KEY_ID/); + assert.match(workflowSource, /secrets\.R2_SECRET_ACCESS_KEY/); + assert.match(workflowSource, /secrets\.LINUX_REPOSITORY_GPG_PRIVATE_KEY_BASE64/); + assert.match(workflowSource, /secrets\.NIX_CACHE_PRIVATE_KEY_BASE64/); + assert.doesNotMatch(workflowSource, /BEGIN (?:PGP|OPENSSH|PRIVATE) PRIVATE KEY/); +}); + +test('stable Nix publication includes reusable project dependencies', () => { + assert.match(workflowSource, /tauritavern\.cargoDeps\.outPath/); + assert.match(workflowSource, /tauritavern\.pnpmDeps\.outPath/); + assert.match(workflowSource, /NIX_CACHE_URL: https:\/\/nix-cache\.tauritavern\.com/); +});