Files
hermes-agent/.github/workflows/desktop-bundled-release.yml
ethernet 6edee205bd perf(signing): reuse verified payload signatures by input hash
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.
2026-09-07 10:33:23 -04:00

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"