Release version management: single VERSION file as source of truth
CI / Gateway plugin tests (push) Successful in 5m19s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m0s

- VERSION at repo root (0.1.2); bump it to cut a release
- App: generated AppVersion.kt (config-cache-safe Gradle task with
  VERSION as declared input) shown in Settings; sent to the gateway
  via X-Iris-App-Version header on the SSE open
- Gateway: reports its own version in hello.ack server_caps.app_version
  (read from the repo-root VERSION via the plugin symlink); stores the
  app's version in the device registry caps (merge, not overwrite, so
  an old app reconnecting without the header doesn't wipe it)
- Settings: app + gateway version rows, mismatch hint, and a best-effort
  Gitea latest-release check (ReleaseCheck) with an 'update available' hint
- Release workflow: reads VERSION from the repo (no manual input), with
  a guard against an empty file
- Docs: frames.schema.json + 04-wire-protocol.md updated for app_version
This commit is contained in:
ARIA committed 2026-08-25 14:42:39 +02:00
1 parent f3f1b37221
commit fb980d12b4
17 files changed
+423 -38

No files matched your search

+7 -7
View File
@@ -3,10 +3,8 @@ name: Release
on:
workflow_dispatch:
inputs:
version:
description: "Release version (e.g. 0.2.0)"
required: true
type: string
# The release version comes from the repo-root VERSION file (the single
# source of truth) — bump it in a commit, then dispatch this workflow.
changelog:
description: "Release notes (markdown, shown on the release page). Single-line field — use literal \\n for line breaks."
required: false
@@ -133,7 +131,8 @@ jobs:
- name: Build APK + AAB
run: |
VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
[ -n "$VERSION" ] || { echo "::error::VERSION file is missing or empty"; exit 1; }
cd app
if [ -n "$ANDROID_KEYSTORE_FILE" ]; then
# APK for direct sideloading, AAB for Play Store uploads.
@@ -155,7 +154,8 @@ jobs:
# for it (jpackage picks the native type: msi on Windows, dmg on macOS).
- name: Build desktop app-image + deb
run: |
VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
[ -n "$VERSION" ] || { echo "::error::VERSION file is missing or empty"; exit 1; }
cd app
# Self-contained app image (JRE bundled via jlink).
./gradlew :desktopApp:jpackage -PappVersion="$VERSION"
@@ -177,7 +177,7 @@ jobs:
SERVER="${GITEA_SERVER_URL:-$GITHUB_SERVER_URL}"
REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}"
TOKEN="${RELEASE_TOKEN:-$GITHUB_TOKEN}"
VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
# The dispatch input is a single-line field; turn literal \n into real newlines.
CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH" | sed 's/\\n/\n/g')
TAG="v$VERSION"
+49 -21
View File
@@ -100,11 +100,13 @@ jobs:
## 3. NEW FILE: `.gitea/workflows/release.yml`
Manual trigger: **repo → Actions → Release → Run workflow**, enter a
`version` (e.g. `0.2.0`) and a `changelog`. It runs the same tests as CI,
builds a signed Android APK + AAB and the Linux desktop packages (jpackage,
JRE bundled), then creates the Gitea release `v<version>` with all artifacts
as download attachments.
The release version is the repo-root **`VERSION` file** (single source of
truth — "everything from here on out is vX.Y.Z" = bump `VERSION` and
commit). Manual trigger: **repo → Actions → Release → Run workflow**,
optionally with a `changelog`. It runs the same tests as CI, builds a signed
Android APK + AAB and the Linux desktop packages (jpackage, JRE bundled),
then creates the Gitea release `v<VERSION>` with all artifacts as download
attachments.
Note: builds + release creation happen in ONE job because Gitea/act_runner
does not implement the GitHub artifacts API (`upload-artifact@v4+` fails
@@ -116,12 +118,10 @@ name: Release
on:
workflow_dispatch:
inputs:
version:
description: "Release version (e.g. 0.2.0)"
required: true
type: string
# The release version comes from the repo-root VERSION file (the single
# source of truth) — bump it in a commit, then dispatch this workflow.
changelog:
description: "Release notes (markdown, shown on the release page)"
description: "Release notes (markdown, shown on the release page). Single-line field — use literal \\n for line breaks."
required: false
type: string
@@ -246,7 +246,8 @@ jobs:
- name: Build APK + AAB
run: |
VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
[ -n "$VERSION" ] || { echo "::error::VERSION file is missing or empty"; exit 1; }
cd app
if [ -n "$ANDROID_KEYSTORE_FILE" ]; then
# APK for direct sideloading, AAB for Play Store uploads.
@@ -268,7 +269,8 @@ jobs:
# for it (jpackage picks the native type: msi on Windows, dmg on macOS).
- name: Build desktop app-image + deb
run: |
VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
[ -n "$VERSION" ] || { echo "::error::VERSION file is missing or empty"; exit 1; }
cd app
# Self-contained app image (JRE bundled via jlink).
./gradlew :desktopApp:jpackage -PappVersion="$VERSION"
@@ -290,20 +292,39 @@ jobs:
SERVER="${GITEA_SERVER_URL:-$GITHUB_SERVER_URL}"
REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}"
TOKEN="${RELEASE_TOKEN:-$GITHUB_TOKEN}"
VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH")
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
# The dispatch input is a single-line field; turn literal \n into real newlines.
CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH" | sed 's/\\n/\n/g')
TAG="v$VERSION"
API="$SERVER/api/v1/repos/$REPO"
AUTH="Authorization: token $TOKEN"
# Re-run safety: drop a previous release (and its tag) for this version.
OLD_ID=$(curl -sf -H "$AUTH" "$API/releases/tags/$TAG" | jq -r '.id // empty')
if [ -n "$OLD_ID" ]; then
curl -sf -X DELETE -H "$AUTH" "$API/releases/$OLD_ID" > /dev/null
# curl wrapper: on HTTP >= 400, print the response body (Gitea's error
# message) before failing — plain `curl -f` hides it (exit 22).
api() {
local code body
body=$(mktemp)
code=$(curl -s -o "$body" -w '%{http_code}' "$@") || { cat "$body"; rm -f "$body"; return 1; }
if [ "${code:0:1}" != "2" ]; then
echo "API error $code: $(cat "$body")" >&2
rm -f "$body"
return 1
fi
cat "$body"
rm -f "$body"
}
# Re-run safety: drop a previous release AND its tag for this version.
# (Gitea's DELETE /releases/:id does NOT remove the tag; a leftover tag
# makes the POST below fail with 409.)
OLD_ID=$(api -H "$AUTH" "$API/releases/tags/$TAG" | jq -r '.id // empty') || true
if [ -n "$OLD_ID" ]; then
api -X DELETE -H "$AUTH" "$API/releases/$OLD_ID" > /dev/null
fi
api -X DELETE -H "$AUTH" "$API/tags/$TAG" > /dev/null || true
# Gitea creates the tag at the default branch HEAD automatically.
RELEASE_ID=$(curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \
RELEASE_ID=$(api -X POST -H "$AUTH" -H "Content-Type: application/json" \
"$API/releases" \
-d "$(jq -n --arg tag "$TAG" --arg title "Iris $VERSION" --arg body "$CHANGELOG" \
'{tag_name:$tag, title:$title, body:$body}')" \
@@ -313,15 +334,22 @@ jobs:
for f in "$GITHUB_WORKSPACE"/iris-android-v* "$GITHUB_WORKSPACE"/iris-desktop-*; do
[ -f "$f" ] || continue
echo "Uploading $(basename "$f")"
curl -sf -X POST -H "$AUTH" -F "attachment=@$f" \
"$API/releases/$RELEASE_ID/attachments" > /dev/null
# Forgejo-style API: release assets live under /assets, not /attachments.
api -X POST -H "$AUTH" -F "attachment=@$f" \
"$API/releases/$RELEASE_ID/assets" > /dev/null
done
echo "Done: $SERVER/$REPO/releases/tag/$TAG"
```
---
## 4. EDITS to existing Gradle files
> **Note (versioning):** the `versionName` / `appVersion` lines shown below
> have since been changed to read the repo-root **`VERSION` file** (single
> source of truth; `-PappVersion` still overrides in CI). See
> `.gitea/workflows/release.yml` and `gateway-plugin/version.py`.
### 4a. `app/androidApp/build.gradle.kts`
**Change 1** — in `defaultConfig`, replace:
+1
View File
@@ -0,0 +1 @@
0.1.2
+10 -3
View File
@@ -16,9 +16,16 @@ android {
// Play Store requires an incrementing versionCode per upload; CI can
// pass -PappVersionCode=<n>. Local builds keep the default.
versionCode = (project.findProperty("appVersionCode")?.toString()?.toIntOrNull()) ?: 1
// CI passes -PappVersion=<version> (release workflow); local builds
// keep the default.
versionName = (project.findProperty("appVersion") as? String) ?: "0.1.0"
// The repo-root VERSION file is the single source of truth (bump it
// to cut a release); CI can still override with -PappVersion.
versionName =
(project.findProperty("appVersion") as? String)
?: project
.file("../../VERSION")
.takeIf { it.exists() }
?.readText()
?.trim()
?: "0.1.0"
}
buildTypes {
+11 -3
View File
@@ -9,9 +9,17 @@ plugins {
val composeVersion = "1.11.1"
val os = OperatingSystem.current()
val arch = System.getProperty("os.arch") ?: "amd64"
// CI passes -PappVersion=<version> (release workflow); local builds keep the
// default. jpackage requires a plain semver (no leading "v").
val appVersion = (project.findProperty("appVersion") as? String) ?: "0.1.0"
// The repo-root VERSION file is the single source of truth (bump it to cut
// a release); CI can still override with -PappVersion. jpackage requires a
// plain semver (no leading "v").
val appVersion =
(project.findProperty("appVersion") as? String)
?: project
.file("../../VERSION")
.takeIf { it.exists() }
?.readText()
?.trim()
?: "0.1.0"
val desktopTarget =
when {
os.isMacOsX -> if (arch == "aarch64") "macos-arm64" else "macos-x64"
+52
View File
@@ -33,6 +33,48 @@ val sqldelightVersion = "2.3.2"
val cameraxVersion = "1.5.1"
val mlKitVersion = "16.1.1"
// ── App version (generated) ─────────────────────────────────────────────
// The repo-root VERSION file is the single source of truth (bump it to cut
// a release; CI can still override with -PappVersion). Generate AppVersion.kt
// into commonMain so both targets can display it in Settings and send it to
// the gateway (X-Iris-App-Version header).
//
// A real task with the VERSION file as a DECLARED input: on a
// configuration-cache hit the script body does not re-run, so only the task
// (keyed on the file's content) can regenerate AppVersion.kt after a bump.
// The doLast reads everything from the task's own inputs/outputs (which are
// configuration-cache serializable) — it must not reference script-scope
// vals, because a .kts script lambda captures the script object and the
// configuration cache rejects that.
val generatedVersionDir = layout.buildDirectory.dir("generated/app-version")
val generateAppVersion by tasks.registering {
inputs.file(project.file("../../VERSION"))
inputs.property("appVersionOverride", (project.findProperty("appVersion") as? String).orEmpty())
val outFile = generatedVersionDir.map { it.file("AppVersion.kt") }
outputs.file(outFile)
doLast {
val override = inputs.properties["appVersionOverride"] as? String ?: ""
val versionFile = inputs.files.singleFile
val version =
override.ifBlank {
versionFile.takeIf { it.exists() }?.readText()?.trim() ?: "0.1.0"
}
val f = outFile.get().asFile
f.parentFile?.mkdirs()
f.writeText(
"""
|package iris
|
|/** Generated from the repo-root VERSION file - do not edit. */
|object AppVersion {
| const val VERSION = "$version"
|}
|
""".trimMargin(),
)
}
}
kotlin {
android {
namespace = "iris.shared"
@@ -53,6 +95,11 @@ kotlin {
}
sourceSets {
// AppVersion.kt is generated from the repo-root VERSION file (see
// generateAppVersion above) into commonMain so both targets can read it.
commonMain {
kotlin.srcDir(generatedVersionDir)
}
// Both targets are JVM-based (androidTarget + jvm("desktop")), so
// shared JVM code (File I/O, SHA-256, media cache) lives in jvmMain.
val jvmMain by creating { dependsOn(commonMain.get()) }
@@ -138,3 +185,8 @@ sqldelight {
}
}
}
// Every Kotlin compile depends on the generated AppVersion.kt being current.
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
dependsOn(generateAppVersion)
}
@@ -1,5 +1,6 @@
package iris.net
import iris.AppVersion
import iris.media.Sha256
import iris.media.isValidMediaId
import iris.protocol.ErrorPayload
@@ -135,6 +136,9 @@ class HttpGateway(
deviceName?.takeIf { it.isNotBlank() }?.let { b.add("X-Iris-Device-Name", it) }
fcmToken()?.takeIf { !it.isNullOrBlank() }?.let { b.add("X-Iris-Fcm-Token", it) }
ntfyTopic()?.takeIf { it.isNotBlank() }?.let { b.add("X-Iris-Ntfy-Topic", it) }
// Release version (repo-root VERSION baked in at build time); the
// gateway stores it in the device registry (docs/04 hello.ack note).
b.add("X-Iris-App-Version", AppVersion.VERSION)
return b.build()
}
@@ -0,0 +1,96 @@
package iris.net
import iris.protocol.IrisJson
import iris.util.IrisLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
/**
* Latest-release check (docs/04 hello.ack note): the repo is public on Gitea,
* so the app can ask for the newest release tag without any token. Settings
* shows "vX.Y.Z available" when the running build is older.
*
* Best-effort by design: any failure (offline, DNS, rate limit) just yields
* `null` — the check must never surface an error in the UI.
*/
object ReleaseCheck {
const val LATEST_RELEASE_URL =
"https://gitea.zephyre.one/api/v1/repos/ARIA/iris_x_hermes/releases/latest"
/**
* One shared client for the process lifetime: an OkHttpClient owns a
* thread pool and connection pool, so it must not be rebuilt per screen
* open (and never needs explicit shutdown — the pools idle out). Short
* timeouts so a black-holed network can't linger the LaunchedEffect.
*/
private val client: OkHttpClient =
OkHttpClient
.Builder()
.connectTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.build()
/**
* The latest release version (tag without the leading "v"), or `null`
* when the check could not be performed.
*/
suspend fun latestVersion(): String? =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(LATEST_RELEASE_URL)
.get()
.build()
try {
client.newCall(request).execute().use { response: Response ->
if (!response.isSuccessful) {
IrisLog.d("ReleaseCheck: HTTP ${response.code}")
return@withContext null
}
val body = response.body?.string() ?: return@withContext null
val tag =
IrisJson
.instance
.parseToJsonElement(body)
.jsonObject
.get("tag_name")
?.jsonPrimitive
?.content
.orEmpty()
tag.removePrefix("v").ifBlank { null }
}
} catch (e: Exception) {
IrisLog.d("ReleaseCheck: ${e.message}")
null
}
}
/**
* True when [latest] is a newer release than [running]. Numeric
* component-wise comparison (so "0.1.10" > "0.1.9"); anything that does
* not parse as dotted numbers is treated as "not newer" — the hint is
* best-effort and must never fire for dev builds ahead of the latest
* release.
*/
fun isNewer(
latest: String,
running: String,
): Boolean {
val a = latest.split('.').mapNotNull { it.takeWhile(Char::isDigit).toIntOrNull() }
val b = running.split('.').mapNotNull { it.takeWhile(Char::isDigit).toIntOrNull() }
if (a.isEmpty() || b.isEmpty()) return false
val n = maxOf(a.size, b.size)
for (i in 0 until n) {
val x = a.getOrElse(i) { 0 }
val y = b.getOrElse(i) { 0 }
if (x != y) return x > y
}
return false
}
}
@@ -140,6 +140,8 @@ data class ServerCaps(
val push: String = "fcm",
@SerialName("push_ntfy_server") val pushNtfyServer: String = "",
val pickers: Boolean = false,
/** Release version of the gateway plugin (repo-root VERSION file). */
@SerialName("app_version") val appVersion: String = "",
)
@Serializable
@@ -158,6 +158,15 @@ class IrisController(
private val _gatewayStatus = MutableStateFlow<String?>(null)
val gatewayStatus: StateFlow<String?> = _gatewayStatus.asStateFlow()
/**
* Release version of the connected gateway (hello.ack `server_caps.app_version`,
* the repo-root VERSION file). Empty when not connected or the gateway is
* old enough not to report it. Settings shows it next to the app version
* and hints when the two differ.
*/
private val _gatewayVersion = MutableStateFlow("")
val gatewayVersion: StateFlow<String> = _gatewayVersion.asStateFlow()
// ── M8: unread indicator ──────────────────────────────────────────────
/** True while the current lane's newest content sits at the bottom of the
@@ -889,6 +898,11 @@ class IrisController(
// just close the in-flight turn's dangling tool cards /
// streaming bubble (nothing spins forever).
chat.finalizeInterrupted()
// The gateway is gone — clear the advertised version so
// Settings doesn't keep showing it (and the mismatch
// hint) while disconnected (matches the KDoc: empty when
// not connected).
_gatewayVersion.value = ""
}
}
}
@@ -927,6 +941,7 @@ class IrisController(
* refreshes (skipped on a plain reconnect via historyLoaded).
*/
private fun onConnectedLane(connected: GatewayClient.State.Connected) {
_gatewayVersion.value = connected.caps.appVersion
// Never wipe the directory with an empty list: the long-poll restore
// path carries no channels when there was no prior SSE hello (lastAck
// null), and the cached directory is still valid then.
@@ -44,6 +44,8 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import iris.AppVersion
import iris.net.ReleaseCheck
import iris.platform.ImageFilePicker
import iris.platform.decodeImageBytes
import iris.platform.loadScaledImage
@@ -79,10 +81,18 @@ fun SettingsScreen(
val runtimeFooterFields by controller.runtimeFooterFields.collectAsState()
val toolDetail by controller.toolDetail.collectAsState()
val fontSizeScale by controller.fontSizeScale.collectAsState()
val gatewayVersion by controller.gatewayVersion.collectAsState()
val theme = LocalUserTheme.current
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) }
var showForgetConfirm by remember { mutableStateOf(false) }
// Best-effort latest-release check (docs/04): one query per screen open,
// failures stay silent (ReleaseCheck returns null).
var latestVersion by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
latestVersion = ReleaseCheck.latestVersion()
}
Box(modifier = Modifier.fillMaxSize().background(theme.background)) {
Column(
modifier =
@@ -434,6 +444,19 @@ fun SettingsScreen(
Text("Forget pairing", fontSize = 12.sp)
}
}
Text(
"About",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
)
SettingsCard {
VersionCard(
appVersion = AppVersion.VERSION,
gatewayVersion = gatewayVersion,
latestVersion = latestVersion,
)
}
}
when (pickerTarget) {
@@ -534,6 +557,47 @@ private fun BackArrow(
}
}
/**
* About card (docs/04 hello.ack note): app version (repo-root VERSION baked in
* at build time), the connected gateway's version (hello.ack
* `server_caps.app_version`), a mismatch hint, and the latest Gitea release
* when the running build is older.
*/
@Composable
private fun VersionCard(
appVersion: String,
gatewayVersion: String,
latestVersion: String?,
) {
Text("📦 Version", fontSize = 14.sp)
Text(
"Iris app v$appVersion",
fontSize = 12.sp,
color = IrisColors.textSecondary,
)
if (gatewayVersion.isNotBlank()) {
Text(
"Gateway v$gatewayVersion",
fontSize = 12.sp,
color = IrisColors.textSecondary,
)
if (gatewayVersion != appVersion) {
Text(
"App and gateway versions differ — update the other side to match.",
fontSize = 12.sp,
color = IrisColors.statusAmber,
)
}
}
latestVersion?.takeIf { ReleaseCheck.isNewer(it, appVersion) }?.let { latest ->
Text(
"New version v$latest available — see the Gitea releases page.",
fontSize = 12.sp,
color = IrisColors.statusAmber,
)
}
}
/** Settings row container (panel card). */
@Composable
private fun SettingsCard(content: @Composable () -> Unit) {
+10 -1
View File
@@ -40,7 +40,8 @@ Pairing succeeded.
```json
{"type":"hello.ack","payload":{
"server_caps":{"streaming":true,"reasoning":true,"tools":true,"media":true,
"search":true,"push":"fcm","pickers":true},
"search":true,"push":"fcm","pickers":true,
"app_version":"0.1.2"},
"sync_cursor":1042,
"last_pushed_cursor":1040,
"device_token":"9f2c…(64 hex)",
@@ -48,6 +49,14 @@ Pairing succeeded.
}}
```
`server_caps.app_version` is the gateway plugin's release version (the
repo-root `VERSION` file, `gateway-plugin/version.py`). The app shows it in
Settings → About (next to its own version) and hints when the app and
gateway versions differ.
The app reports its own version on the SSE open via the
`X-Iris-App-Version` header (stored in the device registry's `caps` JSON,
visible in `~/.hermes/.../devices.db`).
`last_pushed_cursor` is the highest outbox cursor already delivered to THIS
device via the push backend (0 = never). The app skips system notifications
for sync-replayed frames with `cursor <= last_pushed_cursor` — they already
+1 -1
View File
@@ -21,7 +21,7 @@
"hello.ack": {
"description": "Pairing succeeded.",
"payload": {
"server_caps": { "type": "object", "properties": { "streaming": {"type":"boolean"}, "reasoning": {"type":"boolean"}, "tools": {"type":"boolean"}, "media": {"type":"boolean"}, "search": {"type":"boolean"}, "push": {"type":"string","enum":["fcm","ntfy","none"]}, "push_ntfy_server": {"type":"string","description":"ntfy server URL for the app's listener; empty string when the backend is not ntfy."}, "pickers": {"type":"boolean"} } },
"server_caps": { "type": "object", "properties": { "streaming": {"type":"boolean"}, "reasoning": {"type":"boolean"}, "tools": {"type":"boolean"}, "media": {"type":"boolean"}, "search": {"type":"boolean"}, "push": {"type":"string","enum":["fcm","ntfy","none"]}, "push_ntfy_server": {"type":"string","description":"ntfy server URL for the app's listener; empty string when the backend is not ntfy."}, "pickers": {"type":"boolean"}, "app_version": {"type":"string","description":"Release version of the gateway plugin (repo-root VERSION file); the app shows it in Settings and hints on app/gateway mismatch."} } },
"sync_cursor": { "type": "integer" },
"last_pushed_cursor": { "type": "integer", "description": "Highest outbox cursor already delivered to THIS device via the push backend (0 = never). The app skips system notifications for sync-replayed frames at/below it (dedupe, docs/08 §8.7)." },
"device_token": { "type": "string", "description": "Per-device token minted at pairing (docs/09 §9.3). The app stores it and presents it INSTEAD of the shared IRIS_TOKEN from then on; the gateway can revoke it per device. Empty when the gateway didn't issue one (legacy)." },
+4
View File
@@ -135,6 +135,7 @@ from .setup import (
validate_config,
)
from .tool_frames import ToolProgressHandlers
from .version import plugin_version
logger = logging.getLogger(__name__)
@@ -778,6 +779,9 @@ class IrisAdapter(
self._push.server_url if isinstance(self._push, NtfyBackend) else ""
),
"pickers": True, # picker.choice / picker.select (slash-command menus)
# Release version from the repo-root VERSION file (version.py);
# the app shows it in Settings and hints on app/gateway mismatch.
"app_version": plugin_version(),
}
+16 -2
View File
@@ -490,11 +490,20 @@ class HttpServer:
device_name = (handler.headers.get("X-Iris-Device-Name") or "").strip()[:120]
fcm_token = handler.headers.get("X-Iris-Fcm-Token") or None
ntfy_topic = handler.headers.get("X-Iris-Ntfy-Topic") or None
# App release version (the repo-root VERSION baked into the build);
# stored in the device registry's caps JSON so `hermes` can see which
# app version each device runs (old-version awareness). A missing
# header (old app build) must not wipe a previously stored version,
# so merge over the existing caps instead of replacing them.
app_version = (handler.headers.get("X-Iris-App-Version") or "").strip()[:40]
try:
existing_caps = dict(self._devices.get(device_id) or {}).get("caps") or {}
if app_version:
existing_caps["app_version"] = app_version
self._devices.upsert(
device_id,
device_name or device_id,
None,
existing_caps or None,
fcm_token,
ntfy_topic,
)
@@ -523,7 +532,12 @@ class HttpServer:
# lifecycle is the primary "is the device connected?" signal
# for debugging flaky links — a gap here is invisible at the
# gateway's default log level.
logger.info("iris: SSE stream opened: %s (cursor=%d)", device_id, cursor)
logger.info(
"iris: SSE stream opened: %s (cursor=%d, app_version=%s)",
device_id,
cursor,
app_version or "?",
)
# 1. Catch-up from the outbox (id = cursor; the envelope also
# carries the cursor for the app's push dedupe).
max_cursor = cursor
+26
View File
@@ -0,0 +1,26 @@
"""Version discovery -- the repo-root ``VERSION`` file is the single source
of truth for the release version ("everything from here on out is vX.Y.Z"
= bump ``VERSION`` and commit).
The plugin lives at ``<repo>/gateway-plugin`` (installed into
``~/.hermes/plugins/iris`` as a symlink in production), so the ``VERSION``
file is one directory up. The value is advertised to the app in
``hello.ack`` (``server_caps.app_version``) so the app can show which
gateway version it is talking to.
"""
from __future__ import annotations
from pathlib import Path
_FALLBACK = "unknown"
def plugin_version() -> str:
"""The release version from ``<repo>/VERSION``, or ``"unknown"``."""
candidate = Path(__file__).resolve().parent.parent / "VERSION"
try:
version = candidate.read_text().strip()
except OSError:
return _FALLBACK
return version or _FALLBACK
+55
View File
@@ -431,6 +431,61 @@ async def upload_file(
# ── Lifecycle ───────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_hello_ack_advertises_app_version_and_sse_header_is_stored(
adapter, ws_client
):
"""hello.ack server_caps carries the gateway's release version, and the
app's X-Iris-App-Version header (sent on the SSE open) is stored in the
device registry's caps JSON (docs/04)."""
client, ack = ws_client
caps = ack["payload"]["server_caps"]
assert caps["app_version"] == sys.modules["iris_plugin_under_test"].version.plugin_version()
# The test client does not send the header; open a second SSE stream that
# does, and check the device registry picked it up.
from http.client import HTTPConnection
conn = HTTPConnection("127.0.0.1", adapter._http_server.bound_port, timeout=5)
conn.request(
"GET",
"/v1/events?cursor=0",
headers={
"Authorization": f"Bearer {TOKEN}",
"X-Iris-Device": DEVICE_ID,
"X-Iris-App-Version": "9.9.9",
},
)
resp = conn.getresponse()
assert resp.status == 200
resp.read(1) # don't hold the stream
conn.close()
device = adapter._devices.get(DEVICE_ID)
assert device is not None
assert device["caps"].get("app_version") == "9.9.9"
# A reconnect from an OLD app build (no X-Iris-App-Version header) must
# NOT wipe the previously stored version — the caps merge preserves it.
conn2 = HTTPConnection("127.0.0.1", adapter._http_server.bound_port, timeout=5)
conn2.request(
"GET",
"/v1/events?cursor=0",
headers={
"Authorization": f"Bearer {TOKEN}",
"X-Iris-Device": DEVICE_ID,
},
)
resp2 = conn2.getresponse()
assert resp2.status == 200
resp2.read(1)
conn2.close()
device2 = adapter._devices.get(DEVICE_ID)
assert device2 is not None
assert device2["caps"].get("app_version") == "9.9.9"
@pytest.mark.asyncio
async def test_disconnect_broadcasts_status_restarting(adapter):
"""Teardown broadcasts ``status{state=restarting}`` before closing the