Compare commits

..
Author SHA1 Message Date
Bailey Dixon c9ee5348bf Merge origin/dev into feature/release-fast-path 2026-09-01 10:40:01 -04:00
Bailey Dixon ee2a7840a2 test(android): generalize release notice coverage 2026-09-01 10:39:40 -04:00
Bailey Dixon 52dd399565 Merge docs/refine-readme-connection-map-teknium into integration/readme-connection-map-refinement-20260901 2026-09-01 10:38:01 -04:00
Bailey Dixon 86a0421163 docs(release): record tray cache reuse 2026-09-01 10:37:54 -04:00
Bailey Dixon 0e191f946d fix(marketing): refine README connection graphic 2026-09-01 10:37:40 -04:00
Bailey Dixon 065912f2bf fix(release): keep coordinated approval stable-only 2026-09-01 10:37:30 -04:00
Bailey Dixon 075138433e fix(release): report promoted Android signing 2026-09-01 10:35:57 -04:00
Bailey Dixon 92e65bbfc2 Merge origin/dev into feature/release-fast-path
# Conflicts:
#	DEVLOG.md
2026-09-01 10:34:23 -04:00
Bailey Dixon 7ccd4ac4c9 docs(release): document exact-tree promotion 2026-09-01 10:33:49 -04:00
Bailey Dixon 5739e17750 test(ci): cover release fast paths 2026-09-01 10:33:49 -04:00
Bailey Dixon 037f4e91c8 ci(desktop): cache exact tray builds 2026-09-01 10:33:39 -04:00
Bailey Dixon 685c3c5a24 test(android): derive release presentation expectations 2026-09-01 10:33:30 -04:00
Bailey Dixon 6dc18abc4d ci(release): reuse exact-tree verification 2026-09-01 10:33:21 -04:00
Bailey Dixon ee43a87cec feat(release): promote verified Android artifacts 2026-09-01 10:33:09 -04:00
Bailey Dixon ce961b81d9 Merge pull request #529 from Codename-11/fix/desktop-unified-updater
fix(desktop): keep CLI and UI updates in sync
2026-09-01 10:32:16 -04:00
Bailey Dixon c7b392b26f test(desktop): honor injected updater platform 2026-09-01 10:14:52 -04:00
Bailey Dixon 0a04efbbcf chore: merge origin/dev into fix/desktop-unified-updater 2026-09-01 10:09:49 -04:00
Bailey Dixon 11977dad56 fix(release): validate extension tags before creation (#527) 2026-09-01 08:08:37 -04:00
Bailey Dixon c8fea9c061 Merge origin/dev into fix/release-tag-approval 2026-09-01 07:58:08 -04:00
Bailey Dixon b0a8909dc7 Merge pull request #522 from Codename-11/fix/android-tablet-chat-layout
fix(android): adapt Chat and Voice for tablets
2026-09-01 07:56:37 -04:00
Bailey Dixon 57c236e7da fix(release): validate extension tags before creation 2026-09-01 07:48:58 -04:00
Bailey Dixon 8156a821eb chore: refresh tablet layout branch from origin/dev
# Conflicts:
#	CHANGELOG.md
2026-09-01 07:38:43 -04:00
Bailey Dixon b015acb063 fix(desktop): keep CLI and UI updates in sync 2026-09-01 07:18:19 -04:00
Bailey Dixon 2f7bd843bc docs: note Android tablet layout improvements 2026-08-31 21:32:50 -04:00
Bailey Dixon d1527d47e4 chore: refresh tablet layout branch from origin/dev 2026-08-31 21:00:44 -04:00
Bailey Dixon bc0853360a fix(android): adapt chat and voice for tablets 2026-08-31 21:00:07 -04:00
Bailey Dixon 35362b8895 chore: refresh tablet layout branch from origin/dev 2026-08-31 20:06:05 -04:00
Bailey Dixon ff7ca89b90 fix(android): improve tablet chat layout 2026-08-31 20:06:05 -04:00
41 changed files with 2291 additions and 492 deletions
+7
View File
@@ -4,6 +4,7 @@ function classifyCiPaths(paths) {
const forceAll = paths.some((path) => [
'.github/workflows/ci-required.yml',
'.github/workflows/release-backmerge.yml',
'.github/workflows/approve-release-train.yml',
'.github/scripts/classify-ci-paths.cjs',
'.github/scripts/classify-ci-paths.test.cjs',
'scripts/plan_release_backmerge.py',
@@ -18,10 +19,12 @@ function classifyCiPaths(paths) {
'scripts/check-android-locales.py', 'scripts/android-locale-harness.py',
'scripts/check-android-collection-apis.py', 'scripts/check-android-native-compat.py',
'scripts/check-android-release-notes.py',
'scripts/android_release_artifacts.py',
'scripts/android-lane.ps1', 'scripts/android-prepush.py', 'scripts/dev.bat', 'scripts/dev.sh',
'scripts/tests/android_prepush_test.py',
'scripts/tests/check_android_native_compat_test.py',
'scripts/tests/check_android_release_notes_test.py',
'scripts/tests/android_release_artifacts_test.py',
'.github/workflows/android-on-demand.yml', '.github/workflows/ci-android.yml',
'.github/workflows/play-preflight-android.yml',
'.github/workflows/approve-release-android.yml',
@@ -29,12 +32,16 @@ function classifyCiPaths(paths) {
]),
desktop: forceAll || under(['desktop/']) || exact([
'.github/workflows/ci-desktop.yml',
'.github/workflows/approve-release-extensions.yml',
'.github/workflows/release-cli.yml',
]),
plugin: forceAll || paths.some((path) => /^plugin\/[^/]+\.py$/.test(path)) ||
under(['plugin/relay/', 'plugin/tools/', 'plugin/tests/', 'relay_server/', 'hermes_relay_bootstrap/']) || exact([
'plugin/plugin.yaml', 'pyproject.toml', 'scripts/check-plugin-version-sync.py',
'scripts/check-server-version-sync.py', 'scripts/bump-plugin-version.sh',
'scripts/bump-server-version.sh', '.github/workflows/ci-plugin.yml',
'.github/workflows/approve-release-extensions.yml',
'.github/workflows/release-plugin.yml',
]),
dashboard: forceAll || under(['plugin/dashboard/']) || exact([
'.github/workflows/ci-dashboard.yml',
@@ -1,6 +1,8 @@
'use strict';
const assert = require('node:assert/strict');
const { readFileSync } = require('node:fs');
const { join } = require('node:path');
const { classifyCiPaths } = require('./classify-ci-paths.cjs');
const none = {
@@ -17,6 +19,8 @@ assert.deepEqual(classifyCiPaths(['desktop/src/cli.ts']), { ...none, desktop: tr
assert.deepEqual(classifyCiPaths(['relay-core/src/main/kotlin/Wire.kt']), { ...none, android: true });
assert.deepEqual(classifyCiPaths(['scripts/check-android-release-notes.py']), { ...none, android: true });
assert.deepEqual(classifyCiPaths(['scripts/check-android-native-compat.py']), { ...none, android: true });
assert.deepEqual(classifyCiPaths(['scripts/android_release_artifacts.py']), { ...none, android: true });
assert.deepEqual(classifyCiPaths(['scripts/tests/android_release_artifacts_test.py']), { ...none, android: true });
assert.deepEqual(classifyCiPaths(['scripts/tests/check_android_native_compat_test.py']), { ...none, android: true });
assert.deepEqual(classifyCiPaths(['scripts/android-lane.ps1']), { ...none, android: true });
assert.deepEqual(classifyCiPaths(['scripts/android-prepush.py']), { ...none, android: true });
@@ -24,6 +28,13 @@ assert.deepEqual(classifyCiPaths(['scripts/dev.bat']), { ...none, android: true
assert.deepEqual(classifyCiPaths(['scripts/dev.sh']), { ...none, android: true });
assert.deepEqual(classifyCiPaths(['scripts/tests/android_prepush_test.py']), { ...none, android: true });
assert.deepEqual(classifyCiPaths(['.github/workflows/android-on-demand.yml']), { ...none, android: true });
assert.deepEqual(classifyCiPaths(['.github/workflows/approve-release-extensions.yml']), {
...none,
desktop: true,
plugin: true,
});
assert.deepEqual(classifyCiPaths(['.github/workflows/release-cli.yml']), { ...none, desktop: true });
assert.deepEqual(classifyCiPaths(['.github/workflows/release-plugin.yml']), { ...none, plugin: true });
assert.deepEqual(classifyCiPaths(['plugin/relay/server.py']), { ...none, plugin: true });
assert.deepEqual(classifyCiPaths(['plugin/dashboard/src/App.tsx']), { ...none, dashboard: true });
assert.deepEqual(classifyCiPaths(['user-docs/index.md']), { ...none, docs: true });
@@ -47,5 +58,73 @@ assert.deepEqual(classifyCiPaths(['.github/workflows/release-backmerge.yml']), {
contract: true,
docs: true,
});
assert.deepEqual(classifyCiPaths(['.github/workflows/approve-release-train.yml']), {
android: true,
desktop: true,
plugin: true,
dashboard: true,
contract: true,
docs: true,
});
const repoRoot = join(__dirname, '..', '..');
const approvalWorkflow = readFileSync(
join(repoRoot, '.github', 'workflows', 'approve-release-extensions.yml'),
'utf8',
);
const cliReleaseWorkflow = readFileSync(
join(repoRoot, '.github', 'workflows', 'release-cli.yml'),
'utf8',
);
const pluginReleaseWorkflow = readFileSync(
join(repoRoot, '.github', 'workflows', 'release-plugin.yml'),
'utf8',
);
const desktopCiWorkflow = readFileSync(
join(repoRoot, '.github', 'workflows', 'ci-desktop.yml'),
'utf8',
);
const androidPreflightWorkflow = readFileSync(
join(repoRoot, '.github', 'workflows', 'play-preflight-android.yml'),
'utf8',
);
const androidApprovalWorkflow = readFileSync(
join(repoRoot, '.github', 'workflows', 'approve-release-android.yml'),
'utf8',
);
const androidReleaseWorkflow = readFileSync(
join(repoRoot, '.github', 'workflows', 'release-android.yml'),
'utf8',
);
const requiredChecksWorkflow = readFileSync(
join(repoRoot, '.github', 'workflows', 'ci-required.yml'),
'utf8',
);
const releaseTrainWorkflow = readFileSync(
join(repoRoot, '.github', 'workflows', 'approve-release-train.yml'),
'utf8',
);
assert.match(approvalWorkflow, /permissions:\r?\n contents: read/);
assert.match(
approvalWorkflow,
/approve:[\s\S]*?permissions:\r?\n actions: write\r?\n contents: write/,
);
assert.match(
approvalWorkflow,
/ref: \$\{\{ contains\(inputs\.version, '-'\) && 'dev' \|\| 'main' \}\}/,
);
assert.match(cliReleaseWorkflow, /workflow_dispatch:[\s\S]*?Approved CLI\+UI version/);
assert.match(cliReleaseWorkflow, /name: Restore exact-source tray build cache[\s\S]*?actions\/cache@v5/);
assert.match(desktopCiWorkflow, /name: Restore exact-source tray build cache[\s\S]*?actions\/cache@v5/);
assert.match(pluginReleaseWorkflow, /workflow_dispatch:[\s\S]*?Approved Plugin version/);
assert.match(androidPreflightWorkflow, /Package immutable preflight artifacts/);
assert.match(androidApprovalWorkflow, /Android public approval accepts stable SemVer only/);
assert.match(androidReleaseWorkflow, /Download exact stable preflight artifacts/);
assert.match(androidReleaseWorkflow, /artifact-ids: \$\{\{ needs\.validate\.outputs\.preflight_artifact_id \}\}/);
assert.match(requiredChecksWorkflow, /name: Reuse exact-tree required checks/);
assert.match(requiredChecksWorkflow, /name: required-checks-\$\{\{ needs\.changes\.outputs\.tree \}\}/);
assert.match(releaseTrainWorkflow, /name: Hermes-Relay Coordinated Release Approval/);
assert.match(releaseTrainWorkflow, /Coordinated Android approval is stable-only/);
console.log('CI path classification tests passed.');
+16 -4
View File
@@ -40,6 +40,10 @@ jobs:
echo "::error::Hermes-Relay Android Release Approval must run from main, not $GITHUB_REF"
exit 1
fi
if [[ ! "$REQUESTED_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Android public approval accepts stable SemVer only: $REQUESTED_VERSION"
exit 1
fi
TOML_VERSION=$(grep -oP 'appVersionName\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
if [ "$REQUESTED_VERSION" != "$TOML_VERSION" ]; then
echo "::error::Requested version $REQUESTED_VERSION does not match appVersionName $TOML_VERSION"
@@ -56,13 +60,21 @@ jobs:
RELEASE_TREE: ${{ steps.metadata.outputs.tree }}
run: |
ARTIFACT_NAME="play-preflight-${VERSION}-${RELEASE_TREE}"
COUNT=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${ARTIFACT_NAME}" \
--jq '[.artifacts[] | select(.expired == false)] | length')
if [ "$COUNT" -lt 1 ]; then
ARTIFACT=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${ARTIFACT_NAME}" \
--jq '[.artifacts[] | select(.expired == false)] | sort_by(.created_at) | last')
RUN_ID=$(jq -r '.workflow_run.id // empty' <<<"$ARTIFACT")
if [ -z "$RUN_ID" ]; then
echo "::error::No successful Play preflight found for version $VERSION with tree $RELEASE_TREE"
exit 1
fi
echo "Verified Play preflight proof: $ARTIFACT_NAME"
RUN=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}")
CONCLUSION=$(jq -r '.conclusion' <<<"$RUN")
WORKFLOW_PATH=$(jq -r '.path' <<<"$RUN")
if [ "$WORKFLOW_PATH" != ".github/workflows/play-preflight-android.yml" ] || [ "$CONCLUSION" != "success" ]; then
echo "::error::Preflight artifact came from ${WORKFLOW_PATH} with conclusion ${CONCLUSION}"
exit 1
fi
echo "Verified immutable Play preflight artifacts: $ARTIFACT_NAME (run $RUN_ID)"
- name: Ensure release tag does not already exist
env:
@@ -0,0 +1,158 @@
name: Hermes-Relay Plugin and CLI+UI Release Approval
on:
workflow_dispatch:
inputs:
surface:
description: "Release surface"
required: true
type: choice
options:
- plugin
- desktop
version:
description: "Approved version (for example 1.11.2 or 0.4.0-beta.7)"
required: true
type: string
permissions:
contents: read
concurrency:
group: approve-${{ inputs.surface }}-release
cancel-in-progress: false
jobs:
validate:
name: Validate release source and metadata
runs-on: ubuntu-latest
outputs:
source_branch: ${{ steps.metadata.outputs.source_branch }}
source_sha: ${{ steps.metadata.outputs.source_sha }}
tag: ${{ steps.metadata.outputs.tag }}
workflow: ${{ steps.metadata.outputs.workflow }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ contains(inputs.version, '-') && 'dev' || 'main' }}
- name: Validate approval request
id: metadata
env:
REQUESTED_SURFACE: ${{ inputs.surface }}
REQUESTED_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [[ ! "$REQUESTED_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Version must be SemVer with an optional prerelease suffix: $REQUESTED_VERSION"
exit 1
fi
if [[ "$REQUESTED_VERSION" == *-* ]]; then
source_branch="dev"
else
source_branch="main"
fi
if [ "$GITHUB_REF" != "refs/heads/main" ]; then
echo "::error::Release approval must run from the trusted main workflow definition, not $GITHUB_REF"
exit 1
fi
git fetch origin "$source_branch" --no-tags
source_sha="$(git rev-parse HEAD)"
expected_sha="$(git rev-parse FETCH_HEAD)"
if [ "$source_sha" != "$expected_sha" ]; then
echo "::error::Checked out $source_sha, but origin/$source_branch is $expected_sha"
exit 1
fi
case "$REQUESTED_SURFACE" in
plugin)
tag="server-v${REQUESTED_VERSION}"
workflow="release-plugin.yml"
python3 scripts/check-plugin-version-sync.py --expect "$REQUESTED_VERSION"
if ! grep -Eq "^## \[Plugin ${REQUESTED_VERSION}\]" CHANGELOG.md; then
echo "::error::CHANGELOG.md has no Plugin release heading for $REQUESTED_VERSION"
exit 1
fi
;;
desktop)
tag="desktop-v${REQUESTED_VERSION}"
workflow="release-cli.yml"
node desktop/scripts/cli-version-sync.mjs --expect "$REQUESTED_VERSION"
if ! grep -Fq "## [$REQUESTED_VERSION]" CHANGELOG.md; then
echo "::error::CHANGELOG.md has no CLI+UI release heading for $REQUESTED_VERSION"
exit 1
fi
;;
*)
echo "::error::Unsupported release surface: $REQUESTED_SURFACE"
exit 1
;;
esac
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "workflow=$workflow" >> "$GITHUB_OUTPUT"
echo "source_branch=$source_branch" >> "$GITHUB_OUTPUT"
echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT"
approve:
name: Create release tag and start publication
needs: validate
permissions:
actions: write
contents: write
runs-on: ubuntu-latest
steps:
- name: Verify release source has not moved
env:
GH_TOKEN: ${{ github.token }}
SOURCE_BRANCH: ${{ needs.validate.outputs.source_branch }}
SOURCE_SHA: ${{ needs.validate.outputs.source_sha }}
run: |
current_sha=$(gh api "/repos/${GITHUB_REPOSITORY}/git/ref/heads/${SOURCE_BRANCH}" --jq .object.sha)
if [ "$current_sha" != "$SOURCE_SHA" ]; then
echo "::error::$SOURCE_BRANCH moved from $SOURCE_SHA to $current_sha; run approval again"
exit 1
fi
- name: Ensure release tag does not already exist
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
run: |
if gh api "/repos/${GITHUB_REPOSITORY}/git/ref/tags/${RELEASE_TAG}" >/dev/null 2>&1; then
echo "::error::Tag $RELEASE_TAG already exists"
exit 1
fi
- name: Create approved release tag
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
RELEASE_SHA: ${{ needs.validate.outputs.source_sha }}
run: |
gh api --method POST "/repos/${GITHUB_REPOSITORY}/git/refs" \
-f ref="refs/tags/${RELEASE_TAG}" \
-f sha="$RELEASE_SHA"
- name: Start the immutable tag release workflow
env:
GH_TOKEN: ${{ github.token }}
RELEASE_WORKFLOW: ${{ needs.validate.outputs.workflow }}
RELEASE_VERSION: ${{ inputs.version }}
run: |
# A tag created by GITHUB_TOKEN does not recursively start workflows.
# Dispatch the trusted definition from main; release jobs check out
# and validate the immutable tag created above.
gh workflow run "$RELEASE_WORKFLOW" \
--repo "$GITHUB_REPOSITORY" \
--ref main \
-f version="$RELEASE_VERSION"
- name: Approval summary
run: |
echo "## Release approved" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Created \`${{ needs.validate.outputs.tag }}\` from \`${{ needs.validate.outputs.source_branch }}\` at \`${{ needs.validate.outputs.source_sha }}\`." >> "$GITHUB_STEP_SUMMARY"
echo "Dispatched \`${{ needs.validate.outputs.workflow }}\` to validate and publish that immutable tag." >> "$GITHUB_STEP_SUMMARY"
+120
View File
@@ -0,0 +1,120 @@
name: Hermes-Relay Coordinated Release Approval
on:
workflow_dispatch:
inputs:
android:
description: "Approve Hermes-Relay Android"
required: true
default: false
type: boolean
android_version:
description: "Android version when selected"
required: false
type: string
plugin:
description: "Approve Hermes-Relay Plugin"
required: true
default: false
type: boolean
plugin_version:
description: "Plugin version when selected"
required: false
type: string
desktop:
description: "Approve Hermes-Relay CLI+UI"
required: true
default: false
type: boolean
desktop_version:
description: "CLI+UI version when selected"
required: false
type: string
permissions:
actions: write
contents: read
concurrency:
group: approve-coordinated-release
cancel-in-progress: false
jobs:
validate:
name: Validate selected release surfaces
runs-on: ubuntu-latest
steps:
- name: Require versions for every selected surface
env:
ANDROID: ${{ inputs.android }}
ANDROID_VERSION: ${{ inputs.android_version }}
PLUGIN: ${{ inputs.plugin }}
PLUGIN_VERSION: ${{ inputs.plugin_version }}
DESKTOP: ${{ inputs.desktop }}
DESKTOP_VERSION: ${{ inputs.desktop_version }}
run: |
set -euo pipefail
if [ "$ANDROID" != "true" ] && [ "$PLUGIN" != "true" ] && [ "$DESKTOP" != "true" ]; then
echo "::error::Select at least one release surface"
exit 1
fi
for pair in \
"$ANDROID:$ANDROID_VERSION:Android" \
"$PLUGIN:$PLUGIN_VERSION:Plugin" \
"$DESKTOP:$DESKTOP_VERSION:CLI+UI"; do
IFS=: read -r selected version label <<<"$pair"
if [ "$selected" = "true" ] && [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::$label requires a valid SemVer version"
exit 1
fi
done
if [ "$ANDROID" = "true" ] && [[ "$ANDROID_VERSION" == *-* ]]; then
echo "::error::Coordinated Android approval is stable-only; use a dev candidate tag for prereleases"
exit 1
fi
android:
name: Approve Hermes-Relay Android
needs: validate
if: inputs.android
runs-on: ubuntu-latest
steps:
- name: Dispatch Android approval
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.android_version }}
run: gh workflow run approve-release-android.yml --repo "$GITHUB_REPOSITORY" --ref main -f version="$VERSION"
plugin:
name: Approve Hermes-Relay Plugin
needs: validate
if: inputs.plugin
runs-on: ubuntu-latest
steps:
- name: Dispatch Plugin approval
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.plugin_version }}
run: |
gh workflow run approve-release-extensions.yml \
--repo "$GITHUB_REPOSITORY" \
--ref main \
-f surface=plugin \
-f version="$VERSION"
desktop:
name: Approve Hermes-Relay CLI+UI
needs: validate
if: inputs.desktop
runs-on: ubuntu-latest
steps:
- name: Dispatch CLI+UI approval
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.desktop_version }}
run: |
gh workflow run approve-release-extensions.yml \
--repo "$GITHUB_REPOSITORY" \
--ref main \
-f surface=desktop \
-f version="$VERSION"
+12
View File
@@ -107,6 +107,18 @@ jobs:
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Restore exact-source tray build cache
uses: actions/cache@v5
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
desktop/tray/target
key: ${{ runner.os }}-tray-rust-${{ hashFiles('desktop/tray/Cargo.lock') }}-${{ hashFiles('desktop/tray/Cargo.toml', 'desktop/tray/build.rs', 'desktop/tray/src/**/*.rs') }}
restore-keys: |
${{ runner.os }}-tray-rust-${{ hashFiles('desktop/tray/Cargo.lock') }}-
- name: Install deps
run: npm ci && npm --prefix tray ci
+138 -16
View File
@@ -34,6 +34,7 @@ on:
- all-final
permissions:
actions: read
contents: read
pull-requests: read
@@ -52,6 +53,8 @@ jobs:
dashboard: ${{ steps.filter.outputs.dashboard }}
contract: ${{ steps.filter.outputs.contract }}
docs: ${{ steps.filter.outputs.docs }}
release_pr: ${{ steps.release.outputs.release_pr }}
tree: ${{ steps.tree.outputs.tree }}
steps:
- name: Checkout pull request merge
if: github.event_name == 'pull_request'
@@ -69,6 +72,26 @@ jobs:
- name: Test path classifier
run: node .github/scripts/classify-ci-paths.test.cjs
- name: Record checked tree
id: tree
run: echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
- name: Detect canonical release promotion
id: release
env:
BASE_REF: ${{ github.base_ref }}
HEAD_REF: ${{ github.head_ref }}
HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
run: |
if [ "$GITHUB_EVENT_NAME" = "pull_request" ] && \
[ "$BASE_REF" = "main" ] && \
[ "$HEAD_REF" = "dev" ] && \
[ "$HEAD_REPOSITORY" = "$GITHUB_REPOSITORY" ]; then
echo "release_pr=true" >> "$GITHUB_OUTPUT"
else
echo "release_pr=false" >> "$GITHUB_OUTPUT"
fi
- name: Classify changed files
id: filter
uses: actions/github-script@v8
@@ -123,43 +146,117 @@ jobs:
core.notice(`Changed paths: ${paths.join(', ')}`);
core.notice(`Selected checks: ${Object.entries(outputs).filter(([, value]) => value).map(([key]) => key).join(', ') || 'none'}`);
android:
release-proof:
name: Reuse exact-tree required checks
needs: changes
if: needs.changes.outputs.android == 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto')
if: needs.changes.outputs.release_pr == 'true'
runs-on: ubuntu-latest
outputs:
reuse: ${{ steps.proof.outputs.reuse }}
artifact_id: ${{ steps.proof.outputs.artifact_id }}
run_id: ${{ steps.proof.outputs.run_id }}
tree: ${{ steps.proof.outputs.tree }}
steps:
- name: Checkout simulated release merge
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Locate exact-tree proof
id: proof
env:
GH_TOKEN: ${{ github.token }}
DEV_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
merge_tree=$(git rev-parse 'HEAD^{tree}')
dev_tree=$(git rev-parse "${DEV_SHA}^{tree}")
echo "reuse=false" >> "$GITHUB_OUTPUT"
echo "tree=$dev_tree" >> "$GITHUB_OUTPUT"
if [ "$merge_tree" != "$dev_tree" ]; then
echo "Release merge changes the dev tree ($dev_tree -> $merge_tree); running full CI."
exit 0
fi
artifact_name="required-checks-${dev_tree}"
artifact=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${artifact_name}" \
--jq '[.artifacts[] | select(.expired == false)] | sort_by(.created_at) | last')
artifact_id=$(jq -r '.id // empty' <<<"$artifact")
run_id=$(jq -r '.workflow_run.id // empty' <<<"$artifact")
if [ -z "$artifact_id" ] || [ -z "$run_id" ]; then
echo "No reusable proof exists for tree $dev_tree; running full CI."
exit 0
fi
run=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}")
conclusion=$(jq -r '.conclusion' <<<"$run")
workflow_path=$(jq -r '.path' <<<"$run")
if [ "$workflow_path" != ".github/workflows/ci-required.yml" ] || [ "$conclusion" != "success" ]; then
echo "::error::Required-check proof came from ${workflow_path} with conclusion ${conclusion}"
exit 1
fi
echo "artifact_id=$artifact_id" >> "$GITHUB_OUTPUT"
echo "run_id=$run_id" >> "$GITHUB_OUTPUT"
echo "reuse=true" >> "$GITHUB_OUTPUT"
- name: Download exact-tree proof
if: steps.proof.outputs.reuse == 'true'
uses: actions/download-artifact@v8
with:
artifact-ids: ${{ steps.proof.outputs.artifact_id }}
github-token: ${{ github.token }}
repository: ${{ github.repository }}
run-id: ${{ steps.proof.outputs.run_id }}
path: required-check-proof
- name: Verify exact-tree proof
if: steps.proof.outputs.reuse == 'true'
env:
EXPECTED_TREE: ${{ steps.proof.outputs.tree }}
run: |
jq -e \
--arg repository "$GITHUB_REPOSITORY" \
--arg tree "$EXPECTED_TREE" \
'.schemaVersion == 1 and .repository == $repository and .tree == $tree' \
required-check-proof/required-checks.json
android:
needs: [changes, release-proof]
if: ${{ always() && needs.changes.result == 'success' && needs.changes.outputs.android == 'true' && needs.release-proof.outputs.reuse != 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto') }}
uses: ./.github/workflows/ci-android.yml
android_on_demand:
needs: changes
if: github.event_name == 'workflow_dispatch' && inputs.android_preset != 'auto'
needs: [changes, release-proof]
if: ${{ always() && needs.changes.result == 'success' && needs.release-proof.outputs.reuse != 'true' && github.event_name == 'workflow_dispatch' && inputs.android_preset != 'auto' }}
uses: ./.github/workflows/android-on-demand.yml
with:
head_sha: ${{ inputs.head_sha }}
preset: ${{ inputs.android_preset }}
desktop:
needs: changes
if: needs.changes.outputs.desktop == 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto')
needs: [changes, release-proof]
if: ${{ always() && needs.changes.result == 'success' && needs.changes.outputs.desktop == 'true' && needs.release-proof.outputs.reuse != 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto') }}
uses: ./.github/workflows/ci-desktop.yml
plugin:
needs: changes
if: needs.changes.outputs.plugin == 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto')
needs: [changes, release-proof]
if: ${{ always() && needs.changes.result == 'success' && needs.changes.outputs.plugin == 'true' && needs.release-proof.outputs.reuse != 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto') }}
uses: ./.github/workflows/ci-plugin.yml
dashboard:
needs: changes
if: needs.changes.outputs.dashboard == 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto')
needs: [changes, release-proof]
if: ${{ always() && needs.changes.result == 'success' && needs.changes.outputs.dashboard == 'true' && needs.release-proof.outputs.reuse != 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto') }}
uses: ./.github/workflows/ci-dashboard.yml
contract:
needs: changes
if: needs.changes.outputs.contract == 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto')
needs: [changes, release-proof]
if: ${{ always() && needs.changes.result == 'success' && needs.changes.outputs.contract == 'true' && needs.release-proof.outputs.reuse != 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto') }}
uses: ./.github/workflows/ci-contract.yml
docs:
name: Build public docs
needs: changes
if: needs.changes.outputs.docs == 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto')
needs: [changes, release-proof]
if: ${{ always() && needs.changes.result == 'success' && needs.changes.outputs.docs == 'true' && needs.release-proof.outputs.reuse != 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto') }}
runs-on: ubuntu-latest
defaults:
run:
@@ -181,10 +278,12 @@ jobs:
guard:
name: Required checks
if: always()
needs: [changes, android, android_on_demand, desktop, plugin, dashboard, contract, docs]
needs: [changes, release-proof, android, android_on_demand, desktop, plugin, dashboard, contract, docs]
runs-on: ubuntu-latest
env:
CHANGES_RESULT: ${{ needs.changes.result }}
RELEASE_PROOF_RESULT: ${{ needs.release-proof.result }}
REUSED_REQUIRED_CHECKS: ${{ needs.release-proof.outputs.reuse }}
ANDROID_RESULT: ${{ needs.android.result }}
ANDROID_ON_DEMAND_RESULT: ${{ needs.android_on_demand.result }}
DESKTOP_RESULT: ${{ needs.desktop.result }}
@@ -197,7 +296,7 @@ jobs:
shell: bash
run: |
failed=0
for check in CHANGES ANDROID ANDROID_ON_DEMAND DESKTOP PLUGIN DASHBOARD CONTRACT DOCS; do
for check in CHANGES RELEASE_PROOF ANDROID ANDROID_ON_DEMAND DESKTOP PLUGIN DASHBOARD CONTRACT DOCS; do
result_var="${check}_RESULT"
result="${!result_var}"
echo "$check: $result"
@@ -207,3 +306,26 @@ jobs:
esac
done
exit "$failed"
- name: Write exact-tree proof
if: ${{ needs.release-proof.outputs.reuse != 'true' }}
env:
CHECKED_TREE: ${{ needs.changes.outputs.tree }}
run: |
mkdir -p required-check-proof
jq -n \
--arg repository "$GITHUB_REPOSITORY" \
--arg tree "$CHECKED_TREE" \
--arg commit "$GITHUB_SHA" \
--arg run_id "$GITHUB_RUN_ID" \
'{schemaVersion: 1, repository: $repository, tree: $tree, commit: $commit, runId: $run_id}' \
> required-check-proof/required-checks.json
- name: Upload exact-tree proof
if: ${{ needs.release-proof.outputs.reuse != 'true' }}
uses: actions/upload-artifact@v7
with:
name: required-checks-${{ needs.changes.outputs.tree }}
path: required-check-proof/required-checks.json
if-no-files-found: error
retention-days: 30
+32 -20
View File
@@ -2,10 +2,11 @@
#
# Run manually from the final dev or untagged main tree before creating
# android-v*. The job
# builds the same signed release artifacts, scans final DEX, and uploads the
# Google Play bundle as a production DRAFT. A successful upload is the automated
# Play gate while no public GitHub Release or sideload APK exists. Console-only
# pre-review and pre-launch reports are informational and do not block release.
# builds the signed release artifacts once, scans the final packages, and uploads
# the Google Play bundle as a production DRAFT. The exact signed APK/AAB,
# mappings, manifest, and checksums remain private Actions artifacts until
# approval publishes those same bytes. Console-only pre-review and pre-launch
# reports are informational and do not block release.
name: Hermes-Relay Android Play Preflight
@@ -98,7 +99,12 @@ jobs:
HERMES_KEYSTORE_PASSWORD: ${{ secrets.HERMES_KEYSTORE_PASSWORD }}
HERMES_KEY_ALIAS: ${{ secrets.HERMES_KEY_ALIAS }}
HERMES_KEY_PASSWORD: ${{ secrets.HERMES_KEY_PASSWORD }}
run: ./gradlew bundleRelease assembleRelease --console=plain
run: |
./gradlew \
:app:bundleGooglePlayRelease \
:app:assembleGooglePlayRelease \
:app:assembleSideloadRelease \
--console=plain
- name: Scan final release DEX
run: |
@@ -106,6 +112,12 @@ jobs:
--apk app/build/outputs/apk/googlePlay/release/*.apk \
--apk app/build/outputs/apk/sideload/release/*.apk
- name: Verify packaged native compatibility
run: |
python3 scripts/check-android-native-compat.py \
app/build/outputs/apk/googlePlay/release/*.apk \
app/build/outputs/apk/sideload/release/*.apk
- name: Upload private production draft to Play
env:
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
@@ -121,27 +133,27 @@ jobs:
--resolution-strategy=ignore \
--release-name="Hermes-Relay Android v${{ steps.metadata.outputs.version }}"
- name: Record successful preflight for the exact commit
- name: Package immutable preflight artifacts
run: |
mkdir -p app/build/reports
cat > app/build/reports/play-preflight.json <<EOF
{
"version": "${{ steps.metadata.outputs.version }}",
"versionCode": "${{ steps.metadata.outputs.version_code }}",
"commit": "$GITHUB_SHA",
"tree": "${{ steps.metadata.outputs.tree }}",
"track": "production",
"status": "draft"
}
EOF
python3 scripts/android_release_artifacts.py package \
--version "${{ steps.metadata.outputs.version }}" \
--version-code "${{ steps.metadata.outputs.version_code }}" \
--commit "$GITHUB_SHA" \
--tree "${{ steps.metadata.outputs.tree }}" \
--sideload-apk app/build/outputs/apk/sideload/release/*.apk \
--google-play-aab app/build/outputs/bundle/googlePlayRelease/*.aab \
--sideload-mapping app/build/outputs/mapping/sideloadRelease/mapping.txt \
--google-play-mapping app/build/outputs/mapping/googlePlayRelease/mapping.txt \
--output app/build/preflight-artifacts
- name: Upload preflight proof
- name: Upload immutable preflight artifacts
uses: actions/upload-artifact@v7
with:
name: play-preflight-${{ steps.metadata.outputs.version }}-${{ steps.metadata.outputs.tree }}
path: app/build/reports/play-preflight.json
path: app/build/preflight-artifacts/*
if-no-files-found: error
retention-days: 30
compression-level: 0
- name: Preflight summary
run: |
@@ -152,4 +164,4 @@ jobs:
echo "- Release tree: \`${{ steps.metadata.outputs.tree }}\`" >> "$GITHUB_STEP_SUMMARY"
echo "- Play track/status: **Production draft**" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "The signed build, DEX scan, and Play draft upload passed. Ensure this exact release tree is on main, then run **Hermes-Relay Android Release Approval** from main. Console-only reports are informational and non-blocking." >> "$GITHUB_STEP_SUMMARY"
echo "The signed build, package scans, and Play draft upload passed. Approval will publish these exact private artifacts if the unchanged tree reaches main. Console-only reports are informational and non-blocking." >> "$GITHUB_STEP_SUMMARY"
+61 -57
View File
@@ -36,6 +36,9 @@ jobs:
version: ${{ steps.version.outputs.version }}
version_code: ${{ steps.version.outputs.version_code }}
prerelease: ${{ steps.version.outputs.prerelease }}
release_tree: ${{ steps.version.outputs.release_tree }}
preflight_artifact_id: ${{ steps.preflight.outputs.artifact_id }}
preflight_run_id: ${{ steps.preflight.outputs.run_id }}
steps:
- uses: actions/checkout@v7
with:
@@ -66,6 +69,7 @@ jobs:
echo "version=$REF_VERSION" >> "$GITHUB_OUTPUT"
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
echo "release_tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
- name: Verify version sync
run: |
@@ -110,25 +114,38 @@ jobs:
fi
- name: Require successful Play preflight for this exact release tree
id: preflight
if: ${{ !contains(steps.version.outputs.version, '-') }}
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.version.outputs.version }}
run: |
RELEASE_TREE=$(git rev-parse 'HEAD^{tree}')
RELEASE_TREE="${{ steps.version.outputs.release_tree }}"
ARTIFACT_NAME="play-preflight-${VERSION}-${RELEASE_TREE}"
COUNT=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${ARTIFACT_NAME}" \
--jq '[.artifacts[] | select(.expired == false)] | length')
if [ "$COUNT" -lt 1 ]; then
ARTIFACT=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${ARTIFACT_NAME}" \
--jq '[.artifacts[] | select(.expired == false)] | sort_by(.created_at) | last')
ARTIFACT_ID=$(jq -r '.id // empty' <<<"$ARTIFACT")
RUN_ID=$(jq -r '.workflow_run.id // empty' <<<"$ARTIFACT")
if [ -z "$ARTIFACT_ID" ] || [ -z "$RUN_ID" ]; then
echo "::error::No successful Play preflight found for version $VERSION with tree $RELEASE_TREE"
echo "Run Hermes-Relay Android Play Preflight from the final dev tree, merge that unchanged tree to main, then approve the release."
exit 1
fi
echo "Play preflight proof found: $ARTIFACT_NAME"
RUN=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}")
CONCLUSION=$(jq -r '.conclusion' <<<"$RUN")
WORKFLOW_PATH=$(jq -r '.path' <<<"$RUN")
if [ "$WORKFLOW_PATH" != ".github/workflows/play-preflight-android.yml" ] || [ "$CONCLUSION" != "success" ]; then
echo "::error::Preflight artifact came from ${WORKFLOW_PATH} with conclusion ${CONCLUSION}"
exit 1
fi
echo "artifact_id=$ARTIFACT_ID" >> "$GITHUB_OUTPUT"
echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT"
echo "Play preflight artifacts verified: $ARTIFACT_NAME (run $RUN_ID, artifact $ARTIFACT_ID)"
ci:
name: CI Checks
name: CI Checks (prerelease only)
needs: validate
if: ${{ needs.validate.outputs.prerelease == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -168,6 +185,7 @@ jobs:
release:
name: Build & Publish Release
needs: [validate, ci]
if: ${{ always() && needs.validate.result == 'success' && (needs.ci.result == 'success' || needs.ci.result == 'skipped') }}
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -186,31 +204,33 @@ jobs:
with:
cache-read-only: false
- name: Decode release keystore
- name: Download exact stable preflight artifacts
if: ${{ needs.validate.outputs.prerelease != 'true' }}
uses: actions/download-artifact@v8
with:
artifact-ids: ${{ needs.validate.outputs.preflight_artifact_id }}
github-token: ${{ github.token }}
repository: ${{ github.repository }}
run-id: ${{ needs.validate.outputs.preflight_run_id }}
path: app/build/preflight-artifacts
- name: Verify exact stable preflight artifacts
if: ${{ needs.validate.outputs.prerelease != 'true' }}
run: |
python3 scripts/android_release_artifacts.py verify \
--version "${{ needs.validate.outputs.version }}" \
--version-code "${{ needs.validate.outputs.version_code }}" \
--tree "${{ needs.validate.outputs.release_tree }}" \
--directory app/build/preflight-artifacts
- name: Decode release keystore for candidate build
env:
HERMES_KEYSTORE_BASE64: ${{ secrets.HERMES_KEYSTORE_BASE64 }}
if: env.HERMES_KEYSTORE_BASE64 != ''
if: ${{ needs.validate.outputs.prerelease == 'true' && env.HERMES_KEYSTORE_BASE64 != '' }}
run: |
echo "$HERMES_KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.keystore"
echo "HERMES_KEYSTORE_PATH=$RUNNER_TEMP/release.keystore" >> "$GITHUB_ENV"
- name: Build stable release artifacts (APK + AAB)
if: ${{ needs.validate.outputs.prerelease != 'true' }}
env:
HERMES_KEYSTORE_PASSWORD: ${{ secrets.HERMES_KEYSTORE_PASSWORD }}
HERMES_KEY_ALIAS: ${{ secrets.HERMES_KEY_ALIAS }}
HERMES_KEY_PASSWORD: ${{ secrets.HERMES_KEY_PASSWORD }}
# `assembleRelease` and `bundleRelease` are flavor-wide task aliases
# (added by `flavorDimensions += "track"` in app/build.gradle.kts), so
# this one line builds ALL four artifacts at once. Filenames come from
# `archivesName` (set in app/build.gradle.kts) which injects the app
# version, so `<version>` below is `libs.versions.appVersionName`:
# app/build/outputs/apk/googlePlay/release/hermes-relay-<version>-googlePlay-release.apk
# app/build/outputs/apk/sideload/release/hermes-relay-<version>-sideload-release.apk
# app/build/outputs/bundle/googlePlayRelease/hermes-relay-<version>-googlePlay-release.aab
# app/build/outputs/bundle/sideloadRelease/hermes-relay-<version>-sideload-release.aab
run: ./gradlew bundleRelease assembleRelease
- name: Build side-by-side release candidate APK
if: ${{ needs.validate.outputs.prerelease == 'true' }}
env:
@@ -234,10 +254,10 @@ jobs:
if: ${{ needs.validate.outputs.prerelease != 'true' }}
uses: actions/upload-artifact@v7
with:
name: android-r8-mappings-${{ needs.validate.outputs.version }}-${{ github.sha }}
name: android-r8-mappings-${{ needs.validate.outputs.version }}-${{ needs.validate.outputs.release_tree }}
path: |
app/build/outputs/mapping/googlePlayRelease/mapping.txt
app/build/outputs/mapping/sideloadRelease/mapping.txt
app/build/preflight-artifacts/mapping-googlePlayRelease.txt
app/build/preflight-artifacts/mapping-sideloadRelease.txt
if-no-files-found: error
retention-days: 90
@@ -254,8 +274,7 @@ jobs:
if: ${{ needs.validate.outputs.prerelease != 'true' }}
run: |
python3 scripts/check-android-collection-apis.py \
--apk app/build/outputs/apk/googlePlay/release/*.apk \
--apk app/build/outputs/apk/sideload/release/*.apk
--apk app/build/preflight-artifacts/*-sideload-release.apk
- name: Scan candidate DEX for unsupported collection APIs
if: ${{ needs.validate.outputs.prerelease == 'true' }}
@@ -267,8 +286,7 @@ jobs:
if: ${{ needs.validate.outputs.prerelease != 'true' }}
run: |
python3 scripts/check-android-native-compat.py \
app/build/outputs/apk/googlePlay/release/*.apk \
app/build/outputs/apk/sideload/release/*.apk
app/build/preflight-artifacts/*-sideload-release.apk
- name: Verify candidate packaged ONNX Runtime compatibility
if: ${{ needs.validate.outputs.prerelease == 'true' }}
@@ -278,23 +296,10 @@ jobs:
- name: List produced artifacts (debug aid)
run: |
echo "=== APK outputs ==="
echo "=== Reused stable artifacts ==="
find app/build/preflight-artifacts -maxdepth 1 -type f -print 2>/dev/null || true
echo "=== Candidate APK outputs ==="
find app/build/outputs/apk -name '*.apk' -print 2>/dev/null || true
echo "=== AAB outputs ==="
find app/build/outputs/bundle -name '*.aab' -print 2>/dev/null || true
- name: Generate stable checksums
if: ${{ needs.validate.outputs.prerelease != 'true' }}
# Flavor dimension adds an extra path segment to the AGP output layout.
# APKs live under `apk/<flavor>/release/`, AABs under `bundle/<flavor>Release/`
# (note the concatenated camelCase — AGP path quirk, documented but
# different between APK and AAB). Checksums cover EXACTLY the files
# attached to the GitHub Release (see the 2-asset policy on the
# release step below) so SHA256SUMS.txt matches the assets 1:1.
run: |
cd app/build/outputs
sha256sum apk/sideload/release/*.apk bundle/googlePlayRelease/*.aab > SHA256SUMS.txt
cat SHA256SUMS.txt
- name: Generate candidate checksums
if: ${{ needs.validate.outputs.prerelease == 'true' }}
@@ -340,9 +345,9 @@ jobs:
# Deliberate 2-asset policy (#144): attach ONLY the installable
# sideload APK and Play AAB, plus checksums covering those files.
files: |
app/build/outputs/apk/sideload/release/*.apk
app/build/outputs/bundle/googlePlayRelease/*.aab
app/build/outputs/SHA256SUMS.txt
app/build/preflight-artifacts/*-sideload-release.apk
app/build/preflight-artifacts/*-googlePlay-release.aab
app/build/preflight-artifacts/SHA256SUMS.txt
- name: Create candidate GitHub prerelease
if: ${{ needs.validate.outputs.prerelease == 'true' }}
@@ -364,18 +369,17 @@ jobs:
run: |
echo "## Hermes-Relay Android v${{ needs.validate.outputs.version }}" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
if [ "$PRERELEASE" = "true" ] && [ -n "$HERMES_KEYSTORE_BASE64" ]; then
echo "✅ **Release-signed HR Candidate app** — separate package ID; never uploaded to Play" >> "$GITHUB_STEP_SUMMARY"
elif [ "$PRERELEASE" = "true" ]; then
echo "⚠️ **Debug-signed HR Candidate app** — separate package ID; never uploaded to Play" >> "$GITHUB_STEP_SUMMARY"
if [ "$PRERELEASE" != "true" ]; then
echo "✅ **Published the exact signed Play-preflight artifacts**" >> "$GITHUB_STEP_SUMMARY"
elif [ -n "$HERMES_KEYSTORE_BASE64" ]; then
echo "✅ **Signed with release keystore** — suitable for Play Store upload" >> "$GITHUB_STEP_SUMMARY"
echo "✅ **Release-signed HR Candidate app** — separate package ID; never uploaded to Play" >> "$GITHUB_STEP_SUMMARY"
else
echo "⚠️ **Debug-signed** (no \`HERMES_KEYSTORE_BASE64\` secret) — NOT suitable for Play Store. Add the secret in repo settings to enable release signing." >> "$GITHUB_STEP_SUMMARY"
echo "⚠️ **Debug-signed HR Candidate app** — separate package ID; never uploaded to Play" >> "$GITHUB_STEP_SUMMARY"
fi
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "### Artifacts" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
find app/build/preflight-artifacts -maxdepth 1 -type f -exec ls -la {} + >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
find app/build/outputs/apk -name '*.apk' -exec ls -la {} + >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
find app/build/outputs/bundle -name '*.aab' -exec ls -la {} + >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
echo '```' >> "$GITHUB_STEP_SUMMARY"
+49 -15
View File
@@ -3,6 +3,12 @@ name: Hermes-Relay CLI+UI Release
on:
push:
tags: ['desktop-v*']
workflow_dispatch:
inputs:
version:
description: "Approved CLI+UI version"
required: true
type: string
permissions:
contents: write
@@ -20,6 +26,7 @@ jobs:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && format('desktop-v{0}', inputs.version) || github.ref }}
- name: Setup Node.js
uses: actions/setup-node@v7
@@ -34,10 +41,16 @@ jobs:
- name: Extract and validate tag version
id: version
shell: bash
env:
DISPATCHED_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#desktop-v}"
if [[ -z "$version" || "$version" == "$GITHUB_REF_NAME" ]]; then
if [ -n "$DISPATCHED_VERSION" ]; then
version="$DISPATCHED_VERSION"
else
version="${GITHUB_REF_NAME#desktop-v}"
fi
if [ -z "$version" ] || { [ -z "$DISPATCHED_VERSION" ] && [ "$version" = "$GITHUB_REF_NAME" ]; }; then
echo "Expected a desktop-v* tag, got $GITHUB_REF_NAME" >&2
exit 1
fi
@@ -51,11 +64,12 @@ jobs:
- name: Verify tag belongs to the correct integration branch
shell: bash
working-directory: .
env:
TAG_VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#desktop-v}"
tag_commit="$(git rev-parse "${GITHUB_REF_NAME}^{commit}")"
if [[ "$version" == *-* ]]; then
tag_commit="$(git rev-parse HEAD)"
if [[ "$TAG_VERSION" == *-* ]]; then
git fetch origin dev --no-tags
if ! git merge-base --is-ancestor "$tag_commit" origin/dev; then
echo "CLI+UI prereleases must be tagged from dev; $tag_commit is not in origin/dev" >&2
@@ -78,6 +92,8 @@ jobs:
working-directory: desktop
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('desktop-v{0}', inputs.version) || github.ref }}
- name: Setup Node.js (for npm ci + tsc)
uses: actions/setup-node@v7
@@ -271,6 +287,8 @@ jobs:
working-directory: desktop
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('desktop-v{0}', inputs.version) || github.ref }}
- name: Setup Node.js
uses: actions/setup-node@v7
@@ -289,6 +307,18 @@ jobs:
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Restore exact-source tray build cache
uses: actions/cache@v5
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
desktop/tray/target
key: ${{ runner.os }}-tray-rust-${{ hashFiles('desktop/tray/Cargo.lock') }}-${{ hashFiles('desktop/tray/Cargo.toml', 'desktop/tray/build.rs', 'desktop/tray/src/**/*.rs') }}
restore-keys: |
${{ runner.os }}-tray-rust-${{ hashFiles('desktop/tray/Cargo.lock') }}-
- name: Install deps
run: npm ci && npm --prefix tray ci
@@ -411,6 +441,11 @@ jobs:
if ($versionOutput -ne "hermes-relay $env:EXPECTED_DESKTOP_VERSION") {
throw "installed CLI version mismatch: expected $env:EXPECTED_DESKTOP_VERSION, got '$versionOutput'"
}
$tray = Join-Path $installDir 'hermes-relay-tray.exe'
$trayVersion = (Get-Item -LiteralPath $tray).VersionInfo.ProductVersion
if ($trayVersion -ne $env:EXPECTED_DESKTOP_VERSION) {
throw "installed UI version mismatch: expected $env:EXPECTED_DESKTOP_VERSION, got '$trayVersion'"
}
$helpOutput = (& $cli --help | Out-String)
if ($LASTEXITCODE -ne 0 -or $helpOutput -notmatch 'Usage:') {
throw 'installed CLI --help smoke failed'
@@ -451,7 +486,7 @@ jobs:
throw "installer lifecycle changed the pre-existing tray startup preference"
}
Write-Host "packaged installer lifecycle smoke OK version=$versionOutput install=$installDir"
Write-Host "packaged installer lifecycle smoke OK cli=$versionOutput ui=$trayVersion install=$installDir"
} finally {
Get-Process -Name 'hermes-relay-tray' -ErrorAction SilentlyContinue |
Stop-Process -Force -ErrorAction SilentlyContinue
@@ -483,6 +518,7 @@ jobs:
name: Publish GitHub Release
runs-on: ubuntu-latest
needs:
- validate-release
- build-cli-binaries
- smoke-windows-cli-release-asset
- smoke-macos-cli-release-asset
@@ -492,10 +528,8 @@ jobs:
# Needed so CLI_RELEASE_NOTES.md is available to render into the release body
# (the other publish-release steps only consume downloaded build artifacts).
- uses: actions/checkout@v7
- name: Extract CLI+UI version
id: version
run: echo "version=${GITHUB_REF_NAME#desktop-v}" >> "$GITHUB_OUTPUT"
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('desktop-v{0}', inputs.version) || github.ref }}
- uses: actions/download-artifact@v8
with:
@@ -515,8 +549,8 @@ jobs:
# (desktop-v0.3.0) so install/pin commands stay accurate without manual edits.
- name: Render release notes
env:
VERSION: ${{ steps.version.outputs.version }}
TAG: ${{ github.ref_name }}
VERSION: ${{ needs.validate-release.outputs.version }}
TAG: desktop-v${{ needs.validate-release.outputs.version }}
run: |
sed -e "s/__VERSION__/${VERSION}/g" -e "s/__TAG__/${TAG}/g" \
CLI_RELEASE_NOTES.md > cli_release_notes_rendered.md
@@ -525,10 +559,10 @@ jobs:
- name: Publish GitHub Release
uses: softprops/action-gh-release@v3
with:
name: Hermes-Relay CLI+UI v${{ steps.version.outputs.version }}
tag_name: ${{ github.ref_name }}
name: Hermes-Relay CLI+UI v${{ needs.validate-release.outputs.version }}
tag_name: desktop-v${{ needs.validate-release.outputs.version }}
draft: false
prerelease: ${{ contains(steps.version.outputs.version, 'alpha') || contains(steps.version.outputs.version, 'beta') || contains(steps.version.outputs.version, 'rc') }}
prerelease: ${{ contains(needs.validate-release.outputs.version, '-') }}
fail_on_unmatched_files: true
body_path: cli_release_notes_rendered.md
files: |
+20 -1
View File
@@ -4,6 +4,12 @@ on:
push:
tags:
- "server-v*"
workflow_dispatch:
inputs:
version:
description: "Approved Plugin version"
required: true
type: string
permissions:
contents: write
@@ -19,10 +25,19 @@ jobs:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && format('server-v{0}', inputs.version) || github.ref }}
- name: Extract version from tag
id: version
run: echo "version=${GITHUB_REF#refs/tags/server-v}" >> "$GITHUB_OUTPUT"
env:
DISPATCHED_VERSION: ${{ inputs.version }}
run: |
if [ -n "$DISPATCHED_VERSION" ]; then
version="$DISPATCHED_VERSION"
else
version="${GITHUB_REF#refs/tags/server-v}"
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Verify Plugin version sync and changelog
run: |
@@ -61,6 +76,8 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('server-v{0}', inputs.version) || github.ref }}
- name: Set up Python 3.11
uses: actions/setup-python@v7
@@ -99,6 +116,8 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('server-v{0}', inputs.version) || github.ref }}
- name: Set up Python 3.11
uses: actions/setup-python@v7
+8
View File
@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
### Changed
- **Android Chat and Voice use tablet space intentionally.** Expanded layouts keep introductions, transcripts, composer controls, and status chrome on readable centered rails, while landscape Voice Focus separates identity controls from conversation activity without changing phone or portrait interaction behavior.
### Fixed
- **Windows desktop updates keep the installed CLI and UI on one release.** `hermes-relay update` now detects a colocated management UI, reports both installed versions, and uses the verified bundle installer to replace and restart the affected surfaces together. Explicit CLI-only installations retain the standalone binary updater.
## [Android 1.15.0] - 2026-08-31
### Changed
+5
View File
@@ -82,6 +82,11 @@ Use the narrowest command that proves the change:
5. `scripts/dev.bat prepush` only when full local verification is explicitly
wanted or cloud execution is unavailable.
Android release preparation uses `python scripts/android-prepush.py
--release-prep` while version notes are changing. It keeps local feedback to
metadata and release-presentation tests; the exact pushed commit still goes
through required CI and Play preflight before publication.
`install-fast` is intentionally phone-specific. Use `install` for a universal
sideload debug APK or when the target ABI is not arm64. Release builds remain
universal and are unaffected unless `-Phermes.devAbi` is explicitly supplied.
+32
View File
@@ -1,5 +1,37 @@
# Hermes-Relay — Dev Log
## 2026-09-01 — Exact-tree release artifact promotion
Android Play preflight now packages the signed sideload APK, Play AAB, R8
mappings, manifest, and public checksums as one immutable tree-keyed artifact.
Stable approval revalidates and publishes those exact bytes instead of
recompiling the release after Play accepted the draft. Final DEX and native
compatibility checks still run against the promoted package.
Required CI records a separate short-lived proof keyed to the complete Git
tree. Canonical `dev` to `main` promotion reuses that proof only when the
simulated merge tree is byte-identical; missing evidence or any tree change
falls back to the full path-aware matrix. Local Android release iteration now
uses a metadata-and-presentation lane, and a coordinated approval workflow can
start independent Android, Plugin, and CLI+UI release jobs concurrently.
The Windows tray lane also carries an exact-source Cargo/target cache from
trusted branch CI into the tag installer build, with lockfile-scoped incremental
fallback when tray sources change.
## 2026-09-01 — Unified Windows desktop update contract
The desktop updater now treats a detected Windows management UI as an installed
bundle rather than updating only the CLI executable. Update checks compare the
embedded CLI version with the tray executable's product version, expose the
selected `cli` or `cli_ui` target in JSON, and route drift repair through the
checksum-verified NSIS installer. Windows installations without the tray keep
the standalone cooperative CLI swap.
Bundle updates wait for the invoking CLI to exit, stop installer-owned CLI,
daemon, and tray processes, then restore only the daemon and tray processes that
were running before the update. Focused tests cover bundle detection, installer
selection, drift reporting, headless preservation, and stopped-tray behavior.
## 2026-08-31 — Android Gateway compaction watchdog lease
Gateway turns now recognize the upstream `status.update` payload kind
+1 -1
View File
@@ -214,7 +214,7 @@ hermes-relay update # self-update via GitHub Releases
It pairs against the **same relay and credential store** as the Android app — pair once from either, both work. Tagged on the `desktop-v*` [release track](https://github.com/Codename-11/hermes-relay/releases?q=desktop), with historical releases still visible under `cli-v*`.
On Windows, the default installer adds the optional compact **Hermes-Relay CLI UI** tray popup for host selection and pairing, connection and daemon state, per-host Ask/Trusted/Full Access, local grant dialogs, authorized-client revocation, activity, settings, and emergency stop. It is a management surface only—chat, TUI, plugins, voice, and agent sessions remain CLI/upstream concerns.
On Windows, the default installer adds the optional compact **Hermes-Relay CLI UI** tray popup for host selection and pairing, connection and daemon state, per-host Ask/Trusted/Full Access, local grant dialogs, authorized-client revocation, activity, settings, and emergency stop. `hermes-relay update` detects this bundle and updates the CLI and UI together; explicit CLI-only installations stay headless and continue using the standalone binary updater. The UI is a management surface only—chat, TUI, plugins, voice, and agent sessions remain CLI/upstream concerns.
<table>
<tr>
+86 -44
View File
@@ -628,6 +628,21 @@ and the release notes and learn only what the software does.
### 3. Build and verify locally
During release-note/version iteration, use the narrow release-prep lane:
```powershell
python scripts/android-prepush.py --release-prep
```
It runs release metadata checks plus the rendered Changelog/What's New tests in
the serialized Android lane. Once the exact commit is pushed, current-head CI
and Play preflight own lint, focused shards, both-flavor assemblies, signing,
and final package scans. Do not repeat the complete local release build unless
cloud execution is unavailable or explicit local artifact/device proof is
needed.
For that explicit full local proof:
```bat
scripts\dev.bat bundle
keytool -printcert -jarfile app\build\outputs\bundle\googlePlayRelease\hermes-relay-*-googlePlay-release.aab
@@ -661,14 +676,17 @@ The preflight workflow:
3. builds and release-signs the same APK/AAB variants used by the public release;
4. scans the final minified APK DEX for unsupported collection calls;
5. uploads the Google Play AAB as a private **Production draft**; and
6. records a 30-day preflight proof keyed to the version and Git tree hash.
6. retains the exact signed sideload APK, Play AAB, R8 mappings, manifest, and
checksums as one immutable 30-day artifact keyed to version and Git tree.
No sideload APK or GitHub Release is published by preflight. A successful signed
build, final DEX scan, and Production-draft upload is the automated Play release
gate. Play Console pre-review and pre-launch reports are informational and
non-blocking because their detailed results are not exposed through the release
automation API. If the release source changes after preflight, rerun it—the
approval workflow matches the complete Git tree, not just the version number.
build, final package scans, and Production-draft upload is the automated Play
release gate. The private artifact is immutable and hash-verified again before
publication; the stable release workflow does not rebuild those bytes. Play
Console pre-review and pre-launch reports are informational and non-blocking
because their detailed results are not exposed through the release automation
API. If the release source changes after preflight, rerun it—the approval
workflow matches the complete Git tree, not just the version number.
GitHub exposes manual workflows only after their workflow file exists on the
default branch. For the first release that introduces this process, merge the
@@ -709,12 +727,13 @@ from `main`; every release job explicitly checks out and verifies the immutable
an existing tag or changing its artifact tree. Manual stable tags are still
guarded by the same preflight proof in the tag workflow.
The tag-triggered `.github/workflows/release-android.yml` rebuilds and scans the
artifacts, changes the existing Play Production draft to `completed` (submitting
it for review), and only after Play accepts that operation creates the public
GitHub Release with the sideload APK. A missing preflight, changed release tree,
missing Play credential, or Play submission failure prevents public GitHub
publication.
The tag-triggered `.github/workflows/release-android.yml` downloads the exact
private preflight artifact by ID, verifies its source workflow, manifest, tree,
version, sizes, and hashes, reruns the package scanners, then changes the
existing Play Production draft to `completed` (submitting it for review). Only
after Play accepts that operation does it publish those same APK/AAB bytes on
GitHub. A missing preflight, changed release tree, artifact mismatch, missing
Play credential, or Play submission failure prevents public publication.
Plugin/Python version files are intentionally not part of an Android app
release unless the plugin package itself is also being released.
@@ -743,14 +762,19 @@ git commit -m "release(server): server-v0.6.2"
git push origin dev
# Open the release PR (dev -> main) and merge with --no-ff.
# After merge, tag from the new main tip:
git checkout main
git pull --ff-only origin main
git tag server-v0.6.2
git push origin server-v0.6.2
# Then run "Hermes-Relay Plugin and CLI+UI Release Approval" from main,
# select plugin, and enter 0.6.2. The workflow selects and validates main
# before it creates server-v0.6.2 and starts the immutable-tag release workflow.
```
Pushing `server-v*` triggers `.github/workflows/release-plugin.yml`, which
For a Plugin prerelease, keep the release-prepared commit on `dev` and run the
same trusted approval workflow from `main`; the version suffix makes it select
and validate the exact `origin/dev` tip before creating the tag. Stable versions
select `origin/main` instead.
Direct `server-v*` tag pushes remain a recovery path and are guarded by the same
branch-containment and metadata checks.
The approval workflow dispatches `.github/workflows/release-plugin.yml`, which
validates all plugin-owned version metadata with
`scripts/check-plugin-version-sync.py`. Run
`python scripts/check-version-tracks.py` locally before tagging when a change
@@ -781,20 +805,25 @@ git add desktop/package.json desktop/package-lock.json desktop/src/version.ts `
git commit -m "release(desktop): desktop-v0.4.0-alpha.2"
git push origin dev
# Open the release PR (dev -> main) and merge with --no-ff.
# After merge, tag from main:
git switch main
git pull --ff-only origin main
cd desktop
npm run check:version-sync -- --expect 0.4.0-alpha.2
cd ..
git tag desktop-v0.4.0-alpha.2
git push origin desktop-v0.4.0-alpha.2
# This is a prerelease: run "Hermes-Relay Plugin and CLI+UI Release Approval"
# from main, select desktop, and enter 0.4.0-alpha.2. The workflow validates dev
# before it creates the tag and starts the immutable-tag release workflow.
```
The tag workflow rejects version drift and tags whose commit is not in
`origin/main`, reruns CLI tests, builds all four standalone binaries, tests and
packages the Windows tray, generates checksums, and publishes the GitHub Release.
For a stable CLI+UI version, first merge the release PR from `dev` to `main`,
then run the approval workflow from `main`. The version determines the source:
prereleases select the exact `origin/dev` tip and stable releases select the
exact `origin/main` tip before creating any tag. Direct `desktop-v*` tag pushes
remain a recovery path.
The release workflow rejects version drift and requires prerelease tags to be
contained in `origin/dev` and stable tags to be contained in `origin/main`. It
reruns CLI tests, builds all four standalone binaries, tests and packages the
Windows tray, generates checksums, and publishes the GitHub Release.
Trusted desktop CI and the release installer job share a Cargo/target cache
keyed by the lockfile and exact tray sources. A `main` push for the release tree
warms the exact cache before the immutable tag build; a miss safely performs the
ordinary Rust/Tauri build.
### 6. Play review and publishing behavior
@@ -903,28 +932,40 @@ Android, Plugin, dashboard, and desktop now have separate CI/release lanes.
This keeps a dashboard CSS fix from running the full server suite, and keeps
plugin changes from forcing an Android app `versionCode` bump.
Every successful `Required checks` run records a short-lived proof keyed to the
checked Git tree. For the canonical `dev` → `main` release PR, CI first proves
the simulated merge tree is identical to the `dev` tree. If an unexpired proof
from a successful Required-checks run exists, the PR verifies and reuses it;
otherwise it automatically falls back to the normal path-aware matrix. Content
changes can never reuse an older proof because they change the tree hash.
On every push of a tag matching `android-v*`, `.github/workflows/release-android.yml`:
1. Verifies a stable tag resolves to a commit contained in `main`, or a
prerelease tag resolves to a commit contained in `dev`, and that the tag matches `appVersionName` in
`gradle/libs.versions.toml` (mismatches fail the workflow).
2. Runs the Android debug build and the stable sideload pairing/connection
regression slice with explicit timeouts.
3. Decodes `HERMES_KEYSTORE_BASE64` into `$RUNNER_TEMP/release.keystore`
and exports `HERMES_KEYSTORE_PATH` (skipped if the secret is unset).
4. For stable releases, builds all four flavored release artifacts
(`./gradlew bundleRelease assembleRelease`); only the sideload APK and
googlePlay AAB are attached. For prereleases, builds only the side-by-side
`sideloadCandidate` APK.
5. Generates `SHA256SUMS.txt` covering the two attached files.
6. For stable releases only, promotes the exact preflighted Production draft to
2. For stable releases, verifies and downloads the exact immutable Play
preflight artifact; prereleases run the focused CI slice and build the
side-by-side `sideloadCandidate` APK.
3. Revalidates stable artifact hashes, DEX collection compatibility, packaged
native compatibility, and retained R8 mappings without recompiling.
4. Generates candidate checksums when applicable; stable checksums come from
the verified preflight artifact and cover the two public files.
5. For stable releases only, promotes the exact preflighted Production draft to
`completed`; prereleases never upload to Play.
7. Creates a GitHub Release named `Hermes-Relay Android v<version>` with `RELEASE_NOTES.md` as
6. Creates a GitHub Release named `Hermes-Relay Android v<version>` with `RELEASE_NOTES.md` as
the body. Attaches the APK, AAB, and `SHA256SUMS.txt`. Tags any version
containing a dash (e.g. `android-v0.2.0-beta.1`) as a prerelease automatically.
8. Prints a `$GITHUB_STEP_SUMMARY` with the release and Play result.
7. Prints a `$GITHUB_STEP_SUMMARY` with the release and Play result.
On every push of a tag matching `server-v*`,
For an approved multi-surface train, run **Hermes-Relay Coordinated Release
Approval** from `main`, select the affected surfaces, and enter their prepared
versions. It dispatches Android, Plugin, and CLI+UI approval jobs concurrently;
each surface keeps its independent source, validation, tag, artifact, and
publication workflow.
On every direct push of a tag matching `server-v*`, or after an approved
dispatch from `.github/workflows/approve-release-extensions.yml`,
`.github/workflows/release-plugin.yml`:
1. Verifies a stable tag commit is contained in `main`, or a prerelease tag is
@@ -937,7 +978,8 @@ On every push of a tag matching `server-v*`,
5. Creates a GitHub Release named `Hermes-Relay Plugin v<version>` with the wheel,
sdist, and checksum file attached.
On every push of a tag matching `desktop-v*`,
On every direct push of a tag matching `desktop-v*`, or after an approved
dispatch from `.github/workflows/approve-release-extensions.yml`,
`.github/workflows/release-cli.yml` builds and publishes the CLI binaries and
Windows tray installer. Its GitHub Release body comes from `CLI_RELEASE_NOTES.md`
(rewritten per release — the CLI counterpart of `RELEASE_NOTES.md`); the workflow
@@ -0,0 +1,40 @@
package com.hermesandroid.relay.ui
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/** Width policy shared by Chat's content and persistent status surfaces. */
internal data class ChatResponsiveLayout(
val introMaxWidth: Dp?,
val avatarSize: Dp?,
val chromeMaxWidth: Dp?,
val transcriptMaxWidth: Dp?,
val focusVoiceMaxWidth: Dp?,
)
internal fun chatResponsiveLayout(screenWidthDp: Int): ChatResponsiveLayout = when {
screenWidthDp >= 840 -> ChatResponsiveLayout(
introMaxWidth = 720.dp,
avatarSize = 360.dp,
chromeMaxWidth = 960.dp,
transcriptMaxWidth = 960.dp,
focusVoiceMaxWidth = 1120.dp,
)
screenWidthDp >= 600 -> ChatResponsiveLayout(
introMaxWidth = 600.dp,
avatarSize = 300.dp,
chromeMaxWidth = 760.dp,
transcriptMaxWidth = 760.dp,
focusVoiceMaxWidth = 760.dp,
)
else -> ChatResponsiveLayout(
introMaxWidth = null,
avatarSize = null,
chromeMaxWidth = null,
transcriptMaxWidth = null,
focusVoiceMaxWidth = null,
)
}
internal fun useSplitVoiceLayout(screenWidthDp: Int, screenHeightDp: Int): Boolean =
screenWidthDp >= 840 && screenWidthDp > screenHeightDp
@@ -2200,6 +2200,9 @@ fun RelayApp() {
// Routine in-progress reconnect surfaces here (amber cue)
// instead of a take-space banner or a floating toast.
reconnecting = connectionReconnecting,
maxContentWidth = chatResponsiveLayout(
LocalConfiguration.current.screenWidthDp,
).chromeMaxWidth,
modifier = Modifier.petPerchSurface(
key = APP_STATUS_PET_WALK_REGION,
routes = APP_STATUS_PET_ROUTES,
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
@@ -15,6 +16,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
@@ -24,6 +26,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
@@ -48,63 +51,70 @@ fun RelayStatusStrip(
* surfaces — the top chrome stays empty so chat content never shifts.
*/
reconnecting: Boolean = false,
maxContentWidth: Dp? = null,
) {
Column(
modifier = modifier
.fillMaxWidth()
.windowInsetsPadding(
WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom,
),
)
.padding(start = 14.dp, end = 14.dp, top = 3.dp, bottom = 4.dp)
.clip(appearanceRoundedCornerShape(16.dp))
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.relayPanel(
shape = appearanceRoundedCornerShape(16.dp),
background = RelayRefresh.Navy2.copy(alpha = 0.88f),
borderColor = RelayRefresh.Line,
),
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Row(
modifier = Modifier
Column(
modifier = modifier
.then(maxContentWidth?.let { Modifier.widthIn(max = it) } ?: Modifier)
.fillMaxWidth()
.heightIn(min = 22.dp)
.padding(horizontal = 14.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
.windowInsetsPadding(
WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom,
),
)
.padding(start = 14.dp, end = 14.dp, top = 3.dp, bottom = 4.dp)
.clip(appearanceRoundedCornerShape(16.dp))
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.relayPanel(
shape = appearanceRoundedCornerShape(16.dp),
background = RelayRefresh.Navy2.copy(alpha = 0.88f),
borderColor = RelayRefresh.Line,
),
) {
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 22.dp)
.padding(horizontal = 14.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
leadingBadge()
if (securityGlyph != null) {
securityGlyph()
}
// Route is in flux mid-reconnect, so the amber cue replaces the
// route label rather than stacking beside it in the 22dp strip.
when {
reconnecting -> ReconnectingCue(modifier = Modifier.weight(1f))
routeLabel.isNotBlank() -> Text(
text = "· $routeLabel",
style = relayMetadataStyle(),
color = RelayRefresh.Muted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
leadingBadge()
if (securityGlyph != null) {
securityGlyph()
}
// Route is in flux mid-reconnect, so the amber cue replaces the
// route label rather than stacking beside it in the 22dp strip.
when {
reconnecting -> ReconnectingCue(modifier = Modifier.weight(1f))
routeLabel.isNotBlank() -> Text(
text = "· $routeLabel",
style = relayMetadataStyle(),
color = RelayRefresh.Muted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
Spacer(modifier = Modifier.width(10.dp))
Text(
text = trailing,
style = relayMetadataStyle(),
color = RelayRefresh.Muted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = Modifier.width(10.dp))
Text(
text = trailing,
style = relayMetadataStyle(),
color = RelayRefresh.Muted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -23,14 +23,18 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
@@ -71,6 +75,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
@@ -81,6 +86,7 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import com.hermesandroid.relay.data.ChatMessage
@@ -92,7 +98,9 @@ import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar
import com.hermesandroid.relay.ui.components.avatar.LocalBackgroundVisualizationEnabled
import com.hermesandroid.relay.ui.LocalSnackbarHost
import com.hermesandroid.relay.ui.chatResponsiveLayout
import com.hermesandroid.relay.ui.showHumanError
import com.hermesandroid.relay.ui.useSplitVoiceLayout
import com.hermesandroid.relay.util.HumanError
import kotlinx.coroutines.delay
import com.hermesandroid.relay.viewmodel.BackgroundRunPhase
@@ -112,6 +120,8 @@ import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
internal const val VOICE_MODE_MIC_TEST_TAG = "voiceModeMic"
internal const val VOICE_FOCUS_SPLIT_LAYOUT_TEST_TAG = "voiceFocusSplitLayout"
internal const val VOICE_FOCUS_STACKED_LAYOUT_TEST_TAG = "voiceFocusStackedLayout"
/**
* Full-screen voice-mode overlay. Renders the MorphingSphere in its voiceMode
@@ -186,6 +196,56 @@ fun VoiceModeOverlay(
val backgroundVisualizationEnabled = LocalBackgroundVisualizationEnabled.current
val surface = MaterialTheme.colorScheme.surface
val haptic = LocalHapticFeedback.current
val configuration = LocalConfiguration.current
val responsiveLayout = chatResponsiveLayout(configuration.screenWidthDp)
val splitFocusLayout = useSplitVoiceLayout(
screenWidthDp = configuration.screenWidthDp,
screenHeightDp = configuration.screenHeightDp,
)
val focusMicTap: () -> Unit = {
dispatchVoiceMicTap(
uiState = uiState,
onStartListening = {
try {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
} catch (_: Exception) { /* ignore */ }
onMicTap()
},
onStopListening = {
try {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
} catch (_: Exception) { /* ignore */ }
onMicRelease()
},
onInterrupt = onInterrupt,
onPauseAutoMode = onPauseAutoMode,
)
}
val focusMicHoldPress: () -> Unit = {
dispatchVoiceMicHoldPress(
uiState = uiState,
onStartListening = {
try {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
} catch (_: Exception) { /* ignore */ }
onMicTap()
},
onInterruptAndStart = {
try {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
} catch (_: Exception) { /* ignore */ }
onInterrupt()
onMicTap()
},
)
}
val focusMicHoldRelease: () -> Unit = {
try {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
} catch (_: Exception) { /* ignore */ }
onMicRelease()
}
var controlsExpanded by remember { mutableStateOf(false) }
val focusMode = presentationMode == VoicePresentationMode.Focus
@@ -253,6 +313,11 @@ fun VoiceModeOverlay(
onOpenSettings = onOpenSettings,
onExit = onDismiss,
modifier = Modifier
.then(
responsiveLayout.focusVoiceMaxWidth?.let {
Modifier.widthIn(max = it)
} ?: Modifier,
)
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 12.dp, vertical = 8.dp)
@@ -328,162 +393,93 @@ fun VoiceModeOverlay(
exit = fadeOut(tween(120)),
modifier = Modifier.fillMaxSize(),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = 92.dp, bottom = 160.dp, start = 20.dp, end = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1.0f),
contentAlignment = Alignment.Center,
) {
if (backgroundVisualizationEnabled) {
LocalAgentAvatar.current.Render(
state = AvatarRenderState(
state = voiceStateToSphereState(uiState.state),
voiceAmplitude = uiState.amplitude,
voiceMode = true,
),
modifier = Modifier.fillMaxSize(),
if (splitFocusLayout) {
Row(
modifier = Modifier
.widthIn(max = responsiveLayout.focusVoiceMaxWidth ?: 1120.dp)
.fillMaxSize()
.navigationBarsPadding()
.padding(top = 92.dp, bottom = 28.dp, start = 32.dp, end = 32.dp)
.testTag(VOICE_FOCUS_SPLIT_LAYOUT_TEST_TAG),
verticalAlignment = Alignment.CenterVertically,
) {
VoiceFocusIdentityPane(
uiState = uiState,
backgroundVisualizationEnabled = backgroundVisualizationEnabled,
onMicTap = focusMicTap,
onMicHoldPress = focusMicHoldPress,
onMicHoldRelease = focusMicHoldRelease,
showMic = true,
modifier = Modifier
.weight(0.86f)
.fillMaxHeight(),
)
Spacer(Modifier.width(32.dp))
VoiceFocusStatusAndTranscriptPane(
uiState = uiState,
transcriptMessages = visibleTranscriptMessages,
pendingTranscriptText = pendingTranscriptText,
transcriptListState = transcriptListState,
showThinking = showThinking,
onBackgroundRunCancel = onBackgroundRunCancel,
onBackgroundRunTap = onBackgroundRunTap,
onPermissionDeniedChipTap = onPermissionDeniedChipTap,
onHermesConfirmationAnswer = onHermesConfirmationAnswer,
onPresentationModeChange = onPresentationModeChange,
onCardAction = onCardAction,
onCardInput = onCardInput,
horizontalContentPadding = 0.dp,
modifier = Modifier
.weight(1.14f)
.fillMaxHeight(),
)
}
}
VoiceWaveform(
amplitude = uiState.amplitude,
state = uiState.state,
outputAudioActive = uiState.outputAudioActive,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp, vertical = 8.dp),
)
AnimatedVisibility(
visible = uiState.handoffStatus != null,
enter = fadeIn(tween(140)),
exit = fadeOut(tween(180)),
) {
VoiceHandoffStrip(
status = uiState.handoffStatus,
} else {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 4.dp),
)
}
BackgroundRunChip(
run = uiState.backgroundRun,
onCancel = onBackgroundRunCancel,
onTap = onBackgroundRunTap,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 4.dp),
)
Spacer(Modifier.height(8.dp))
DestructiveCountdownRow(
countdown = uiState.destructiveCountdown,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
)
PermissionDeniedChip(
callout = uiState.permissionDeniedCallout,
onTap = onPermissionDeniedChipTap,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 4.dp),
)
HermesConfirmationCard(
confirmation = uiState.hermesConfirmation,
onAnswer = onHermesConfirmationAnswer,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 4.dp),
)
Spacer(Modifier.height(4.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1.25f, fill = true),
) {
val hasTranscript = visibleTranscriptMessages.isNotEmpty() ||
pendingTranscriptText != null
if (hasTranscript) {
val latestId = visibleTranscriptMessages.lastOrNull()?.id
LazyColumn(
state = transcriptListState,
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
// Match ChatScreen's identity contract. A history
// reconcile can adopt the same authoritative server
// id into a live row while Compose still holds the
// pre-reconcile row for a frame. uiKey remains stable
// and unique across that transition.
items(visibleTranscriptMessages, key = ::voiceTranscriptItemKey) { msg ->
CompactTranscriptRow(
message = msg,
showThinking = showThinking,
expanded = msg.id == latestId || msg.isStreaming,
onViewConversation = {
onPresentationModeChange(VoicePresentationMode.Conversation)
},
onCardAction = onCardAction,
onCardInput = onCardInput,
)
}
if (pendingTranscriptText != null) {
item(key = "aux:pending-voice-transcript") {
CompactTranscriptRow(
message = ChatMessage(
id = "pending-voice-transcript",
role = MessageRole.USER,
content = pendingTranscriptText,
timestamp = System.currentTimeMillis(),
isStreaming = true,
),
showThinking = showThinking,
expanded = true,
onViewConversation = {
onPresentationModeChange(VoicePresentationMode.Conversation)
},
onCardAction = onCardAction,
onCardInput = onCardInput,
)
}
}
item { Spacer(Modifier.height(12.dp)) }
}
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
AnimatedContent(
targetState = stateHint(uiState.state),
transitionSpec = {
fadeIn(tween(200)) togetherWith fadeOut(tween(200))
},
label = "stateHint",
) { hint ->
Text(
text = hint,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
}
.then(
responsiveLayout.focusVoiceMaxWidth?.let {
Modifier.widthIn(max = it)
} ?: Modifier,
)
.fillMaxSize()
.padding(top = 92.dp, bottom = 160.dp, start = 20.dp, end = 20.dp)
.testTag(VOICE_FOCUS_STACKED_LAYOUT_TEST_TAG),
horizontalAlignment = Alignment.CenterHorizontally,
) {
VoiceFocusIdentityPane(
uiState = uiState,
backgroundVisualizationEnabled = backgroundVisualizationEnabled,
onMicTap = focusMicTap,
onMicHoldPress = focusMicHoldPress,
onMicHoldRelease = focusMicHoldRelease,
showMic = false,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
)
VoiceFocusStatusAndTranscriptPane(
uiState = uiState,
transcriptMessages = visibleTranscriptMessages,
pendingTranscriptText = pendingTranscriptText,
transcriptListState = transcriptListState,
showThinking = showThinking,
onBackgroundRunCancel = onBackgroundRunCancel,
onBackgroundRunTap = onBackgroundRunTap,
onPermissionDeniedChipTap = onPermissionDeniedChipTap,
onHermesConfirmationAnswer = onHermesConfirmationAnswer,
onPresentationModeChange = onPresentationModeChange,
onCardAction = onCardAction,
onCardInput = onCardInput,
horizontalContentPadding = 24.dp,
modifier = Modifier
.fillMaxWidth()
.weight(1.25f),
)
}
}
}
@@ -498,6 +494,12 @@ fun VoiceModeOverlay(
exit = fadeOut(tween(180)),
modifier = Modifier
.align(Alignment.BottomCenter)
.then(
responsiveLayout.chromeMaxWidth?.let {
Modifier.widthIn(max = it)
} ?: Modifier,
)
.fillMaxWidth()
.padding(bottom = 176.dp, start = 24.dp, end = 24.dp),
) {
BackgroundRunChip(
@@ -515,6 +517,12 @@ fun VoiceModeOverlay(
exit = fadeOut(),
modifier = Modifier
.align(Alignment.TopCenter)
.then(
responsiveLayout.chromeMaxWidth?.let {
Modifier.widthIn(max = it)
} ?: Modifier,
)
.fillMaxWidth()
.padding(top = 64.dp, start = 16.dp, end = 16.dp),
) {
Surface(
@@ -570,57 +578,205 @@ fun VoiceModeOverlay(
// that visually said "Stop" but was actually wired to "Start."
VoiceMicButton(
uiState = uiState,
onTap = {
dispatchVoiceMicTap(
uiState = uiState,
onStartListening = {
try {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
} catch (_: Exception) { /* ignore */ }
onMicTap()
},
onStopListening = {
try {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
} catch (_: Exception) { /* ignore */ }
onMicRelease()
},
onInterrupt = onInterrupt,
onPauseAutoMode = onPauseAutoMode,
)
},
onHoldPress = {
dispatchVoiceMicHoldPress(
uiState = uiState,
onStartListening = {
try {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
} catch (_: Exception) { /* ignore */ }
onMicTap()
},
onInterruptAndStart = {
try {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
} catch (_: Exception) { /* ignore */ }
onInterrupt()
onMicTap()
},
)
},
onHoldRelease = {
try {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
} catch (_: Exception) { /* ignore */ }
onMicRelease()
},
onTap = focusMicTap,
onHoldPress = focusMicHoldPress,
onHoldRelease = focusMicHoldRelease,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 48.dp),
visible = focusMode,
visible = focusMode && !splitFocusLayout,
)
}
}
@Composable
private fun VoiceFocusIdentityPane(
uiState: VoiceUiState,
backgroundVisualizationEnabled: Boolean,
onMicTap: () -> Unit,
onMicHoldPress: () -> Unit,
onMicHoldRelease: () -> Unit,
showMic: Boolean,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.testTag("voiceFocusIdentityPane"),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center,
) {
if (backgroundVisualizationEnabled) {
LocalAgentAvatar.current.Render(
state = AvatarRenderState(
state = voiceStateToSphereState(uiState.state),
voiceAmplitude = uiState.amplitude,
voiceMode = true,
),
modifier = Modifier.fillMaxSize(),
)
}
}
VoiceWaveform(
amplitude = uiState.amplitude,
state = uiState.state,
outputAudioActive = uiState.outputAudioActive,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 8.dp),
)
if (showMic) {
Spacer(Modifier.height(16.dp))
VoiceMicButton(
uiState = uiState,
onTap = onMicTap,
onHoldPress = onMicHoldPress,
onHoldRelease = onMicHoldRelease,
visible = true,
)
Spacer(Modifier.height(8.dp))
}
}
}
@Composable
private fun VoiceFocusStatusAndTranscriptPane(
uiState: VoiceUiState,
transcriptMessages: List<ChatMessage>,
pendingTranscriptText: String?,
transcriptListState: LazyListState,
showThinking: Boolean,
onBackgroundRunCancel: () -> Unit,
onBackgroundRunTap: () -> Unit,
onPermissionDeniedChipTap: (PermissionDeniedCallout) -> Unit,
onHermesConfirmationAnswer: (String) -> Unit,
onPresentationModeChange: (VoicePresentationMode) -> Unit,
onCardAction: (messageId: String, cardKey: String, action: HermesCardAction) -> Unit,
onCardInput: (messageId: String, cardKey: String, value: String) -> Unit,
horizontalContentPadding: Dp,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.testTag("voiceFocusTranscriptPane")) {
AnimatedVisibility(
visible = uiState.handoffStatus != null,
enter = fadeIn(tween(140)),
exit = fadeOut(tween(180)),
) {
VoiceHandoffStrip(
status = uiState.handoffStatus,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalContentPadding, vertical = 4.dp),
)
}
BackgroundRunChip(
run = uiState.backgroundRun,
onCancel = onBackgroundRunCancel,
onTap = onBackgroundRunTap,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalContentPadding, vertical = 4.dp),
)
Spacer(Modifier.height(8.dp))
DestructiveCountdownRow(
countdown = uiState.destructiveCountdown,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalContentPadding),
)
PermissionDeniedChip(
callout = uiState.permissionDeniedCallout,
onTap = onPermissionDeniedChipTap,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalContentPadding, vertical = 4.dp),
)
HermesConfirmationCard(
confirmation = uiState.hermesConfirmation,
onAnswer = onHermesConfirmationAnswer,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalContentPadding, vertical = 4.dp),
)
Spacer(Modifier.height(4.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f, fill = true),
) {
val hasTranscript = transcriptMessages.isNotEmpty() || pendingTranscriptText != null
if (hasTranscript) {
val latestId = transcriptMessages.lastOrNull()?.id
LazyColumn(
state = transcriptListState,
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
// uiKey remains stable while Gateway reconciliation adopts
// an authoritative server id into a live transcript row.
items(transcriptMessages, key = ::voiceTranscriptItemKey) { msg ->
CompactTranscriptRow(
message = msg,
showThinking = showThinking,
expanded = msg.id == latestId || msg.isStreaming,
onViewConversation = {
onPresentationModeChange(VoicePresentationMode.Conversation)
},
onCardAction = onCardAction,
onCardInput = onCardInput,
)
}
if (pendingTranscriptText != null) {
item(key = "aux:pending-voice-transcript") {
CompactTranscriptRow(
message = ChatMessage(
id = "pending-voice-transcript",
role = MessageRole.USER,
content = pendingTranscriptText,
timestamp = System.currentTimeMillis(),
isStreaming = true,
),
showThinking = showThinking,
expanded = true,
onViewConversation = {
onPresentationModeChange(VoicePresentationMode.Conversation)
},
onCardAction = onCardAction,
onCardInput = onCardInput,
)
}
}
item { Spacer(Modifier.height(12.dp)) }
}
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
AnimatedContent(
targetState = stateHint(uiState.state),
transitionSpec = {
fadeIn(tween(200)) togetherWith fadeOut(tween(200))
},
label = "stateHint",
) { hint ->
Text(
text = hint,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
}
}
@Composable
private fun VoiceMicButton(
uiState: VoiceUiState,
@@ -98,6 +98,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
@@ -119,6 +120,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.chatResponsiveLayout
import com.hermesandroid.relay.ui.theme.radialNavyBackground
import com.hermesandroid.relay.network.upstream.ApiModelOption
import com.hermesandroid.relay.network.upstream.ChatMode
@@ -749,6 +751,7 @@ fun ChatScreen(
gitWorkspaceAvailable: Boolean = gitWorkspaceSummary != null,
onNavigateToGitWorkspace: () -> Unit = {},
) {
val responsiveLayout = chatResponsiveLayout(LocalConfiguration.current.screenWidthDp)
val supervised = supervisedPolicy.enabled
val supervisedVisibility = supervisedPolicy.visibility.resolved()
LaunchedEffect(supervisedPolicy) {
@@ -3311,7 +3314,13 @@ fun ChatScreen(
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 32.dp)
modifier = Modifier
.padding(horizontal = 32.dp)
.then(
responsiveLayout.introMaxWidth?.let {
Modifier.widthIn(max = it)
} ?: Modifier,
),
) {
Spacer(modifier = Modifier.weight(0.15f))
@@ -3320,12 +3329,15 @@ fun ChatScreen(
LocalBackgroundVisualizationEnabled.current &&
(!supervised || supervisedVisibility.showAgentIdentity)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.weight(0.7f, fill = false)
) {
val avatarModifier = responsiveLayout.avatarSize?.let { size ->
Modifier
.size(size)
.clipToBounds()
} ?: Modifier
.fillMaxWidth()
.aspectRatio(1f)
.weight(0.7f, fill = false)
Box(modifier = avatarModifier) {
LocalAgentAvatar.current.Render(
state = AvatarRenderState(
state = if (error != null) SphereState.Error else SphereState.Idle,
@@ -3568,7 +3580,14 @@ fun ChatScreen(
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.align(Alignment.Center)
.fillMaxHeight()
.then(
responsiveLayout.transcriptMaxWidth?.let {
Modifier.widthIn(max = it)
} ?: Modifier,
)
.fillMaxWidth()
.padding(horizontal = 12.dp)
.padding(top = if (showTranscriptSearch) 112.dp else 0.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
@@ -4650,6 +4669,13 @@ fun ChatScreen(
supervisedPolicy.capabilities.attachmentCategories
)) pasteImageFromClipboard else ({ }),
onLongPressAttach = { if (!supervised) showCommandPalette = true },
modifier = Modifier
.align(Alignment.CenterHorizontally)
.then(
responsiveLayout.chromeMaxWidth?.let {
Modifier.widthIn(max = it)
} ?: Modifier,
),
charLimit = charLimit,
caption = turnStatus ?: inputCaption,
voiceReady = voiceReady,
@@ -5,6 +5,8 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import com.hermesandroid.relay.ui.components.ChangelogStore
import com.hermesandroid.relay.ui.components.ChangelogVersion
import com.hermesandroid.relay.ui.screens.ChangelogScreen
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import org.junit.Rule
@@ -20,15 +22,19 @@ class ChangelogHistoryScreenshotTest {
@get:Rule val compose = createComposeRule()
@Test fun latestReleaseShowsHighlightsAndCompleteDetails() {
lateinit var latest: ChangelogVersion
compose.setContent {
latest = ChangelogStore.load(androidx.compose.ui.platform.LocalContext.current).versions.first()
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
ChangelogScreen(onClose = {})
}
}
compose.onNodeWithText("v1.15.0 — Standard Hermes first, with clearer Relay boundaries").assertExists()
compose.onNodeWithText("v${latest.version} — ${requireNotNull(latest.title)}").assertExists()
compose.onNodeWithText("Highlights").assertExists()
compose.onNodeWithText("Fixed").assertExists()
compose.onNodeWithText("Keep Standard voice after removing Relay").assertExists()
latest.remainingChangesOfKind("fixed").firstOrNull()?.let { fixed ->
compose.onNodeWithText("Fixed").assertExists()
compose.onNodeWithText(fixed.title).assertExists()
}
compose.onNodeWithText("Compatibility").assertExists()
compose.onNodeWithText("Installed").assertExists()
compose.onRoot().captureRoboImage("build/ui-regression/changelog-history.png")
@@ -21,6 +21,7 @@ import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import com.hermesandroid.relay.ui.components.ChangelogStore
import com.hermesandroid.relay.ui.components.ChangelogVersion
import com.hermesandroid.relay.ui.components.WhatsNewToast
import com.hermesandroid.relay.ui.components.WhatsNewToastContent
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
@@ -38,10 +39,12 @@ class WhatsNewToastScreenshotTest {
@get:Rule val compose = createComposeRule()
@Test fun compactNoticeRemainsClearAtLargeText() {
lateinit var latest: ChangelogVersion
compose.setContent {
val context = LocalContext.current
val density = LocalDensity.current
val entry = ChangelogStore.load(context).versions.first()
latest = entry
CompositionLocalProvider(LocalDensity provides Density(density.density, 1.35f)) {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
Box(
@@ -61,12 +64,12 @@ class WhatsNewToastScreenshotTest {
}
}
}
compose.onNodeWithText("Standard Hermes first, with clearer Relay boundaries").assertExists()
compose.onNodeWithText("Also: 2 improvements · 9 fixes").assertExists()
compose.onNodeWithText(
"See which features need Relay, Read the complete release record…",
).assertExists()
compose.onNodeWithText("View all").assertExists()
compose.onNodeWithText(requireNotNull(latest.title)).assertExists()
latest.visibleDigest()?.let { digest ->
compose.onNodeWithText("Also: ${digest.countText()}").assertExists()
compose.onNodeWithText(digest.preview.joinToString(", ") + "…").assertExists()
compose.onNodeWithText("View all").assertExists()
}
compose.onNodeWithContentDescription("Close").assertExists()
compose.onRoot().captureRoboImage("build/ui-regression/whats-new-toast-large-text.png")
}
@@ -74,8 +77,10 @@ class WhatsNewToastScreenshotTest {
@Test fun exposesExpandAndCloseActions() {
var expanded = false
var dismissed = false
lateinit var latest: ChangelogVersion
compose.setContent {
val entry = ChangelogStore.load(LocalContext.current).versions.first()
latest = entry
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
WhatsNewToastContent(
entry = entry,
@@ -86,9 +91,12 @@ class WhatsNewToastScreenshotTest {
}
}
compose.onNodeWithText("Standard Hermes first, with clearer Relay boundaries").performClick()
expanded = false
compose.onNodeWithText("View all").performClick()
compose.onNodeWithText(requireNotNull(latest.title)).performClick()
assertTrue(expanded)
latest.visibleDigest()?.let {
expanded = false
compose.onNodeWithText("View all").performClick()
}
compose.onNodeWithContentDescription("Close").performClick()
assertTrue(expanded)
@@ -97,8 +105,10 @@ class WhatsNewToastScreenshotTest {
@Test fun horizontalSwipeDismissesTheNotice() {
var dismissed = false
lateinit var latest: ChangelogVersion
compose.mainClock.autoAdvance = false
compose.setContent {
latest = ChangelogStore.load(LocalContext.current).versions.first()
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
WhatsNewToast(
onDismiss = { dismissed = true },
@@ -108,10 +118,29 @@ class WhatsNewToastScreenshotTest {
}
}
compose.mainClock.advanceTimeBy(300L)
compose.onNodeWithText("Standard Hermes first, with clearer Relay boundaries")
compose.onNodeWithText(requireNotNull(latest.title))
.performTouchInput { swipeLeft(durationMillis = 300L) }
compose.mainClock.advanceTimeBy(300L)
assertTrue(dismissed)
}
private fun com.hermesandroid.relay.ui.components.ChangelogToastDigest.countText(): String =
buildList {
if (additionalFeatureCount > 0) {
add("$additionalFeatureCount additional ${if (additionalFeatureCount == 1) "feature" else "features"}")
}
if (improvementCount > 0) {
add("$improvementCount ${if (improvementCount == 1) "improvement" else "improvements"}")
}
if (fixCount > 0) {
add("$fixCount ${if (fixCount == 1) "fix" else "fixes"}")
}
}.joinToString(" · ")
private fun ChangelogVersion.visibleDigest():
com.hermesandroid.relay.ui.components.ChangelogToastDigest? =
resolvedToastDigest()?.takeIf {
it.additionalFeatureCount > 0 || it.improvementCount > 0 || it.fixCount > 0
}
}
@@ -0,0 +1,50 @@
package com.hermesandroid.relay.ui
import androidx.compose.ui.unit.dp
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatResponsiveLayoutTest {
@Test
fun compactPhonesKeepExistingUnboundedLayout() {
val layout = chatResponsiveLayout(screenWidthDp = 599)
assertNull(layout.introMaxWidth)
assertNull(layout.avatarSize)
assertNull(layout.chromeMaxWidth)
assertNull(layout.transcriptMaxWidth)
assertNull(layout.focusVoiceMaxWidth)
}
@Test
fun mediumWindowsUseBoundedChatSurfaces() {
val layout = chatResponsiveLayout(screenWidthDp = 600)
assertEquals(600.dp, layout.introMaxWidth)
assertEquals(300.dp, layout.avatarSize)
assertEquals(760.dp, layout.chromeMaxWidth)
assertEquals(760.dp, layout.transcriptMaxWidth)
assertEquals(760.dp, layout.focusVoiceMaxWidth)
}
@Test
fun expandedTabletsUseReadableCenteredRails() {
val layout = chatResponsiveLayout(screenWidthDp = 1280)
assertEquals(720.dp, layout.introMaxWidth)
assertEquals(360.dp, layout.avatarSize)
assertEquals(960.dp, layout.chromeMaxWidth)
assertEquals(960.dp, layout.transcriptMaxWidth)
assertEquals(1120.dp, layout.focusVoiceMaxWidth)
}
@Test
fun focusVoiceSplitsOnlyOnExpandedLandscapeWindows() {
assertTrue(useSplitVoiceLayout(screenWidthDp = 1280, screenHeightDp = 800))
assertFalse(useSplitVoiceLayout(screenWidthDp = 800, screenHeightDp = 1280))
assertFalse(useSplitVoiceLayout(screenWidthDp = 839, screenHeightDp = 600))
}
}
@@ -86,6 +86,35 @@ class VoiceModeOverlayInteractionTest {
}
}
@Test
@Config(qualifiers = "w1280dp-h800dp-xhdpi")
fun expandedLandscape_usesSplitLayoutAndKeepsMicSemanticAction() {
var micTaps = 0
compose.mainClock.autoAdvance = false
compose.setContent {
MaterialTheme {
VoiceModeOverlay(
uiState = idleVoiceState(),
onMicTap = { micTaps += 1 },
onMicRelease = {},
onInterrupt = {},
onDismiss = {},
onModeChange = {},
onClearError = {},
)
}
}
compose.onNodeWithTag(VOICE_FOCUS_SPLIT_LAYOUT_TEST_TAG).assertExists()
compose.onNodeWithTag(VOICE_FOCUS_STACKED_LAYOUT_TEST_TAG).assertDoesNotExist()
compose.onNodeWithTag(VOICE_MODE_MIC_TEST_TAG)
.assertHasClickAction()
.performSemanticsAction(SemanticsActions.OnClick)
compose.runOnIdle { assertEquals(1, micTaps) }
}
@Test
fun focusMode_emptySpaceDoesNotClickThroughToChat() {
var backgroundTaps = 0
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

+2 -17
View File
@@ -1,26 +1,11 @@
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { homedir } from 'node:os'
import { basename, dirname, join, win32 } from 'node:path'
import type { ParsedArgs } from '../cli.js'
import { uiExecutablePath } from '../windowsBundle.js'
import { updateCommand } from './update.js'
export function uiExecutablePath(env = process.env, executable = process.execPath): string {
if (env.HERMES_RELAY_UI_PATH) return env.HERMES_RELAY_UI_PATH
if (env.HERMES_RELAY_INSTALL_DIR) {
const pathApi = win32.isAbsolute(env.HERMES_RELAY_INSTALL_DIR) ? win32 : { join }
return pathApi.join(env.HERMES_RELAY_INSTALL_DIR, 'hermes-relay-tray.exe')
}
const executableName = win32.basename(executable).toLowerCase()
if (executableName === 'hermes-relay.exe') {
return win32.join(win32.dirname(executable), 'hermes-relay-tray.exe')
}
if (basename(executable).toLowerCase() === 'hermes-relay') {
return join(dirname(executable), 'hermes-relay-tray.exe')
}
return join(homedir(), '.hermes', 'bin', 'hermes-relay-tray.exe')
}
export { uiExecutablePath }
function printHelp(): void {
process.stdout.write(`Usage: hermes-relay ui [open|status|install]\n\n`)
+108 -33
View File
@@ -29,8 +29,23 @@ import {
} from '../updater.js'
import { VERSION } from '../version.js'
import { scheduleWindowsInstaller } from '../windowsInstaller.js'
import { detectWindowsBundle, type WindowsBundleState } from '../windowsBundle.js'
import { isDaemonProcessAlive, readDaemonStatus } from '../lib/daemonStatus.js'
export interface UpdateCommandOptions {
platform?: NodeJS.Platform
arch?: string
bundle?: WindowsBundleState
}
export function installerRunsSilently(
bundleInstalled: boolean,
autoYes: boolean,
wantJson: boolean
): boolean {
return bundleInstalled || autoYes || wantJson
}
function humanBytes(n: number | null): string {
if (n === null) return '?'
if (n < 1024) return `${n} B`
@@ -80,6 +95,10 @@ function renderProgressBar(bytes: number, total: number, width = 28): string {
interface JsonReport {
current: string
target: 'cli' | 'cli_ui'
ui_installed: boolean
ui_version: string | null
versions_in_sync: boolean
up_to_date: boolean
ahead_of_latest: boolean
latest_tag: string | null
@@ -96,9 +115,13 @@ interface JsonReport {
error: string | null
}
function emptyReport(): JsonReport {
function emptyReport(target: 'cli' | 'cli_ui', bundle: WindowsBundleState): JsonReport {
return {
current: VERSION,
target,
ui_installed: bundle.installed,
ui_version: bundle.version,
versions_in_sync: !bundle.installed || bundle.version === VERSION,
up_to_date: true,
ahead_of_latest: false,
latest_tag: null,
@@ -116,11 +139,22 @@ function emptyReport(): JsonReport {
}
}
function reportFromInfo(info: UpdateInfo): JsonReport {
function reportFromInfo(
info: UpdateInfo,
target: 'cli' | 'cli_ui',
bundle: WindowsBundleState
): JsonReport {
const versionsInSync = !bundle.installed || bundle.version === VERSION
const bundleNeedsRepair = target === 'cli_ui' && bundle.installed && !versionsInSync
const uiAhead = bundle.version !== null && compareVersions(bundle.version, info.latest_version) > 0
return {
current: info.current,
up_to_date: !info.is_upgrade,
ahead_of_latest: compareVersions(info.current, info.latest_version) > 0,
target,
ui_installed: bundle.installed,
ui_version: bundle.version,
versions_in_sync: versionsInSync,
up_to_date: !info.is_upgrade && !bundleNeedsRepair,
ahead_of_latest: compareVersions(info.current, info.latest_version) > 0 || uiAhead,
latest_tag: info.latest_tag,
latest_version: info.latest_version,
is_prerelease: info.is_prerelease,
@@ -136,27 +170,38 @@ function reportFromInfo(info: UpdateInfo): JsonReport {
}
}
export async function updateCommand(args: ParsedArgs): Promise<number> {
export async function updateCommand(
args: ParsedArgs,
options: UpdateCommandOptions = {}
): Promise<number> {
const wantJson = !!args.flags.json
const checkOnly = !!args.flags.check
const autoYes = !!args.flags.yes
const installer = !!args.flags.installer
const installerRequested = !!args.flags.installer
const downloadOnly = !!args.flags['download-only']
const force = !!args.flags.force
const repo = typeof args.flags.repo === 'string' ? args.flags.repo : undefined
const platform = options.platform ?? process.platform
const arch = options.arch ?? process.arch
const bundle = options.bundle ?? detectWindowsBundle({ platform })
const installer = installerRequested || (platform === 'win32' && bundle.installed)
const target = installer ? 'cli_ui' : 'cli'
const selectedAssetName = installer
? 'hermes-relay-windows-x64-setup.exe'
: assetNameForPlatform(platform, arch)
let info: UpdateInfo | null = null
try {
const checkOpts = {
...(repo !== undefined ? { repo } : {}),
...(installer ? { assetName: 'hermes-relay-windows-x64-setup.exe' } : {})
...(selectedAssetName !== null ? { assetName: selectedAssetName } : {})
}
info = await checkForUpdate(checkOpts)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (wantJson) {
const report = emptyReport()
const report = emptyReport(target, bundle)
report.error = msg
process.stdout.write(JSON.stringify(report, null, 2) + '\n')
return 1
@@ -168,7 +213,7 @@ export async function updateCommand(args: ParsedArgs): Promise<number> {
if (!info) {
// No CLI release-track rows at all — surface truthfully, don't crash.
if (wantJson) {
process.stdout.write(JSON.stringify(emptyReport(), null, 2) + '\n')
process.stdout.write(JSON.stringify(emptyReport(target, bundle), null, 2) + '\n')
return 0
}
process.stdout.write(`Current version: ${VERSION}\n`)
@@ -176,7 +221,7 @@ export async function updateCommand(args: ParsedArgs): Promise<number> {
return 0
}
const report = reportFromInfo(info)
const report = reportFromInfo(info, target, bundle)
if (wantJson && checkOnly) {
process.stdout.write(JSON.stringify(report, null, 2) + '\n')
@@ -184,7 +229,10 @@ export async function updateCommand(args: ParsedArgs): Promise<number> {
}
if (installer && report.ahead_of_latest && !force) {
const msg = `latest UI bundle (${info.latest_version}) is older than this CLI (${VERSION}); pass --force to downgrade explicitly`
const installedVersions = bundle.version === null
? `CLI ${VERSION}`
: `CLI ${VERSION}, UI ${bundle.version}`
const msg = `latest CLI+UI bundle (${info.latest_version}) is older than the installed ${installedVersions}; pass --force to downgrade explicitly`
if (wantJson) {
report.error = msg
process.stdout.write(JSON.stringify(report, null, 2) + '\n')
@@ -195,28 +243,45 @@ export async function updateCommand(args: ParsedArgs): Promise<number> {
}
if (!wantJson) {
process.stdout.write(`Current version: ${info.current}\n`)
if (installer && bundle.installed) {
process.stdout.write(`Installed versions: CLI ${info.current}, UI ${bundle.version ?? 'unknown'}\n`)
if (!report.versions_in_sync) {
process.stdout.write('CLI/UI drift detected; the verified Windows bundle will repair both surfaces together.\n')
}
} else {
process.stdout.write(`Current version: ${info.current}\n`)
}
process.stdout.write(`Checking GitHub Releases...\n`)
}
// The full Windows installer is also how a CLI-only installation adds the
// optional UI. Do not suppress that operation merely because the bundled
// CLI version is already current.
if (!info.is_upgrade && !installer) {
if (report.up_to_date && !installerRequested) {
if (wantJson) {
process.stdout.write(JSON.stringify(report, null, 2) + '\n')
return 0
}
process.stdout.write(`Up to date — you're on the latest CLI release.\n`)
process.stdout.write(
target === 'cli_ui'
? `Up to date — the installed CLI and UI match the latest release.\n`
: `Up to date — you're on the latest CLI release.\n`
)
return 0
}
// Upgrade available.
if (!wantJson) {
const pre = info.is_prerelease ? ' (prerelease)' : ''
process.stdout.write(
`Upgrade available: ${info.current} → ${info.latest_version}${pre}\n`
)
if (info.is_upgrade) {
process.stdout.write(
`Upgrade available: ${info.current} → ${info.latest_version}${pre}\n`
)
} else if (!report.versions_in_sync) {
process.stdout.write(`Bundle repair available: align CLI and UI on ${info.latest_version}${pre}\n`)
} else if (installerRequested && !bundle.installed) {
process.stdout.write(`CLI+UI bundle ready: ${info.latest_version}${pre}\n`)
}
process.stdout.write(` published: ${humanDate(info.published_at)}\n`)
process.stdout.write(
` asset: ${info.asset_name} (${humanBytes(info.asset_size)})\n`
@@ -232,7 +297,7 @@ export async function updateCommand(args: ParsedArgs): Promise<number> {
}
// Refuse to proceed if this platform has no matching asset.
const wantAsset = installer ? 'hermes-relay-windows-x64-setup.exe' : assetNameForPlatform()
const wantAsset = selectedAssetName
if (!wantAsset || !info.asset_url) {
const msg = `no binary available for ${process.platform}/${process.arch}`
if (wantJson) {
@@ -278,29 +343,35 @@ export async function updateCommand(args: ParsedArgs): Promise<number> {
: undefined
try {
const downloadOpts: DownloadOptions = onProgress ? { onProgress } : {}
if (installer) {
downloadOpts.targetPath = join(
tmpdir(),
`hermes-relay-${info.latest_version}-${process.pid}-${Date.now()}-setup.exe`
)
downloadOpts.cooperative = false
}
const downloadOpts: DownloadOptions = onProgress ? { onProgress } : {}
if (installer) {
downloadOpts.targetPath = join(
tmpdir(),
`hermes-relay-${info.latest_version}-${process.pid}-${Date.now()}-setup.exe`
)
downloadOpts.cooperative = false
}
const result = await downloadAndInstall(info, downloadOpts)
if (installer && !downloadOnly) {
if (process.platform !== 'win32') {
if (platform !== 'win32') {
throw new Error('the Hermes-Relay CLI UI installer is Windows-only')
}
const daemon = await readDaemonStatus()
const installDir = process.execPath.toLowerCase().endsWith('hermes-relay.exe')
? dirname(process.execPath)
: join(homedir(), '.hermes', 'bin')
const installDir = bundle.installed
? dirname(bundle.path)
: process.execPath.toLowerCase().endsWith('hermes-relay.exe')
? dirname(process.execPath)
: join(homedir(), '.hermes', 'bin')
scheduleWindowsInstaller(result.installedPath, {
silent: autoYes || wantJson,
// An existing bundle update already received CLI confirmation. Run
// setup silently so its finish-page checkbox cannot start a tray that
// was stopped before the update; the helper restores exact state.
silent: installerRunsSilently(bundle.installed, autoYes, wantJson),
restartDaemon: daemon ? isDaemonProcessAlive(daemon) : false,
installDir,
cliPath: join(installDir, 'hermes-relay.exe'),
trayPath: join(installDir, 'hermes-relay-tray.exe')
trayPath: join(installDir, 'hermes-relay-tray.exe'),
restartTray: bundle.installed ? bundle.running : true
})
}
if (useTtyProgress) process.stdout.write('\n')
@@ -315,7 +386,11 @@ export async function updateCommand(args: ParsedArgs): Promise<number> {
: `-> installed at ${result.installedPath}\n`
)
if (installer && !downloadOnly) {
process.stdout.write(' The installer updates the CLI and lets you enable the optional tray at sign-in.\n')
process.stdout.write(
bundle.installed
? ' The installer updates the CLI and UI together and restores their prior running state.\n'
: ' The installer adds the CLI and optional UI and lets you enable the tray at sign-in.\n'
)
} else if (result.needsRestart) {
process.stdout.write(
` On next \`hermes-relay\` invocation, the CLI will swap this in automatically.\n`
+88
View File
@@ -0,0 +1,88 @@
import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { homedir } from 'node:os'
import { basename, dirname, join, win32 } from 'node:path'
export interface WindowsBundleState {
installed: boolean
path: string
version: string | null
running: boolean
}
interface BundleProbe {
version: string | null
running: boolean
}
export function uiExecutablePath(env = process.env, executable = process.execPath): string {
if (env.HERMES_RELAY_UI_PATH) return env.HERMES_RELAY_UI_PATH
if (env.HERMES_RELAY_INSTALL_DIR) {
const pathApi = win32.isAbsolute(env.HERMES_RELAY_INSTALL_DIR) ? win32 : { join }
return pathApi.join(env.HERMES_RELAY_INSTALL_DIR, 'hermes-relay-tray.exe')
}
const executableName = win32.basename(executable).toLowerCase()
if (executableName === 'hermes-relay.exe') {
return win32.join(win32.dirname(executable), 'hermes-relay-tray.exe')
}
if (basename(executable).toLowerCase() === 'hermes-relay') {
return join(dirname(executable), 'hermes-relay-tray.exe')
}
return join(homedir(), '.hermes', 'bin', 'hermes-relay-tray.exe')
}
function probeWindowsBundle(path: string): BundleProbe | null {
const script = [
"$item = Get-Item -LiteralPath $env:HERMES_RELAY_UI_PROBE_PATH -ErrorAction Stop",
"$running = @(Get-Process -Name 'hermes-relay-tray' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $item.FullName }).Count -gt 0",
'[pscustomobject]@{ version = $item.VersionInfo.ProductVersion; running = $running } | ConvertTo-Json -Compress'
].join('; ')
const result = spawnSync('powershell.exe', [
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-Command',
script
], {
encoding: 'utf8',
timeout: 5_000,
windowsHide: true,
env: { ...process.env, HERMES_RELAY_UI_PROBE_PATH: path }
})
if (result.status !== 0 || !result.stdout.trim()) return null
try {
const value = JSON.parse(result.stdout) as { version?: unknown; running?: unknown }
return {
version: typeof value.version === 'string' && value.version.trim()
? value.version.trim()
: null,
running: value.running === true
}
} catch {
return null
}
}
export function detectWindowsBundle(options: {
platform?: NodeJS.Platform
env?: NodeJS.ProcessEnv
executable?: string
exists?: (path: string) => boolean
probe?: (path: string) => BundleProbe | null
} = {}): WindowsBundleState {
const path = uiExecutablePath(options.env, options.executable)
const installed = (options.platform ?? process.platform) === 'win32' &&
(options.exists ?? existsSync)(path)
if (!installed) return { installed: false, path, version: null, running: false }
const probe = (options.probe ?? probeWindowsBundle)(path)
return {
installed: true,
path,
version: probe?.version ?? null,
// If the process probe fails, restarting is safer than leaving a UI that
// was running before setup permanently stopped.
running: probe?.running ?? true
}
}
+5 -2
View File
@@ -12,6 +12,7 @@ export interface WindowsInstallerLaunchOptions {
installDir?: string
cliPath?: string
trayPath?: string
restartTray?: boolean
delayMs?: number
callerPid?: number
}
@@ -31,6 +32,7 @@ export function windowsInstallerLaunchPlan(
installDir = '',
cliPath = '',
trayPath = '',
restartTray = true,
delayMs = 1200,
callerPid = process.pid
} = options
@@ -44,7 +46,7 @@ export function windowsInstallerLaunchPlan(
'$process = Start-Process -FilePath $installer -ArgumentList $installerArgs -PassThru',
'$process.WaitForExit()',
"if ($process.ExitCode -eq 0 -and $env:HERMES_RELAY_SETUP_RESTART_DAEMON -eq '1') { Start-Process -FilePath $env:HERMES_RELAY_SETUP_CLI -ArgumentList @('daemon','start') -WindowStyle Hidden | Out-Null }",
"if ($process.ExitCode -eq 0 -and $env:HERMES_RELAY_SETUP_TRAY) { Start-Process -FilePath $env:HERMES_RELAY_SETUP_TRAY -ArgumentList '--show' | Out-Null }",
"if ($process.ExitCode -eq 0 -and $env:HERMES_RELAY_SETUP_RESTART_TRAY -eq '1' -and $env:HERMES_RELAY_SETUP_TRAY) { Start-Process -FilePath $env:HERMES_RELAY_SETUP_TRAY -ArgumentList '--show' | Out-Null }",
'Remove-Item -LiteralPath $installer -Force -ErrorAction SilentlyContinue',
'exit $process.ExitCode'
].join('; ')
@@ -73,7 +75,8 @@ export function windowsInstallerLaunchPlan(
HERMES_RELAY_SETUP_RESTART_DAEMON: restartDaemon ? '1' : '0',
HERMES_RELAY_SETUP_INSTALL_DIR: installDir,
HERMES_RELAY_SETUP_CLI: cliPath,
HERMES_RELAY_SETUP_TRAY: trayPath
HERMES_RELAY_SETUP_TRAY: trayPath,
HERMES_RELAY_SETUP_RESTART_TRAY: restartTray ? '1' : '0'
}
}
}
+32
View File
@@ -6,6 +6,7 @@ import { tmpdir } from 'node:os'
import test from 'node:test'
import { uiExecutablePath } from '../src/commands/ui.js'
import { detectWindowsBundle } from '../src/windowsBundle.js'
import { windowsInstallerLaunchPlan } from '../src/windowsInstaller.js'
test('UI executable follows an explicit install directory', () => {
@@ -31,6 +32,7 @@ test('installer launch is delayed until the running CLI can exit', () => {
assert.equal(plan.options.env?.HERMES_RELAY_SETUP_PATH, 'C:\\Temp\\hermes setup.exe')
assert.equal(plan.options.env?.HERMES_RELAY_SETUP_SILENT, '1')
assert.equal(plan.options.env?.HERMES_RELAY_SETUP_INSTALL_DIR, 'C:\\Hermes custom')
assert.equal(plan.options.env?.HERMES_RELAY_SETUP_RESTART_TRAY, '1')
assert.equal(plan.options.env?.HERMES_RELAY_SETUP_CALLER_PID, String(process.pid))
assert.match(plan.args.join(' '), /Wait-Process -Id \$callerPid/)
assert.match(plan.args.join(' '), /Start-Sleep/)
@@ -39,6 +41,35 @@ test('installer launch is delayed until the running CLI can exit', () => {
assert.equal(plan.options.detached, true)
})
test('Windows bundle detection reads the installed UI version and running state', () => {
const state = detectWindowsBundle({
platform: 'win32',
executable: 'C:\\Hermes\\hermes-relay.exe',
exists: () => true,
probe: path => ({
version: path.endsWith('hermes-relay-tray.exe') ? '0.4.0-beta.4' : null,
running: true
})
})
assert.deepEqual(state, {
installed: true,
path: 'C:\\Hermes\\hermes-relay-tray.exe',
version: '0.4.0-beta.4',
running: true
})
})
test('installer helper preserves a stopped tray state during a bundle update', () => {
const plan = windowsInstallerLaunchPlan('C:\\Temp\\hermes setup.exe', {
silent: true,
installDir: 'C:\\Hermes',
trayPath: 'C:\\Hermes\\hermes-relay-tray.exe',
restartTray: false
})
assert.equal(plan.options.env?.HERMES_RELAY_SETUP_RESTART_TRAY, '0')
assert.match(plan.args.join(' '), /HERMES_RELAY_SETUP_RESTART_TRAY/)
})
test('PowerShell launches an installer whose path contains spaces via environment transport', {
skip: process.platform !== 'win32'
}, async () => {
@@ -129,6 +160,7 @@ test('POSIX installer only advertises artifacts produced by the release workflow
assert.match(workflow, /if \[ "\$exit_code" -ne 0 \]/)
assert.match(workflow, /Smoke exact macOS CLI release asset/)
assert.match(workflow, /release-assets\/\$native_asset" --version/)
assert.match(workflow, /installed UI version mismatch/)
})
test('bootstrap installers paginate release discovery beyond the first API page', async () => {
+110 -2
View File
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { updateCommand } from '../src/commands/update.js'
import { installerRunsSilently, updateCommand } from '../src/commands/update.js'
import {
assetNameForPlatform,
checkForUpdate,
@@ -147,6 +147,14 @@ test('installer check reports a newer local preview without treating it as an er
command: 'update',
positional: [],
flags: { installer: true, check: true, json: true, repo: 'example/hermes-relay' }
}, {
platform: 'win32',
bundle: {
installed: false,
path: 'C:\\Hermes\\hermes-relay-tray.exe',
version: null,
running: false
}
}))
assert.equal(check.code, 0)
const report = JSON.parse(check.output)
@@ -160,10 +168,110 @@ test('installer check reports a newer local preview without treating it as an er
command: 'update',
positional: [],
flags: { installer: true, yes: true, json: true, repo: 'example/hermes-relay' }
}, {
platform: 'win32',
bundle: {
installed: false,
path: 'C:\\Hermes\\hermes-relay-tray.exe',
version: null,
running: false
}
}))
assert.equal(install.code, 2)
assert.match(JSON.parse(install.output).error, /older than this CLI/)
assert.match(JSON.parse(install.output).error, /older than the installed CLI/)
} finally {
globalThis.fetch = originalFetch
}
})
test('default Windows update targets the installer when an older UI is installed', async () => {
const originalFetch = globalThis.fetch
globalThis.fetch = async () => new Response(JSON.stringify([{
tag_name: `desktop-v${VERSION}`,
prerelease: VERSION.includes('-'),
published_at: '2026-09-01T00:00:00Z',
assets: [
{
name: 'hermes-relay-win-x64.exe',
browser_download_url: 'https://download.test/hermes-relay.exe',
size: 10
},
{
name: 'hermes-relay-windows-x64-setup.exe',
browser_download_url: 'https://download.test/setup.exe',
size: 20
}
]
}]), { status: 200, headers: { 'content-type': 'application/json' } })
try {
const check = await captureStdout(() => updateCommand({
command: 'update',
positional: [],
flags: { check: true, json: true, repo: 'example/hermes-relay' }
}, {
platform: 'win32',
bundle: {
installed: true,
path: 'C:\\Hermes\\hermes-relay-tray.exe',
version: '0.4.0-beta.4',
running: true
}
}))
assert.equal(check.code, 0)
const report = JSON.parse(check.output)
assert.equal(report.target, 'cli_ui')
assert.equal(report.asset_name, 'hermes-relay-windows-x64-setup.exe')
assert.equal(report.ui_installed, true)
assert.equal(report.ui_version, '0.4.0-beta.4')
assert.equal(report.versions_in_sync, false)
assert.equal(report.up_to_date, false)
} finally {
globalThis.fetch = originalFetch
}
})
test('default Windows update preserves a headless CLI-only installation', async () => {
const originalFetch = globalThis.fetch
globalThis.fetch = async () => new Response(JSON.stringify([{
tag_name: `desktop-v${VERSION}`,
prerelease: VERSION.includes('-'),
published_at: '2026-09-01T00:00:00Z',
assets: [{
name: 'hermes-relay-win-x64.exe',
browser_download_url: 'https://download.test/hermes-relay.exe',
size: 10
}]
}]), { status: 200, headers: { 'content-type': 'application/json' } })
try {
const check = await captureStdout(() => updateCommand({
command: 'update',
positional: [],
flags: { check: true, json: true, repo: 'example/hermes-relay' }
}, {
platform: 'win32',
bundle: {
installed: false,
path: 'C:\\Hermes\\hermes-relay-tray.exe',
version: null,
running: false
}
}))
assert.equal(check.code, 0)
const report = JSON.parse(check.output)
assert.equal(report.target, 'cli')
assert.equal(report.asset_name, 'hermes-relay-win-x64.exe')
assert.equal(report.ui_installed, false)
assert.equal(report.versions_in_sync, true)
assert.equal(report.up_to_date, true)
} finally {
globalThis.fetch = originalFetch
}
})
test('existing bundle updates suppress the installer finish-page restart choice', () => {
assert.equal(installerRunsSilently(true, false, false), true)
assert.equal(installerRunsSilently(false, false, false), false)
assert.equal(installerRunsSilently(false, true, false), true)
})
+10
View File
@@ -74,6 +74,16 @@ Check the lane without starting work:
`status` exits 0 when idle and 1 when busy. It intentionally does not expose the
owning process's arguments because Gradle properties can contain credentials.
For Android release-note/version iteration, use the narrow release-prep lane:
```powershell
python scripts/android-prepush.py --release-prep
```
It runs repository metadata checks plus the rendered Changelog and What's New
tests. It intentionally omits lint and assembly; after the exact commit is
pushed, required CI and Play preflight provide those expensive proofs.
Use `exec` for a connected/device workflow that must exclude every
wrapper-managed Gradle lane, including explicit APK installation:
+92
View File
@@ -4304,3 +4304,95 @@ API-only/headless clients, compatibility testing, and existing API records, but
they are no longer automatic recovery for standard Chat. Users retry or sign in
without losing local work, named profiles cannot cross databases silently, and
readiness reflects the conversation that will actually receive the next turn.
---
## ADR 72 — Plugin and CLI+UI release tags are approval-created
**Status:** Accepted (2026-09-01).
**Context.** Plugin and CLI+UI tag workflows correctly rejected stable tags
outside `main` and prerelease tags outside `dev`, but that validation occurred
only after an operator pushed the tag. The CLI+UI release recipe also told
operators to tag a prerelease from `main`, contradicting the canonical branch
contract. A rejected tag therefore left a failed check on an otherwise healthy
release commit and required destructive tag recovery before publication.
**Decision.** The normal Plugin and CLI+UI release path validates first and
creates the tag second. A single manual approval workflow:
- always runs the trusted workflow definition from `main`, then selects the
exact `origin/main` tip for stable versions or `origin/dev` for prereleases;
- verifies the surface-owned version metadata and matching changelog heading;
- refuses an existing tag, then creates the exact `server-v*` or `desktop-v*`
ref at the validated commit; and
- dispatches the trusted release workflow from `main`, whose jobs explicitly
check out and revalidate that immutable tag.
Direct tag pushes remain a recovery path and retain the same fail-closed
metadata and branch-containment checks. Approval does not weaken release tests,
change stable/prerelease source branches, or combine the independently
versioned Plugin and CLI+UI artifacts.
**Consequences.** Routine releases cannot create a known-invalid tag before
discovering a branch mismatch. A tag created with `GITHUB_TOKEN` does not
recursively trigger Actions, so approval explicitly dispatches the release
workflow and the release workflow supports both tag-push and approved-dispatch
events. The workflow must exist on `main` before the approval surface is used.
**Key files:**
- `.github/workflows/approve-release-extensions.yml`
- `.github/workflows/release-plugin.yml`
- `.github/workflows/release-cli.yml`
- `RELEASE.md`
---
## ADR 73 — Release promotion reuses immutable exact-tree evidence
**Status:** Accepted (2026-09-01).
**Context.** A coordinated release previously compiled Android up to four
times: local release verification, release-prep PR CI, private Play preflight,
and public tag publication. The canonical `dev` to `main` PR also repeated the
same path-aware matrix even when its synthetic merge produced the exact Git
tree already tested before integration. Rebuilding identical source increased
elapsed time without adding byte-level provenance.
**Decision.** Release evidence is content-addressed by Git tree and promoted
only through immutable GitHub Actions artifacts.
- Play preflight is the one stable Android signing/build authority. It stores
the exact signed sideload APK, Play AAB, both R8 mappings, a manifest with
version/commit/tree/size/hash metadata, and checksums for public assets.
- Stable Android publication downloads that artifact by immutable ID, verifies
its successful trusted workflow run and full manifest, reruns package-level
DEX/native checks, promotes the existing Play draft, and publishes the same
APK/AAB bytes. Prerelease candidates retain their independent build path.
- Every successful Required-checks run stores a small tree-keyed proof after
all selected jobs pass. A canonical `dev` to `main` PR may reuse it only when
the simulated merge tree equals the `dev` tree exactly. Missing proof or any
content change automatically runs the ordinary matrix.
- Local Android release iteration runs metadata and release-presentation tests;
exact pushed commits still require CI and Play preflight.
- A coordinated approval workflow dispatches independently validated surface
approvals concurrently; it does not combine tags or artifact contracts.
- Trusted desktop CI and the CLI+UI release workflow share a lockfile- and
exact-source-keyed Rust/Tauri target cache. Release tags may restore the
default branch's exact build state; cache misses retain the full build path.
**Consequences.** Stable Android bytes are built once, evidence reuse fails
closed on tree or hash drift, and release PRs avoid duplicate work without
weakening branch protection. Actions artifact retention becomes part of the
release window: expired evidence causes a safe rebuild/fallback, never an
approval bypass.
**Key files:**
- `.github/workflows/play-preflight-android.yml`
- `.github/workflows/release-android.yml`
- `.github/workflows/ci-required.yml`
- `.github/workflows/approve-release-train.yml`
- `scripts/android_release_artifacts.py`
- `scripts/android-prepush.py`
+25 -7
View File
@@ -1,11 +1,13 @@
#!/usr/bin/env python3
"""Run the optional full local Android pre-push gate.
"""Run focused local Android feedback or the optional full pre-push gate.
By default the command checks the primary Play debug variant and focused CI
unit tests in one Gradle invocation. `--both-flavors` expands that to full lint
and both focused flavor shards. Agents should prefer Android On-Demand after an
exact commit is already pushed; this remains available when full local proof is
explicitly wanted or cloud execution is unavailable.
explicitly wanted or cloud execution is unavailable. ``--release-prep`` keeps
local release iteration to metadata plus the release-presentation tests; cloud
CI and Play preflight own the expensive exact-commit build lanes.
"""
from __future__ import annotations
@@ -38,6 +40,10 @@ FOCUSED_TESTS = (
"com.hermesandroid.relay.ui.screens.ChangelogScreenTest",
"com.hermesandroid.relay.ui.screens.ChatUnreadStateTest",
)
RELEASE_PREP_TESTS = (
"com.hermesandroid.relay.screenshots.ChangelogHistoryScreenshotTest",
"com.hermesandroid.relay.screenshots.WhatsNewToastScreenshotTest",
)
REPOSITORY_CHECKS = (
"check-android-locales.py",
"check-user-docs-locales.py",
@@ -79,14 +85,22 @@ def main() -> int:
action="store_true",
help="Run full lint and focused tests for sideload and Google Play",
)
parser.add_argument(
"--release-prep",
action="store_true",
help="Run metadata checks and release-presentation tests only",
)
args = parser.parse_args()
if args.release_prep and (args.skip_lint or args.skip_tests or args.both_flavors):
parser.error("--release-prep cannot be combined with other lane flags")
env = android_environment()
for script in REPOSITORY_CHECKS:
run(script, [sys.executable, str(REPO_ROOT / "scripts" / script)], env)
tasks: list[str] = []
if not args.skip_lint:
selected_tests = RELEASE_PREP_TESTS if args.release_prep else FOCUSED_TESTS
if not args.skip_lint and not args.release_prep:
tasks.append("lint" if args.both_flavors else ":app:lintGooglePlayDebug")
if not args.skip_tests:
tasks.append(":app:testSideloadDebugUnitTest")
@@ -114,12 +128,16 @@ def main() -> int:
*tasks,
])
if not args.skip_tests:
for test_name in FOCUSED_TESTS:
for test_name in selected_tests:
gradle.extend(("--tests", test_name))
label = (
"Full local Android lint and focused tests"
if args.both_flavors
else "Google Play lint and focused tests"
"Android release metadata and presentation tests"
if args.release_prep
else (
"Full local Android lint and focused tests"
if args.both_flavors
else "Google Play lint and focused tests"
)
)
run(label, gradle, env)
print("\nAndroid pre-push checks passed.")
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""Package and verify immutable Android Play-preflight release artifacts."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import shutil
import sys
from pathlib import Path
from typing import Any
SCHEMA_VERSION = 1
MANIFEST_NAME = "play-preflight.json"
CHECKSUMS_NAME = "SHA256SUMS.txt"
GIT_OBJECT_PATTERN = re.compile(r"^[0-9a-f]{40}$")
EXPECTED_ROLES = {
"sideload-apk": "hermes-relay-{version}-sideload-release.apk",
"google-play-aab": "hermes-relay-{version}-googlePlay-release.aab",
"sideload-mapping": "mapping-sideloadRelease.txt",
"google-play-mapping": "mapping-googlePlayRelease.txt",
}
PUBLIC_ROLES = {"sideload-apk", "google-play-aab"}
class ArtifactError(ValueError):
"""Raised when a release artifact set violates its contract."""
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def safe_name(name: str) -> str:
candidate = Path(name)
if candidate.name != name or name in {"", ".", ".."}:
raise ArtifactError(f"unsafe artifact filename: {name!r}")
return name
def checksum_text(entries: list[dict[str, Any]]) -> str:
public = [entry for entry in entries if entry["role"] in PUBLIC_ROLES]
return "".join(
f"{entry['sha256']} {entry['name']}\n"
for entry in sorted(public, key=lambda item: item["name"])
)
def package_artifacts(args: argparse.Namespace) -> None:
if not GIT_OBJECT_PATTERN.fullmatch(args.commit):
raise ArtifactError(f"invalid commit id: {args.commit!r}")
if not GIT_OBJECT_PATTERN.fullmatch(args.tree):
raise ArtifactError(f"invalid tree id: {args.tree!r}")
if not str(args.version_code).isdigit():
raise ArtifactError(f"invalid versionCode: {args.version_code!r}")
output = args.output.resolve()
output.mkdir(parents=True, exist_ok=True)
if any(output.iterdir()):
raise ArtifactError(f"output directory must be empty: {output}")
sources = {
"sideload-apk": args.sideload_apk.resolve(),
"google-play-aab": args.google_play_aab.resolve(),
"sideload-mapping": args.sideload_mapping.resolve(),
"google-play-mapping": args.google_play_mapping.resolve(),
}
entries: list[dict[str, Any]] = []
for role, source in sources.items():
if not source.is_file():
raise ArtifactError(f"missing {role}: {source}")
destination_name = safe_name(EXPECTED_ROLES[role].format(version=args.version))
if role in PUBLIC_ROLES and source.name != destination_name:
raise ArtifactError(
f"{role} must be named {destination_name}, got {source.name}"
)
destination = output / destination_name
shutil.copy2(source, destination)
entries.append(
{
"role": role,
"name": destination.name,
"sha256": sha256(destination),
"size": destination.stat().st_size,
}
)
manifest = {
"schemaVersion": SCHEMA_VERSION,
"version": args.version,
"versionCode": str(args.version_code),
"commit": args.commit,
"tree": args.tree,
"track": "production",
"status": "draft",
"artifacts": sorted(entries, key=lambda item: item["role"]),
}
(output / MANIFEST_NAME).write_text(
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
(output / CHECKSUMS_NAME).write_text(
checksum_text(entries),
encoding="utf-8",
newline="\n",
)
print(json.dumps(manifest, sort_keys=True))
def verify_artifacts(args: argparse.Namespace) -> None:
if not GIT_OBJECT_PATTERN.fullmatch(args.tree):
raise ArtifactError(f"invalid expected tree id: {args.tree!r}")
if not str(args.version_code).isdigit():
raise ArtifactError(f"invalid expected versionCode: {args.version_code!r}")
directory = args.directory.resolve()
try:
manifest = json.loads((directory / MANIFEST_NAME).read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise ArtifactError(f"missing {MANIFEST_NAME}") from exc
except json.JSONDecodeError as exc:
raise ArtifactError(f"invalid {MANIFEST_NAME}: {exc}") from exc
if not isinstance(manifest, dict):
raise ArtifactError(f"{MANIFEST_NAME} must contain an object")
expected_metadata = {
"schemaVersion": SCHEMA_VERSION,
"version": args.version,
"versionCode": str(args.version_code),
"tree": args.tree,
"track": "production",
"status": "draft",
}
for key, expected in expected_metadata.items():
if str(manifest.get(key)) != str(expected):
raise ArtifactError(
f"manifest {key} mismatch: expected {expected!r}, got {manifest.get(key)!r}"
)
commit = manifest.get("commit")
if not isinstance(commit, str) or not GIT_OBJECT_PATTERN.fullmatch(commit):
raise ArtifactError(f"manifest commit is invalid: {commit!r}")
raw_entries = manifest.get("artifacts")
if not isinstance(raw_entries, list):
raise ArtifactError("manifest artifacts must be a list")
entries: dict[str, dict[str, Any]] = {}
for raw_entry in raw_entries:
if not isinstance(raw_entry, dict):
raise ArtifactError("every artifact entry must be an object")
role = raw_entry.get("role")
if role not in EXPECTED_ROLES:
raise ArtifactError(f"unexpected artifact role: {role!r}")
if role in entries:
raise ArtifactError(f"duplicate artifact role: {role}")
entries[str(role)] = raw_entry
if set(entries) != set(EXPECTED_ROLES):
missing = sorted(set(EXPECTED_ROLES) - set(entries))
raise ArtifactError("missing artifact roles: " + ", ".join(missing))
verified: list[dict[str, Any]] = []
for role, expected_pattern in EXPECTED_ROLES.items():
entry = entries[role]
name = safe_name(str(entry.get("name", "")))
expected_name = expected_pattern.format(version=args.version)
if name != expected_name:
raise ArtifactError(f"{role} must be named {expected_name}, got {name}")
path = directory / name
if not path.is_file():
raise ArtifactError(f"missing artifact file: {name}")
if sha256(path) != entry.get("sha256"):
raise ArtifactError(f"SHA-256 mismatch for {name}")
if path.stat().st_size != entry.get("size"):
raise ArtifactError(f"size mismatch for {name}")
verified.append(entry)
try:
actual_checksums = (directory / CHECKSUMS_NAME).read_text(encoding="utf-8")
except FileNotFoundError as exc:
raise ArtifactError(f"missing {CHECKSUMS_NAME}") from exc
if actual_checksums != checksum_text(verified):
raise ArtifactError(f"{CHECKSUMS_NAME} does not match the manifest")
allowed = {
MANIFEST_NAME,
CHECKSUMS_NAME,
*(entry["name"] for entry in verified),
}
actual = {path.name for path in directory.iterdir() if path.is_file()}
if actual != allowed:
raise ArtifactError(
"artifact set mismatch: expected "
+ ", ".join(sorted(allowed))
+ "; got "
+ ", ".join(sorted(actual))
)
if any(not path.is_file() for path in directory.iterdir()):
raise ArtifactError("artifact directory contains a non-file entry")
print(json.dumps(manifest, sort_keys=True))
def parser() -> argparse.ArgumentParser:
root = argparse.ArgumentParser(description=__doc__)
commands = root.add_subparsers(dest="command", required=True)
package = commands.add_parser("package")
package.add_argument("--version", required=True)
package.add_argument("--version-code", required=True)
package.add_argument("--commit", required=True)
package.add_argument("--tree", required=True)
package.add_argument("--sideload-apk", required=True, type=Path)
package.add_argument("--google-play-aab", required=True, type=Path)
package.add_argument("--sideload-mapping", required=True, type=Path)
package.add_argument("--google-play-mapping", required=True, type=Path)
package.add_argument("--output", required=True, type=Path)
package.set_defaults(func=package_artifacts)
verify = commands.add_parser("verify")
verify.add_argument("--version", required=True)
verify.add_argument("--version-code", required=True)
verify.add_argument("--tree", required=True)
verify.add_argument("--directory", required=True, type=Path)
verify.set_defaults(func=verify_artifacts)
return root
def main() -> int:
args = parser().parse_args()
try:
args.func(args)
except ArtifactError as exc:
print(f"artifact contract failed: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+22
View File
@@ -42,6 +42,28 @@ class AndroidPrepushTest(unittest.TestCase):
self.assertIn(":app:testSideloadDebugUnitTest", gradle)
self.assertIn(":app:testGooglePlayDebugUnitTest", gradle)
def test_release_prep_runs_only_release_presentation_tests(self) -> None:
calls = self.run_main("--release-prep")
gradle = calls[-1].args[1]
self.assertNotIn(":app:lintGooglePlayDebug", gradle)
self.assertNotIn("lint", gradle)
self.assertIn(":app:testSideloadDebugUnitTest", gradle)
for test_name in android_prepush.RELEASE_PREP_TESTS:
self.assertIn(test_name, gradle)
self.assertNotIn(android_prepush.FOCUSED_TESTS[0], gradle)
def test_release_prep_rejects_full_lane_flags(self) -> None:
with (
mock.patch.object(
sys,
"argv",
["android-prepush.py", "--release-prep", "--both-flavors"],
),
self.assertRaises(SystemExit),
):
android_prepush.main()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,103 @@
from __future__ import annotations
import importlib.util
import io
import json
import tempfile
import unittest
from argparse import Namespace
from contextlib import redirect_stdout
from pathlib import Path
SCRIPT = Path(__file__).parents[1] / "android_release_artifacts.py"
SPEC = importlib.util.spec_from_file_location("android_release_artifacts", SCRIPT)
assert SPEC and SPEC.loader
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)
class AndroidReleaseArtifactsTest(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
self.apk = self.root / "hermes-relay-1.15.1-sideload-release.apk"
self.aab = self.root / "hermes-relay-1.15.1-googlePlay-release.aab"
self.sideload_mapping = self.root / "sideload-mapping.txt"
self.play_mapping = self.root / "play-mapping.txt"
for path, payload in (
(self.apk, b"apk"),
(self.aab, b"aab"),
(self.sideload_mapping, b"sideload mapping"),
(self.play_mapping, b"play mapping"),
):
path.write_bytes(payload)
self.output = self.root / "output"
self.package_args = Namespace(
version="1.15.1",
version_code="54",
commit="a" * 40,
tree="b" * 40,
sideload_apk=self.apk,
google_play_aab=self.aab,
sideload_mapping=self.sideload_mapping,
google_play_mapping=self.play_mapping,
output=self.output,
)
self.verify_args = Namespace(
version="1.15.1",
version_code="54",
tree="b" * 40,
directory=self.output,
)
def tearDown(self) -> None:
self.temp.cleanup()
def package(self) -> None:
with redirect_stdout(io.StringIO()):
MODULE.package_artifacts(self.package_args)
def test_package_and_verify_round_trip(self) -> None:
self.package()
with redirect_stdout(io.StringIO()):
MODULE.verify_artifacts(self.verify_args)
manifest = json.loads(
(self.output / MODULE.MANIFEST_NAME).read_text(encoding="utf-8")
)
self.assertEqual(
set(MODULE.EXPECTED_ROLES),
{item["role"] for item in manifest["artifacts"]},
)
checksums = (self.output / MODULE.CHECKSUMS_NAME).read_text(encoding="utf-8")
self.assertIn(self.apk.name, checksums)
self.assertIn(self.aab.name, checksums)
self.assertNotIn("mapping", checksums)
def test_verify_rejects_tampered_binary(self) -> None:
self.package()
(self.output / self.apk.name).write_bytes(b"tampered")
with self.assertRaisesRegex(MODULE.ArtifactError, "SHA-256 mismatch"):
MODULE.verify_artifacts(self.verify_args)
def test_verify_rejects_wrong_tree(self) -> None:
self.package()
self.verify_args.tree = "c" * 40
with self.assertRaisesRegex(MODULE.ArtifactError, "tree mismatch"):
MODULE.verify_artifacts(self.verify_args)
def test_verify_rejects_extra_file(self) -> None:
self.package()
(self.output / "unexpected.bin").write_bytes(b"unexpected")
with self.assertRaisesRegex(MODULE.ArtifactError, "artifact set mismatch"):
MODULE.verify_artifacts(self.verify_args)
def test_package_requires_canonical_public_names(self) -> None:
self.package_args.sideload_apk = self.root / "wrong.apk"
self.package_args.sideload_apk.write_bytes(b"wrong")
with self.assertRaisesRegex(MODULE.ArtifactError, "must be named"):
MODULE.package_artifacts(self.package_args)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -156,7 +156,7 @@ While inside the shell/TUI session (bare `hermes-relay`, the default mode), `Ctr
- **[Native paste / screenshot / image](./subcommands.md)** — the chord set above, plus REPL slash commands `/paste`, `/screenshot`, `/screenshot primary`, `/screenshot 1`, `/image <path>`. Multi-monitor aware: `/screenshot` defaults to the virtual-screen union; `primary` / a 1-indexed display narrows. Identical wire format to a local Hermes paste.
- **[Local tool routing](./tools.md)** — 23 agent-callable tools: file I/O, unified-diff patching, ripgrep, shell + PowerShell exec, process control, a background-job API, archive/transfer, clipboard, screenshot, and editor-launcher. Strict consent gate per relay URL; non-TTY stdin fails closed. The experimental computer-use family is off by default and has a separate persistent enablement switch.
- **[Self-update](./subcommands.md#hermes-relay-update)** — `hermes-relay update` polls GitHub Releases, semver-compares, downloads + verifies SHA256, and atomic-swaps the binary. POSIX renames in place; Windows uses cooperative `.new.exe` swap on next start.
- **[Self-update](./subcommands.md#hermes-relay-update)** — `hermes-relay update` polls GitHub Releases, semver-compares, and verifies SHA256. POSIX and Windows CLI-only installs replace the standalone binary; a detected Windows UI installation updates the complete CLI+UI bundle and restores the processes that were running.
- **[Surface plugins](./subcommands.md#hermes-relay-plugins)** — install, update, and launch terminal dashboard plugins from the CLI. The first built-in plugin is [Herm](https://github.com/liftaris/herm), installed as `herm-tui` and resumed with `herm -c`.
- **[Workspace awareness](./subcommands.md#hermes-relay-workspace)** — on connect, the client advertises `cwd`, `git_root`, `git_branch`, `repo_name`, `hostname`, `platform`, `active_shell` to the relay so the agent knows which repo you're in. Client-side capability shipped in alpha.6; server-side prompt-context consumption is on the way (see [ROADMAP.md](https://github.com/Codename-11/hermes-relay/blob/main/ROADMAP.md#desktop-track-parallel-lane-to-android--experimental)).
- **[Conversation picker](./subcommands.md#hermes-relay-shell)** — on first or fresh attach, choose from recent server-side Hermes conversations with first-prompt previews before the TUI starts.
+4 -3
View File
@@ -209,12 +209,13 @@ hermes-relay update --json # machine-readable status
The updater:
1. Polls the GitHub Releases API and picks the SemVer-max `desktop-v*` tag, with a migration fallback to historical `cli-v*` releases (prereleases included). The same resolver as the install scripts — fixed in alpha.11; pre-alpha.11 builds may report "Up to date" when a newer alpha exists, so use the install one-liner once to bootstrap onto alpha.11+ if you're stuck below it.
2. SemVer-compares to your running version (`hermes-relay --version` — embedded at build time, accurate inside Bun-compiled binaries).
2. SemVer-compares to your running version (`hermes-relay --version` — embedded at build time, accurate inside Bun-compiled binaries). On Windows, it also reads the colocated UI executable's product version when the UI is installed and reports any CLI/UI drift.
3. Downloads the platform asset and verifies SHA256.
4. **POSIX (macOS / Linux):** atomic `fs.rename` over the running binary. The running process keeps the old inode open, so `hermes-relay daemon` (if running) keeps serving until restarted; the next `hermes-relay <verb>` invocation picks up the new binary.
5. **Windows:** can't replace a running `.exe`, so the updater writes to `<bin>.new.exe` and `finalizePendingUpdate()` runs at the top of `main()` on every subsequent invocation to rename it into place. Result: the swap completes the **next** time you run `hermes-relay`.
5. **Windows CLI-only:** can't replace a running `.exe`, so the updater writes to `<bin>.new.exe` and `finalizePendingUpdate()` runs at the top of `main()` on every subsequent invocation to rename it into place. Result: the swap completes the **next** time you run `hermes-relay`.
6. **Windows CLI+UI:** when the colocated management UI exists, the normal command selects the checksum-verified NSIS bundle instead of the standalone CLI asset. Setup waits for the invoking CLI to exit, replaces both executables, and restores the daemon and tray only when they were running before the update. A stopped tray remains stopped.
`hermes-relay update` updates the CLI binary only. On Windows, `hermes-relay update --installer` updates the complete CLI + UI bundle. The UI's update action uses the same bundle path, restarts the affected processes, and reopens the UI after replacement.
`hermes-relay update` automatically updates the complete CLI+UI bundle when the Windows management UI is installed. Explicit Windows CLI-only installations and all macOS/Linux installations keep the standalone binary path. `--installer` remains available to install or repair the Windows bundle explicitly, and the UI's update action uses the same verified installer contract.
If `hermes-relay update --check` says "Up to date" but you know there's a newer alpha, see the [troubleshooting note](./troubleshooting.md#hermes-relay-update-says-up-to-date-but-i-know-there-s-a-newer-alpha).
+7 -4
View File
@@ -386,10 +386,13 @@ hermes-relay update --json # machine-readable status
hermes-relay update --installer # Windows: update the complete CLI + UI bundle
```
The normal update path replaces only the CLI binary. On Windows, `--installer`
downloads the checksum-verified NSIS bundle so the CLI and management UI stay on
the same release. The UI's update action invokes this bundle path, stops the
affected processes, installs the update, and reopens the UI.
The normal update path replaces only the CLI binary on macOS, Linux, and
explicit Windows CLI-only installations. When a Windows management UI is
detected, the same command reports both installed versions and automatically
downloads the checksum-verified NSIS bundle so the CLI and UI cannot silently
drift. `--installer` explicitly installs or repairs that bundle. Bundle updates
stop the affected processes and restore only the daemon and tray processes that
were running before replacement; the UI's update action uses the same path.
## `hermes-relay ui`