name: Desktop Release run-name: >- ${{ github.event_name == 'workflow_dispatch' && format('Desktop Release · {0} · {1}', inputs.release_mode, github.ref_name) || format('Desktop Release · {0} · {1}', github.event_name, github.ref_name) }} on: push: tags: - "v*" schedule: - cron: "16 20 * * *" workflow_dispatch: inputs: release_mode: description: What to build and publish required: true type: choice default: patch_rc_release options: - unsigned_dry_run - patch_beta_release - patch_rc_release - patch_release - minor_release - major_release - explicit_version_release version: description: Release semver for explicit_version_release, such as 0.1.0, 0.1.0-rc.0, or 0.1.0-beta.0 required: false type: string target_commitish: description: Optional commit-ish override required: false type: string notify_feishu: description: Send a Feishu card after publishing required: false type: boolean default: true permissions: contents: write id-token: write concurrency: group: desktop-release-${{ github.event_name }}-${{ inputs.release_mode || github.ref_name }}-${{ inputs.version || github.ref_name }} cancel-in-progress: false env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: resolve: if: ${{ vars.TUTTI_DESKTOP_RELEASE_WORKFLOW_ENABLED == 'true' }} runs-on: ubuntu-latest outputs: release_dry_run: ${{ steps.mode.outputs.dry_run }} release_make_latest: ${{ steps.release.outputs.release_make_latest }} release_prerelease: ${{ steps.release.outputs.release_prerelease }} release_tag: ${{ steps.release.outputs.release_tag }} release_target: ${{ steps.release.outputs.release_target }} release_version: ${{ steps.release.outputs.release_version }} release_channel: ${{ steps.release.outputs.release_channel }} release_candidate: ${{ steps.release.outputs.release_candidate }} release_candidate_id: ${{ steps.release.outputs.release_candidate_id }} release_candidate_tag: ${{ steps.release.outputs.release_candidate_tag }} source_ref: ${{ steps.target.outputs.source_ref }} notify_feishu: ${{ steps.notify.outputs.notify_feishu }} steps: - name: Summarize workflow dispatch inputs if: ${{ github.event_name == 'workflow_dispatch' }} shell: bash env: WORKFLOW_INPUTS: ${{ toJSON(inputs) }} run: | { echo "## Desktop release request" echo printf -- '- Triggered by: %s\n' "${GITHUB_ACTOR}" printf -- '- Ref: %s\n' "${GITHUB_REF_NAME}" printf -- '- Commit: %s\n' "${GITHUB_SHA}" echo echo "### Workflow dispatch inputs" echo printf '%s\n' "${WORKFLOW_INPUTS}" | sed 's/^/ /' } >> "${GITHUB_STEP_SUMMARY}" - name: Resolve target commit id: target shell: bash run: | release_target="${{ inputs.target_commitish || github.sha }}" echo "release_target=${release_target}" >> "$GITHUB_OUTPUT" echo "source_ref=${{ github.ref_type == 'branch' && github.ref_name || '' }}" >> "$GITHUB_OUTPUT" - name: Checkout uses: actions/checkout@v6 with: ref: ${{ steps.target.outputs.release_target }} fetch-depth: 0 - name: Validate release dispatch branch shell: bash env: RELEASE_EVENT_NAME: ${{ github.event_name }} RELEASE_MODE: ${{ inputs.release_mode }} RELEASE_REF_NAME: ${{ github.ref_name }} RELEASE_REF_TYPE: ${{ github.ref_type }} run: | if [[ "${RELEASE_EVENT_NAME}" != "workflow_dispatch" ]]; then exit 0 fi case "${RELEASE_MODE:-}" in patch_rc_release|patch_release|minor_release|major_release) ;; *) exit 0 ;; esac if [[ "${RELEASE_REF_TYPE}" != "branch" ]]; then echo "Manual desktop release mode ${RELEASE_MODE} must be run from main or a release/* branch." >&2 exit 1 fi case "${RELEASE_REF_NAME}" in main|release/*) ;; *) echo "Manual desktop release mode ${RELEASE_MODE} must be run from main or a release/* branch, not ${RELEASE_REF_NAME}." >&2 exit 1 ;; esac case "${RELEASE_MODE:-}" in patch_release|minor_release|major_release) ;; *) exit 0 ;; esac if [[ "${RELEASE_REF_NAME}" == "main" ]]; then release_branch_refs="$(git ls-remote --heads origin "refs/heads/release/*")" if [[ -n "${release_branch_refs}" ]]; then echo "Stable desktop release modes must run from a release/* branch while release branches exist." >&2 echo "Found release branches:" >&2 printf "%s\n" "${release_branch_refs}" | sed -E "s#^[[:xdigit:]]+[[:space:]]+refs/heads/#- #" >&2 exit 1 fi fi - name: Setup Node.js uses: actions/setup-node@v6 with: node-version-file: .node-version - name: Resolve release mode id: mode shell: bash env: RELEASE_EVENT_NAME: ${{ github.event_name }} RELEASE_MODE: ${{ inputs.release_mode }} run: | dry_run=false strategy=patch if [[ "${RELEASE_EVENT_NAME}" == "schedule" ]]; then strategy=patch_rc else case "${RELEASE_MODE:-}" in "") strategy=explicit_tag ;; unsigned_dry_run) dry_run=true strategy=explicit_version ;; patch_release) strategy=patch ;; patch_beta_release) strategy=patch_beta ;; minor_release) strategy=minor ;; major_release) strategy=major ;; patch_rc_release) strategy=patch_rc ;; minor_rc_release) strategy=minor_rc ;; major_rc_release) strategy=major_rc ;; explicit_version_release) strategy=explicit_version ;; *) echo "Unknown release_mode: ${RELEASE_MODE}" >&2 exit 1 ;; esac fi echo "dry_run=${dry_run}" >> "$GITHUB_OUTPUT" echo "strategy=${strategy}" >> "$GITHUB_OUTPUT" - name: Verify managed app runtime is published before release build if: steps.mode.outputs.dry_run != 'true' run: node tools/scripts/verify-tutti-app-runtime-release.mjs - name: Resolve release tag id: release shell: bash env: RELEASE_DRY_RUN: ${{ steps.mode.outputs.dry_run }} RELEASE_STRATEGY: ${{ steps.mode.outputs.strategy }} INPUT_VERSION: ${{ inputs.version }} run: | if [[ "${{ github.event_name }}" == "push" ]]; then args=(--strategy explicit_tag --tag "${{ github.ref_name }}" --json) else args=(--strategy "${RELEASE_STRATEGY}" --json) if [[ "${RELEASE_STRATEGY}" == "explicit_version" ]]; then version="${INPUT_VERSION:-0.1.0}" args+=(--version "${version}") fi args=(node apps/desktop/scripts/resolve-release-tag.mjs "${args[@]}") fi if [[ "${{ github.event_name }}" == "push" ]]; then release_json="$(node apps/desktop/scripts/resolve-release-tag.mjs "${args[@]}")" else release_json="$("${args[@]}")" fi release_make_latest="$(node -e "process.stdout.write(String(JSON.parse(process.argv[1]).makeLatest))" "${release_json}")" release_prerelease="$(node -e "process.stdout.write(String(JSON.parse(process.argv[1]).prerelease))" "${release_json}")" release_tag="$(node -e "process.stdout.write(JSON.parse(process.argv[1]).tag)" "${release_json}")" release_version="$(node -e "process.stdout.write(JSON.parse(process.argv[1]).version)" "${release_json}")" release_channel="$(node -e "process.stdout.write(JSON.parse(process.argv[1]).channel)" "${release_json}")" release_candidate=false if [[ "${release_channel}" == "stable" && "${RELEASE_DRY_RUN}" != "true" && "${{ github.event_name }}" != "push" ]]; then release_candidate=true if git ls-remote --exit-code --tags origin "refs/tags/${release_tag}" >/dev/null 2>&1; then echo "Stable tag ${release_tag} already exists; retry promotion or choose a new version." >&2 exit 1 fi elif [[ "${{ github.event_name }}" != "push" && "${RELEASE_DRY_RUN}" != "true" ]]; then reserve_args=(--strategy explicit_tag --tag "${release_tag}" --target "$(git rev-parse HEAD)" --json) node apps/desktop/scripts/reserve-release-tag.mjs "${reserve_args[@]}" >/dev/null fi release_target="$(git rev-parse HEAD)" release_candidate_id="${release_tag}-$(git rev-parse --short=7 HEAD)-run${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" release_candidate_tag="candidate-${release_tag}" echo "release_make_latest=${release_make_latest}" >> "$GITHUB_OUTPUT" echo "release_prerelease=${release_prerelease}" >> "$GITHUB_OUTPUT" echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT" echo "release_version=${release_version}" >> "$GITHUB_OUTPUT" echo "release_channel=${release_channel}" >> "$GITHUB_OUTPUT" echo "release_candidate=${release_candidate}" >> "$GITHUB_OUTPUT" echo "release_candidate_id=${release_candidate_id}" >> "$GITHUB_OUTPUT" echo "release_candidate_tag=${release_candidate_tag}" >> "$GITHUB_OUTPUT" echo "release_target=${release_target}" >> "$GITHUB_OUTPUT" - name: Resolve Feishu notification id: notify shell: bash run: echo "notify_feishu=${{ github.event_name != 'workflow_dispatch' || inputs.notify_feishu != false }}" >> "$GITHUB_OUTPUT" build-macos: name: Build macOS (${{ matrix.arch }}) needs: resolve runs-on: macos-latest strategy: fail-fast: false matrix: arch: [x64, arm64, universal] env: TUTTI_DESKTOP_MAC_ARCH: ${{ matrix.arch }} TUTTI_DESKTOP_RELEASE_TAG: ${{ needs.resolve.outputs.release_tag }} TUTTI_DESKTOP_RELEASE_TARGET: ${{ needs.resolve.outputs.release_target }} TUTTI_DESKTOP_DRY_RUN: ${{ needs.resolve.outputs.release_dry_run }} TUTTI_DESKTOP_RELEASE_CHANNEL: ${{ needs.resolve.outputs.release_channel }} TUTTI_DESKTOP_RELEASE_CANDIDATE: ${{ needs.resolve.outputs.release_candidate }} TUTTI_DESKTOP_RELEASE_CANDIDATE_ID: ${{ needs.resolve.outputs.release_candidate_id }} TUTTI_DESKTOP_RELEASE_CANDIDATE_TAG: ${{ needs.resolve.outputs.release_candidate_tag }} steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ env.TUTTI_DESKTOP_RELEASE_TARGET }} fetch-depth: 0 - name: Setup pnpm uses: pnpm/action-setup@v6 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version-file: .node-version cache: pnpm cache-dependency-path: pnpm-lock.yaml - name: Setup Go uses: actions/setup-go@v6 with: go-version: "1.24.5" cache-dependency-path: | go.work go.work.sum packages/workbench/service/go.mod packages/workspace/files/go.mod packages/workspace/issues/go.mod services/tuttid/go.mod services/tuttid/go.sum - name: Install dependencies run: pnpm install --frozen-lockfile - name: Install Electron runtime run: pnpm --filter @tutti-os/desktop exec install-electron - name: Align package version with release tag working-directory: apps/desktop run: node scripts/apply-ci-release-version.mjs "${TUTTI_DESKTOP_RELEASE_TAG}" - name: Prepare Apple notarization API key if: env.TUTTI_DESKTOP_DRY_RUN != 'true' env: APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} run: | if [ -z "${APPLE_API_KEY_P8_BASE64}" ] || [ -z "${APPLE_API_KEY_ID}" ]; then echo "APPLE_API_KEY_P8_BASE64 and APPLE_API_KEY_ID secrets are required for signed macOS releases." >&2 exit 1 fi apple_api_key_path="${RUNNER_TEMP}/AuthKey_${APPLE_API_KEY_ID}.p8" node -e "require('node:fs').writeFileSync(process.argv[1], Buffer.from(process.env.APPLE_API_KEY_P8_BASE64, 'base64'))" "${apple_api_key_path}" chmod 600 "${apple_api_key_path}" echo "APPLE_API_KEY=${apple_api_key_path}" >> "${GITHUB_ENV}" - name: Prepare macOS signing certificate if: env.TUTTI_DESKTOP_DRY_RUN != 'true' env: MACOS_CSC_LINK: ${{ secrets.MACOS_CSC_LINK }} MACOS_CSC_KEY_PASSWORD: ${{ secrets.MACOS_CSC_KEY_PASSWORD }} run: | if [ -z "${MACOS_CSC_LINK}" ] || [ -z "${MACOS_CSC_KEY_PASSWORD}" ]; then echo "MACOS_CSC_LINK and MACOS_CSC_KEY_PASSWORD secrets are required for signed macOS releases." >&2 exit 1 fi case "${MACOS_CSC_LINK}" in https://*|file://*|/*|./*|../*|~/*) echo "CSC_LINK=${MACOS_CSC_LINK}" >> "${GITHUB_ENV}" ;; *) certificate_path="${RUNNER_TEMP}/macos-codesign-certificate.p12" CERTIFICATE_PATH="${certificate_path}" node <<'NODE' const fs = require("node:fs"); const raw = process.env.MACOS_CSC_LINK ?? ""; const output = process.env.CERTIFICATE_PATH; const normalized = raw .replace(/\\n/g, "\n") .replace(/^data:.*;base64,/, "") .replace(/-----BEGIN [^-]+-----/g, "") .replace(/-----END [^-]+-----/g, "") .replace(/\s+/g, "") .replace(/-/g, "+") .replace(/_/g, "/"); const base64 = normalized.padEnd( normalized.length + ((4 - (normalized.length % 4)) % 4), "=" ); if (!/^[A-Za-z0-9+/]*={0,2}$/.test(base64)) { throw new Error("MACOS_CSC_LINK must be a URL, path, or base64-encoded .p12 certificate."); } const bytes = Buffer.from(base64, "base64"); if (bytes.length === 0 || bytes[0] !== 0x30) { throw new Error("MACOS_CSC_LINK did not decode to a valid PKCS#12 certificate."); } fs.writeFileSync(output, bytes, { mode: 0o600 }); NODE echo "CSC_LINK=${certificate_path}" >> "${GITHUB_ENV}" ;; esac echo "CSC_KEY_PASSWORD=${MACOS_CSC_KEY_PASSWORD}" >> "${GITHUB_ENV}" - name: Build release artifacts env: APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} NODE_OPTIONS: --max-old-space-size=4096 run: | if [[ "${TUTTI_DESKTOP_DRY_RUN}" == "true" ]]; then unset CSC_LINK CSC_KEY_PASSWORD APPLE_API_KEY APPLE_API_KEY_ID APPLE_API_ISSUER pnpm --filter @tutti-os/desktop build:mac:unsigned else pnpm --filter @tutti-os/desktop build:mac:signed fi - name: Verify prerelease updater metadata if: ${{ needs.resolve.outputs.release_channel != 'stable' }} run: | # The generic updater provider derives the prerelease channel from # the version, so electron-builder writes rc-mac.yml or beta-mac.yml # directly instead of latest-mac.yml. updater_metadata="apps/desktop/dist/${TUTTI_DESKTOP_RELEASE_CHANNEL}-mac.yml" if [[ ! -f "${updater_metadata}" ]]; then echo "${updater_metadata} is required for the ${TUTTI_DESKTOP_RELEASE_CHANNEL} updater channel." >&2 exit 1 fi - name: Upload release artifacts uses: actions/upload-artifact@v7 with: name: tutti-desktop-release-assets-macos-${{ matrix.arch }} compression-level: 0 path: | apps/desktop/dist/*.blockmap apps/desktop/dist/*.dmg apps/desktop/dist/*.zip if-no-files-found: error retention-days: 7 build-windows: name: Prepare Windows application needs: resolve runs-on: windows-latest timeout-minutes: 60 env: TUTTI_DESKTOP_RELEASE_TAG: ${{ needs.resolve.outputs.release_tag }} TUTTI_DESKTOP_RELEASE_TARGET: ${{ needs.resolve.outputs.release_target }} TUTTI_DESKTOP_DRY_RUN: ${{ needs.resolve.outputs.release_dry_run }} TUTTI_DESKTOP_RELEASE_CHANNEL: ${{ needs.resolve.outputs.release_channel }} steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ env.TUTTI_DESKTOP_RELEASE_TARGET }} fetch-depth: 0 - name: Setup pnpm uses: pnpm/action-setup@v6 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version-file: .node-version cache: pnpm cache-dependency-path: pnpm-lock.yaml - name: Setup Go uses: actions/setup-go@v6 with: go-version: "1.24.5" cache-dependency-path: | go.work go.work.sum packages/workbench/service/go.mod packages/workspace/files/go.mod packages/workspace/issues/go.mod services/tuttid/go.mod services/tuttid/go.sum - name: Install dependencies shell: pwsh run: pnpm install --frozen-lockfile - name: Install Electron runtime shell: pwsh run: pnpm --filter @tutti-os/desktop exec install-electron - name: Align package version with release tag working-directory: apps/desktop shell: pwsh run: node scripts/apply-ci-release-version.mjs "$env:TUTTI_DESKTOP_RELEASE_TAG" - name: Build Windows package input shell: pwsh env: CSC_IDENTITY_AUTO_DISCOVERY: "false" run: | if ($env:TUTTI_DESKTOP_DRY_RUN -eq 'true') { pnpm --filter @tutti-os/desktop build:win } else { pnpm --filter @tutti-os/desktop build:win:unpacked } - name: Verify Windows release metadata if: env.TUTTI_DESKTOP_DRY_RUN == 'true' shell: pwsh run: node apps/desktop/scripts/validate-windows-release-artifacts.mjs apps/desktop/dist "$env:TUTTI_DESKTOP_RELEASE_CHANNEL" "$env:TUTTI_DESKTOP_RELEASE_TAG" - name: Upload unsigned Windows dry-run artifacts if: env.TUTTI_DESKTOP_DRY_RUN == 'true' uses: actions/upload-artifact@v7 with: name: tutti-desktop-release-assets-windows compression-level: 0 path: | apps/desktop/dist/*.blockmap apps/desktop/dist/*.exe apps/desktop/dist/*.yml if-no-files-found: error retention-days: 7 - name: Upload Windows application prepared for Certum signing if: env.TUTTI_DESKTOP_DRY_RUN != 'true' uses: actions/upload-artifact@v7 with: name: tutti-windows-prepared-${{ github.run_id }} compression-level: 0 path: apps/desktop/dist/win-unpacked/** if-no-files-found: error retention-days: 7 sign-windows: name: Sign Windows application and NSIS needs: [resolve, build-windows] if: needs.resolve.outputs.release_dry_run != 'true' runs-on: ubuntu-latest timeout-minutes: 45 concurrency: group: certum-simplysign-signing cancel-in-progress: false container: image: ghcr.io/reactiveui/certum-signer@sha256:fb03892c379b6703bee9bfd7a2f66df3a5696de149d6d6240e1897ae15afc3e4 env: CERTUM_USER_ID: ${{ secrets.CERTUM_USER_ID }} CERTUM_OTP_URI: ${{ secrets.CERTUM_OTP_URI }} CERTUM_CERT_FINGERPRINT: ${{ vars.CERTUM_CERT_FINGERPRINT }} TSH_WINDOWS_EXPECTED_PUBLISHER: ${{ vars.TSH_WINDOWS_EXPECTED_PUBLISHER }} TUTTI_DESKTOP_RELEASE_TAG: ${{ needs.resolve.outputs.release_tag }} TUTTI_DESKTOP_RELEASE_TARGET: ${{ needs.resolve.outputs.release_target }} TSH_CERTUM_LOGIN_MAX_ATTEMPTS: "3" TSH_CERTUM_SIGN_MAX_ATTEMPTS: "3" steps: - name: Validate Certum signing configuration shell: bash run: | set -euo pipefail : "${CERTUM_USER_ID:?CERTUM_USER_ID organization secret is required}" : "${CERTUM_OTP_URI:?CERTUM_OTP_URI organization secret is required}" : "${CERTUM_CERT_FINGERPRINT:?CERTUM_CERT_FINGERPRINT organization variable is required}" : "${TSH_WINDOWS_EXPECTED_PUBLISHER:?TSH_WINDOWS_EXPECTED_PUBLISHER organization variable is required}" [[ "${CERTUM_CERT_FINGERPRINT}" =~ ^[[:xdigit:]]{64}$ ]] - name: Checkout release target uses: actions/checkout@v6 with: ref: ${{ env.TUTTI_DESKTOP_RELEASE_TARGET }} fetch-depth: 1 persist-credentials: false - name: Checkout immutable signing tooling uses: actions/checkout@v6 with: ref: ${{ github.workflow_sha }} path: .release-tooling persist-credentials: false sparse-checkout: | tools/scripts/windows/connect-simplysign-linux.sh tools/scripts/windows/certum-sign-windows.cjs sparse-checkout-cone-mode: false - name: Setup pnpm uses: pnpm/action-setup@v6 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version-file: .node-version - name: Install NSIS cross-build and verification tools shell: bash run: | set -euo pipefail dpkg --add-architecture i386 apt-get update apt-get install -y --no-install-recommends osslsigncode p7zip-full wine wine32:i386 wine64 rm -rf /var/lib/apt/lists/* - name: Install Electron Builder dependencies run: pnpm install --frozen-lockfile --ignore-scripts - name: Align package version with release tag working-directory: apps/desktop run: node scripts/apply-ci-release-version.mjs "${TUTTI_DESKTOP_RELEASE_TAG}" - name: Download unpacked Windows application uses: actions/download-artifact@v4 with: name: tutti-windows-prepared-${{ github.run_id }} path: apps/desktop/dist/win-unpacked - name: Connect SimplySign with bounded retries shell: bash run: bash .release-tooling/tools/scripts/windows/connect-simplysign-linux.sh - name: Sign every executable in the unpacked application shell: bash run: >- node .release-tooling/tools/scripts/windows/certum-sign-windows.cjs --root apps/desktop/dist/win-unpacked - name: Build and sign final NSIS artifacts shell: bash env: TSH_WINDOWS_PREPACKAGED_DIR: ${{ github.workspace }}/apps/desktop/dist/win-unpacked TSH_CERTUM_ELECTRON_BUILDER_SIGNER: ${{ github.workspace }}/.release-tooling/tools/scripts/windows/certum-sign-windows.cjs run: pnpm --filter @tutti-os/desktop build:win:prepackaged - name: Verify signed NSIS payload and embedded uninstaller shell: bash run: | set -euo pipefail mapfile -d '' installers < <(find apps/desktop/dist -maxdepth 1 -type f -name '*.exe' -print0) if (( ${#installers[@]} != 1 )); then echo "Expected exactly one signed Windows installer, found ${#installers[@]}." >&2 exit 1 fi node .release-tooling/tools/scripts/windows/certum-sign-windows.cjs \ --verify-root apps/desktop/dist/win-unpacked outer="${RUNNER_TEMP}/signed-nsis-outer" mkdir -p "${outer}" 7z x -y "-o${outer}" "${installers[0]}" >/dev/null node .release-tooling/tools/scripts/windows/certum-sign-windows.cjs --verify-root "${outer}" node apps/desktop/scripts/validate-windows-release-artifacts.mjs \ apps/desktop/dist "${{ needs.resolve.outputs.release_channel }}" "${TUTTI_DESKTOP_RELEASE_TAG}" - name: Upload signed Windows package uses: actions/upload-artifact@v7 with: name: tutti-windows-signed-${{ github.run_id }} compression-level: 0 path: | apps/desktop/dist/*.blockmap apps/desktop/dist/*.exe apps/desktop/dist/*.yml if-no-files-found: error retention-days: 7 verify-signed-windows: name: Verify signed Windows package needs: [resolve, sign-windows] if: needs.resolve.outputs.release_dry_run != 'true' runs-on: windows-latest env: TSH_WINDOWS_EXPECTED_PUBLISHER: ${{ vars.TSH_WINDOWS_EXPECTED_PUBLISHER }} steps: - name: Checkout release target uses: actions/checkout@v6 with: ref: ${{ needs.resolve.outputs.release_target }} fetch-depth: 1 persist-credentials: false - name: Download signed Windows package uses: actions/download-artifact@v4 with: name: tutti-windows-signed-${{ github.run_id }} path: apps/desktop/dist - name: Independently verify Publisher, Authenticode, and timestamp shell: pwsh run: | $sevenZip = (Get-Command 7z.exe -ErrorAction Stop).Source tools/scripts/windows/verify-certum-windows-package.ps1 ` -DistDirectory 'apps/desktop/dist' ` -SevenZipExecutable $sevenZip ` -ExpectedPublisher $env:TSH_WINDOWS_EXPECTED_PUBLISHER - name: Publish verified Windows release artifacts uses: actions/upload-artifact@v7 with: name: tutti-desktop-release-assets-windows compression-level: 0 path: | apps/desktop/dist/*.blockmap apps/desktop/dist/*.exe apps/desktop/dist/*.yml if-no-files-found: error retention-days: 7 submit-store: name: Submit stable package to Microsoft Store needs: [resolve, promote] if: >- ${{ needs.resolve.outputs.release_channel == 'stable' && needs.resolve.outputs.release_dry_run != 'true' && needs.resolve.outputs.release_candidate != 'true' && vars.TUTTI_WINDOWS_STORE_SUBMISSION_ENABLED == 'true' }} uses: ./.github/workflows/desktop-store-submit.yml with: release_tag: ${{ needs.resolve.outputs.release_tag }} store_environment: microsoft-store-production submit: true target_commit: ${{ needs.resolve.outputs.release_target }} secrets: inherit stage: name: Stage Release Draft needs: [resolve, build-macos, build-windows, sign-windows, verify-signed-windows] if: >- ${{ always() && needs.resolve.result == 'success' && (github.event_name == 'push' || needs.resolve.outputs.release_dry_run != 'true') && needs.build-macos.result == 'success' && needs.build-windows.result == 'success' && needs.sign-windows.result == 'success' && needs.verify-signed-windows.result == 'success' }} runs-on: ubuntu-latest outputs: release_url: ${{ needs.resolve.outputs.release_candidate == 'true' && steps.resolve-candidate-release-url.outputs.release_url || steps.stage-release.outputs.url }} env: AWS_REGION: ${{ vars.AWS_REGION }} TUTTI_ARTIFACTS_AWS_ROLE_ARN: ${{ vars.TUTTI_ARTIFACTS_AWS_ROLE_ARN }} TUTTI_DESKTOP_RELEASE_ASSETS_BASE_URL: ${{ vars.TUTTI_DESKTOP_RELEASE_ASSETS_BASE_URL }} TUTTI_DESKTOP_RELEASE_ASSETS_S3_BUCKET: ${{ vars.TUTTI_DESKTOP_RELEASE_ASSETS_S3_BUCKET }} TUTTI_DESKTOP_RELEASE_ASSETS_S3_PREFIX: ${{ vars.TUTTI_DESKTOP_RELEASE_ASSETS_S3_PREFIX }} TUTTI_DESKTOP_RELEASE_CHANNEL: ${{ needs.resolve.outputs.release_channel }} TUTTI_DESKTOP_RELEASE_CANDIDATE: ${{ needs.resolve.outputs.release_candidate }} TUTTI_DESKTOP_RELEASE_CANDIDATE_ID: ${{ needs.resolve.outputs.release_candidate_id }} TUTTI_DESKTOP_RELEASE_CANDIDATE_TAG: ${{ needs.resolve.outputs.release_candidate_tag }} TUTTI_DESKTOP_RELEASE_TAG: ${{ needs.resolve.outputs.release_tag }} TUTTI_DESKTOP_RELEASE_TARGET: ${{ needs.resolve.outputs.release_target }} TUTTI_DESKTOP_RELEASE_VERSION: ${{ needs.resolve.outputs.release_version }} steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ env.TUTTI_DESKTOP_RELEASE_TARGET }} fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version-file: .node-version - name: Download macOS built artifacts uses: actions/download-artifact@v4 with: pattern: tutti-desktop-release-assets-macos-* merge-multiple: false path: release-assets-input/macos - name: Download Windows built artifacts uses: actions/download-artifact@v4 with: name: tutti-desktop-release-assets-windows path: release-assets-input/windows - name: Merge macOS release artifacts and updater metadata env: RELEASE_CHANNEL: ${{ env.TUTTI_DESKTOP_RELEASE_CHANNEL }} RELEASE_VERSION: ${{ env.TUTTI_DESKTOP_RELEASE_VERSION }} run: | node apps/desktop/scripts/merge-macos-release-artifacts.mjs \ release-assets-input/macos \ release-assets - name: Add Windows release artifacts shell: bash run: | shopt -s nullglob globstar windows_files=( release-assets-input/windows/**/*.blockmap release-assets-input/windows/**/*.exe release-assets-input/windows/**/*.yml ) copied=0 for file in "${windows_files[@]}"; do if [[ -f "${file}" ]]; then cp "${file}" release-assets/"$(basename "${file}")" copied=$((copied + 1)) fi done if [[ "${copied}" -eq 0 ]]; then echo "No Windows release artifacts were downloaded" >&2 exit 1 fi - name: Validate Windows release artifacts run: node apps/desktop/scripts/validate-windows-release-artifacts.mjs release-assets "${TUTTI_DESKTOP_RELEASE_CHANNEL}" "${TUTTI_DESKTOP_RELEASE_TAG}" - name: Generate desktop release summary env: AGNES_API_KEY: ${{ secrets.AGNES_API_KEY }} RELEASE_CHANNEL: ${{ needs.resolve.outputs.release_channel }} RELEASE_TAG: ${{ env.TUTTI_DESKTOP_RELEASE_TAG }} RELEASE_TARGET: ${{ env.TUTTI_DESKTOP_RELEASE_TARGET }} run: | node apps/desktop/scripts/generate-release-summary.mjs \ --output release-assets/release-summary.json - name: Validate desktop release summary env: RELEASE_CHANNEL: ${{ needs.resolve.outputs.release_channel }} RELEASE_TAG: ${{ env.TUTTI_DESKTOP_RELEASE_TAG }} RELEASE_TARGET: ${{ env.TUTTI_DESKTOP_RELEASE_TARGET }} run: node apps/desktop/scripts/validate-release-summary.mjs release-assets/release-summary.json - name: Generate checksums run: node apps/desktop/scripts/generate-checksums.mjs release-assets - name: Build stable release candidate manifest if: ${{ needs.resolve.outputs.release_candidate == 'true' }} env: RELEASE_CANDIDATE_ID: ${{ needs.resolve.outputs.release_candidate_id }} RELEASE_SOURCE_REF: ${{ needs.resolve.outputs.source_ref }} RELEASE_TAG: ${{ env.TUTTI_DESKTOP_RELEASE_TAG }} RELEASE_TARGET: ${{ env.TUTTI_DESKTOP_RELEASE_TARGET }} RELEASE_VERSION: ${{ env.TUTTI_DESKTOP_RELEASE_VERSION }} RELEASE_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | node apps/desktop/scripts/build-release-candidate-manifest.mjs \ release-assets/SHA256SUMS.txt \ release-assets/release-summary.json \ release-assets/release-candidate.json - id: previous-release-tag name: Resolve previous GitHub release tag env: RELEASE_CHANNEL: ${{ env.TUTTI_DESKTOP_RELEASE_CHANNEL }} RELEASE_TAG: ${{ env.TUTTI_DESKTOP_RELEASE_TAG }} run: | previous_tag="$(node apps/desktop/scripts/resolve-previous-release-tag.mjs)" echo "tag=${previous_tag}" >> "$GITHUB_OUTPUT" - id: stage-release name: Stage GitHub release assets if: ${{ needs.resolve.outputs.release_candidate != 'true' }} uses: softprops/action-gh-release@v2 with: token: ${{ secrets.GITHUB_TOKEN }} tag_name: ${{ env.TUTTI_DESKTOP_RELEASE_TAG }} target_commitish: ${{ env.TUTTI_DESKTOP_RELEASE_TARGET }} name: ${{ env.TUTTI_DESKTOP_RELEASE_TAG }} draft: true prerelease: ${{ needs.resolve.outputs.release_prerelease == 'true' }} make_latest: ${{ needs.resolve.outputs.release_make_latest == 'true' }} generate_release_notes: true previous_tag: ${{ steps.previous-release-tag.outputs.tag }} files: | release-assets/* - id: stage-candidate-release name: Create or refresh stable GitHub draft if: ${{ needs.resolve.outputs.release_candidate == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \ --paginate \ --jq ".[] | select(.draft and .tag_name == \"${TUTTI_DESKTOP_RELEASE_CANDIDATE_TAG}\") | .id" | head -1)" if [[ -z "${release_id}" ]]; then gh api --method POST "repos/${GITHUB_REPOSITORY}/releases" \ -f tag_name="${TUTTI_DESKTOP_RELEASE_CANDIDATE_TAG}" \ -f target_commitish="${TUTTI_DESKTOP_RELEASE_TARGET}" \ -f name="${TUTTI_DESKTOP_RELEASE_TAG}" \ -F draft=true \ -F prerelease=false \ -F generate_release_notes=true > staged-release.json else git tag -f "${TUTTI_DESKTOP_RELEASE_CANDIDATE_TAG}" "${TUTTI_DESKTOP_RELEASE_TARGET}" git push origin "refs/tags/${TUTTI_DESKTOP_RELEASE_CANDIDATE_TAG}" --force gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${release_id}" \ -f target_commitish="${TUTTI_DESKTOP_RELEASE_TARGET}" \ -f name="${TUTTI_DESKTOP_RELEASE_TAG}" \ -F draft=true \ -F prerelease=false > staged-release.json gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?per_page=100" \ --jq '.[].id' | while IFS= read -r asset_id; do gh api --method DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" done fi node apps/desktop/scripts/upload-github-release-assets.mjs \ staged-release.json \ release-assets echo "release_id=$(jq -r .id staged-release.json)" >> "$GITHUB_OUTPUT" - name: Install AWS CLI timeout-minutes: 5 run: | if ! command -v aws >/dev/null 2>&1; then case "$(uname -m)" in x86_64) awscli_arch="x86_64" ;; aarch64|arm64) awscli_arch="aarch64" ;; *) echo "Unsupported architecture for AWS CLI installer: $(uname -m)" >&2 exit 1 ;; esac awscli_dir="$(mktemp -d "${RUNNER_TEMP}/aws-cli.XXXXXX")" curl --fail --silent --show-error --location \ --retry 3 \ --retry-all-errors \ --connect-timeout 10 \ --max-time 180 \ "https://awscli.amazonaws.com/awscli-exe-linux-${awscli_arch}.zip" \ --output "${awscli_dir}/awscliv2.zip" python3 -m zipfile -e "${awscli_dir}/awscliv2.zip" "${awscli_dir}" sudo "${awscli_dir}/aws/install" --update fi aws --version - name: Validate AWS S3 release asset configuration run: | missing=() for name in AWS_REGION TUTTI_ARTIFACTS_AWS_ROLE_ARN TUTTI_DESKTOP_RELEASE_ASSETS_S3_BUCKET TUTTI_DESKTOP_RELEASE_ASSETS_S3_PREFIX; do if [[ -z "${!name:-}" ]]; then missing+=("$name") fi done if (( ${#missing[@]} > 0 )); then echo "Missing required AWS S3 release asset configuration: ${missing[*]}" >&2 exit 1 fi if [[ -z "${TUTTI_DESKTOP_RELEASE_ASSETS_BASE_URL:-}" ]]; then echo "TUTTI_DESKTOP_RELEASE_ASSETS_BASE_URL=https://${TUTTI_DESKTOP_RELEASE_ASSETS_S3_BUCKET}.s3-accelerate.amazonaws.com/${TUTTI_DESKTOP_RELEASE_ASSETS_S3_PREFIX%/}" >> "$GITHUB_ENV" fi - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ env.TUTTI_ARTIFACTS_AWS_ROLE_ARN }} aws-region: ${{ env.AWS_REGION }} - name: Upload immutable draft assets to AWS S3 run: | release_asset_path="${TUTTI_DESKTOP_RELEASE_TAG}" if [[ "${TUTTI_DESKTOP_RELEASE_CANDIDATE}" == "true" ]]; then release_asset_path="candidates/${TUTTI_DESKTOP_RELEASE_CANDIDATE_ID}" fi remote_dir="s3://${TUTTI_DESKTOP_RELEASE_ASSETS_S3_BUCKET}/${TUTTI_DESKTOP_RELEASE_ASSETS_S3_PREFIX%/}/${release_asset_path}" aws s3 cp release-assets "${remote_dir}" \ --recursive \ --cache-control "public, max-age=31536000, immutable" - name: Upload desktop release summary artifact uses: actions/upload-artifact@v7 with: name: tutti-desktop-release-summary path: release-assets/release-summary.json - name: Update release notes with summary and direct downloads env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | export RELEASE_TAG="${TUTTI_DESKTOP_RELEASE_TAG}" export RELEASE_ASSET_BASE_URL="${TUTTI_DESKTOP_RELEASE_ASSETS_BASE_URL}" if [[ "${TUTTI_DESKTOP_RELEASE_CANDIDATE}" == "true" ]]; then export RELEASE_ASSET_PATH="candidates/${TUTTI_DESKTOP_RELEASE_CANDIDATE_ID}" fi github_release_tag="${TUTTI_DESKTOP_RELEASE_TAG}" if [[ "${TUTTI_DESKTOP_RELEASE_CANDIDATE}" == "true" ]]; then github_release_tag="${TUTTI_DESKTOP_RELEASE_CANDIDATE_TAG}" fi gh release view "${github_release_tag}" --json body --jq .body > release-body.md node apps/desktop/scripts/upsert-release-summary.mjs \ release-body.md \ release-assets/release-summary.json \ release-body-with-summary.md node apps/desktop/scripts/upsert-release-download-links.mjs \ release-body-with-summary.md \ release-assets \ updated-release-body.md gh release edit "${github_release_tag}" --notes-file updated-release-body.md - id: resolve-candidate-release-url name: Resolve current stable GitHub draft URL if: ${{ needs.resolve.outputs.release_candidate == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_ID: ${{ steps.stage-candidate-release.outputs.release_id }} run: | if [[ -z "${RELEASE_ID}" || "${RELEASE_ID}" == "null" ]]; then echo "Stable GitHub draft release id is missing" >&2 exit 1 fi release_url="$(gh api "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" --jq .html_url)" echo "release_url=${release_url}" >> "$GITHUB_OUTPUT" promote: name: Promote Release needs: [resolve, stage] if: ${{ always() && needs.stage.result == 'success' && needs.resolve.outputs.release_candidate != 'true' }} uses: ./.github/workflows/desktop-release-promote.yml with: release_tag: ${{ needs.resolve.outputs.release_tag }} expected_target: ${{ needs.resolve.outputs.release_target }} expected_channel: ${{ needs.resolve.outputs.release_channel }} source_ref: ${{ github.ref_name }} notify_feishu: ${{ needs.resolve.outputs.notify_feishu == 'true' }} secrets: FEISHU_RELEASE_WEBHOOK_URL: ${{ secrets.FEISHU_RELEASE_WEBHOOK_URL }} notify-candidate-feishu: name: Notify Release Candidate needs: [resolve, stage] if: ${{ always() && needs.stage.result == 'success' && needs.resolve.outputs.notify_feishu == 'true' && needs.resolve.outputs.release_candidate == 'true' }} runs-on: ubuntu-latest env: FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_RELEASE_WEBHOOK_URL }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TUTTI_DESKTOP_RELEASE_ASSETS_BASE_URL: ${{ vars.TUTTI_DESKTOP_RELEASE_ASSETS_BASE_URL }} TUTTI_DESKTOP_RELEASE_ASSETS_S3_BUCKET: ${{ vars.TUTTI_DESKTOP_RELEASE_ASSETS_S3_BUCKET }} TUTTI_DESKTOP_RELEASE_ASSETS_S3_PREFIX: ${{ vars.TUTTI_DESKTOP_RELEASE_ASSETS_S3_PREFIX }} RELEASE_ACTOR: ${{ github.actor }} RELEASE_BRANCH: ${{ github.ref_type == 'branch' && github.ref_name || '' }} RELEASE_ASSET_PATH: candidates/${{ needs.resolve.outputs.release_candidate_id }} RELEASE_CANDIDATE_ID: ${{ needs.resolve.outputs.release_candidate_id }} RELEASE_LOOKUP_TAG: ${{ needs.resolve.outputs.release_candidate_tag }} RELEASE_PUBLICATION_STATUS: candidate RELEASE_TAG: ${{ needs.resolve.outputs.release_tag }} RELEASE_TARGET: ${{ needs.resolve.outputs.release_target }} RELEASE_URL: ${{ needs.stage.outputs.release_url }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} PROMOTION_URL: ${{ github.server_url }}/${{ github.repository }}/actions/workflows/desktop-release-promote.yml steps: - name: Checkout notification script if: env.FEISHU_WEBHOOK_URL != '' uses: actions/checkout@v6 with: ref: ${{ needs.resolve.outputs.release_target }} - name: Setup Node.js if: env.FEISHU_WEBHOOK_URL != '' uses: actions/setup-node@v6 with: node-version-file: .node-version - name: Download release summary if: env.FEISHU_WEBHOOK_URL != '' uses: actions/download-artifact@v4 with: name: tutti-desktop-release-summary path: release-summary - name: Send candidate release card if: env.FEISHU_WEBHOOK_URL != '' env: RELEASE_SUMMARY_PATH: release-summary/release-summary.json run: node apps/desktop/scripts/send-release-feishu-card.mjs - name: Skip Feishu card if: env.FEISHU_WEBHOOK_URL == '' run: echo "FEISHU_RELEASE_WEBHOOK_URL is not configured; skipping candidate notification."