name: Desktop Release on: push: branches: [main] release: types: [published] workflow_dispatch: inputs: channel: description: Release channel to build required: true default: nightly type: choice options: - nightly - stable release_tag: description: "Required when channel=stable (example: v2026.5.5)" required: false type: string permissions: contents: write concurrency: group: desktop-release-${{ github.ref }} cancel-in-progress: false jobs: prepare: if: github.repository == 'NousResearch/hermes-agent' runs-on: ubuntu-latest outputs: channel: ${{ steps.meta.outputs.channel }} release_name: ${{ steps.meta.outputs.release_name }} release_tag: ${{ steps.meta.outputs.release_tag }} version: ${{ steps.meta.outputs.version }} is_stable: ${{ steps.meta.outputs.is_stable }} steps: - id: meta env: EVENT_NAME: ${{ github.event_name }} INPUT_CHANNEL: ${{ github.event.inputs.channel }} INPUT_RELEASE_TAG: ${{ github.event.inputs.release_tag }} RELEASE_TAG_FROM_EVENT: ${{ github.event.release.tag_name }} GITHUB_SHA: ${{ github.sha }} run: | set -euo pipefail channel="nightly" release_tag="desktop-nightly" is_stable="false" if [[ "$EVENT_NAME" == "release" ]]; then channel="stable" release_tag="$RELEASE_TAG_FROM_EVENT" is_stable="true" elif [[ "$EVENT_NAME" == "workflow_dispatch" && "$INPUT_CHANNEL" == "stable" ]]; then channel="stable" release_tag="$INPUT_RELEASE_TAG" is_stable="true" fi if [[ "$channel" == "stable" ]]; then if [[ -z "$release_tag" ]]; then echo "Stable desktop releases require a release tag." >&2 exit 1 fi version="${release_tag#v}" release_name="Hermes Desktop ${release_tag}" else stamp="$(date -u +%Y%m%d)" short_sha="${GITHUB_SHA::7}" version="0.0.0-nightly.${stamp}.${short_sha}" release_name="Hermes Desktop Nightly ${stamp}-${short_sha}" fi { echo "channel=$channel" echo "release_name=$release_name" echo "release_tag=$release_tag" echo "version=$version" echo "is_stable=$is_stable" } >> "$GITHUB_OUTPUT" build: if: github.repository == 'NousResearch/hermes-agent' needs: prepare strategy: fail-fast: false matrix: include: - platform: mac runner: macos-latest build_args: --mac dmg zip - platform: win runner: windows-latest build_args: --win nsis msi - platform: linux runner: ubuntu-latest build_args: --linux AppImage deb rpm runs-on: ${{ matrix.runner }} env: DESKTOP_CHANNEL: ${{ needs.prepare.outputs.channel }} DESKTOP_VERSION: ${{ needs.prepare.outputs.version }} MAC_CSC_LINK: ${{ secrets.CSC_LINK }} MAC_CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }} WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 20 cache: npm cache-dependency-path: package-lock.json - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.11" - name: Enforce signing gates for stable releases if: needs.prepare.outputs.is_stable == 'true' shell: bash run: | set -euo pipefail missing=() if [[ "${{ matrix.platform }}" == "mac" ]]; then [[ -z "${MAC_CSC_LINK:-}" ]] && missing+=("CSC_LINK") [[ -z "${MAC_CSC_KEY_PASSWORD:-}" ]] && missing+=("CSC_KEY_PASSWORD") [[ -z "${APPLE_API_KEY:-}" ]] && missing+=("APPLE_API_KEY") [[ -z "${APPLE_API_KEY_ID:-}" ]] && missing+=("APPLE_API_KEY_ID") [[ -z "${APPLE_API_ISSUER:-}" ]] && missing+=("APPLE_API_ISSUER") elif [[ "${{ matrix.platform }}" == "win" ]]; then [[ -z "${WIN_CSC_LINK:-}" ]] && missing+=("WIN_CSC_LINK") [[ -z "${WIN_CSC_KEY_PASSWORD:-}" ]] && missing+=("WIN_CSC_KEY_PASSWORD") fi if (( ${#missing[@]} > 0 )); then echo "::error::Stable desktop release missing required secrets: ${missing[*]}" exit 1 fi - name: Install workspace dependencies run: npm ci - name: Install TUI dependencies run: npm --prefix ui-tui ci - name: Build bundled TUI payload run: npm --prefix ui-tui run build - name: Build desktop renderer run: npm --prefix apps/desktop run build - name: Map macOS signing credentials if: matrix.platform == 'mac' shell: bash run: | set -euo pipefail has_link=0 has_pass=0 [[ -n "${MAC_CSC_LINK:-}" ]] && has_link=1 [[ -n "${MAC_CSC_KEY_PASSWORD:-}" ]] && has_pass=1 if [[ $has_link -eq 1 && $has_pass -eq 1 ]]; then echo "CSC_LINK=${MAC_CSC_LINK}" >> "$GITHUB_ENV" echo "CSC_KEY_PASSWORD=${MAC_CSC_KEY_PASSWORD}" >> "$GITHUB_ENV" elif [[ $has_link -eq 1 || $has_pass -eq 1 ]]; then echo "::error::macOS signing secrets are partially configured. Set both CSC_LINK and CSC_KEY_PASSWORD." exit 1 fi - name: Map Windows signing credentials if: matrix.platform == 'win' shell: bash run: | set -euo pipefail has_link=0 has_pass=0 [[ -n "${WIN_CSC_LINK:-}" ]] && has_link=1 [[ -n "${WIN_CSC_KEY_PASSWORD:-}" ]] && has_pass=1 if [[ $has_link -eq 1 && $has_pass -eq 1 ]]; then echo "CSC_LINK=${WIN_CSC_LINK}" >> "$GITHUB_ENV" echo "CSC_KEY_PASSWORD=${WIN_CSC_KEY_PASSWORD}" >> "$GITHUB_ENV" echo "CSC_FOR_PULL_REQUEST=true" >> "$GITHUB_ENV" elif [[ $has_link -eq 1 || $has_pass -eq 1 ]]; then echo "::error::Windows signing secrets are partially configured. Set both WIN_CSC_LINK and WIN_CSC_KEY_PASSWORD." exit 1 fi - name: Build desktop installers shell: bash env: NODE_OPTIONS: --max-old-space-size=16384 run: | set -euo pipefail npm --prefix apps/desktop run builder -- \ ${{ matrix.build_args }} \ --publish never \ --config.extraMetadata.version="${DESKTOP_VERSION}" \ --config.extraMetadata.desktopChannel="${DESKTOP_CHANNEL}" - name: Notarize and staple macOS DMG if: matrix.platform == 'mac' && needs.prepare.outputs.is_stable == 'true' shell: bash run: | set -euo pipefail dmg_path="$(ls apps/desktop/release/*.dmg | head -n 1)" node apps/desktop/scripts/notarize-artifact.cjs "$dmg_path" - name: Validate macOS notarization and Gatekeeper trust if: matrix.platform == 'mac' && needs.prepare.outputs.is_stable == 'true' shell: bash run: | set -euo pipefail app_path="$(ls -d apps/desktop/release/mac*/Hermes.app | head -n 1)" dmg_path="$(ls apps/desktop/release/*.dmg | head -n 1)" xcrun stapler validate "$app_path" xcrun stapler validate "$dmg_path" spctl --assess --type execute --verbose=4 "$app_path" - name: Generate desktop checksums shell: bash run: | set -euo pipefail node <<'EOF' const crypto = require('node:crypto') const fs = require('node:fs') const path = require('node:path') const releaseDir = path.resolve('apps/desktop/release') const platform = process.env.PLATFORM const extensionsByPlatform = { mac: ['.dmg', '.zip'], win: ['.exe', '.msi'], linux: ['.AppImage', '.deb', '.rpm'], } const extensions = extensionsByPlatform[platform] ?? [] const files = fs .readdirSync(releaseDir) .filter(name => extensions.some(ext => name.endsWith(ext))) .sort() if (!files.length) { throw new Error(`No release artifacts were produced for ${platform}`) } const lines = files.map(name => { const full = path.join(releaseDir, name) const hash = crypto.createHash('sha256').update(fs.readFileSync(full)).digest('hex') return `${hash} ${name}` }) fs.writeFileSync(path.join(releaseDir, `SHA256SUMS-${platform}.txt`), `${lines.join('\n')}\n`) EOF env: PLATFORM: ${{ matrix.platform }} - name: Upload packaged desktop artifacts uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: desktop-${{ matrix.platform }} path: | apps/desktop/release/*.dmg apps/desktop/release/*.zip apps/desktop/release/*.exe apps/desktop/release/*.msi apps/desktop/release/*.AppImage apps/desktop/release/*.deb apps/desktop/release/*.rpm apps/desktop/release/SHA256SUMS-${{ matrix.platform }}.txt if-no-files-found: error publish: if: github.repository == 'NousResearch/hermes-agent' needs: [prepare, build] runs-on: ubuntu-latest env: GH_TOKEN: ${{ github.token }} CHANNEL: ${{ needs.prepare.outputs.channel }} RELEASE_NAME: ${{ needs.prepare.outputs.release_name }} RELEASE_TAG: ${{ needs.prepare.outputs.release_tag }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: pattern: desktop-* merge-multiple: true path: dist/desktop - name: Publish desktop assets to GitHub release shell: bash run: | set -euo pipefail shopt -s globstar nullglob files=( dist/desktop/**/*.dmg dist/desktop/**/*.zip dist/desktop/**/*.exe dist/desktop/**/*.msi dist/desktop/**/*.AppImage dist/desktop/**/*.deb dist/desktop/**/*.rpm dist/desktop/**/SHA256SUMS-*.txt ) if (( ${#files[@]} == 0 )); then echo "No desktop artifacts were downloaded for publishing." >&2 exit 1 fi if [[ "$CHANNEL" == "nightly" ]]; then git tag -f "$RELEASE_TAG" "$GITHUB_SHA" git push origin "refs/tags/$RELEASE_TAG" --force notes="Automated nightly desktop build from main. This prerelease is replaced on each new run." if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then while IFS= read -r asset_name; do gh release delete-asset "$RELEASE_TAG" "$asset_name" --yes done < <(gh release view "$RELEASE_TAG" --json assets -q '.assets[].name') gh release edit "$RELEASE_TAG" \ --title "$RELEASE_NAME" \ --prerelease \ --notes "$notes" else gh release create "$RELEASE_TAG" \ --target "$GITHUB_SHA" \ --title "$RELEASE_NAME" \ --notes "$notes" \ --prerelease fi else if ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then notes="Automated desktop artifacts attached by desktop-release workflow." gh release create "$RELEASE_TAG" \ --target "$GITHUB_SHA" \ --title "$RELEASE_NAME" \ --notes "$notes" fi fi gh release upload "$RELEASE_TAG" "${files[@]}" --clobber