name: Release (Stable) on: push: tags: - 'stable-v*' env: # Bump this when Tauri/Rust target artifacts capture stale absolute paths. RUST_TARGET_CACHE_VERSION: v2026-04-14-tolaria concurrency: group: release-stable-${{ github.ref }} cancel-in-progress: true jobs: # ───────────────────────────────────────────────────────────── # Phase 1: Compute the stable version string once # ───────────────────────────────────────────────────────────── version: name: Compute stable version runs-on: ubuntu-latest outputs: version: ${{ steps.ver.outputs.version }} display_version: ${{ steps.ver.outputs.display_version }} tag: ${{ steps.ver.outputs.tag }} steps: - id: ver shell: bash run: | python3 <<'PY' > version.env import os import re from datetime import date tag = os.environ["GITHUB_REF_NAME"] version = tag.removeprefix("stable-v") match = re.fullmatch(r"(\d{4})\.(\d{1,2})\.(\d{1,2})", version) if not match: raise SystemExit(f"Stable tags must use stable-vYYYY.M.D, got {tag}") date(*map(int, match.groups())) print(f"version={version}") print(f"display_version={version}") print(f"tag={tag}") PY cat version.env >> "$GITHUB_OUTPUT" DISPLAY_VERSION=$(grep '^display_version=' version.env | cut -d= -f2-) echo "### Stable version: \`$DISPLAY_VERSION\`" >> "$GITHUB_STEP_SUMMARY" # ───────────────────────────────────────────────────────────── # Phase 2: Build each architecture in parallel # ───────────────────────────────────────────────────────────── build: name: Build (${{ matrix.arch }}) needs: version runs-on: macos-15 strategy: fail-fast: true matrix: include: - arch: aarch64 target: aarch64-apple-darwin steps: - uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 10 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '22' cache: 'pnpm' - name: Setup Bun (required for bundle-qmd.sh) uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Setup Rust uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} - name: Cache Rust dependencies uses: actions/cache@v4 with: path: | ~/.cargo/registry ~/.cargo/git src-tauri/target key: ${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }} restore-keys: | ${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}- - name: Install frontend dependencies run: pnpm install --frozen-lockfile - name: Clear cached bundle artifacts run: | rm -rf src-tauri/target/${{ matrix.target }}/release/bundle - name: Set version run: | VERSION="${{ needs.version.outputs.version }}" jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml - name: Import Apple Developer certificate into keychain env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} run: | CERT_PATH="$RUNNER_TEMP/apple_cert.p12" KEYCHAIN_PATH="$RUNNER_TEMP/laputa-signing.keychain-db" KEYCHAIN_PASSWORD="$(uuidgen)" echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERT_PATH" security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" security list-keychain -d user -s "$KEYCHAIN_PATH" security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" - name: Validate telemetry env env: VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }} VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }} run: | python3 <<'PY' import os import re import sys from urllib.parse import urlparse DISALLOWED_PLACEHOLDERS = { "", "-", "_", "false", "true", "null", "undefined", "none", "disabled", } def normalize(name: str) -> str: value = os.getenv(name, "").strip() if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'): value = value[1:-1].strip() return value def normalize_http_like(value: str) -> str: if "://" in value: return value return f"https://{value}" def normalize_hostname(hostname: str) -> str: normalized = hostname.strip().rstrip('.').lower() if normalized.startswith('[') and normalized.endswith(']'): normalized = normalized[1:-1] return normalized def is_ip_address(hostname: str) -> bool: if re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", hostname): return all(0 <= int(part) <= 255 for part in hostname.split('.')) return ':' in hostname and re.fullmatch(r"[\da-f:]+", hostname, re.IGNORECASE) is not None def is_allowed_hostname(hostname: str) -> bool: normalized = normalize_hostname(hostname) if not normalized or normalized in DISALLOWED_PLACEHOLDERS: return False if normalized == 'localhost': return True return '.' in normalized or is_ip_address(normalized) def is_http_url(value: str) -> bool: parsed = urlparse(normalize_http_like(value)) return parsed.scheme in {"http", "https"} and is_allowed_hostname(parsed.hostname or "") values = { name: normalize(name) for name in ( "VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_KEY", "VITE_POSTHOG_HOST", ) } errors = [] for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"): value = values[name] if value.lower() in DISALLOWED_PLACEHOLDERS: errors.append(f"{name} must be set to a real value, not a placeholder") elif not is_http_url(value): errors.append(f"{name} must be a valid http(s) URL with a non-placeholder host") if values["VITE_POSTHOG_KEY"].lower() in DISALLOWED_PLACEHOLDERS: errors.append("VITE_POSTHOG_KEY must be set to a real project API key, not a placeholder") if errors: print("Telemetry env validation failed:", file=sys.stderr) for error in errors: print(f"- {error}", file=sys.stderr) raise SystemExit(1) print("Telemetry env validation passed.") PY - name: Build Tauri app (with signing + notarization) env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }} VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }} run: | pnpm tauri build --target ${{ matrix.target }} - name: Upload .dmg uses: actions/upload-artifact@v4 with: name: dmg-${{ matrix.arch }} path: src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg retention-days: 1 - name: Upload updater artifacts (.tar.gz + .sig) uses: actions/upload-artifact@v4 with: name: updater-${{ matrix.arch }} path: | src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig retention-days: 1 # ───────────────────────────────────────────────────────────── # Phase 3: Publish GitHub Release # ───────────────────────────────────────────────────────────── release: name: GitHub Release (stable) needs: [version, build] runs-on: ubuntu-latest permissions: contents: write steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Download all artifacts uses: actions/download-artifact@v4 - name: Generate release notes run: | PREV_TAG=$(git tag --list 'stable-v*' --sort=-version:refname | grep -vx "${{ needs.version.outputs.tag }}" | head -n 1 || echo "") if [ -z "$PREV_TAG" ]; then NOTES=$(git log --oneline --no-merges -20) else NOTES=$(git log --oneline --no-merges "${PREV_TAG}..${{ needs.version.outputs.tag }}") fi { echo "## What's Changed" echo "" echo "$NOTES" | while IFS= read -r line; do echo "- $line"; done echo "" echo "---" echo "**Stable release — manually promoted from \`main\`**" echo "" echo "**Requires Apple Silicon (M1/M2/M3)**" echo "" echo "*Built from \`$(git rev-parse --short ${{ needs.version.outputs.tag }})\` on $(date -u +%Y-%m-%d)*" } > release_notes.md - name: Build stable-latest.json run: | VERSION="${{ needs.version.outputs.version }}" TAG="${{ needs.version.outputs.tag }}" REPO="${GITHUB_REPOSITORY}" REPO_NAME="${REPO#*/}" PAGES_URL="https://refactoringhq.github.io/${REPO_NAME}/" ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig) ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename) ARM_DMG=$(ls dmg-aarch64/*.dmg | xargs basename) cat > stable-latest.json << EOF { "version": "${VERSION}", "notes": "Stable release. See ${PAGES_URL} for full release notes.", "pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", "platforms": { "darwin-aarch64": { "signature": "${ARM_SIG}", "url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_TARBALL}", "dmg_url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_DMG}" } } } EOF echo "stable-latest.json:"; cat stable-latest.json - name: Publish GitHub Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.version.outputs.tag }} name: Tolaria ${{ needs.version.outputs.display_version }} body_path: release_notes.md draft: false prerelease: false files: | dmg-aarch64/*.dmg updater-aarch64/*.app.tar.gz updater-aarch64/*.app.tar.gz.sig stable-latest.json # ───────────────────────────────────────────────────────────── # Phase 4: Update GitHub Pages # ───────────────────────────────────────────────────────────── pages: name: Update release history page needs: [version, release] runs-on: ubuntu-latest permissions: contents: write concurrency: group: github-pages cancel-in-progress: false steps: - uses: actions/checkout@v4 - name: Setup Bun uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Build release history page env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | mkdir -p _site/alpha _site/stable gh api -H "Accept: application/vnd.github.html+json" repos/${{ github.repository }}/releases --paginate > _site/releases.json PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}" curl -fsSL "${PAGES_URL}/alpha/latest.json" -o _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "stable-latest.json" --output _site/stable/latest.json || echo '{}' > _site/stable/latest.json bun scripts/build-release-download-page.ts --latest-json _site/stable/latest.json --releases-json _site/releases.json --output-file _site/stable/download/index.html bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/index.html mkdir -p _site/download cp _site/stable/download/index.html _site/download/index.html cp _site/alpha/latest.json _site/latest.json cp _site/alpha/latest.json _site/latest-canary.json - name: Deploy to GitHub Pages uses: peaceiris/actions-gh-pages@v4 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./_site commit_message: "Update release history for ${{ needs.version.outputs.tag }}"