Files
tutti/.github/workflows/desktop-release.yml
T
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

1071 lines
43 KiB
YAML

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