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