The Windows release signed 1,106 payload binaries for each of the bundled and Store variants. Both passes repeated remote signing and timestamping. Cache signed payload bytes by exact input SHA256 and signing policy. Paths and release versions do not affect entry identity. Verify content binding, publisher and timestamp before restoring a hit. Sign duplicate inputs once and publish cache entries only after successful verification. Keep product EXEs and package envelopes on the fresh signing path. Persist the cache across release runs and test its native verification in the Windows release lane. Targeted signing tests: 35 passed. Real Azure signing of three mixed binaries took 9.6s cold and 1.8s warm. Warm probes restored identical signed bytes with no signtool calls. Full release performance and cache transfer overhead remain unverified.
1597 lines
74 KiB
YAML
1597 lines
74 KiB
YAML
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/<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/<stable|canary>/*.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/<channel>/<channel>-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/<stable|canary>/<ch>.appinstaller App Installer feed
|
|
# releases/win32/<stable|canary>/*.msixbundle (publish-win32-updater)
|
|
# releases/darwin/<stable|canary>/<channel>-mac.yml electron-updater feed
|
|
# (dmg/zip live once in releases/tag/<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
|
|
# <Store-*.msixbundle> (-f <flightId> for canaries) -id <productId>
|
|
# 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/<tag> —
|
|
# 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/<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/<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/<channel>/<channel>-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/<tag> —
|
|
# 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 <channel>-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/<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 <flightId>),
|
|
# 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/<channel>/<channel>-mac.yml via
|
|
# scripts/r2-release.mjs finalize. The binaries were staged by the build
|
|
# legs (releases/tag/<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/<channel>/<channel>-mac.yml pointing at the
|
|
# already-staged /releases/tag/<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 <tag>` (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/<suite>/ + 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/<tag>/) and the APT feed never lands at
|
|
# releases/termux/<channel>/.
|
|
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"
|