name: Desktop Release on: push: tags: - "v*" - "desktop-v*" - "desktop-macos-v*" - "desktop-linux-v*" - "desktop-windows-v*" workflow_dispatch: inputs: tag: description: "Existing tag to build (e.g. v0.1.0)" required: true type: string platform: description: "Optional desktop platform to build." required: false default: "all" type: choice options: - all - macos - linux - windows checkout_ref: description: "Optional branch/ref to checkout while using tag for release metadata." required: false default: "" type: string publish: description: "Publish built artifacts to GitHub Releases." required: false default: "true" type: choice options: - "true" - "false" rollout_hours: description: "Linear rollout duration in hours. Use 0 for instant rollout." required: false default: "36" type: string concurrency: group: desktop-release-${{ github.ref }} cancel-in-progress: false env: SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} CHECKOUT_REF: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.checkout_ref || github.ref_name) || github.ref_name }} SHOULD_PUBLISH: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false' }} ROLLOUT_HOURS: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.rollout_hours || '36' }} DESKTOP_PACKAGE_PATH: "packages/desktop" jobs: create-release: if: ${{ (github.event_name == 'push' && !startsWith(github.ref_name, 'desktop-macos-v') && !startsWith(github.ref_name, 'desktop-linux-v') && !startsWith(github.ref_name, 'desktop-windows-v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.platform == 'all' && github.event.inputs.publish != 'false') }} permissions: contents: write runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: sparse-checkout: scripts ref: ${{ env.CHECKOUT_REF }} - name: Resolve release metadata shell: bash run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV" - name: Create GitHub release if: env.IS_SMOKE_TAG != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" > /dev/null 2>&1; then echo "Release $RELEASE_TAG already exists, skipping creation" else prerelease_flag="" if [[ "$IS_PRERELEASE" == "true" ]]; then prerelease_flag="--prerelease" fi gh release create "$RELEASE_TAG" \ --repo "${{ github.repository }}" \ --title "Paseo $RELEASE_TAG" \ --notes "" \ $prerelease_flag || { echo "Release creation raced with another workflow; continuing." } fi publish-macos: needs: [create-release] if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v')))) }} strategy: fail-fast: false matrix: include: - runner: macos-14 electron_arch: arm64 - runner: macos-15-intel electron_arch: x64 permissions: contents: write packages: read runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 ref: ${{ env.CHECKOUT_REF }} - name: Resolve release metadata shell: bash run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV" - name: Setup Node uses: actions/setup-node@v4 with: node-version: "22" cache: "npm" cache-dependency-path: package-lock.json registry-url: "https://npm.pkg.github.com" scope: "@boudra" - name: Install JS dependencies run: node scripts/npm-retry.mjs ci env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Set desktop package version from tag shell: bash run: | node <<'NODE' const fs = require('node:fs'); const path = require('node:path'); const version = process.env.DESKTOP_VERSION; if (!version) throw new Error('DESKTOP_VERSION env var is missing'); const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json'); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); packageJson.version = version; fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); NODE - name: Build desktop release shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} EP_GH_IGNORE_TIME: true CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }} CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} PASEO_DESKTOP_SMOKE: "1" PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke run: | set -euo pipefail build_args=(-- --publish never --mac --${{ matrix.electron_arch }}) build_args+=("-c.publish.releaseType=$RELEASE_TYPE") build_args+=("-c.publish.channel=$RELEASE_CHANNEL") npm run build:desktop "${build_args[@]}" - name: Upload packaged smoke diagnostics if: failure() uses: actions/upload-artifact@v4 with: name: desktop-smoke-macos-${{ matrix.electron_arch }} path: ${{ runner.temp }}/desktop-smoke if-no-files-found: ignore retention-days: 7 - name: Upload desktop artifacts to release if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | set -euo pipefail release_dir="${DESKTOP_PACKAGE_PATH}/release" files=() while IFS= read -r -d '' f; do files+=("$f") done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z) if (( ${#files[@]} == 0 )); then echo "::error::No release artifacts found in $release_dir" exit 1 fi gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}" - name: Upload desktop artifacts to workflow if: env.SHOULD_PUBLISH != 'true' uses: actions/upload-artifact@v4 with: name: desktop-macos-${{ matrix.electron_arch }} path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/*.dmg retention-days: 7 - name: Upload manifest artifact if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true' uses: actions/upload-artifact@v4 with: name: mac-manifest-${{ matrix.electron_arch }} path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}-mac.yml retention-days: 1 publish-linux: needs: [create-release] if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v')))) }} permissions: contents: write packages: read runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 with: fetch-depth: 0 ref: ${{ env.CHECKOUT_REF }} - name: Resolve release metadata shell: bash run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV" - name: Setup Node uses: actions/setup-node@v4 with: node-version: "22" cache: "npm" cache-dependency-path: package-lock.json registry-url: "https://npm.pkg.github.com" scope: "@boudra" - name: Install JS dependencies run: node scripts/npm-retry.mjs ci env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Set desktop package version from tag shell: bash run: | node <<'NODE' const fs = require('node:fs'); const path = require('node:path'); const version = process.env.DESKTOP_VERSION; if (!version) throw new Error('DESKTOP_VERSION env var is missing'); const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json'); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); packageJson.version = version; fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); NODE - name: Install Linux smoke display run: sudo apt-get update && sudo apt-get install -y xvfb xauth - name: Build desktop release shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} EP_GH_IGNORE_TIME: true PASEO_DESKTOP_SMOKE: "1" PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke run: | set -euo pipefail build_args=(-- --publish never --linux --x64) build_args+=("-c.publish.releaseType=$RELEASE_TYPE") build_args+=("-c.publish.channel=$RELEASE_CHANNEL") npm run build:desktop "${build_args[@]}" - name: Upload packaged smoke diagnostics if: failure() uses: actions/upload-artifact@v4 with: name: desktop-smoke-linux-x64 path: ${{ runner.temp }}/desktop-smoke if-no-files-found: ignore retention-days: 7 - name: Upload desktop artifacts to release if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | set -euo pipefail release_dir="${DESKTOP_PACKAGE_PATH}/release" files=() while IFS= read -r -d '' f; do files+=("$f") done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z) if (( ${#files[@]} == 0 )); then echo "::error::No release artifacts found in $release_dir" exit 1 fi gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}" - name: Upload desktop artifacts to workflow if: env.SHOULD_PUBLISH != 'true' uses: actions/upload-artifact@v4 with: name: desktop-linux path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/* retention-days: 7 - name: Upload manifest artifact if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true' uses: actions/upload-artifact@v4 with: name: linux-manifest path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}-linux.yml retention-days: 1 publish-windows: needs: [create-release] if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v')))) }} permissions: contents: write packages: read runs-on: windows-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 ref: ${{ env.CHECKOUT_REF }} - name: Resolve release metadata shell: bash run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV" - name: Setup Node uses: actions/setup-node@v4 with: node-version: "22" cache: "npm" cache-dependency-path: package-lock.json registry-url: "https://npm.pkg.github.com" scope: "@boudra" - name: Install JS dependencies run: node scripts/npm-retry.mjs ci env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Set desktop package version from tag shell: bash run: | node <<'NODE' const fs = require('node:fs'); const path = require('node:path'); const version = process.env.DESKTOP_VERSION; if (!version) throw new Error('DESKTOP_VERSION env var is missing'); const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json'); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); packageJson.version = version; fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); NODE - name: Build desktop release shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} EP_GH_IGNORE_TIME: true PASEO_DESKTOP_SMOKE: "1" PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke run: | set -euo pipefail build_args=(-- --publish never --win --x64 --arm64) build_args+=("-c.publish.releaseType=$RELEASE_TYPE") build_args+=("-c.publish.channel=$RELEASE_CHANNEL") npm run build:desktop "${build_args[@]}" - name: Upload packaged smoke diagnostics if: failure() uses: actions/upload-artifact@v4 with: name: desktop-smoke-windows-x64 path: ${{ runner.temp }}/desktop-smoke if-no-files-found: ignore retention-days: 7 - name: Upload desktop artifacts to release if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | set -euo pipefail release_dir="${DESKTOP_PACKAGE_PATH}/release" files=() while IFS= read -r -d '' f; do files+=("$f") done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z) if (( ${#files[@]} == 0 )); then echo "::error::No release artifacts found in $release_dir" exit 1 fi gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}" - name: Upload desktop artifacts to workflow if: env.SHOULD_PUBLISH != 'true' uses: actions/upload-artifact@v4 with: name: desktop-windows path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/* retention-days: 7 - name: Upload manifest artifact if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true' uses: actions/upload-artifact@v4 with: name: windows-manifest path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}.yml retention-days: 1 finalize-rollout: needs: [publish-macos, publish-linux, publish-windows] if: ${{ always() && (needs.publish-macos.result == 'success' || needs.publish-macos.result == 'skipped') && (needs.publish-linux.result == 'success' || needs.publish-linux.result == 'skipped') && (needs.publish-windows.result == 'success' || needs.publish-windows.result == 'skipped') && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }} permissions: contents: write runs-on: ubuntu-latest concurrency: group: desktop-rollout-${{ github.event.inputs.tag || github.ref_name }} cancel-in-progress: false steps: - uses: actions/checkout@v4 with: sparse-checkout: | package.json package-lock.json scripts ref: ${{ env.CHECKOUT_REF }} - name: Resolve release tag shell: bash run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV" - name: Setup Node uses: actions/setup-node@v4 with: node-version: "22" cache: "npm" cache-dependency-path: package-lock.json registry-url: "https://npm.pkg.github.com" scope: "@boudra" - name: Install JS dependencies run: node scripts/npm-retry.mjs ci env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Download mac manifest artifacts if: env.IS_SMOKE_TAG != 'true' && needs.publish-macos.result == 'success' uses: actions/download-artifact@v4 with: pattern: mac-manifest-* path: release-manifests - name: Download Linux manifest artifact if: env.IS_SMOKE_TAG != 'true' && needs.publish-linux.result == 'success' uses: actions/download-artifact@v4 with: name: linux-manifest path: release-manifests/linux-manifest - name: Download Windows manifest artifact if: env.IS_SMOKE_TAG != 'true' && needs.publish-windows.result == 'success' uses: actions/download-artifact@v4 with: name: windows-manifest path: release-manifests/windows-manifest - name: Assemble and upload stamped manifests if: env.IS_SMOKE_TAG != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | set -euo pipefail cd release-manifests manifests_dir="$PWD/final" mkdir -p "$manifests_dir" if [[ "${{ needs.publish-macos.result }}" == "success" ]]; then manifest_name="${RELEASE_CHANNEL}-mac.yml" node ../scripts/merge-mac-manifest.mjs \ "mac-manifest-arm64/${manifest_name}" \ "mac-manifest-x64/${manifest_name}" \ "$manifests_dir/${manifest_name}" fi if [[ "${{ needs.publish-linux.result }}" == "success" ]]; then cp "linux-manifest/${RELEASE_CHANNEL}-linux.yml" "$manifests_dir/" fi if [[ "${{ needs.publish-windows.result }}" == "success" ]]; then cp "windows-manifest/${RELEASE_CHANNEL}.yml" "$manifests_dir/" fi shopt -s nullglob files=( "$manifests_dir"/*.yml ) if (( ${#files[@]} == 0 )); then echo "::error::No manifest artifacts were available to publish" exit 1 fi timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")" node ../scripts/stamp-rollout.mjs --release-date "$timestamp" --rollout-hours "$ROLLOUT_HOURS" "${files[@]}" ROLLOUT_HOURS_EXPECTED="$ROLLOUT_HOURS" RELEASE_DATE_EXPECTED="$timestamp" node -e ' const yaml = require("js-yaml"); const fs = require("fs"); const expectedHours = Number(process.env.ROLLOUT_HOURS_EXPECTED); const expectedDate = process.env.RELEASE_DATE_EXPECTED; if (!Number.isFinite(expectedHours) || expectedHours < 0) { throw new Error(`expected non-negative rolloutHours, got ${process.env.ROLLOUT_HOURS_EXPECTED}`); } for (const f of process.argv.slice(1)) { const m = yaml.load(fs.readFileSync(f, "utf8")) ?? {}; if (m.rolloutHours !== expectedHours) { throw new Error(`${f}: rolloutHours=${m.rolloutHours}, expected ${expectedHours}`); } if (m.releaseDate !== expectedDate) { throw new Error(`${f}: releaseDate=${m.releaseDate}, expected ${expectedDate}`); } if (typeof m.version !== "string" || m.version.length === 0) { throw new Error(`${f}: missing or invalid version`); } } ' "${files[@]}" gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"