feat(ci): automate stable releases and Linux repositories

This commit is contained in:
Darkatse 2026-07-26 07:17:18 +08:00
parent 07797c14a8
commit 1c07729e88
7 changed files with 1094 additions and 0 deletions

481
.github/workflows/stable-release.yml vendored Normal file
View file

@ -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

View file

@ -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"

90
scripts/ci/publish-nix-cache.sh Executable file
View file

@ -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'

View file

@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -ne 2 ]; then
echo "Usage: $0 <package.deb> <version>" >&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"

View file

@ -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`);

View file

@ -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/);
});

View file

@ -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/);
});