name: Desktop Bundled Release # Builds the bundled desktop installer for each (os, arch) target. # # tag admission (shape + pyproject lockstep + ancestry on origin/main, # resolved to an IMMUTABLE full SHA) → per-OS builder jobs → stage # artifacts to R2 (releases/tag//) → per-OS publish jobs write the # feeds → builds table. # # Per-OS topology (MSIX work is gated on the WINDOWS build only — mac/linux # legs never block the win32 feed or Store submission): # # build-win32 (win32-x64 + win32-arm64) → stage to R2 + upload *.msix # build-darwin (darwin-arm64 + darwin-x64) → sign + notarize + stage # dmg/zip/blockmap to R2; per-arch feed ymls as artifacts # build-linux → DISABLED for now (dummy skips) # publish-win32-updater → App Installer feed # (needs build-win32): releases/win32//*.appinstaller + # *.msixbundle (stage-msixbundle.mjs --variant bundled) # publish-win32-store → Windows Store submission # (needs build-win32, PARALLEL with the updater feed): bundle the two # Store-*.msix into one universal Store .msixbundle and submit via the # MSStore CLI. Stable → production; canary → package flight ring # (delete-then-replace, newest always wins). Gated on MS_STORE_PRODUCT_ID # (+ MS_STORE_CANARY_FLIGHT_ID for the canary arm). # publish-darwin-updater → macOS electron-updater feed # (needs BOTH darwin legs): r2-release.mjs finalize merges the per-arch # ymls into releases/darwin//-mac.yml — the feed # pointer is written LAST, and the job's concurrency group serializes # same-channel publications. # # Feed layout (matches apps/desktop/electron/app-updater.ts's arms): # releases/win32//.appinstaller App Installer feed # releases/win32//*.msixbundle (publish-win32-updater) # releases/darwin//-mac.yml electron-updater feed # (dmg/zip live once in releases/tag//; the merged feed points at # them with absolute object keys) # The publish jobs write the feeds ONCE after their whole build matrix is # green, so a failed leg can never publish a partial channel. # # Payload staging is `hermes pm bundle` on the native runner. There is # no cross-target staging. The darwin legs sign (CSC_LINK) and notarize # (afterSign notarize.mjs) when the release-signing environment carries the # Apple credentials, and FAIL rather than publish unsigned — forks without # the credentials can only build (upload_release=false), never publish. # # scripts/bundles/desktop.py is the one driver. Local and CI run # the same command. This workflow adds caching and upload only. # # Triggers: workflow_dispatch with an explicit tag. A tag push does not # start this workflow (a bot-pushed canary tag would never fire). # # R2 secrets (repo-level or the release-signing environment): the R2 # account id + an R2 API token (S3-compatible) with read/write on the # release bucket; CLOUDFLARE_R2_BUCKET and CLOUDFLARE_R2_PUBLIC_URL are # non-secret vars. scripts/r2-release.mjs derives the S3 endpoint from # the account id and needs only node — no npm ci, no extra deps. # # Windows Store submission (publish-win32-store): MSStore CLI via # microsoft/microsoft-store-apppublisher. Credentials live in the # release-signing environment: # secrets: MS_STORE_TENANT_ID, MS_STORE_SELLER_ID, MS_STORE_CLIENT_ID, # MS_STORE_CLIENT_SECRET # vars: MS_STORE_PRODUCT_ID (the Partner Center product ID), # MS_STORE_CANARY_FLIGHT_ID (package flight for canaries — # optional; without it canaries skip the store) # msstore reconfigure → (delete pending) → msstore publish # (-f for canaries) -id # The Store bundle is UNSIGNED on purpose — Partner Center re-signs on # ingestion (see sign-msix.mjs). on: workflow_dispatch: inputs: tag: description: 'Release tag to bundle (vX.Y.Z or vX.Y.0-canary.YYYYMMDDHHMMSS). Must exist on the repo.' required: true type: string termux_only: description: 'Build and publish only the Termux package' required: false type: boolean default: false termux_upgrade_from_run: description: 'Successful Termux run whose package must upgrade to this build' required: false type: string default: '' upload_release: description: 'Upload artifacts to the R2 release bucket (staging + feeds)' required: false type: boolean default: false permissions: contents: write actions: read packages: write id-token: write concurrency: group: desktop-bundled-release-${{ inputs.tag }} cancel-in-progress: false jobs: validate: name: Validate the tag runs-on: ubuntu-24.04 timeout-minutes: 5 outputs: # The tag's commit, resolved ONCE here and exported as a full SHA. # Every privileged job checks out THIS — never the tag ref, which a # force-push can move between the validate and build jobs. # The tag's channel (stable | canary). publish-darwin-updater scopes # its concurrency group on this so two dispatches for the SAME # channel can never race their feed writes (a stable and a canary # publish in parallel by design — different feed files). channel: ${{ steps.admission.outputs.channel }} sha: ${{ steps.admission.outputs.sha }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.tag }} # Full history: the admission gate below runs merge-base against # origin/main, which needs the shared ancestry, not a depth-1 tip. fetch-depth: 0 fetch-tags: true # but we only need one file :3 filter: tree:0 sparse-checkout: pyproject.toml sparse-checkout-cone-mode: false - name: Validate tag shape, pyproject lockstep, and ancestry on origin/main id: admission env: TAG: ${{ inputs.tag }} run: | case "$TAG" in v[0-9]*.[0-9]*.[0-9]*-canary.20[0-9][0-9][0-9][0-9][0-9][0-9]*) echo "canary tag: $TAG" ;; v[0-9]*.[0-9]*.[0-9]*) echo "tag: $TAG" PYVER=$(sed -n 's/^version = "\([^"]*\)"/\1/p' pyproject.toml | head -1) if [ "$TAG" != "v$PYVER" ]; then echo "::error::tag $TAG does not match pyproject.toml version $PYVER" exit 1 fi ;; *) echo "::error::'$TAG' is not a release tag (vX.Y.Z or vX.Y.0-canary.YYYYMMDDHHMMSS)" exit 1 ;; esac SHA="$(git rev-parse --verify "$TAG^{commit}")" if [ -z "$SHA" ]; then echo "::error::could not resolve $TAG to a commit" exit 1 fi # A correctly-shaped tag on an unreviewed commit is NOT a valid # build input for a release-signing workflow: only a commit that # is already an ancestor of origin/main may be built. git fetch origin main if ! git merge-base --is-ancestor "$SHA" origin/main; then echo "::error::tag $TAG (${SHA:0:12}) is not an ancestor of origin/main — refusing to build from an unreviewed commit" exit 1 fi echo "tag $TAG resolves to $SHA (on origin/main)" case "$TAG" in *-canary.*) echo "channel=canary" >> "$GITHUB_OUTPUT" ;; *) echo "channel=stable" >> "$GITHUB_OUTPUT" ;; esac echo "sha=$SHA" >> "$GITHUB_OUTPUT" # ── Windows builders (REAL) ─────────────────────────────────────────────── # The only active builder legs. Every win32 downstream job (updater feed + # Store submission) is gated on THIS job, never on mac/linux. build-win32: name: bundled ${{ matrix.target.label }} if: inputs.termux_only != true needs: validate runs-on: ${{ matrix.target.runner }} environment: release-signing timeout-minutes: 900 strategy: fail-fast: false matrix: target: - label: win32-x64 runner: windows-2025 - label: win32-arm64 runner: windows-11-arm env: HERMES_DESKTOP_VARIANT: bundled HERMES_PAYLOAD_TAG: ${{ inputs.tag }} ELECTRON_BUILDER_CACHE: ${{ github.workspace }}/.cache/electron-builder ELECTRON_CACHE: ${{ github.workspace }}/.cache/electron electron_config_cache: ${{ github.workspace }}/.cache/electron CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }} CLOUDFLARE_R2_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }} CLOUDFLARE_R2_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }} CLOUDFLARE_R2_BUCKET: ${{ vars.CLOUDFLARE_R2_BUCKET }} CLOUDFLARE_R2_PUBLIC_URL: ${{ vars.CLOUDFLARE_R2_PUBLIC_URL }} steps: - name: Exclude build write paths from Defender scanning shell: powershell run: | $paths = @( $env:GITHUB_WORKSPACE, $env:RUNNER_TEMP, (npm config get cache) ) foreach ($p in $paths) { try { Add-MpPreference -ExclusionPath $p Write-Host "Defender exclusion added for $p" } catch { Write-Host "Defender exclusion not applied for $p ($($_.Exception.Message)) - continuing" } } try { Set-MpPreference -DisableRealtimeMonitoring $true } catch { Write-Host "Could not disable windows defender" } # Check out the SHA the validate job admitted — never the tag ref, # which a force-push can move between jobs. This is the privileged # (release-signing) build; it must run the reviewed bytes. - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ needs.validate.outputs.sha }} fetch-tags: true - name: Resolve toolchain pins from pm/lock.json id: pins shell: bash # The host toolchain that BUILDS the artifact comes from the same # pin table as the embedded runtimes (pm/lock.json), so gate == pin # by construction in bundles/desktop.py's toolchain gates. run: | python -c ' import json pkgs = json.load(open("pm/lock.json"))["packages"] for tool in ("node", "npm", "uv"): print(tool + "=" + pkgs[tool]["version"]) ' >> "$GITHUB_OUTPUT" - name: Resolve toolchain cache key id: toolchain shell: bash run: | node -e ' const l = require("./package-lock.json") const el = l.packages["apps/desktop/node_modules/electron"].version const eb = l.packages["node_modules/electron-builder"].version if (!el || !eb) process.exit(1) console.log(`electron=${el}`) console.log(`builder=${eb}`) ' >> "$GITHUB_OUTPUT" - uses: actions/setup-node@v7.0.0 # immutable release. safe to pin. with: node-version: ${{ steps.pins.outputs.node }} cache: npm - name: Install pinned npm shell: bash env: NPM_PIN: ${{ steps.pins.outputs.npm }} run: npm --version | grep -qx "$NPM_PIN" || npm i -g "npm@$NPM_PIN" - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # 9.0.0 with: version: ${{ steps.pins.outputs.uv }} enable-cache: false - name: Cache verified payload signatures uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ github.workspace }}/.cache/electron-builder-payload-signatures # Each run saves additions. Content and signing policy select entries. key: payload-signatures-v1-${{ runner.os }}-${{ matrix.target.label }}-${{ github.run_id }}-${{ github.run_attempt }} restore-keys: | payload-signatures-v1-${{ runner.os }}-${{ matrix.target.label }}- payload-signatures-v1-${{ runner.os }}- - name: Cache vcpkg OpenSSL (arm64) if: matrix.target.label == 'win32-arm64' uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: C:\vcpkg\installed\arm64-windows-static-md key: vcpkg-openssl-arm64-windows-static-md-${{ runner.os }} - name: Install OpenSSL (arm64) if: matrix.target.label == 'win32-arm64' shell: bash run: | if [ ! -f "/c/vcpkg/installed/arm64-windows-static-md/lib/libcrypto.lib" ]; then "$VCPKG_INSTALLATION_ROOT/vcpkg" install openssl:arm64-windows-static-md fi { printf 'OPENSSL_DIR=C:\\vcpkg\\installed\\arm64-windows-static-md\n' printf 'OPENSSL_STATIC=1\n' } >> "$GITHUB_ENV" - name: Cache pm store # Tag-dispatched runs (every canary) scope actions/cache under the # dispatch ref, which GitHub mangles to refs/heads/refs/tags/ — # a different scope per tag, so an exact key can never be restored # by a later canary. The content key below is stable across tags # when pm/lock.json + uv.lock are unchanged; the restore-keys prefix # (which ignores the tag entirely) rescues the previous canary's # store when the locks DID move. uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: apps/desktop/build/agent-payload/tools key: pm-store-v2-${{ matrix.target.label }}-${{ hashFiles('pm/lock.json', 'uv.lock') }} restore-keys: | pm-store-v2-${{ matrix.target.label }}- - name: Resolve electron's default download cache path shell: bash run: | # @electron/get does NOT honor ELECTRON_CACHE/electron_config_cache: # the electron-builder build's electron zip download uses the # default env-paths cache root. It must be in the actions/cache # path list or every build re-downloads electron (~115MB). case "$RUNNER_OS" in Windows) echo "ELECTRON_DEFAULT_CACHE=$LOCALAPPDATA/electron/Cache" >> "$GITHUB_ENV" ;; macOS) echo "ELECTRON_DEFAULT_CACHE=$HOME/Library/Caches/electron" >> "$GITHUB_ENV" ;; *) echo "ELECTRON_DEFAULT_CACHE=$HOME/.cache/electron" >> "$GITHUB_ENV" ;; esac - name: Cache electron + electron-builder toolchain uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ${{ github.workspace }}/.cache/electron-builder ${{ github.workspace }}/.cache/electron ${{ env.ELECTRON_DEFAULT_CACHE }} # eb2: bumped from eb- (2026-08-28) — the old eb- caches never # contained the electron zip (it lives in @electron/get's default # cache root, which wasn't in the path list), so every build # re-downloaded electron. The new key forces a fresh save that # includes the default cache root. key: eb2-${{ runner.os }}-${{ runner.arch }}-electron-${{ steps.toolchain.outputs.electron }}-builder-${{ steps.toolchain.outputs.builder }} # An electron/builder bump misses the exact key, but the previous # dist is still mostly reusable (electron's postinstall skips the # download when dist/ exists) — restore it and let npm ci top up. restore-keys: | eb2-${{ runner.os }}-${{ runner.arch }}- - name: Cache node_modules uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | node_modules apps/*/node_modules ui-tui/node_modules ui-tui/packages/*/node_modules web/node_modules tests-js/node_modules key: node-modules-${{ matrix.target.label }}-node${{ steps.pins.outputs.node }}-npm${{ steps.pins.outputs.npm }}-${{ hashFiles('package-lock.json') }} # npm ci rm -rf's node_modules before installing, so a restore is # never shipped stale — but a restore-key hit still makes the # reinstall incremental (postinstall outputs like node-pty's # prebuilds/ and esbuild's platform binary survive in place). # The build-bundled install-stamp gate (lock sha + node + npm + # target) is the real guard against stale trees shipping. restore-keys: | node-modules-${{ matrix.target.label }}-node${{ steps.pins.outputs.node }}-npm${{ steps.pins.outputs.npm }}- - name: Cache node-pty prebuilds (postinstall output, emulated-gyp tax on arm64) uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | node_modules/node-pty/prebuilds node_modules/node-pty/build key: node-pty-prebuilds-${{ matrix.target.label }}-${{ hashFiles('package-lock.json') }} restore-keys: | node-pty-prebuilds-${{ matrix.target.label }}- - name: Azure login (OIDC) if: vars.AZURE_CLIENT_ID != '' uses: azure/login@f5d393ae46f8fde4be8b75f32e3fc50e654ad0ca # v3.0.1 with: client-id: ${{ vars.AZURE_CLIENT_ID }} tenant-id: ${{ vars.AZURE_TENANT_ID }} allow-no-subscriptions: true - name: Mint federated token for the signing dlib if: vars.AZURE_CLIENT_ID != '' shell: bash run: | file="$RUNNER_TEMP/azure-federated-token" mint() { curl -sSf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=api://AzureADTokenExchange" \ | jq -r .value > "$file.tmp" && mv -f "$file.tmp" "$file" } mint ( while sleep 240; do mint || true; done ) & echo "AZURE_FEDERATED_TOKEN_FILE=$file" >> "$GITHUB_ENV" - name: Pin CMake < 4 for sdist builds # python-olm (matrix extra) builds libolm from sdist on non-Linux # targets, and its libolm/CMakeLists.txt requires CMake < 3.5 # compat (removed in CMake 4, which the darwin + win32 runners # ship). Pin a CMake 3.x first on PATH for those legs so the sdist # build configures. Linux uses the manylinux wheel — no build, no # cmake needed. The pip cmake package ships a binary wheel for # every non-Linux target (macos universal2, win_amd64, win_arm64). shell: bash run: | uv tool install cmake==3.31.6 echo "$(uv tool dir --bin)" >> "$GITHUB_PATH" - name: Build and package shell: bash timeout-minutes: 900 env: PYTHONUTF8: '1' CC_aarch64_pc_windows_msvc: ${{ matrix.target.label == 'win32-arm64' && 'clang' || '' }} AZURE_SIGN_ENDPOINT: ${{ vars.AZURE_SIGN_ENDPOINT }} AZURE_SIGN_ACCOUNT: ${{ vars.AZURE_SIGN_ACCOUNT }} AZURE_SIGN_PROFILE: ${{ vars.AZURE_SIGN_PROFILE }} AZURE_SIGN_PUBLISHER: ${{ vars.AZURE_SIGN_PUBLISHER }} AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} AZURE_TOKEN_CREDENTIALS: prod run: | uv run --no-project --python 3.11 python scripts/bundles/desktop.py --tag="$HERMES_PAYLOAD_TAG" --variant=bundled # The Store-submission MSIX is the same bundled payload re-packed # with the Partner Center packaging identity (publish-win32-store # bundles these into the universal Store .msixbundle and submits it; # they also land in the tag archive, never a feed dir). uv run --no-project --python 3.11 python scripts/bundles/desktop.py --tag="$HERMES_PAYLOAD_TAG" --variant=store - name: Verify native signature cache contracts shell: bash working-directory: apps/desktop run: node ../../node_modules/vitest/vitest.mjs run --project electron scripts/payload-sign-cache.test.mjs scripts/batch-sign-binaries.test.mjs - name: Audit bundle architecture shell: bash run: | MATRIX_LABEL="${{ matrix.target.label }}" node apps/desktop/scripts/audit-bundle-arch.mjs \ --arch="${MATRIX_LABEL##*-}" --root=apps/desktop/release - name: Upload artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: # The win32 legs' actual .msix binaries: publish-win32-updater bundles # the per-arch .msix for the App Installer feed. name: hermes-bundled-${{ matrix.target.label }}-${{ inputs.tag }} path: | apps/desktop/release/*.msix retention-days: 30 if-no-files-found: error - name: Stage to Cloudflare R2 if: inputs.upload_release == true shell: bash # Every artifact goes to releases/tag// (immutable staging). # publish-win32-updater consumes the per-arch .msix to build the App # Installer feed; publish-win32-store bundles the Store-*.msix — never # from a leg, so a failed leg cannot publish a partial feed. run: | shopt -s nullglob # Store-*.msix are archived by the dedicated loop below — exclude # them here so they are not uploaded twice. files=(apps/desktop/release/*.msix) if [ ${#files[@]} -eq 0 ]; then echo "::error::no release artifacts found"; exit 1 fi for f in "${files[@]}"; do case "$f" in */Store-*) continue ;; # archived by the Store loop below esac node scripts/r2-release.mjs put \ --tag "$HERMES_PAYLOAD_TAG" --key "$(basename "$f")" --file "$f" done # The Store-submission MSIX (built by the win legs) goes to the tag # archive too — hidden from the builds table and never a feed dir. for f in apps/desktop/release/Store-*.msix; do [ -f "$f" ] || continue node scripts/r2-release.mjs put \ --tag "$HERMES_PAYLOAD_TAG" --key "$(basename "$f")" --file "$f" done # ── macOS builders (REAL) ────────────────────────────────────────────────── # Native per-arch darwin builds via scripts/bundles/desktop.py (the # one driver: same `--mac dmg zip` pass a local mac build runs). Each leg # signs (CSC_LINK) and notarizes (afterSign notarize.mjs) when the # release-signing environment carries the Apple credentials; a publishing # run FAILS the leg instead of shipping unsigned (no silent unsigned # publish). Binaries stage to releases/tag//; the per-arch feed ymls # travel as workflow artifacts (arch-prefixed so both legs survive the # merge-multiple download) and publish-darwin-updater merges them into # releases/darwin//-mac.yml — artifacts first, feed # pointer last, whole channel green before anything publishes. build-darwin: name: bundled ${{ matrix.target.label }} if: inputs.termux_only != true needs: validate runs-on: ${{ matrix.target.runner }} environment: release-signing timeout-minutes: 180 strategy: fail-fast: false matrix: target: - label: darwin-arm64 runner: macos-15 - label: darwin-x64 runner: macos-15-intel env: HERMES_DESKTOP_VARIANT: bundled HERMES_PAYLOAD_TAG: ${{ inputs.tag }} ELECTRON_BUILDER_CACHE: ${{ github.workspace }}/.cache/electron-builder ELECTRON_CACHE: ${{ github.workspace }}/.cache/electron electron_config_cache: ${{ github.workspace }}/.cache/electron CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }} CLOUDFLARE_R2_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }} CLOUDFLARE_R2_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }} CLOUDFLARE_R2_BUCKET: ${{ vars.CLOUDFLARE_R2_BUCKET }} CLOUDFLARE_R2_PUBLIC_URL: ${{ vars.CLOUDFLARE_R2_PUBLIC_URL }} steps: - name: Disable Spotlight indexing for DMG staging # Spotlight indexes the freshly-mounted dmg staging image past # hdiutil's detach retries (per-VM, not a cross-job race). shell: bash run: | sudo mdutil -a -i off || true # Check out the SHA the validate job admitted — never the tag ref, # which a force-push can move between jobs. This is the privileged # (release-signing) build; it must run the reviewed bytes. - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ needs.validate.outputs.sha }} fetch-tags: true fetch-depth: 0 - name: Resolve toolchain pins from pm/lock.json id: pins shell: bash # The host toolchain that BUILDS the artifact comes from the same # pin table as the embedded runtimes (pm/lock.json), so gate == pin # by construction in bundles/desktop.py's toolchain gates. run: | python3 -c ' import json pkgs = json.load(open("pm/lock.json"))["packages"] for tool in ("node", "npm", "uv"): print(tool + "=" + pkgs[tool]["version"]) ' >> "$GITHUB_OUTPUT" - name: Resolve toolchain cache key id: toolchain shell: bash run: | node -e ' const l = require("./package-lock.json") const el = l.packages["apps/desktop/node_modules/electron"].version const eb = l.packages["node_modules/electron-builder"].version if (!el || !eb) process.exit(1) console.log(`electron=${el}`) console.log(`builder=${eb}`) ' >> "$GITHUB_OUTPUT" - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ steps.pins.outputs.node }} cache: npm - name: Install pinned npm shell: bash env: NPM_PIN: ${{ steps.pins.outputs.npm }} run: npm --version | grep -qx "$NPM_PIN" || npm i -g "npm@$NPM_PIN" - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # 9.0.0 with: version: ${{ steps.pins.outputs.uv }} enable-cache: false - name: Cache pm store # Tag-dispatched runs (every canary) scope actions/cache under the # dispatch ref, which GitHub mangles to refs/heads/refs/tags/ — # a different scope per tag, so an exact key can never be restored # by a later canary. The content key below is stable across tags # when pm/lock.json + uv.lock are unchanged; the restore-keys prefix # (which ignores the tag entirely) rescues the previous canary's # store when the locks DID move. uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: apps/desktop/build/agent-payload/tools key: pm-store-v2-${{ matrix.target.label }}-${{ hashFiles('pm/lock.json', 'uv.lock') }} restore-keys: | pm-store-v2-${{ matrix.target.label }}- - name: Resolve electron's default download cache path shell: bash run: | # @electron/get does NOT honor ELECTRON_CACHE/electron_config_cache: # the electron-builder build's electron zip download uses the # default env-paths cache root. It must be in the actions/cache # path list or every build re-downloads electron (~115MB). case "$RUNNER_OS" in Windows) echo "ELECTRON_DEFAULT_CACHE=$LOCALAPPDATA/electron/Cache" >> "$GITHUB_ENV" ;; macOS) echo "ELECTRON_DEFAULT_CACHE=$HOME/Library/Caches/electron" >> "$GITHUB_ENV" ;; *) echo "ELECTRON_DEFAULT_CACHE=$HOME/.cache/electron" >> "$GITHUB_ENV" ;; esac - name: Cache electron + electron-builder toolchain uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ${{ github.workspace }}/.cache/electron-builder ${{ github.workspace }}/.cache/electron ${{ env.ELECTRON_DEFAULT_CACHE }} key: eb2-${{ runner.os }}-${{ runner.arch }}-electron-${{ steps.toolchain.outputs.electron }}-builder-${{ steps.toolchain.outputs.builder }} # An electron/builder bump misses the exact key, but the previous # dist is still mostly reusable (electron's postinstall skips the # download when dist/ exists) — restore it and let npm ci top up. restore-keys: | eb2-${{ runner.os }}-${{ runner.arch }}- - name: Cache node_modules uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | node_modules apps/*/node_modules ui-tui/node_modules ui-tui/packages/*/node_modules web/node_modules tests-js/node_modules key: node-modules-${{ matrix.target.label }}-node${{ steps.pins.outputs.node }}-npm${{ steps.pins.outputs.npm }}-${{ hashFiles('package-lock.json') }} # npm ci rm -rf's node_modules before installing, so a restore is # never shipped stale — but a restore-key hit still makes the # reinstall incremental (postinstall outputs like node-pty's # prebuilds/ and esbuild's platform binary survive in place). # The build-bundled install-stamp gate (lock sha + node + npm + # target) is the real guard against stale trees shipping. restore-keys: | node-modules-${{ matrix.target.label }}-node${{ steps.pins.outputs.node }}-npm${{ steps.pins.outputs.npm }}- - name: Cache node-pty prebuilds (postinstall output) uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | node_modules/node-pty/prebuilds node_modules/node-pty/build key: node-pty-prebuilds-${{ matrix.target.label }}-${{ hashFiles('package-lock.json') }} restore-keys: | node-pty-prebuilds-${{ matrix.target.label }}- # Signing/notarization gate. A publishing run MUST have the Apple # credentials; missing credentials fail the leg here (before any # build work) instead of producing an unsigned artifact that a later # job would publish. A non-publishing run (upload_release=false, # e.g. forks) builds unsigned on purpose. - name: Require signing credentials when publishing if: inputs.upload_release == true shell: bash env: CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} run: | missing=() [ -z "$CSC_LINK" ] && missing+=(CSC_LINK) [ -z "$CSC_KEY_PASSWORD" ] && missing+=(CSC_KEY_PASSWORD) [ -z "$APPLE_API_KEY_P8" ] && missing+=(APPLE_API_KEY_P8) [ -z "$APPLE_API_KEY_ID" ] && missing+=(APPLE_API_KEY_ID) [ -z "$APPLE_API_ISSUER" ] && missing+=(APPLE_API_ISSUER) [ -z "$CLOUDFLARE_R2_PUBLIC_URL" ] && missing+=(CLOUDFLARE_R2_PUBLIC_URL) [ -z "$CLOUDFLARE_R2_BUCKET" ] && missing+=(CLOUDFLARE_R2_BUCKET) if [ ${#missing[@]} -gt 0 ]; then echo "::error::upload_release=true but required signing/notarization credentials are not set in the release-signing environment: ${missing[*]} — refusing to produce an unsigned publishable build" exit 1 fi - name: Write App Store Connect key for notarytool # notarytool takes a FILE PATH for --key; raw .p8 content in argv # dies with `Invalid option: ***`. The build step must NOT re-declare # APPLE_API_KEY in its env: step env shadows GITHUB_ENV. if: inputs.upload_release == true shell: bash env: APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} run: | printf '%s\n' "$APPLE_API_KEY_P8" > "$RUNNER_TEMP/apple-api-key.p8" echo "APPLE_API_KEY=$RUNNER_TEMP/apple-api-key.p8" >> "$GITHUB_ENV" - name: Pin CMake < 4 for sdist builds # python-olm (matrix extra) builds libolm from sdist on non-Linux # targets, and its libolm/CMakeLists.txt requires CMake < 3.5 # compat (removed in CMake 4, which the darwin runners ship). Pin a # CMake 3.x first on PATH so the sdist build configures. shell: bash run: | uv tool install cmake==3.31.6 echo "$(uv tool dir --bin)" >> "$GITHUB_PATH" - name: Build and package shell: bash timeout-minutes: 160 env: GITHUB_SHA: ${{ needs.validate.outputs.sha }} GITHUB_REF_NAME: ${{ inputs.tag }} PYTHONUTF8: '1' # electron-osx-sign*/electron-notarize* keep the sign+notarize # phase visible: without them NOTHING logs between "signing # file=..." and a queue-wait timeout, so a slow notary queue is # indistinguishable from a hang. DEBUG: 'electron-osx-sign*,electron-notarize*' APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} run: | # Sign + notarize of a bundled-payload app runs long (Apple scans # every Mach-O) — raise the fd limit and let DEBUG show progress. ulimit -n 16384 2>/dev/null || true echo "file descriptor limit: soft=$(ulimit -Sn) hard=$(ulimit -Hn)" uv run --no-project --python 3.11 python scripts/bundles/desktop.py --tag="$HERMES_PAYLOAD_TAG" --variant=bundled - name: Audit bundle architecture shell: bash run: | MATRIX_LABEL="${{ matrix.target.label }}" node apps/desktop/scripts/audit-bundle-arch.mjs \ --arch="${MATRIX_LABEL##*-}" --root=apps/desktop/release - name: Verify the build is signed and notarized # The backstop against a silent unsigned publish: assess the packed # app against the real Gatekeeper policy (requires a Developer ID # signature AND a stapled notarization ticket to pass offline). if: inputs.upload_release == true shell: bash run: | shopt -s nullglob apps=(apps/desktop/release/mac*/*.app) if [ ${#apps[@]} -eq 0 ]; then echo "::error::no packed .app found under apps/desktop/release to verify" exit 1 fi for app in "${apps[@]}"; do codesign --verify --strict --verbose=2 "$app" xcrun stapler validate "$app" spctl -a -vv -t exec "$app" echo "signed + notarized: $app" done - name: Rename the feed yml per arch if: inputs.upload_release == true # electron-builder writes the channel feed yml (stable-mac.yml / # canary-mac.yml) with the SAME name on both legs; prefix the arch # so the publish job's merge-multiple download keeps both and # r2-release finalize can merge them into -mac.yml. The # channel token is preserved verbatim in the staged name # (arm64-stable-mac.yml / x64-canary-mac.yml …). shell: bash run: | shopt -s nullglob MATRIX_LABEL="${{ matrix.target.label }}" arch="${MATRIX_LABEL##*-}" channel="${{ needs.validate.outputs.channel }}" f="apps/desktop/release/${channel}-mac.yml" test -s "$f" mv "$f" "apps/desktop/release/${arch}-${channel}-mac.yml" - name: Upload feed metadata if: inputs.upload_release == true uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: hermes-bundled-${{ matrix.target.label }}-${{ inputs.tag }} path: | apps/desktop/release/*-mac.yml retention-days: 30 if-no-files-found: error - name: Retain non-publishing build artifacts if: inputs.upload_release != true uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: hermes-bundled-${{ matrix.target.label }}-${{ inputs.tag }} path: | apps/desktop/release/*.dmg apps/desktop/release/*.zip apps/desktop/release/*.blockmap retention-days: 30 if-no-files-found: error - name: Stage to Cloudflare R2 if: inputs.upload_release == true shell: bash # Every artifact goes to releases/tag// (immutable staging). # publish-darwin-updater merges the per-arch ymls into the channel # feed AFTER both legs are green — never from a leg, so a failed # leg cannot publish a partial channel. run: | shopt -s nullglob files=(apps/desktop/release/*.dmg apps/desktop/release/*.zip \ apps/desktop/release/*.blockmap) if [ ${#files[@]} -eq 0 ]; then echo "::error::no darwin release artifacts found"; exit 1 fi for f in "${files[@]}"; do node scripts/r2-release.mjs put \ --tag "$HERMES_PAYLOAD_TAG" --key "$(basename "$f")" --file "$f" done # ── Linux builders (DISABLED for now) ───────────────────────────────────── build-linux: name: bundled linux (disabled for now) needs: validate runs-on: ubuntu-24.04 timeout-minutes: 5 strategy: fail-fast: false matrix: target: - { label: linux-x64 } # runner: ubuntu-24.04 - { label: linux-arm64 } # runner: ubuntu-24.04-arm steps: - name: Disabled shell: bash run: echo "::notice::linux bundled builds are disabled for now — re-enable in desktop-bundled-release.yml" # ── Windows updater channels (REAL — gated on build-win32 only) ────────── publish-win32-updater: name: Publish the win32 App Installer feeds needs: [validate, build-win32] if: inputs.upload_release == true runs-on: windows-2025 environment: release-signing timeout-minutes: 45 env: HERMES_PAYLOAD_TAG: ${{ inputs.tag }} ELECTRON_BUILDER_CACHE: ${{ github.workspace }}/.cache/electron-builder CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }} CLOUDFLARE_R2_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }} CLOUDFLARE_R2_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }} CLOUDFLARE_R2_BUCKET: ${{ vars.CLOUDFLARE_R2_BUCKET }} CLOUDFLARE_R2_PUBLIC_URL: ${{ vars.CLOUDFLARE_R2_PUBLIC_URL }} AZURE_SIGN_ENDPOINT: ${{ vars.AZURE_SIGN_ENDPOINT }} AZURE_SIGN_ACCOUNT: ${{ vars.AZURE_SIGN_ACCOUNT }} AZURE_SIGN_PROFILE: ${{ vars.AZURE_SIGN_PROFILE }} AZURE_SIGN_PUBLISHER: ${{ vars.AZURE_SIGN_PUBLISHER }} steps: # Full checkout: stage-msixbundle.mjs imports ./msix-shared.mjs and the # job runs it with no checkout at all today — ERR_MODULE_NOT_FOUND. - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # Privileged job: pin to the SHA validate admitted, not the tag. ref: ${{ needs.validate.outputs.sha }} # stage-msixbundle derives the canary MSIX build number as # minutes-since-the-last-stable from git tags — must see them. fetch-tags: true - name: Resolve toolchain pins from pm/lock.json id: pins shell: bash run: | python -c ' import json pkgs = json.load(open("pm/lock.json"))["packages"] for tool in ("node", "npm", "uv"): print(tool + "=" + pkgs[tool]["version"]) ' >> "$GITHUB_OUTPUT" - name: Resolve toolchain cache key id: toolchain shell: bash run: | node -e ' const l = require("./package-lock.json") const el = l.packages["apps/desktop/node_modules/electron"].version const eb = l.packages["node_modules/electron-builder"].version if (!el || !eb) process.exit(1) console.log(`electron=${el}`) console.log(`builder=${eb}`) ' >> "$GITHUB_OUTPUT" # makeappx + signtool live in the winCodeSign toolset that # electron-builder downloads into its cache during the build legs; the # publish jobs run on a fresh runner, so restore the same eb2 cache # the win32 legs saved. The path list MUST match the build legs' # byte-for-byte — actions/cache derives the version hash from the paths # input, so a shorter list computes a different version and the restore # misses ("Cache not found") even with the identical key. - name: Resolve electron's default download cache path shell: bash run: | case "$RUNNER_OS" in Windows) echo "ELECTRON_DEFAULT_CACHE=$LOCALAPPDATA/electron/Cache" >> "$GITHUB_ENV" ;; macOS) echo "ELECTRON_DEFAULT_CACHE=$HOME/Library/Caches/electron" >> "$GITHUB_ENV" ;; *) echo "ELECTRON_DEFAULT_CACHE=$HOME/.cache/electron" >> "$GITHUB_ENV" ;; esac - name: Cache electron + electron-builder toolchain uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ${{ github.workspace }}/.cache/electron-builder ${{ github.workspace }}/.cache/electron ${{ env.ELECTRON_DEFAULT_CACHE }} key: eb2-${{ runner.os }}-${{ runner.arch }}-electron-${{ steps.toolchain.outputs.electron }}-builder-${{ steps.toolchain.outputs.builder }} restore-keys: | eb2-${{ runner.os }}-${{ runner.arch }}- - name: Download win legs' MSIX uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: pattern: hermes-bundled-win32-*-${{ inputs.tag }} path: apps/desktop/release merge-multiple: true - name: Azure login (OIDC) uses: azure/login@f5d393ae46f8fde4be8b75f32e3fc50e654ad0ca # v3.0.1 with: client-id: ${{ vars.AZURE_CLIENT_ID }} tenant-id: ${{ vars.AZURE_TENANT_ID }} allow-no-subscriptions: true - name: Mint federated token for the signing dlib shell: bash run: | file="$RUNNER_TEMP/azure-federated-token" mint() { curl -sSf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=api://AzureADTokenExchange" \ | jq -r .value > "$file.tmp" && mv -f "$file.tmp" "$file" } mint ( while sleep 240; do mint || true; done ) & echo "AZURE_FEDERATED_TOKEN_FILE=$file" >> "$GITHUB_ENV" - name: Bundle + stage the MSIX feeds shell: bash env: AZURE_TOKEN_CREDENTIALS: prod AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} run: | # Out-of-store feed (bundled variant): universal .msixbundle + # .appinstaller per channel; plus re-upload the Store-submission # .msix to the tag archive (never a feed dir). node scripts/stage-msixbundle.mjs --tag "$HERMES_PAYLOAD_TAG" --variant bundled # ── Windows Store submission (REAL — PARALLEL with publish-win32-updater) ─ # Bundles the two Store-*.msix into one universal Store .msixbundle and # submits it to the Windows Store via the MSStore CLI. # # stable tags → production submission (msstore publish -id) # canary tags → package flight ring (msstore publish -f ), # delete-then-replace: any pending flight submission is # deleted first so the newest canary always wins the # single-slot submission queue (chosen over skip-if-pending # — always ship the newest, at the cost of cert churn). # # Gating: runs for stable whenever MS_STORE_PRODUCT_ID is set; runs for # canary only when MS_STORE_CANARY_FLIGHT_ID is ALSO set (so the flight # ring stays off until the flight exists in Partner Center). publish-win32-store: name: Publish the Windows Store submission (production + canary flight) needs: [validate, build-win32] if: | inputs.upload_release == true && vars.MS_STORE_PRODUCT_ID != '' && (contains(inputs.tag, '-canary.') == false || vars.MS_STORE_CANARY_FLIGHT_ID != '') runs-on: windows-2025 environment: release-signing timeout-minutes: 60 env: HERMES_PAYLOAD_TAG: ${{ inputs.tag }} ELECTRON_BUILDER_CACHE: ${{ github.workspace }}/.cache/electron-builder CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }} CLOUDFLARE_R2_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }} CLOUDFLARE_R2_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }} CLOUDFLARE_R2_BUCKET: ${{ vars.CLOUDFLARE_R2_BUCKET }} CLOUDFLARE_R2_PUBLIC_URL: ${{ vars.CLOUDFLARE_R2_PUBLIC_URL }} MS_STORE_TENANT_ID: ${{ secrets.MS_STORE_TENANT_ID }} MS_STORE_SELLER_ID: ${{ secrets.MS_STORE_SELLER_ID }} MS_STORE_CLIENT_ID: ${{ secrets.MS_STORE_CLIENT_ID }} MS_STORE_CLIENT_SECRET: ${{ secrets.MS_STORE_CLIENT_SECRET }} MS_STORE_PRODUCT_ID: ${{ vars.MS_STORE_PRODUCT_ID }} MS_STORE_CANARY_FLIGHT_ID: ${{ vars.MS_STORE_CANARY_FLIGHT_ID }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # Privileged job: pin to the SHA validate admitted, not the tag. ref: ${{ needs.validate.outputs.sha }} fetch-tags: true - name: Resolve toolchain pins from pm/lock.json id: pins shell: bash run: | python -c ' import json pkgs = json.load(open("pm/lock.json"))["packages"] for tool in ("node", "npm", "uv"): print(tool + "=" + pkgs[tool]["version"]) ' >> "$GITHUB_OUTPUT" - name: Resolve toolchain cache key id: toolchain shell: bash run: | node -e ' const l = require("./package-lock.json") const el = l.packages["apps/desktop/node_modules/electron"].version const eb = l.packages["node_modules/electron-builder"].version if (!el || !eb) process.exit(1) console.log(`electron=${el}`) console.log(`builder=${eb}`) ' >> "$GITHUB_OUTPUT" - uses: actions/setup-node@v7.0.0 # immutable release. safe to pin. with: node-version: ${{ steps.pins.outputs.node }} # makeappx for the Store bundle lives in the same winCodeSign toolset # the win32 legs downloaded — restore the identical eb2 cache. - name: Resolve electron's default download cache path shell: bash run: | case "$RUNNER_OS" in Windows) echo "ELECTRON_DEFAULT_CACHE=$LOCALAPPDATA/electron/Cache" >> "$GITHUB_ENV" ;; macOS) echo "ELECTRON_DEFAULT_CACHE=$HOME/Library/Caches/electron" >> "$GITHUB_ENV" ;; *) echo "ELECTRON_DEFAULT_CACHE=$HOME/.cache/electron" >> "$GITHUB_ENV" ;; esac - name: Cache electron + electron-builder toolchain uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ${{ github.workspace }}/.cache/electron-builder ${{ github.workspace }}/.cache/electron ${{ env.ELECTRON_DEFAULT_CACHE }} key: eb2-${{ runner.os }}-${{ runner.arch }}-electron-${{ steps.toolchain.outputs.electron }}-builder-${{ steps.toolchain.outputs.builder }} restore-keys: | eb2-${{ runner.os }}-${{ runner.arch }}- - name: Download win legs' MSIX (incl. Store-*.msix) uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: pattern: hermes-bundled-win32-*-${{ inputs.tag }} path: apps/desktop/release merge-multiple: true - name: Bundle the Store submission MSIX id: storebundle shell: bash run: | # Prints the absolute bundle path on stdout (the machine-readable # result); logs go to stderr. bundle="$(node scripts/bundle-store-msixbundle.mjs --tag "$HERMES_PAYLOAD_TAG")" echo "bundle=$bundle" >> "$GITHUB_OUTPUT" echo "Store bundle: $bundle" - name: Archive the Store bundle to the tag dir shell: bash run: | # The per-arch Store-*.msix are already in the immutable archive # (uploaded by the build legs); keep the assembled universal bundle # there too as the record of exactly what was submitted. node scripts/r2-release.mjs put \ --tag "$HERMES_PAYLOAD_TAG" \ --key "$(basename "${{ steps.storebundle.outputs.bundle }}")" \ --file "${{ steps.storebundle.outputs.bundle }}" - name: Setup MSStore CLI uses: microsoft/microsoft-store-apppublisher@cc9910a8d59f2eb55cbb83df0a3800cf3b5300e0 # v1.4 - name: Configure Store credentials shell: bash run: | msstore reconfigure \ --tenantId "$MS_STORE_TENANT_ID" \ --sellerId "$MS_STORE_SELLER_ID" \ --clientId "$MS_STORE_CLIENT_ID" \ --clientSecret "$MS_STORE_CLIENT_SECRET" - name: Publish the Store submission shell: bash run: | # The universal Store .msixbundle is accepted directly by msstore # publish (PackageFilesExtensionInclude: .msix/.msixbundle/.msixupload). # Partner Center signs on ingestion — no signing here. # # delete-then-replace (chosen over skip-if-pending): Partner Center # allows ONE pending submission per app/flight at a time, so clear # any in-flight draft before publishing — the newest canary always # wins the queue. A missing pending submission errors the delete, # which is fine (nothing to clear) — only the publish gates the job. bundle="${{ steps.storebundle.outputs.bundle }}" if [[ "$HERMES_PAYLOAD_TAG" == *-canary.* ]]; then echo "canary tag → package flight '$MS_STORE_CANARY_FLIGHT_ID'" msstore flights submission delete "$MS_STORE_PRODUCT_ID" "$MS_STORE_CANARY_FLIGHT_ID" --no-confirm \ || echo "no pending flight submission to clear" msstore publish "$bundle" -f "$MS_STORE_CANARY_FLIGHT_ID" -id "$MS_STORE_PRODUCT_ID" else echo "stable tag → production submission" msstore submission delete "$MS_STORE_PRODUCT_ID" --no-confirm \ || echo "no pending submission to clear" msstore publish "$bundle" -id "$MS_STORE_PRODUCT_ID" fi # ── macOS updater channel (REAL) ─────────────────────────────────────────── # Merges the per-arch feed ymls (arm64-stable-mac.yml / x64-stable-mac.yml # …) into releases/darwin//-mac.yml via # scripts/r2-release.mjs finalize. The binaries were staged by the build # legs (releases/tag//); the feed POINTER is written here, last, only # after BOTH darwin legs are green — a failed leg can never publish a # partial channel. The concurrency group is scoped to the channel so two # dispatches for the same channel serialize their feed writes (r2-release # finalize also refuses backward publication); stable and canary still # publish in parallel by design (distinct feed files). publish-darwin-updater: name: Publish the macOS updater feed needs: [validate, build-darwin] if: inputs.upload_release == true && inputs.termux_only != true runs-on: ubuntu-24.04 environment: release-signing timeout-minutes: 15 concurrency: group: darwin-updater-feed-${{ needs.validate.outputs.channel }} cancel-in-progress: false env: HERMES_PAYLOAD_TAG: ${{ inputs.tag }} CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }} CLOUDFLARE_R2_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }} CLOUDFLARE_R2_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }} CLOUDFLARE_R2_BUCKET: ${{ vars.CLOUDFLARE_R2_BUCKET }} CLOUDFLARE_R2_PUBLIC_URL: ${{ vars.CLOUDFLARE_R2_PUBLIC_URL }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # Privileged job: pin to the SHA validate admitted, not the tag. ref: ${{ needs.validate.outputs.sha }} - name: Install locked feed tooling run: npm ci --workspaces=false --ignore-scripts --no-audit --no-fund - name: Download both darwin legs' feed ymls uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: pattern: hermes-bundled-darwin-*-${{ inputs.tag }} path: staged merge-multiple: true - name: Assert the per-arch feeds are present shell: bash # finalize merges *-mac.yml and (feed-side) rejects mixed versions # and conflicting digests; here we fail fast if a leg never # produced its yml so a single-arch feed can never publish. run: | shopt -s nullglob ymls=(staged/*-mac.yml) if [ ${#ymls[@]} -ne 2 ]; then echo "::error::expected the arm64 AND x64 feed ymls in the staged artifacts, found ${#ymls[@]}: ${ymls[*]}" exit 1 fi archs=() for f in "${ymls[@]}"; do archs+=("$(basename "$f" | cut -d- -f1)"); done printf '%s\n' "${archs[@]}" | sort -u | grep -qx arm64 || { echo "::error::arm64 feed yml missing"; exit 1; } printf '%s\n' "${archs[@]}" | sort -u | grep -qx x64 || { echo "::error::x64 feed yml missing"; exit 1; } echo "staged feeds: ${ymls[*]}" - name: Finalize the macOS updater feeds # Writes releases/darwin//-mac.yml pointing at the # already-staged /releases/tag// binaries. Immutable objects # referenced by the feed are verified before the pointer uploads # (feed-side); the pointer upload is the last write of the run. shell: bash run: | node scripts/r2-release.mjs finalize --tag "$HERMES_PAYLOAD_TAG" --dir staged termux-deb: name: Build + publish the termux .deb (aarch64) needs: [validate] if: inputs.upload_release == true runs-on: ubuntu-24.04-arm environment: release-signing timeout-minutes: 90 env: HERMES_PAYLOAD_TAG: ${{ inputs.tag }} # termux_build.sh gates on `gh release view ` (refuse to build # before the release exists); gh needs GH_TOKEN or it errors out and # the gate misreads that as "release not found". GH_TOKEN: ${{ github.token }} CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }} CLOUDFLARE_R2_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }} CLOUDFLARE_R2_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }} CLOUDFLARE_R2_BUCKET: ${{ vars.CLOUDFLARE_R2_BUCKET }} CLOUDFLARE_R2_PUBLIC_URL: ${{ vars.CLOUDFLARE_R2_PUBLIC_URL }} steps: # Use the admitted commit, not a tag that can move after validation. - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ needs.validate.outputs.sha }} fetch-depth: 0 fetch-tags: true - name: Resolve toolchain pins from pm/lock.json id: toolchain_pins shell: bash # Same pin table as the desktop legs: the uv that drives the # wheelhouse resolution is the pinned toolchain uv, not whatever # happens to be on the runner image. run: | python -c ' import json pkgs = json.load(open("pm/lock.json"))["packages"] print("uv=" + pkgs["uv"]["version"]) ' >> "$GITHUB_OUTPUT" - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # 9.0.0 with: version: ${{ steps.toolchain_pins.outputs.uv }} enable-cache: false - name: Derive the channel from the tag id: channel shell: bash # Single source of truth: deb_version.py --channel uses the same # _TAG_RE as the Debian version derivation, so the channel and the # .deb version can never disagree about the tag's shape. env: TAG: ${{ inputs.tag }} run: | echo "channel=$(python3 scripts/termux/deb_version.py --channel "$TAG")" >> "$GITHUB_OUTPUT" - name: Read the termux-docker digest pin id: pins shell: bash # The digest is pinned in pm/lock.json (the termux-docker package) # beside every other third-party pin; the build scripts re-read it # themselves. This step surfaces the effective pin in the run log # and fails fast if the lock row is missing. run: | echo "digest=$(python3 -c 'import sys; sys.path.insert(0, "."); from pm.lock import termux_docker_digest; print(termux_docker_digest())')" >> "$GITHUB_OUTPUT" - name: Restore the CPython + node + uv payload builds # Restore/save are split: the save runs right after staging (with # `always()`), so a later phase's failure cannot throw away 20+ # minutes of builds. The exact key is content-only on pm/lock.json + # the stagers, so it is stable across dispatches; the restore-keys # prefix rescues the previous pin's builds when the lock moved. uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | termux-build/payload/python termux-build/payload/node termux-build/payload/uv termux-build/payload/npm termux-build/payload/ffmpeg termux-build/payload/ripgrep termux-build/payload/runtime-libs key: termux-payload-${{ hashFiles('pm/lock.json', 'scripts/termux/build_cpython.sh', 'scripts/termux/build_node.sh', 'scripts/termux/build_uv.sh', 'scripts/termux/runtime_libs.json', 'scripts/termux/stage_runtime_libs.py') }} restore-keys: | termux-payload- - name: Build CPython into the payload shell: bash run: bash scripts/termux/build_cpython.sh termux-build/payload - name: Build node into the payload shell: bash run: bash scripts/termux/build_node.sh termux-build/payload - name: Stage the runtime libs into the payload shell: bash # The payload interpreters' runtime libs (python's C-extension deps, # node's, uv's zstd) ship INSIDE the sealed deb: the device's termux # tree may have none of them installed. Pin table derived from the # suppliers' own Depends metadata (see runtime_libs.json). run: python3 scripts/termux/stage_runtime_libs.py termux-build/payload - name: Stage uv into the payload shell: bash # The wheelhouse resolver + runtime lazy-install tool: pm stages # the bionic uv .deb beside python/node (no PATH install; the # payload env composition exposes it, desktop payload model). run: bash scripts/termux/build_uv.sh termux-build/payload - name: Stage the remaining CLI runtime tools shell: bash run: | for tool in npm ffmpeg ripgrep; do bash scripts/termux/termux_pkg_build.sh "$tool" "$tool" termux-build/payload done - name: Save the payload builds # Save regardless of the wheelhouse/deb phases' outcome: the staged # tool payload is only stale when the lock or a stager changes, and # losing it to a downstream failure re-pays the full stage cost. if: always() uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | termux-build/payload/python termux-build/payload/node termux-build/payload/uv termux-build/payload/npm termux-build/payload/ffmpeg termux-build/payload/ripgrep termux-build/payload/runtime-libs key: termux-payload-${{ hashFiles('pm/lock.json', 'scripts/termux/build_cpython.sh', 'scripts/termux/build_node.sh', 'scripts/termux/build_uv.sh', 'scripts/termux/runtime_libs.json', 'scripts/termux/stage_runtime_libs.py') }} - name: Ensure the derived builder image (toolchain pre-baked) id: builder_image shell: bash env: GITHUB_TOKEN: ${{ github.token }} GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} # Content-addressed off the pm lock digest: builds once per lock # bump, then no-ops. The wheelhouse container phase boots this # instead of the bare base, skipping the ~10-min toolchain apt. run: | mkdir -p termux-build # The builder script prints progress (build logs, smoke output) # plus the image ref on the LAST line -- command substitution # would swallow all of it into one $GITHUB_OUTPUT value. bash scripts/termux/build_builder_image.sh | tee termux-build/.builder-image.log img="$(tail -n1 termux-build/.builder-image.log)" echo "builder image: $img" echo "image=$img" >> "$GITHUB_OUTPUT" - name: Restore the wheelhouse # The manufactured native wheels are the expensive artifact of this # job. build_wheels.py skips any dist whose exact-pinned wheel is # already present, so a restored wheelhouse turns the 20-minute # native compile phase into a cache-hit verification. uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | termux-build/payload/wheelhouse termux-build/payload/index.json termux-build/payload/SHA256SUMS termux-build/payload/.work/resolved.txt termux-build/payload/.work/build_set.txt key: termux-wheelhouse-${{ hashFiles('uv.lock', 'pyproject.toml', 'pm/lock.json', 'scripts/termux/runtime_libs.json', 'scripts/termux/build_config.sh', 'scripts/termux/build_wheels.py', 'scripts/termux/termux_build.sh', 'scripts/termux/retag_wheel.py', 'scripts/termux/python_linkage.py', 'scripts/termux/wheelhouse_cache.py', 'scripts/termux/termux-builder.Dockerfile') }} restore-keys: | termux-wheelhouse- - name: Build the wheelhouse shell: bash env: TERMUX_BUILDER_IMAGE: ${{ steps.builder_image.outputs.image }} # termux_build.sh lands wheelhouse/ + index.json + SHA256SUMS in the # payload root, where build_deb.sh's --no-index pip install finds it. run: | bash scripts/termux/termux_build.sh \ --repo . \ --tag "$HERMES_PAYLOAD_TAG" \ --out termux-build/payload - name: Save the wheelhouse # Only proven wheels enter the cache. Saving before deb assembly # preserves them if a later packaging step fails. uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | termux-build/payload/wheelhouse termux-build/payload/index.json termux-build/payload/SHA256SUMS termux-build/payload/.work/resolved.txt termux-build/payload/.work/build_set.txt key: termux-wheelhouse-${{ hashFiles('uv.lock', 'pyproject.toml', 'pm/lock.json', 'scripts/termux/runtime_libs.json', 'scripts/termux/build_config.sh', 'scripts/termux/build_wheels.py', 'scripts/termux/termux_build.sh', 'scripts/termux/retag_wheel.py', 'scripts/termux/python_linkage.py', 'scripts/termux/wheelhouse_cache.py', 'scripts/termux/termux-builder.Dockerfile') }} - name: Build the TUI bundle for the deb shell: bash # The deb ships the PREBUILT TUI (hermes --tui runs it directly via # the payload node; there is no npm on a phone). Same shape as the # desktop payload: git archive never carries built dists, so plant # the just-built entry.js into the staged app tree. run: | npm ci --workspace ui-tui --include=dev --no-fund --no-audit --silent npm run build --workspace ui-tui python3 scripts/bundles/payload.py surfaces termux-build/payload --repo-dir app --tui-only - name: Assemble the .deb shell: bash env: TERMUX_BUILDER_IMAGE: ${{ steps.builder_image.outputs.image }} # build_deb.sh always validates in a fresh pinned container (no # opt-out flag); the channel is derived from the tag inside the # script via deb_version.py. run: | bash scripts/termux/build_deb.sh \ --repo . \ --tag "$HERMES_PAYLOAD_TAG" \ --payload termux-build/payload \ --out termux-build/deb - name: Upload the .deb as a workflow artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: # Matches the publish jobs' download pattern hermes-bundled-* so # the release leg's artifact sweep sees it. name: hermes-bundled-termux-${{ inputs.tag }} path: termux-build/deb/*.deb retention-days: 30 if-no-files-found: error - name: Verify the previous Termux run if: inputs.termux_upgrade_from_run != '' env: PREVIOUS_RUN: ${{ inputs.termux_upgrade_from_run }} run: | [[ "$PREVIOUS_RUN" =~ ^[0-9]+$ ]] gh api "repos/$GITHUB_REPOSITORY/actions/runs/$PREVIOUS_RUN" > "$RUNNER_TEMP/termux-previous-run.json" python3 - "$RUNNER_TEMP/termux-previous-run.json" <<'PY' import json, subprocess, sys run = json.load(open(sys.argv[1], encoding="utf-8")) assert run["conclusion"] == "success", "previous run did not succeed" assert run["path"] == ".github/workflows/desktop-bundled-release.yml", "wrong artifact producer" subprocess.run(["git", "merge-base", "--is-ancestor", run["head_sha"], "origin/main"], check=True) PY - name: Download the previous Termux package if: inputs.termux_upgrade_from_run != '' uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: github-token: ${{ github.token }} run-id: ${{ inputs.termux_upgrade_from_run }} pattern: hermes-bundled-termux-* merge-multiple: true path: termux-build/previous - name: Write the APT signing key shell: bash env: TERMUX_APT_GPG_KEY: ${{ secrets.TERMUX_APT_GPG_KEY }} run: | if [ -z "$TERMUX_APT_GPG_KEY" ]; then echo "::error::TERMUX_APT_GPG_KEY secret not set — the APT repo would stage unsigned" exit 1 fi printf '%s\n' "$TERMUX_APT_GPG_KEY" > "$RUNNER_TEMP/termux-apt-gpg.asc" - name: Stage the APT repo and publish to R2 shell: bash env: CHANNEL: ${{ steps.channel.outputs.channel }} PREVIOUS_RUN: ${{ inputs.termux_upgrade_from_run }} # Passphrase-protected signing keys only; unset for bare keys. TERMUX_APT_GPG_PASSPHRASE: ${{ secrets.TERMUX_APT_GPG_PASSPHRASE }} run: | # stage_apt_repo.py builds dists// + pool/ under --out and # signs Release/InRelease with the key file. Exit 3 (unsigned # Release) must fail the run. python3 scripts/termux/stage_apt_repo.py \ --pool termux-build/deb \ --out termux-build/apt \ --suite "hermes-$CHANNEL" \ --gpg-key-file "$RUNNER_TEMP/termux-apt-gpg.asc" debs=(termux-build/deb/*.deb) test "${#debs[@]}" -eq 1 version=$(dpkg-deb --field "${debs[0]}" Version) digest=$(python3 -c 'from pm.lock import termux_docker_digest; print(termux_docker_digest())') docker run --rm --platform linux/arm64 --user 1000:1000 --network none \ -v "$PWD/termux-build/apt:/apt:ro" \ -v "$PWD/scripts/termux/check_apt.sh:/tmp/check-apt.sh:ro" \ -v "$PWD/scripts/termux/validate_installed.py:/tmp/validate_installed.py:ro" \ "termux/termux-docker@$digest" bash /tmp/check-apt.sh "hermes-$CHANNEL" "$version" if [ -n "$PREVIOUS_RUN" ]; then previous=(termux-build/previous/*.deb) test "${#previous[@]}" -eq 1 docker run --rm --platform linux/arm64 --user 1000:1000 --network none \ -v "$PWD/termux-build/apt:/apt:ro" \ -v "$PWD/${previous[0]}:/previous.deb:ro" \ -v "$PWD/scripts/termux/check_apt.sh:/tmp/check-apt.sh:ro" \ -v "$PWD/scripts/termux/validate_installed.py:/tmp/validate_installed.py:ro" \ "termux/termux-docker@$digest" bash /tmp/check-apt.sh "hermes-$CHANNEL" "$version" fi # --key-is-full is MANDATORY on every feed-dir upload: without it # r2-release.mjs re-prefixes the key into the tag archive # (releases/tag//) and the APT feed never lands at # releases/termux//. shopt -s globstar nullglob # cd into the staged repo for the glob, but resolve the r2 client # absolutely first -- after the cd, scripts/ is no longer in the # cwd and a bare `node scripts/r2-release.mjs` would ENOENT. R2="$GITHUB_WORKSPACE/scripts/r2-release.mjs" cd termux-build/apt files=(pool/**/*.deb "dists/hermes-$CHANNEL"/main/binary-aarch64/by-hash/*/* key.asc "dists/hermes-$CHANNEL/main/binary-aarch64/Packages" "dists/hermes-$CHANNEL/main/binary-aarch64/Packages.gz" "dists/hermes-$CHANNEL/Release" "dists/hermes-$CHANNEL/Release.gpg" "dists/hermes-$CHANNEL/InRelease") # APT reads InRelease first. Publish it only after every referenced # index and package has been uploaded and read back successfully. for f in "${files[@]}"; do test -f "$f" node "$R2" put \ --tag "$HERMES_PAYLOAD_TAG" \ --key "releases/termux/$CHANNEL/$f" \ --file "$f" \ --key-is-full done # Verify the public CDN path, not only the object-store origin. public_repo="${CLOUDFLARE_R2_PUBLIC_URL%/}/releases/termux/$CHANNEL" curl --fail --silent --show-error "$public_repo/key.asc" -o "$RUNNER_TEMP/termux-public-key.asc" cmp key.asc "$RUNNER_TEMP/termux-public-key.asc" docker run --rm --platform linux/arm64 --user 1000:1000 \ -v "$PWD:/apt:ro" \ -v "$GITHUB_WORKSPACE/scripts/termux/check_apt.sh:/tmp/check-apt.sh:ro" \ -v "$GITHUB_WORKSPACE/scripts/termux/validate_installed.py:/tmp/validate_installed.py:ro" \ "termux/termux-docker@$digest" bash /tmp/check-apt.sh "hermes-$CHANNEL" "$version" "$public_repo" builds-pending: name: Mark the builds table as in progress if: inputs.upload_release == true && inputs.termux_only != true runs-on: ubuntu-24.04 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Render the placeholder env: GH_TOKEN: ${{ github.token }} HERMES_PAYLOAD_TAG: ${{ inputs.tag }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | if ! gh release view "$HERMES_PAYLOAD_TAG" >/dev/null 2>&1; then echo "::error::no release exists for $HERMES_PAYLOAD_TAG — cannot mark the builds table" exit 1 fi python3 scripts/render-builds-table.py \ --tag "$HERMES_PAYLOAD_TAG" --repo "$GITHUB_REPOSITORY" \ --pending-run-url "$RUN_URL" builds-table: name: Render the release builds table needs: [validate, build-win32, build-darwin, build-linux, publish-win32-updater, publish-darwin-updater, termux-deb] if: inputs.upload_release == true runs-on: ubuntu-24.04 environment: release-signing steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # Privileged job: pin to the SHA validate admitted. A bare checkout # here would land on the dispatch branch, not the release bytes. ref: ${{ needs.validate.outputs.sha }} - name: Render env: GH_TOKEN: ${{ github.token }} HERMES_PAYLOAD_TAG: ${{ inputs.tag }} CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }} CLOUDFLARE_R2_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }} CLOUDFLARE_R2_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }} CLOUDFLARE_R2_BUCKET: ${{ vars.CLOUDFLARE_R2_BUCKET }} CLOUDFLARE_R2_PUBLIC_URL: ${{ vars.CLOUDFLARE_R2_PUBLIC_URL }} run: | if ! gh release view "$HERMES_PAYLOAD_TAG" >/dev/null 2>&1; then echo "::error::no release exists for $HERMES_PAYLOAD_TAG — cannot render the builds table" exit 1 fi python3 scripts/render-builds-table.py \ --tag "$HERMES_PAYLOAD_TAG" --repo "$GITHUB_REPOSITORY" publish-canary: name: Publish the canary release needs: [build-win32, build-darwin, build-linux, builds-table, publish-win32-updater, publish-darwin-updater] if: | always() && inputs.upload_release == true && contains(inputs.tag, '-canary.') && needs.build-win32.result == 'success' && needs.build-darwin.result == 'success' && needs.build-linux.result == 'success' && needs.builds-table.result == 'success' && needs.publish-win32-updater.result == 'success' && needs.publish-darwin-updater.result == 'success' runs-on: ubuntu-24.04 steps: - name: Publish env: GH_TOKEN: ${{ github.token }} TAG: ${{ inputs.tag }} run: | gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false echo "Published $TAG"