diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c4875f554..ed5bcf759 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -35,6 +35,8 @@ jobs:
- run: bun run format:check
- run: bun run lint
- run: bun run typecheck
+ - name: Desktop version tracks the release version
+ run: bun desktop/scripts/desktop-version.ts check
# The chart's default image tag is its appVersion, and a release must move both together. They
# drifted once, appVersion left pointing at a tag that was never published, and a default
# `helm install` sat in ImagePullBackOff. This refuses a tree where package.json (the last
diff --git a/.github/workflows/desktop-signing.yml b/.github/workflows/desktop-signing.yml
index 6c08409f0..7e7d53dc5 100644
--- a/.github/workflows/desktop-signing.yml
+++ b/.github/workflows/desktop-signing.yml
@@ -15,6 +15,7 @@ on:
types: [opened, synchronize, reopened, labeled]
paths:
- desktop/**
+ - package.json
- .github/workflows/desktop-signing.yml
- docs/windows-signing.md
@@ -111,10 +112,13 @@ jobs:
working-directory: desktop
- run: bun run typecheck
working-directory: desktop
+ - name: Prepare internal build version
+ run: bun scripts/desktop-version.ts internal
+ working-directory: desktop
- name: Build and sign
env:
WINDOWS_SIGNING: keyvault
- run: bun run tauri build --config src-tauri/tauri.windows-signing.conf.json --bundles nsis
+ run: bun run tauri build --config src-tauri/tauri.windows-signing.conf.json --config src-tauri/tauri.build-version.conf.json --bundles nsis
working-directory: desktop
# Tauri restores the unsigned build output after bundling. Verify the app
# employees receive by extracting its signed payload from the installer.
@@ -125,7 +129,9 @@ jobs:
& 7z e $installers[0].FullName '-odesktop/signed-app' '-r' '-y' 'openbot-desktop.exe'
if ($LASTEXITCODE -ne 0) { throw 'Could not extract signed app from NSIS installer.' }
- name: Verify publisher, trust, and timestamp on both executables
- run: ./desktop/scripts/verify-windows-signatures.ps1
+ run: |
+ $build = Get-Content desktop/build-version.json -Raw | ConvertFrom-Json
+ ./desktop/scripts/verify-windows-signatures.ps1 -ExpectedVersion $build.version
- name: Retain verified binaries
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
@@ -133,6 +139,7 @@ jobs:
path: |
desktop/signed-app/openbot-desktop.exe
desktop/src-tauri/target/release/bundle/nsis/*-setup.exe
+ desktop/build-version.json
if-no-files-found: error
retention-days: 14
- name: Retain verification evidence
diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml
index c8d59a3f6..2167b17f1 100644
--- a/.github/workflows/desktop.yml
+++ b/.github/workflows/desktop.yml
@@ -5,10 +5,10 @@ name: Desktop
# this fails. So it tests and builds on all three, on every change to it.
on:
pull_request:
- paths: ["desktop/**", ".github/workflows/desktop.yml"]
+ paths: ["desktop/**", "package.json", ".github/workflows/desktop.yml"]
push:
branches: [main]
- paths: ["desktop/**", ".github/workflows/desktop.yml"]
+ paths: ["desktop/**", "package.json", ".github/workflows/desktop.yml"]
workflow_call:
permissions:
@@ -26,6 +26,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable
with:
@@ -62,6 +63,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
@@ -81,6 +83,12 @@ jobs:
working-directory: desktop
- run: bun run typecheck
working-directory: desktop
+ - name: Desktop version regressions
+ run: bun test scripts/desktop-version.test.ts
+ working-directory: desktop
+ - name: Prepare internal build version
+ run: bun scripts/desktop-version.ts internal
+ working-directory: desktop
# `tauri build`, not `cargo build --release`. tauri-build emits `cargo:rustc-cfg=dev`
# for anything the Tauri CLI did not build, so a plain cargo release binary still points
# at the dev server and shows "Could not connect to localhost" when it is run. That
@@ -89,8 +97,37 @@ jobs:
# Bundling is included because the bundle is the product, and because the failures live
# there: the Windows resource step needs an .ico, and macOS wants an .icns, neither of
# which a compile would miss.
- - run: bun run tauri build
+ - run: bun run tauri build --config src-tauri/tauri.build-version.conf.json
working-directory: desktop
+ env:
+ APPLE_SIGNING_IDENTITY: ${{ matrix.platform.name == 'macos' && '-' || '' }}
+ - name: Verify packaged Mac version and ad-hoc signature
+ if: matrix.platform.name == 'macos'
+ run: |
+ # Tauri removes the temporary .app after producing a DMG. Check its payload.
+ images=(desktop/src-tauri/target/release/bundle/dmg/*.dmg)
+ test "${#images[@]}" -eq 1
+ mount="$RUNNER_TEMP/openbot-version-check"
+ mkdir -p "$mount"
+ hdiutil attach -readonly -nobrowse -mountpoint "$mount" "${images[0]}"
+ trap 'hdiutil detach "$mount"' EXIT
+ export OPENBOT_VERIFY_APP="$mount/OpenBot.app"
+ python3 - <<'PY'
+ import json, os, pathlib, plistlib, subprocess
+ build = json.loads(pathlib.Path('desktop/build-version.json').read_text())
+ app = pathlib.Path(os.environ['OPENBOT_VERIFY_APP'])
+ info = plistlib.loads((app / 'Contents/Info.plist').read_bytes())
+ for key, expected in {
+ 'CFBundleShortVersionString': build['releaseVersion'],
+ 'CFBundleVersion': build['releaseVersion'],
+ 'OpenBotBuildVersion': build['version'],
+ 'OpenBotSourceRevision': build['sourceSha'],
+ }.items():
+ if info.get(key) != expected:
+ raise SystemExit(f'{key}: expected {expected}, got {info.get(key)}')
+ subprocess.run(['codesign', '--verify', '--deep', '--strict', '--verbose=2', str(app)], check=True)
+ print(f"Verified packaged Mac version: {build['version']}")
+ PY
# Frontend assets and platform dependencies are ready after the packaged build.
# Include main.rs regressions as well as lib.rs; ignored live tests remain opt-in.
- name: Rust regression tests
@@ -107,5 +144,6 @@ jobs:
desktop/src-tauri/target/release/bundle/**/*.AppImage
desktop/src-tauri/target/release/bundle/**/*.deb
desktop/src-tauri/target/release/bundle/**/*.rpm
+ desktop/build-version.json
if-no-files-found: error
retention-days: 7
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 05aff0910..8d43bfc8a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -102,6 +102,7 @@ jobs:
console.log(pkg.version);
' -- "$BUMP" > /tmp/version
version=$(cat /tmp/version)
+ bun desktop/scripts/desktop-version.ts sync
# The chart's default image tag is its appVersion, so a release that moves package.json
# without moving this leaves a default `helm install` pulling the previous image, or an
@@ -144,7 +145,7 @@ jobs:
- name: Preview
if: inputs.dry_run
run: |
- git --no-pager diff -- package.json CHANGELOG.md charts/openbot/Chart.yaml
+ git --no-pager diff -- package.json CHANGELOG.md charts/openbot/Chart.yaml desktop/src-tauri/Cargo.toml desktop/src-tauri/Cargo.lock
cat /tmp/pr-body.md
# A pull request opened by a workflow does not trigger the pull_request workflows, so the
diff --git a/desktop/.gitignore b/desktop/.gitignore
index 6a9a5af98..f7738b1b6 100644
--- a/desktop/.gitignore
+++ b/desktop/.gitignore
@@ -5,3 +5,6 @@ target/
# Written by Tauri from the capabilities and the plugins in Cargo.toml. Build output that
# happens to be JSON, so it is regenerated on every build and belongs to nobody to review.
src-tauri/gen/
+build-version.json
+src-tauri/tauri.build-version.conf.json
+src-tauri/build-version.plist
diff --git a/desktop/scripts/desktop-version.test.ts b/desktop/scripts/desktop-version.test.ts
new file mode 100644
index 000000000..f78a6141d
--- /dev/null
+++ b/desktop/scripts/desktop-version.test.ts
@@ -0,0 +1,192 @@
+import { afterEach, expect, test } from "bun:test";
+import {
+ copyFileSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+
+const directories: string[] = [];
+const cargo =
+ '[package]\nname = "openbot-desktop"\nversion = "0.0.10"\n\n[dependencies]\nexample = "0.0.10"\n';
+const lock =
+ 'version = 4\n\n[[package]]\nname = "example"\nversion = "0.0.10"\n\n[[package]]\nname = "openbot-desktop"\nversion = "0.0.10"\ndependencies = ["example"]\n';
+
+function fixture() {
+ const root = mkdtempSync(join(tmpdir(), "openbot-desktop-version-"));
+ directories.push(root);
+ mkdirSync(join(root, "desktop/src-tauri"), { recursive: true });
+ mkdirSync(join(root, "desktop/scripts"));
+ const write = (path: string, text: string) =>
+ writeFileSync(join(root, path), text);
+ const read = (path: string) => readFileSync(join(root, path), "utf8");
+ write("package.json", '{"version":"0.0.10"}');
+ write(
+ "desktop/src-tauri/tauri.conf.json",
+ '{"version":"../../package.json"}',
+ );
+ write("desktop/src-tauri/Cargo.toml", cargo);
+ write("desktop/src-tauri/Cargo.lock", lock);
+ copyFileSync(
+ join(import.meta.dir, "desktop-version.ts"),
+ join(root, "desktop/scripts/desktop-version.ts"),
+ );
+ const run = (command: string) => {
+ const result = Bun.spawnSync(
+ [
+ process.execPath,
+ join(root, "desktop/scripts/desktop-version.ts"),
+ command,
+ ],
+ { cwd: tmpdir() },
+ );
+ return {
+ code: result.exitCode,
+ stderr: result.stderr.toString(),
+ stdout: result.stdout.toString(),
+ };
+ };
+ return { root, write, read, run };
+}
+
+afterEach(() => {
+ for (const directory of directories.splice(0))
+ rmSync(directory, { recursive: true, force: true });
+});
+
+test("the checked-in Tauri version resolves the root release number", () => {
+ const config = JSON.parse(
+ readFileSync(
+ resolve(import.meta.dir, "../src-tauri/tauri.conf.json"),
+ "utf8",
+ ),
+ );
+ expect(config.version).toBe("../../package.json");
+});
+
+test("sync changes only the desktop package versions and check rejects drift", () => {
+ const f = fixture();
+ f.write("package.json", '{"version":"0.0.11"}');
+ expect(f.run("check").stderr).toContain("version drift");
+ expect(f.run("sync").code).toBe(0);
+ expect(f.read("desktop/src-tauri/Cargo.toml")).toBe(
+ cargo.replace('version = "0.0.10"', 'version = "0.0.11"'),
+ );
+ expect(f.read("desktop/src-tauri/Cargo.lock")).toBe(
+ lock.replace(
+ 'name = "openbot-desktop"\nversion = "0.0.10"',
+ 'name = "openbot-desktop"\nversion = "0.0.11"',
+ ),
+ );
+ expect(f.run("check").code).toBe(0);
+});
+
+test.each([
+ "{}",
+ '{"version":10}',
+ '{"version":"0.0.0"}',
+ '{"version":"01.2.3"}',
+ '{"version":"0.0.10-beta.1"}',
+ "{",
+])("rejects invalid root release metadata: %s", (json) => {
+ const f = fixture();
+ f.write("package.json", json);
+ expect(f.run("sync").code).toBe(1);
+ expect(f.read("desktop/src-tauri/Cargo.toml")).toBe(cargo);
+});
+
+test.each([
+ "package.json",
+ "desktop/src-tauri/Cargo.toml",
+ "desktop/src-tauri/Cargo.lock",
+])("rejects a missing version source: %s", (path) => {
+ const f = fixture();
+ rmSync(join(f.root, path));
+ expect(f.run("check").code).toBe(1);
+});
+
+test.each([
+ ["desktop/src-tauri/tauri.conf.json", '{"version":"0.0.10"}'],
+ [
+ "desktop/src-tauri/Cargo.toml",
+ cargo.replace('version = "0.0.10"', 'version = "0.0.9"'),
+ ],
+ [
+ "desktop/src-tauri/Cargo.lock",
+ lock.replace(
+ 'name = "openbot-desktop"\nversion = "0.0.10"',
+ 'name = "openbot-desktop"\nversion = "0.0.9"',
+ ),
+ ],
+ ["desktop/src-tauri/Cargo.toml", '[package]\nname = "openbot-desktop"\n'],
+ [
+ "desktop/src-tauri/Cargo.lock",
+ '[[package]]\nname = "example"\nversion = "0.0.10"\n',
+ ],
+ ["desktop/src-tauri/Cargo.toml", "[package"],
+ ["desktop/src-tauri/Cargo.lock", "[[package"],
+])("rejects stale or malformed native metadata: %s", (path, value) => {
+ const f = fixture();
+ f.write(path, value);
+ expect(f.run("check").code).toBe(1);
+ expect(f.run("internal").code).toBe(1);
+});
+
+test.each(["internal", "release"])(
+ "%s emits actual commit identity and compatible macOS metadata",
+ (channel) => {
+ const f = fixture();
+ const git = (...args: string[]) => {
+ const result = Bun.spawnSync(["git", "-C", f.root, ...args]);
+ expect(result.exitCode).toBe(0);
+ return result.stdout.toString().trim();
+ };
+ git("init", "--quiet");
+ git(
+ "-c",
+ "user.name=Version test",
+ "-c",
+ "user.email=version@example.invalid",
+ "-c",
+ "commit.gpgsign=false",
+ "commit",
+ "--allow-empty",
+ "--quiet",
+ "-m",
+ "fixture",
+ );
+ const sourceSha = git("rev-parse", "HEAD");
+ const version =
+ channel === "internal"
+ ? `0.0.10-internal.g${sourceSha.slice(0, 12)}`
+ : "0.0.10";
+ expect(f.run(channel).code).toBe(0);
+ expect(JSON.parse(f.read("desktop/build-version.json"))).toEqual({
+ version,
+ releaseVersion: "0.0.10",
+ sourceSha,
+ channel,
+ });
+ expect(
+ JSON.parse(f.read("desktop/src-tauri/tauri.build-version.conf.json")),
+ ).toEqual({
+ version,
+ bundle: { macOS: { infoPlist: "build-version.plist" } },
+ });
+ const plist = f.read("desktop/src-tauri/build-version.plist");
+ for (const [key, value] of Object.entries({
+ CFBundleShortVersionString: "0.0.10",
+ CFBundleVersion: "0.0.10",
+ OpenBotBuildVersion: version,
+ OpenBotSourceRevision: sourceSha,
+ })) {
+ expect(plist).toContain(`${key}${value}`);
+ }
+ expect(f.read("desktop/src-tauri/Cargo.toml")).toBe(cargo);
+ expect(f.read("desktop/src-tauri/Cargo.lock")).toBe(lock);
+ },
+);
diff --git a/desktop/scripts/desktop-version.ts b/desktop/scripts/desktop-version.ts
new file mode 100644
index 000000000..ff58dc297
--- /dev/null
+++ b/desktop/scripts/desktop-version.ts
@@ -0,0 +1,171 @@
+/** Root package.json owns release versions; generated overlays identify each build. */
+import { readFileSync, writeFileSync } from "node:fs";
+import { join, resolve } from "node:path";
+
+const root = resolve(import.meta.dir, "../..");
+const native = "desktop/src-tauri/";
+const packageName = "openbot-desktop";
+const read = (path: string) => readFileSync(join(root, path), "utf8");
+const write = (path: string, text: string) =>
+ writeFileSync(join(root, path), text);
+const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`;
+
+function object(value: unknown): value is Record {
+ return value !== null && typeof value === "object" && !Array.isArray(value);
+}
+
+function version(value: unknown, label: string, allowPlaceholder = false) {
+ if (
+ typeof value !== "string" ||
+ !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value) ||
+ value.split(".").some((part) => Number(part) > 65535) ||
+ (!allowPlaceholder && value === "0.0.0")
+ ) {
+ throw new Error(
+ `${label}: expected a non-placeholder numeric release version (major.minor.patch)`,
+ );
+ }
+ return value;
+}
+
+function state() {
+ const manifest: unknown = JSON.parse(read("package.json"));
+ const releaseVersion = version(
+ object(manifest) ? manifest.version : undefined,
+ "package.json",
+ );
+ const config: unknown = JSON.parse(read(`${native}tauri.conf.json`));
+ if (!object(config) || config.version !== "../../package.json") {
+ throw new Error('tauri.conf.json version must be "../../package.json"');
+ }
+ const cargoText = read(`${native}Cargo.toml`);
+ const lockText = read(`${native}Cargo.lock`);
+ const cargoDocument = Bun.TOML.parse(cargoText);
+ const lockDocument = Bun.TOML.parse(lockText);
+ const cargo = "package" in cargoDocument ? cargoDocument.package : undefined;
+ const packages = "package" in lockDocument ? lockDocument.package : undefined;
+ const entries = Array.isArray(packages)
+ ? packages.filter((entry) => object(entry) && entry.name === packageName)
+ : [];
+ if (!object(cargo) || cargo.name !== packageName || entries.length !== 1) {
+ throw new Error(
+ `Cargo.toml and Cargo.lock must each contain one ${packageName} package`,
+ );
+ }
+ const locked = entries[0];
+ if (!object(locked))
+ throw new Error("Cargo.lock desktop package is malformed");
+ return {
+ releaseVersion,
+ cargoText,
+ lockText,
+ cargoVersion: version(cargo.version, "Cargo.toml", true),
+ lockVersion: version(locked.version, "Cargo.lock", true),
+ };
+}
+
+function replaceVersion(text: string, releaseVersion: string, lock: boolean) {
+ let changed = 0;
+ const result = text
+ .split(/(?=^[ \t]*\[)/m)
+ .map((section) => {
+ const matches = lock
+ ? /^[ \t]*\[\[package\]\]/.test(section) &&
+ /^[ \t]*name[ \t]*=[ \t]*["']openbot-desktop["']/m.test(section)
+ : /^[ \t]*\[package\]/.test(section);
+ if (!matches) return section;
+ return section.replace(
+ /^([ \t]*version[ \t]*=[ \t]*)(?:"[^"\r\n]*"|'[^'\r\n]*')/m,
+ (_, prefix: string) => {
+ changed += 1;
+ return `${prefix}"${releaseVersion}"`;
+ },
+ );
+ })
+ .join("");
+ if (changed !== 1)
+ throw new Error(
+ "Expected exactly one desktop package version to synchronize",
+ );
+ Bun.TOML.parse(result);
+ return result;
+}
+
+function main(command: string | undefined) {
+ if (!["sync", "check", "internal", "release"].includes(command ?? "")) {
+ throw new Error(
+ "Usage: bun desktop/scripts/desktop-version.ts sync|check|internal|release",
+ );
+ }
+ const current = state();
+ const { releaseVersion } = current;
+ if (command === "sync") {
+ // Validate both replacements before writing either file; dependencies stay byte-for-byte intact.
+ const cargo = replaceVersion(current.cargoText, releaseVersion, false);
+ const lock = replaceVersion(current.lockText, releaseVersion, true);
+ write(`${native}Cargo.toml`, cargo);
+ write(`${native}Cargo.lock`, lock);
+ } else if (
+ current.cargoVersion !== releaseVersion ||
+ current.lockVersion !== releaseVersion
+ ) {
+ throw new Error(
+ `Desktop version drift: root=${releaseVersion}, Cargo.toml=${current.cargoVersion}, Cargo.lock=${current.lockVersion}; run desktop-version.ts sync`,
+ );
+ }
+ if (command === "sync" || command === "check") {
+ console.log(`Desktop release version: ${releaseVersion}`);
+ return;
+ }
+ const git = Bun.spawnSync(["git", "rev-parse", "HEAD"], { cwd: root });
+ const sourceSha = git.stdout.toString().trim();
+ if (git.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(sourceSha)) {
+ throw new Error(
+ `Could not resolve the source commit: ${git.stderr.toString().trim()}`,
+ );
+ }
+ const buildVersion =
+ command === "internal"
+ ? `${releaseVersion}-internal.g${sourceSha.slice(0, 12)}`
+ : releaseVersion;
+ const metadata = {
+ version: buildVersion,
+ releaseVersion,
+ sourceSha,
+ channel: command,
+ };
+ // Tauri copies semver into both Apple keys unchanged. Keep those numeric while
+ // retaining the complete internal identity in custom plist keys and app metadata.
+ const fields = {
+ CFBundleShortVersionString: releaseVersion,
+ CFBundleVersion: releaseVersion,
+ OpenBotBuildVersion: buildVersion,
+ OpenBotSourceRevision: sourceSha,
+ };
+ write(
+ `${native}build-version.plist`,
+ `\n\n\n${Object.entries(
+ fields,
+ )
+ .map(([key, value]) => `${key}${value}`)
+ .join("\n")}\n\n`,
+ );
+ write(
+ `${native}tauri.build-version.conf.json`,
+ json({
+ version: buildVersion,
+ bundle: { macOS: { infoPlist: "build-version.plist" } },
+ }),
+ );
+ write("desktop/build-version.json", json(metadata));
+ console.log(json(metadata).trim());
+}
+
+if (import.meta.main) {
+ try {
+ main(process.argv[2]);
+ } catch (error) {
+ console.error(error instanceof Error ? error.message : error);
+ process.exitCode = 1;
+ }
+}
diff --git a/desktop/scripts/test-windows-signing.ps1 b/desktop/scripts/test-windows-signing.ps1
index 09d02bd0e..1c03f71ef 100644
--- a/desktop/scripts/test-windows-signing.ps1
+++ b/desktop/scripts/test-windows-signing.ps1
@@ -34,6 +34,23 @@ $global:SigningTestState = @{
azExit = 0; signExit = 0; verifyExit = 0; token = 'synthetic-test-token'
signCalls = 0; signArguments = @(); verifyCalls = @(); status = 'Valid'
publisher = 'Tawkit, Inc.'; timestamp = $true; invalidFile = ''
+ productVersion = '0.0.10-internal.gabcdef012345'; fileVersion = '0.0.10-internal.gabcdef012345'; wrongVersionFile = ''
+}
+function global:Get-Item {
+ param([string]$LiteralPath)
+ $item = Microsoft.PowerShell.Management\Get-Item -LiteralPath $LiteralPath
+ if ($item -is [System.IO.FileInfo]) {
+ $wrong = $LiteralPath -eq $global:SigningTestState.wrongVersionFile
+ return [pscustomobject]@{
+ Name = $item.Name
+ FullName = $item.FullName
+ VersionInfo = [pscustomobject]@{
+ ProductVersion = if ($wrong) { $global:SigningTestState.productVersion } else { '0.0.10-internal.gabcdef012345' }
+ FileVersion = if ($wrong) { $global:SigningTestState.fileVersion } else { '0.0.10-internal.gabcdef012345' }
+ }
+ }
+ }
+ $item
}
function global:az {
$global:LASTEXITCODE = $global:SigningTestState.azExit
@@ -127,6 +144,22 @@ try {
Assert-True (($call[0..4] -join ' ') -eq 'verify /pa /all /v /tw') 'Trust or timestamp verification was omitted.'
}
$passed++
+ $verifyParameters.ExpectedVersion = '0.0.10-internal.gabcdef012345'
+ foreach ($wrongFile in @($app, $installer)) {
+ $global:SigningTestState.wrongVersionFile = $wrongFile
+ foreach ($field in @('productVersion', 'fileVersion')) {
+ $global:SigningTestState[$field] = '0.0.0'
+ Assert-Throws { & $verify @verifyParameters } 'Unexpected embedded version'
+ $global:SigningTestState[$field] = $verifyParameters.ExpectedVersion
+ }
+ }
+ $global:SigningTestState.wrongVersionFile = ''
+ & $verify @verifyParameters | Out-Null
+ $versionReport = Get-Content -LiteralPath (Join-Path $evidenceDirectory 'signatures.json') -Raw | ConvertFrom-Json
+ foreach ($record in $versionReport.files) {
+ Assert-True ($record.productVersion -ceq $verifyParameters.ExpectedVersion -and $record.fileVersion -ceq $verifyParameters.ExpectedVersion) 'Evidence omitted the embedded version.'
+ }
+ $passed++
Remove-Item -LiteralPath $app
Assert-Throws { & $verify @verifyParameters } 'Application executable is missing'
Set-Content -LiteralPath $app -Value 'restored fixture'
@@ -163,7 +196,7 @@ try {
Write-Host "Passed $passed Windows signing regression cases."
} finally {
foreach ($name in $environmentNames) { [Environment]::SetEnvironmentVariable($name, $originalEnvironment[$name]) }
- Remove-Item Function:az, Function:AzureSignTool.exe, Function:Test-SignTool, Function:Get-AuthenticodeSignature
+ Remove-Item Function:az, Function:AzureSignTool.exe, Function:Test-SignTool, Function:Get-AuthenticodeSignature, Function:Get-Item
Remove-Variable SigningTestState -Scope Global
Remove-Item -LiteralPath $directory -Recurse -Force
}
diff --git a/desktop/scripts/verify-windows-signatures.ps1 b/desktop/scripts/verify-windows-signatures.ps1
index 0e142e14c..9f04ccf10 100644
--- a/desktop/scripts/verify-windows-signatures.ps1
+++ b/desktop/scripts/verify-windows-signatures.ps1
@@ -5,7 +5,8 @@ param(
[string]$InstallerDirectory = "$PSScriptRoot/../src-tauri/target/release/bundle/nsis",
[string]$EvidenceDirectory = "$PSScriptRoot/../signing-evidence",
[string]$SignToolPath,
- [string]$SourceSha = $env:SIGNING_SOURCE_SHA
+ [string]$SourceSha = $env:SIGNING_SOURCE_SHA,
+ [string]$ExpectedVersion
)
$ErrorActionPreference = 'Stop'
@@ -35,6 +36,10 @@ if (-not $SignToolPath) {
New-Item -ItemType Directory -Path $EvidenceDirectory -Force | Out-Null
$records = @()
foreach ($file in @((Get-Item -LiteralPath $AppPath), $installers[0])) {
+ $version = (Get-Item -LiteralPath $file.FullName).VersionInfo
+ if ($ExpectedVersion -and ($version.ProductVersion -cne $ExpectedVersion -or $version.FileVersion -cne $ExpectedVersion)) {
+ throw "Unexpected embedded version on $($file.Name): product=$($version.ProductVersion), file=$($version.FileVersion), expected=$ExpectedVersion"
+ }
$signature = Get-AuthenticodeSignature -LiteralPath $file.FullName
if ($signature.Status -ne 'Valid') {
throw "Invalid Authenticode signature on $($file.Name): $($signature.Status)"
@@ -59,6 +64,8 @@ foreach ($file in @((Get-Item -LiteralPath $AppPath), $installers[0])) {
}
$records += [ordered]@{
file = $file.Name
+ productVersion = $version.ProductVersion
+ fileVersion = $version.FileVersion
sha256 = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash
status = [string]$signature.Status
publisher = $publisher
@@ -72,6 +79,7 @@ foreach ($file in @((Get-Item -LiteralPath $AppPath), $installers[0])) {
[ordered]@{
sourceSha = $SourceSha
+ expectedVersion = $ExpectedVersion
verifiedAtUtc = [DateTime]::UtcNow.ToString('o')
files = $records
} | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $EvidenceDirectory 'signatures.json')
diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock
index db296f65f..5e2458d47 100644
--- a/desktop/src-tauri/Cargo.lock
+++ b/desktop/src-tauri/Cargo.lock
@@ -2488,7 +2488,7 @@ dependencies = [
[[package]]
name = "openbot-desktop"
-version = "0.0.0"
+version = "0.0.10"
dependencies = [
"base64 0.22.1",
"flate2",
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index ddf423cb8..f562e26af 100644
--- a/desktop/src-tauri/Cargo.toml
+++ b/desktop/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "openbot-desktop"
-version = "0.0.0"
+version = "0.0.10"
description = "OpenBot Desktop"
edition = "2021"
rust-version = "1.77"
diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json
index ef66381c8..0cba16c33 100644
--- a/desktop/src-tauri/tauri.conf.json
+++ b/desktop/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenBot",
- "version": "0.0.0",
+ "version": "../../package.json",
"identifier": "ai.copilotkit.openbot.desktop",
"build": {
"frontendDist": "../dist",
diff --git a/desktop/tsconfig.json b/desktop/tsconfig.json
index d883e64b8..cc8c2ac7c 100644
--- a/desktop/tsconfig.json
+++ b/desktop/tsconfig.json
@@ -10,5 +10,5 @@
"skipLibCheck": true,
"types": ["vite/client", "bun-types"]
},
- "include": ["src"]
+ "include": ["src", "scripts/*.ts"]
}
diff --git a/docs/releasing.md b/docs/releasing.md
index e890c9928..a7a0f79fd 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -10,13 +10,42 @@ step involves a terminal, a tag pushed by hand, or an image built on somebody's
the person reading the diff and these notes are for the person deciding whether to upgrade.
2. Run **Create release PR** from the Actions tab, choosing `patch`, `minor` or `major`. Use
`dry_run` first if you want to see the version and the notes without opening anything.
-3. Review the pull request it opens. It contains exactly three changes: the version in `package.json`,
- the `## Unreleased` heading becoming `## X.Y.Z`, and the Helm chart's `appVersion` moving to the
- same number so a default `helm install` pulls the image this release builds.
+3. Review the pull request it opens. It updates `package.json`, promotes the changelog's
+ `## Unreleased` heading to `## X.Y.Z`, and moves the Helm chart's `appVersion` and the desktop
+ Cargo package/lockfile to the same version. Tauri reads its version directly from the root
+ `package.json`.
4. Merge it. That is the publish.
Merging is the trigger, so a release is always a reviewed commit on `main`.
+## Desktop build versions
+
+The root `package.json` is the release version source. CI rejects drift in the desktop Cargo
+manifest or lockfile. The release workflow updates those files automatically; after a manual
+root version change, run `bun desktop/scripts/desktop-version.ts sync`.
+
+Internal desktop artifacts use `X.Y.Z-internal.g` so testers can identify the source
+revision. Both desktop CI and protected Windows signing prepare this version before compilation
+and packaging. The artifact includes `build-version.json` with the full commit and release version.
+
+To build locally, from the repository root:
+
+```sh
+bun desktop/scripts/desktop-version.ts internal
+cd desktop
+APPLE_SIGNING_IDENTITY=- bun run tauri build --config src-tauri/tauri.build-version.conf.json --bundles dmg
+```
+
+Use `release` instead of `internal` to prepare the plain release version. This only builds an
+artifact; it does not publish a release. macOS keeps numeric system version fields and stores
+the full internal identifier in `OpenBotBuildVersion` inside the app's `Info.plist`. Windows
+retains the full identifier in `ProductVersion` and `FileVersion`; its fixed numeric fields
+contain the release number. The protected signing job checks both embedded string versions.
+
+Ad-hoc signed Mac builds require the first-open exception described in
+[Apple's instructions](https://support.apple.com/en-us/102445). They are for internal testing
+and are not Apple-notarized. See [Windows signing](windows-signing.md) for signed NSIS builds.
+
## What merging does
`publish-release.yml` runs on every push to `main` and starts by deciding whether the commit is a
diff --git a/docs/windows-signing.md b/docs/windows-signing.md
index 717759e94..83a0b7cb8 100644
--- a/docs/windows-signing.md
+++ b/docs/windows-signing.md
@@ -1,5 +1,9 @@
# Windows desktop signing
+Builds use the root OpenBot release number plus `-internal.g`. The workflow verifies
+that both the packaged app and installer embed that version, and includes `build-version.json`
+with the binaries. See [desktop build versions](releasing.md#desktop-build-versions).
+
The [Desktop Windows signing workflow](../.github/workflows/desktop-signing.yml)
builds OpenBot and its NSIS installer with the existing DigiCert certificate in
Azure Key Vault. It retains verified binaries and signature evidence as Actions