Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9ee5348bf | ||
|
|
ee2a7840a2 | ||
|
|
52dd399565 | ||
|
|
86a0421163 | ||
|
|
0e191f946d | ||
|
|
065912f2bf | ||
|
|
075138433e | ||
|
|
92e65bbfc2 | ||
|
|
7ccd4ac4c9 | ||
|
|
5739e17750 | ||
|
|
037f4e91c8 | ||
|
|
685c3c5a24 | ||
|
|
6dc18abc4d | ||
|
|
ee43a87cec | ||
|
|
ce961b81d9 | ||
|
|
c7b392b26f | ||
|
|
0a04efbbcf | ||
|
|
11977dad56 | ||
|
|
c8fea9c061 | ||
|
|
b0a8909dc7 | ||
|
|
57c236e7da | ||
|
|
b015acb063 |
@@ -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.');
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
- **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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+9
-3
@@ -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")
|
||||
|
||||
+39
-10
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.1 MiB |
@@ -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
@@ -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`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user