From 1b6e788e318cee9ff06fc0c5109b73279a786a81 Mon Sep 17 00:00:00 2001 From: Karsten Samaschke Date: Sat, 14 Mar 2026 11:03:48 +0100 Subject: [PATCH 1/2] feat(desktop): enforce certification gates --- .github/workflows/release-sign.yml | 60 ++++++- docs/release-signing.md | 33 +++- docs/testing/desktop-rollout-validation.md | 22 ++- package.json | 1 + scripts/release/build-desktop-manifests.mjs | 19 +- scripts/release/desktop-targets.mjs | 96 +++++++++- scripts/release/validate-desktop-release.mjs | 170 ++++++++++++++++++ src/desktop-electron/controlPlane.ts | 19 +- tests/installer/desktop-control-plane.test.ts | 38 ++++ .../desktop-rollout-validation.test.ts | 88 +++++++-- tests/installer/desktop-updater.test.ts | 41 +++++ 11 files changed, 555 insertions(+), 32 deletions(-) create mode 100644 scripts/release/validate-desktop-release.mjs create mode 100644 tests/installer/desktop-control-plane.test.ts diff --git a/.github/workflows/release-sign.yml b/.github/workflows/release-sign.yml index 2bc17b7..bae1530 100644 --- a/.github/workflows/release-sign.yml +++ b/.github/workflows/release-sign.yml @@ -80,6 +80,50 @@ jobs: set -euo pipefail diff -u dist-original/SHA256SUMS.txt dist-rebuild/SHA256SUMS.txt + desktop-certification: + runs-on: ubuntu-latest + needs: verify-source + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Setup Node + uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run desktop certification tests + run: | + set -euo pipefail + npm run build:quick + node --test \ + dist/tests/installer/control-plane-client.test.js \ + dist/tests/installer/desktop-control-plane.test.js \ + dist/tests/installer/desktop-preview-startup.test.js \ + dist/tests/installer/desktop-rollout-validation.test.js \ + dist/tests/installer/desktop-shell-ux.test.js \ + dist/tests/installer/desktop-updater.test.js + + - name: Generate desktop certification report + run: | + set -euo pipefail + ./scripts/release/build-artifacts.sh "${GITHUB_REF_NAME}" "${GITHUB_SHA}" dist + node scripts/release/validate-desktop-release.mjs --write-certification-report dist + + - name: Upload desktop certification report + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: desktop-certification-${{ github.ref_name }} + path: | + dist/desktop-certification-report.json + if-no-files-found: error + build-desktop: strategy: fail-fast: false @@ -138,6 +182,7 @@ jobs: runs-on: ubuntu-latest needs: - verify-source + - desktop-certification - build-desktop permissions: contents: write @@ -170,6 +215,15 @@ jobs: name: desktop-ubuntu-latest-${{ github.ref_name }} path: dist/desktop + - name: Download desktop certification report + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: desktop-certification-${{ github.ref_name }} + path: dist + + - name: Validate desktop certification gate before draft release + run: npm run validate:desktop:release -- dist + - name: Install Cosign uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0 @@ -182,7 +236,7 @@ jobs: --output-signature "${file}.sig" \ --output-certificate "${file}.pem" \ "${file}" - done < <(find dist -type f \( -name '*.gz' -o -name '*.zip' -o -name '*.dmg' -o -name '*.exe' -o -name '*.AppImage' -o -name 'latest*.yml' -o -name '*.blockmap' -o -name 'SHA256SUMS.txt' -o -name 'desktop-validation-matrix.json' \) | sort) + done < <(find dist -type f \( -name '*.gz' -o -name '*.zip' -o -name '*.dmg' -o -name '*.exe' -o -name '*.AppImage' -o -name 'latest*.yml' -o -name '*.blockmap' -o -name 'SHA256SUMS.txt' -o -name 'desktop-validation-matrix.json' -o -name 'desktop-certification-report.json' \) | sort) - name: Create draft GitHub release uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 @@ -195,6 +249,7 @@ jobs: dist/ica-${{ github.ref_name }}-source.zip dist/SHA256SUMS.txt dist/desktop-validation-matrix.json + dist/desktop-certification-report.json dist/desktop/**/*.dmg dist/desktop/**/*.exe dist/desktop/**/*.AppImage @@ -203,5 +258,8 @@ jobs: dist/**/*.sig dist/**/*.pem + - name: Re-validate desktop certification gate before release promotion + run: npm run validate:desktop:release -- dist + - name: Publish release only after validation passes run: gh release edit "${GITHUB_REF_NAME}" --draft=false diff --git a/docs/release-signing.md b/docs/release-signing.md index edb8487..a4c1b24 100644 --- a/docs/release-signing.md +++ b/docs/release-signing.md @@ -1,4 +1,4 @@ -# Release Signing and Desktop Promotion +# Release Signing, Certification, and Desktop Promotion This repository publishes releases from `.github/workflows/release-sign.yml` when a SemVer tag such as `v12.3.0` is pushed. @@ -10,6 +10,7 @@ The workflow still produces deterministic source archives and verifies them in a - `ica--source.zip` - `SHA256SUMS.txt` - `desktop-validation-matrix.json` +- `desktop-certification-report.json` These source artifacts are rebuilt and compared before any desktop release publication continues. @@ -23,21 +24,42 @@ Desktop packaging now runs on platform-native runners instead of shipping placeh The Electron packaging contract lives in `electron-builder.json` and publishes to GitHub Releases only. +## Desktop Certification + +Desktop releases now have an explicit certification gate before draft creation and before draft promotion. The gate is driven by: + +- `desktop-validation-matrix.json`: target matrix, required acceptance checks, certification gates, updater artifacts, and credential expectations +- `desktop-certification-report.json`: CI-produced pass report for required desktop acceptance checks +- `scripts/release/validate-desktop-release.mjs`: fail-closed validator that checks manifests, certification evidence, and packaged desktop artifacts + +The certification suite covers these required behaviors for every supported target: + +- install flow availability +- startup and launch diagnostics +- control-plane availability +- sync flow availability +- publish flow availability +- updater feed and updater lifecycle behavior +- failure and recovery UX + ## Signing and Notarization - macOS uses Apple code signing and notarization when `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, and `APPLE_TEAM_ID` are configured. - Windows uses Authenticode-compatible signing inputs via `CSC_LINK` and `CSC_KEY_PASSWORD`. - Linux artifacts and updater metadata are signed with Cosign in the release workflow. - Release assets are signed only after the platform matrix and source verification complete. +- Local builds can emit release artifacts without production signing credentials, but desktop certification keeps those signing and notarization checks marked as CI-required and not locally exercised. ## Draft Promotion Flow Desktop releases are created as a draft release first. The release is published only after validation passes: 1. source reproducibility succeeds -2. every desktop platform job uploads its signed artifact set -3. updater metadata files (`latest*.yml`) are present for the packaged outputs -4. `desktop-validation-matrix.json` ships with the signed release asset set +2. the desktop certification test suite passes and emits `desktop-certification-report.json` +3. every desktop platform job uploads its signed artifact set +4. updater metadata files (`latest*.yml`) are present for the packaged outputs +5. `desktop-validation-matrix.json` and `desktop-certification-report.json` ship with the signed release asset set +6. `scripts/release/validate-desktop-release.mjs` passes before draft creation and again before draft-to-published promotion If any platform packaging, signing, or updater verification step fails, the release remains unpublished. @@ -46,4 +68,5 @@ If any platform packaging, signing, or updater verification step fails, the rele 1. Install dependencies: `npm ci` 2. Build preview bundle: `npm run build:desktop:preview` 3. Build release artifacts locally: `npm run build:desktop:release` -4. Publish from CI using the tag-driven workflow +4. Run local certification validation when artifacts are available: `npm run validate:desktop:release -- dist` +5. Publish from CI using the tag-driven workflow diff --git a/docs/testing/desktop-rollout-validation.md b/docs/testing/desktop-rollout-validation.md index 848f8ff..0512b3b 100644 --- a/docs/testing/desktop-rollout-validation.md +++ b/docs/testing/desktop-rollout-validation.md @@ -1,6 +1,6 @@ # Desktop Rollout Validation -This repository publishes a `desktop-validation-matrix.json` artifact alongside the desktop release metadata so release operators can verify platform coverage and rollout gates before promoting a desktop release. +This repository publishes a `desktop-validation-matrix.json` artifact alongside a CI-generated `desktop-certification-report.json` so release operators can verify platform coverage and certification gates before promoting a desktop release. ## Validation Matrix Contents @@ -13,25 +13,33 @@ The validation matrix covers every supported desktop target: - `linux/x64` - `linux/arm64` -Each target includes these required smoke checks: +Each target includes these required acceptance checks: - `package-contract`: the per-target desktop package artifact exists and matches the release plan - `updater-feed`: the updater feed path is stable and publishable - `desktop-startup`: the packaged dashboard bundle boots successfully in the desktop shell - `desktop-control-plane`: the Electron bridge and control-plane path stay available +- `install-flow`: install requests remain reachable through the desktop control-plane contract +- `sync-flow`: sync requests remain reachable through the desktop control-plane contract +- `publish-flow`: publish requests remain reachable through the desktop shell +- `updater-lifecycle`: check/download/quit-install flows remain explicit for preview and packaged runtimes +- `failure-recovery-ux`: startup and update failures expose recovery diagnostics instead of silent breakage -## Rollout Gates +## Certification Gates -Every release must satisfy these rollout gates before promotion: +Every release must satisfy these certification gates before promotion: - `reproducible-build`: release artifacts rebuild deterministically before signing - `signed-release-metadata`: signed release metadata is keylessly produced and verified in CI - `validation-matrix-published`: `desktop-validation-matrix.json` is uploaded with the signed release asset set +- `desktop-artifacts-present`: every supported target publishes its package plus updater metadata outputs +- `desktop-acceptance-passed`: CI emits a `desktop-certification-report.json` marking every required acceptance check as passed ## Operator Flow 1. Push the release tag and wait for `.github/workflows/release-sign.yml` to finish. -2. Download `desktop-release-plan.json`, `desktop-updater-manifest.json`, and `desktop-validation-matrix.json`. +2. Download `desktop-release-plan.json`, `desktop-updater-manifest.json`, `desktop-validation-matrix.json`, and `desktop-certification-report.json`. 3. Confirm the target list matches the intended OS and architecture matrix. -4. Verify the rollout gates were enforced by CI before publishing or promoting the release. -5. Use the matrix as the release-readiness checklist for desktop rollout approval. +4. Verify the certification report marks install, startup, control-plane, sync, publish, updater, failure, and recovery checks as passed. +5. Verify the certification gates were enforced by CI before publishing or promoting the release. +6. Use the matrix as the release-readiness checklist for desktop rollout approval. diff --git a/package.json b/package.json index c2f373b..bf0e191 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "build:desktop:preview": "npm run build:quick && npm run build:dashboard:web", "build:desktop:release": "npm run build:desktop:preview && electron-builder --config electron-builder.json --publish never", "build:desktop:publish": "npm run build:desktop:preview && electron-builder --config electron-builder.json --publish always", + "validate:desktop:release": "node scripts/release/validate-desktop-release.mjs", "start:desktop": "npm run build:desktop:preview && electron dist/src/desktop-electron/app.js", "start:desktop:dev": "npm run build:quick && ICA_DESKTOP_MODE=dev electron dist/src/desktop-electron/app.js", "start:dashboard": "node dist/src/installer-dashboard/server/index.js", diff --git a/scripts/release/build-desktop-manifests.mjs b/scripts/release/build-desktop-manifests.mjs index bb6c8e2..43146b7 100755 --- a/scripts/release/build-desktop-manifests.mjs +++ b/scripts/release/build-desktop-manifests.mjs @@ -2,7 +2,13 @@ import fs from "node:fs"; import path from "node:path"; -import { createDesktopSmokeChecks, desktopRolloutGates, desktopTargets } from "./desktop-targets.mjs"; +import { + createDesktopAcceptanceChecks, + desktopCertificationGates, + desktopTargets, + getDesktopSigningCredentialStatus, + getDesktopUpdaterArtifacts, +} from "./desktop-targets.mjs"; const [versionTag, outputDirArg] = process.argv.slice(2); @@ -26,6 +32,7 @@ const releaseTargets = desktopTargets.map((target) => { const id = `${target.platform}-${target.arch}`; const artifactName = `ica-desktop-${versionTag}-${target.osToken}-${target.arch}.${target.artifactFormat}`; const publishPath = `desktop/stable/${target.platform}/${target.arch}/${artifactName}`; + const updaterArtifacts = getDesktopUpdaterArtifacts(target).map((artifact) => artifact.replace("ica-desktop-", `ica-desktop-${versionTag}`)); return { id, platform: target.platform, @@ -34,9 +41,11 @@ const releaseTargets = desktopTargets.map((target) => { artifactFormat: target.artifactFormat, publishPath, updaterChannel: "stable", + updaterArtifacts, signing: { provider: "sigstore-keyless", requirements: target.signingRequirements, + credentials: getDesktopSigningCredentialStatus(target), }, }; }); @@ -65,14 +74,18 @@ const validationMatrix = { schemaVersion: 1, generatedAt, version, - rolloutGates: desktopRolloutGates, + certificationGates: desktopCertificationGates, targets: releaseTargets.map((target) => ({ id: target.id, platform: target.platform, arch: target.arch, packageArtifactName: target.artifactName, + packageFormat: target.artifactFormat, updaterFeedPath: `desktop/stable/${target.platform}/${target.arch}/latest.json`, - smokeChecks: createDesktopSmokeChecks(), + updaterArtifacts: target.updaterArtifacts, + signingRequirements: target.signing.requirements, + signingCredentials: target.signing.credentials, + acceptanceChecks: createDesktopAcceptanceChecks(), })), }; diff --git a/scripts/release/desktop-targets.mjs b/scripts/release/desktop-targets.mjs index 3c514cf..ea94400 100644 --- a/scripts/release/desktop-targets.mjs +++ b/scripts/release/desktop-targets.mjs @@ -43,45 +43,137 @@ export const desktopTargets = [ }, ]; -export const desktopRolloutGates = [ +export const desktopCertificationGates = [ { id: "reproducible-build", required: true, description: "Release artifacts must rebuild deterministically before publishing.", + validationSource: "ci", }, { id: "signed-release-metadata", required: true, description: "Release metadata artifacts must be keylessly signed and verified in CI.", + validationSource: "ci", }, { id: "validation-matrix-published", required: true, description: "The desktop validation matrix must ship with the signed release metadata set.", + validationSource: "artifacts", + }, + { + id: "desktop-artifacts-present", + required: true, + description: "Every supported desktop target must publish its package and updater metadata artifacts.", + validationSource: "artifacts", + }, + { + id: "desktop-acceptance-passed", + required: true, + description: "The desktop acceptance certification suite must pass before release promotion.", + validationSource: "ci", }, ]; -export function createDesktopSmokeChecks() { +export function getDesktopUpdaterArtifacts(target) { + if (target.platform === "darwin") { + return ["latest-mac.yml", `ica-desktop--macos-${target.arch}.${target.artifactFormat}.blockmap`]; + } + if (target.platform === "win32") { + return ["latest.yml", `ica-desktop--windows-${target.arch}.${target.artifactFormat}.blockmap`]; + } + return ["latest-linux.yml", `ica-desktop--linux-${target.arch}.${target.artifactFormat}.blockmap`]; +} + +export function getDesktopSigningCredentialStatus(target) { + if (target.platform === "darwin") { + return { + mode: "ci-required", + localStatus: "not-exercised", + variables: ["APPLE_ID", "APPLE_APP_SPECIFIC_PASSWORD", "APPLE_TEAM_ID"], + }; + } + + if (target.platform === "win32") { + return { + mode: "ci-required", + localStatus: "not-exercised", + variables: ["CSC_LINK", "CSC_KEY_PASSWORD"], + }; + } + + return { + mode: "ci-validated", + localStatus: "not-required", + variables: [], + }; +} + +export function createDesktopAcceptanceChecks() { return [ { id: "package-contract", required: true, description: "Per-target desktop package contract exists and matches release metadata.", + automation: "automated", + status: "pending", }, { id: "updater-feed", required: true, description: "Per-target updater feed path remains stable and publishable.", + automation: "automated", + status: "pending", }, { id: "desktop-startup", required: true, description: "Desktop renderer boots the packaged dashboard bundle for the target.", + automation: "ci", + status: "pending", }, { id: "desktop-control-plane", required: true, description: "Desktop bridge and control-plane path stay available for the target.", + automation: "automated", + status: "pending", + }, + { + id: "install-flow", + required: true, + description: "Install apply requests remain available through the desktop control-plane contract.", + automation: "automated", + status: "pending", + }, + { + id: "sync-flow", + required: true, + description: "Sync apply requests remain available through the desktop control-plane contract.", + automation: "automated", + status: "pending", + }, + { + id: "publish-flow", + required: true, + description: "Skill publish requests remain available through the desktop shell and control-plane path.", + automation: "automated", + status: "pending", + }, + { + id: "updater-lifecycle", + required: true, + description: "Update check, download, and quit/install lifecycle states remain explicit for desktop runtimes.", + automation: "automated", + status: "pending", + }, + { + id: "failure-recovery-ux", + required: true, + description: "Desktop failure diagnostics and recovery UX remain available when renderer startup or update flows fail.", + automation: "ci", + status: "pending", }, ]; } diff --git a/scripts/release/validate-desktop-release.mjs b/scripts/release/validate-desktop-release.mjs new file mode 100644 index 0000000..0720d2c --- /dev/null +++ b/scripts/release/validate-desktop-release.mjs @@ -0,0 +1,170 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; + +const args = process.argv.slice(2); +const writeReport = args[0] === "--write-certification-report"; +const artifactDirArg = writeReport ? args[1] : args[0]; + +if (!artifactDirArg) { + console.error("Usage: node scripts/release/validate-desktop-release.mjs "); + console.error(" or: node scripts/release/validate-desktop-release.mjs --write-certification-report "); + process.exit(64); +} + +const artifactDir = path.resolve(process.cwd(), artifactDirArg); +const matrix = readJson(path.join(artifactDir, "desktop-validation-matrix.json")); + +if (writeReport) { + const report = { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + version: matrix.version, + status: "passed", + targets: matrix.targets.map((target) => ({ + id: target.id, + acceptanceChecks: target.acceptanceChecks.map((check) => ({ + id: check.id, + status: "passed", + })), + })), + }; + fs.writeFileSync(path.join(artifactDir, "desktop-certification-report.json"), `${JSON.stringify(report, null, 2)}\n`, "utf8"); + process.exit(0); +} + +const errors = []; +const releasePlan = readRequiredJson(artifactDir, "desktop-release-plan.json", errors); +const updaterManifest = readRequiredJson(artifactDir, "desktop-updater-manifest.json", errors); +const certificationReport = readRequiredJson(artifactDir, "desktop-certification-report.json", errors); + +validateCertificationGates(matrix, errors); + +if (releasePlan && updaterManifest) { + validateTargetArtifacts(artifactDir, matrix, releasePlan, updaterManifest, errors); +} + +if (certificationReport) { + validateCertificationReport(matrix, certificationReport, errors); +} + +if (errors.length > 0) { + console.error("Desktop certification validation failed:"); + for (const error of errors) { + console.error(`- ${error}`); + } + process.exit(1); +} + +function readRequiredJson(rootDir, basename, errors) { + const filePath = path.join(rootDir, basename); + if (!fs.existsSync(filePath)) { + errors.push(`Missing required desktop certification artifact '${basename}'.`); + return null; + } + return readJson(filePath); +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function validateCertificationGates(matrixPayload, errors) { + const requiredGates = new Map( + (matrixPayload.certificationGates || []) + .filter((gate) => gate.required) + .map((gate) => [gate.id, gate]), + ); + + for (const gateId of [ + "reproducible-build", + "signed-release-metadata", + "validation-matrix-published", + "desktop-artifacts-present", + "desktop-acceptance-passed", + ]) { + if (!requiredGates.has(gateId)) { + errors.push(`Desktop certification gate '${gateId}' is missing from desktop-validation-matrix.json.`); + } + } +} + +function validateTargetArtifacts(rootDir, matrixPayload, releasePlanPayload, updaterManifestPayload, errors) { + const discoveredFiles = collectRelativeFiles(rootDir); + const releaseTargets = new Map((releasePlanPayload.targets || []).map((target) => [target.id, target])); + const updaterChannels = new Map((updaterManifestPayload.channels || []).map((channel) => [channel.id, channel])); + + for (const target of matrixPayload.targets || []) { + const releaseTarget = releaseTargets.get(target.id); + if (!releaseTarget) { + errors.push(`Release plan is missing target '${target.id}'.`); + continue; + } + + const updaterChannel = updaterChannels.get(target.id); + if (!updaterChannel) { + errors.push(`Updater manifest is missing target '${target.id}'.`); + continue; + } + + if (releaseTarget.artifactFormat !== target.packageFormat) { + errors.push(`Target '${target.id}' package format mismatch between validation matrix and release plan.`); + } + + if (updaterChannel.feedPath !== target.updaterFeedPath) { + errors.push(`Target '${target.id}' updater feed path mismatch between validation matrix and updater manifest.`); + } + + assertFileExists(discoveredFiles, target.packageArtifactName, `desktop package for '${target.id}'`, errors); + for (const artifactName of target.updaterArtifacts || []) { + assertFileExists(discoveredFiles, artifactName, `updater metadata '${artifactName}' for '${target.id}'`, errors); + } + } +} + +function validateCertificationReport(matrixPayload, reportPayload, errors) { + const targetReports = new Map((reportPayload.targets || []).map((target) => [target.id, target])); + + for (const target of matrixPayload.targets || []) { + const reportTarget = targetReports.get(target.id); + if (!reportTarget) { + errors.push(`Desktop certification report is missing target '${target.id}'.`); + continue; + } + + const checkStatuses = new Map((reportTarget.acceptanceChecks || []).map((check) => [check.id, check.status])); + for (const check of target.acceptanceChecks || []) { + if (!check.required) { + continue; + } + if (checkStatuses.get(check.id) !== "passed") { + errors.push(`Desktop certification report did not mark '${target.id}/${check.id}' as passed.`); + } + } + } +} + +function collectRelativeFiles(rootDir) { + const discovered = new Set(); + walk(rootDir, rootDir, discovered); + return discovered; +} + +function walk(rootDir, currentDir, discovered) { + for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) { + const fullPath = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + walk(rootDir, fullPath, discovered); + continue; + } + discovered.add(path.relative(rootDir, fullPath).replace(/\\/g, "/")); + } +} + +function assertFileExists(discoveredFiles, basename, label, errors) { + const found = Array.from(discoveredFiles).some((file) => file === basename || file.endsWith(`/${basename}`)); + if (!found) { + errors.push(`Missing ${label}.`); + } +} diff --git a/src/desktop-electron/controlPlane.ts b/src/desktop-electron/controlPlane.ts index 71192d4..7c7884e 100644 --- a/src/desktop-electron/controlPlane.ts +++ b/src/desktop-electron/controlPlane.ts @@ -20,7 +20,7 @@ export interface CreateDesktopControlPlaneOptions { applicationService?: Partial; } -interface OperationDescriptor { +export interface OperationDescriptor { channel: RealtimeChannel; started: RealtimeEventType; completed: RealtimeEventType; @@ -51,7 +51,7 @@ function parseJsonBody>(value: unknown): T { return value as T; } -function describeOperation(payload: DesktopBridgeRequestMap["control-plane.request"]): OperationDescriptor | null { +export function describeDesktopOperation(payload: DesktopBridgeRequestMap["control-plane.request"]): OperationDescriptor | null { const method = (payload.method || "GET").toUpperCase(); const body = parseJsonBody(payload.body); @@ -122,6 +122,19 @@ function describeOperation(payload: DesktopBridgeRequestMap["control-plane.reque }; } + if (method === "POST" && payload.pathname === "/api/v1/skills/publish") { + return { + channel: "operation", + started: "operation.started", + completed: "operation.completed", + failed: "operation.failed", + payload: { + operation: "publish", + sourceId: typeof body.sourceId === "string" ? body.sourceId : undefined, + }, + }; + } + return null; } @@ -154,7 +167,7 @@ export async function createDesktopControlPlane(options: CreateDesktopControlPla return { async request(payload) { - const operation = describeOperation(payload); + const operation = describeDesktopOperation(payload); const opId = operation ? `op_${crypto.randomUUID()}` : undefined; if (operation && opId) { emit(buildRealtimeEvent(operation.channel, operation.started, operation.payload, opId)); diff --git a/tests/installer/desktop-control-plane.test.ts b/tests/installer/desktop-control-plane.test.ts new file mode 100644 index 0000000..7decf38 --- /dev/null +++ b/tests/installer/desktop-control-plane.test.ts @@ -0,0 +1,38 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { describeDesktopOperation } from "../../src/desktop-electron/controlPlane"; + +test("desktop control-plane classifies install and sync apply requests as operation events", () => { + const install = describeDesktopOperation({ + pathname: "/api/v1/install/apply", + method: "POST", + body: { + targets: ["codex"], + }, + }); + const sync = describeDesktopOperation({ + pathname: "/api/v1/sync/apply", + method: "POST", + body: { + targets: ["claude"], + }, + }); + + assert.equal(install?.payload.operation, "install"); + assert.deepEqual(install?.payload.targets, ["codex"]); + assert.equal(sync?.payload.operation, "sync"); + assert.deepEqual(sync?.payload.targets, ["claude"]); +}); + +test("desktop control-plane classifies publish requests as operation events", () => { + const publish = describeDesktopOperation({ + pathname: "/api/v1/skills/publish", + method: "POST", + body: { + sourceId: "official", + }, + }); + + assert.equal(publish?.payload.operation, "publish"); + assert.equal(publish?.payload.sourceId, "official"); +}); diff --git a/tests/installer/desktop-rollout-validation.test.ts b/tests/installer/desktop-rollout-validation.test.ts index 35f2b02..fd2d0cc 100644 --- a/tests/installer/desktop-rollout-validation.test.ts +++ b/tests/installer/desktop-rollout-validation.test.ts @@ -15,17 +15,29 @@ interface ValidationMatrixTarget { arch: string; packageArtifactName: string; updaterFeedPath: string; - smokeChecks: Array<{ id: string; required: boolean }>; + packageFormat: string; + updaterArtifacts: string[]; + signingRequirements: string[]; + acceptanceChecks: Array<{ + id: string; + required: boolean; + automation: "automated" | "manual" | "ci"; + status: "pending" | "passed" | "not-run"; + }>; } interface ValidationMatrix { schemaVersion: number; version: string; - rolloutGates: Array<{ id: string; required: boolean }>; + certificationGates: Array<{ + id: string; + required: boolean; + validationSource: "manifest" | "artifacts" | "ci"; + }>; targets: ValidationMatrixTarget[]; } -test("desktop validation matrix defines required rollout checks for every supported target", () => { +test("desktop validation matrix defines required acceptance and certification checks for every supported target", () => { const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "ica-desktop-validation-")); execFileSync("node", ["scripts/release/build-desktop-manifests.mjs", "v12.3.0", outDir], { @@ -51,34 +63,88 @@ test("desktop validation matrix defines required rollout checks for every suppor ], ); assert.deepEqual( - validationMatrix.rolloutGates.map((gate) => gate.id).sort(), - ["reproducible-build", "signed-release-metadata", "validation-matrix-published"], + validationMatrix.certificationGates.map((gate) => gate.id).sort(), + [ + "desktop-acceptance-passed", + "desktop-artifacts-present", + "reproducible-build", + "signed-release-metadata", + "validation-matrix-published", + ], ); + assert.ok(validationMatrix.certificationGates.every((gate) => gate.required)); + assert.ok(validationMatrix.certificationGates.some((gate) => gate.validationSource === "ci")); for (const target of validationMatrix.targets) { assert.match(target.packageArtifactName, /^ica-desktop-v12\.3\.0-(macos|windows|linux)-(x64|arm64)\.(dmg|exe|AppImage)$/); + assert.ok(target.packageFormat.length > 0); assert.ok(target.updaterFeedPath.startsWith("desktop/stable/")); + assert.ok(target.updaterArtifacts.length > 0); + assert.ok(target.signingRequirements.length > 0); assert.deepEqual( - target.smokeChecks.map((check) => check.id).sort(), - ["desktop-control-plane", "desktop-startup", "package-contract", "updater-feed"], + target.acceptanceChecks.map((check) => check.id).sort(), + [ + "desktop-control-plane", + "desktop-startup", + "failure-recovery-ux", + "install-flow", + "package-contract", + "publish-flow", + "sync-flow", + "updater-feed", + "updater-lifecycle", + ], ); - assert.ok(target.smokeChecks.every((check) => check.required)); + assert.ok(target.acceptanceChecks.every((check) => check.required)); + assert.ok(target.acceptanceChecks.some((check) => check.automation === "ci")); + assert.ok(target.acceptanceChecks.every((check) => check.status === "pending")); + + if (target.platform === "darwin") { + assert.deepEqual(target.updaterArtifacts, ["latest-mac.yml", `${target.packageArtifactName}.blockmap`]); + } else if (target.platform === "win32") { + assert.deepEqual(target.updaterArtifacts, ["latest.yml", `${target.packageArtifactName}.blockmap`]); + } else { + assert.deepEqual(target.updaterArtifacts, ["latest-linux.yml", `${target.packageArtifactName}.blockmap`]); + } } }); -test("desktop validation matrix is included in release signing automation and operator docs", () => { +test("desktop certification is included in release signing automation and operator docs", () => { const buildScript = readWorkspaceFile("scripts/release/build-artifacts.sh"); const workflow = readWorkspaceFile(".github/workflows/release-sign.yml"); const releaseDocs = readWorkspaceFile("docs/release-signing.md"); const rolloutDocsPath = path.resolve(process.cwd(), "docs/testing/desktop-rollout-validation.md"); assert.match(buildScript, /desktop-validation-matrix\.json/); + assert.match(workflow, /validate-desktop-release\.mjs/); assert.match(workflow, /desktop-validation-matrix\.json/); assert.equal(fs.existsSync(rolloutDocsPath), true, "Rollout validation operator guide should exist."); const rolloutDocs = fs.readFileSync(rolloutDocsPath, "utf8"); assert.match(releaseDocs, /desktop-validation-matrix\.json/); + assert.match(releaseDocs, /desktop certification/i); assert.match(rolloutDocs, /desktop-validation-matrix\.json/); - assert.match(rolloutDocs, /reproducible/i); - assert.match(rolloutDocs, /signed release metadata/i); + assert.match(rolloutDocs, /install/i); + assert.match(rolloutDocs, /sync/i); + assert.match(rolloutDocs, /publish/i); + assert.match(rolloutDocs, /failure/i); + assert.match(rolloutDocs, /recovery/i); +}); + +test("desktop release validation script fails closed when required certification evidence is missing", () => { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "ica-desktop-certification-")); + + execFileSync("node", ["scripts/release/build-desktop-manifests.mjs", "v12.3.0", outDir], { + cwd: process.cwd(), + stdio: "pipe", + }); + + assert.throws( + () => + execFileSync("node", ["scripts/release/validate-desktop-release.mjs", outDir], { + cwd: process.cwd(), + stdio: "pipe", + }), + /desktop certification|validation/i, + ); }); diff --git a/tests/installer/desktop-updater.test.ts b/tests/installer/desktop-updater.test.ts index 22e8e71..2690cc4 100644 --- a/tests/installer/desktop-updater.test.ts +++ b/tests/installer/desktop-updater.test.ts @@ -87,3 +87,44 @@ test("desktop packaged updater download and quit/install advance update lifecycl assert.equal(installResult.accepted, true); assert.equal(nativeUpdater.quitCalls, 1); }); + +test("desktop preview mode rejects automatic download and quit/install flows", async () => { + const coordinator = createDesktopUpdateCoordinator({ + currentVersion: "12.3.0", + packaged: false, + fetchLatestRelease: async () => ({ + version: "12.4.0", + url: "https://github.com/intelligentcode-ai/intelligent-code-agents/releases/tag/v12.4.0", + }), + }); + + await coordinator.checkForAppUpdate(true); + const downloadResult = await coordinator.downloadAppUpdate(); + const installResult = await coordinator.quitAndInstallAppUpdate(); + + assert.equal(downloadResult.runtime, "desktop-preview"); + assert.equal(downloadResult.canAutoApply, false); + assert.match(downloadResult.error || "", /preview mode/i); + assert.equal(installResult.accepted, false); +}); + +test("desktop packaged updater reports download failures without losing lifecycle state", async () => { + const nativeUpdater = createNativeUpdaterStub(); + nativeUpdater.downloadUpdate = async function downloadUpdateFailure() { + this.downloadCalls += 1; + throw new Error("network timeout"); + }; + + const coordinator = createDesktopUpdateCoordinator({ + currentVersion: "12.3.0", + packaged: true, + nativeUpdater, + }); + + await coordinator.checkForAppUpdate(true); + const result = await coordinator.downloadAppUpdate(); + + assert.equal(result.downloaded, false); + assert.equal(result.canAutoApply, true); + assert.match(result.error || "", /network timeout/i); +}); From 523c6a6ac7a25ced4b138b13f288e3945124f856 Mon Sep 17 00:00:00 2001 From: Karsten Samaschke Date: Sat, 14 Mar 2026 11:17:19 +0100 Subject: [PATCH 2/2] fix(release): build full desktop target matrix --- .github/workflows/release-sign.yml | 53 +++++++++++++++---- scripts/release/build-desktop-manifests.mjs | 2 +- scripts/release/desktop-targets.mjs | 10 +++- .../desktop-release-packaging.test.ts | 8 +++ .../desktop-rollout-validation.test.ts | 2 +- 5 files changed, 61 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release-sign.yml b/.github/workflows/release-sign.yml index bae1530..db4fc36 100644 --- a/.github/workflows/release-sign.yml +++ b/.github/workflows/release-sign.yml @@ -130,14 +130,29 @@ jobs: matrix: include: - os: macos-latest - target: "--mac dmg" - artifact_glob: "dist/desktop-release/*.dmg\ndist/desktop-release/latest-mac.yml\ndist/desktop-release/*.blockmap" + arch: x64 + target: "--mac dmg --x64" + artifact_glob: "dist/desktop-release/*-mac-x64.dmg\ndist/desktop-release/latest-mac.yml\ndist/desktop-release/*-mac-x64.dmg.blockmap" - os: windows-latest - target: "--win nsis" - artifact_glob: "dist/desktop-release/*.exe\ndist/desktop-release/latest.yml\ndist/desktop-release/*.blockmap" + arch: x64 + target: "--win nsis --x64" + artifact_glob: "dist/desktop-release/*-win-x64.exe\ndist/desktop-release/latest.yml\ndist/desktop-release/*-win-x64.exe.blockmap" - os: ubuntu-latest - target: "--linux AppImage" - artifact_glob: "dist/desktop-release/*.AppImage\ndist/desktop-release/latest-linux.yml\ndist/desktop-release/*.blockmap" + arch: x64 + target: "--linux AppImage --x64" + artifact_glob: "dist/desktop-release/*-linux-x64.AppImage\ndist/desktop-release/latest-linux.yml\ndist/desktop-release/*-linux-x64.AppImage.blockmap" + - os: macos-latest + arch: arm64 + target: "--mac dmg --arm64" + artifact_glob: "dist/desktop-release/*-mac-arm64.dmg\ndist/desktop-release/latest-mac.yml\ndist/desktop-release/*-mac-arm64.dmg.blockmap" + - os: windows-latest + arch: arm64 + target: "--win nsis --arm64" + artifact_glob: "dist/desktop-release/*-win-arm64.exe\ndist/desktop-release/latest.yml\ndist/desktop-release/*-win-arm64.exe.blockmap" + - os: ubuntu-latest + arch: arm64 + target: "--linux AppImage --arm64" + artifact_glob: "dist/desktop-release/*-linux-arm64.AppImage\ndist/desktop-release/latest-linux.yml\ndist/desktop-release/*-linux-arm64.AppImage.blockmap" runs-on: ${{ matrix.os }} permissions: contents: read @@ -174,7 +189,7 @@ jobs: - name: Upload desktop release assets uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: desktop-${{ matrix.os }}-${{ github.ref_name }} + name: desktop-${{ matrix.os }}-${{ matrix.arch }}-${{ github.ref_name }} path: ${{ matrix.artifact_glob }} if-no-files-found: error @@ -200,19 +215,37 @@ jobs: - name: Download macOS desktop assets uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - name: desktop-macos-latest-${{ github.ref_name }} + name: desktop-macos-latest-x64-${{ github.ref_name }} + path: dist/desktop + + - name: Download macOS arm64 desktop assets + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: desktop-macos-latest-arm64-${{ github.ref_name }} path: dist/desktop - name: Download Windows desktop assets uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - name: desktop-windows-latest-${{ github.ref_name }} + name: desktop-windows-latest-x64-${{ github.ref_name }} + path: dist/desktop + + - name: Download Windows arm64 desktop assets + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: desktop-windows-latest-arm64-${{ github.ref_name }} path: dist/desktop - name: Download Linux desktop assets uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - name: desktop-ubuntu-latest-${{ github.ref_name }} + name: desktop-ubuntu-latest-x64-${{ github.ref_name }} + path: dist/desktop + + - name: Download Linux arm64 desktop assets + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: desktop-ubuntu-latest-arm64-${{ github.ref_name }} path: dist/desktop - name: Download desktop certification report diff --git a/scripts/release/build-desktop-manifests.mjs b/scripts/release/build-desktop-manifests.mjs index 43146b7..d476ff9 100755 --- a/scripts/release/build-desktop-manifests.mjs +++ b/scripts/release/build-desktop-manifests.mjs @@ -30,7 +30,7 @@ const generatedAt = resolveGeneratedAt(process.env.SOURCE_DATE_EPOCH); const releaseTargets = desktopTargets.map((target) => { const id = `${target.platform}-${target.arch}`; - const artifactName = `ica-desktop-${versionTag}-${target.osToken}-${target.arch}.${target.artifactFormat}`; + const artifactName = `ica-desktop-${versionTag}-${target.artifactNameToken}-${target.arch}.${target.artifactFormat}`; const publishPath = `desktop/stable/${target.platform}/${target.arch}/${artifactName}`; const updaterArtifacts = getDesktopUpdaterArtifacts(target).map((artifact) => artifact.replace("ica-desktop-", `ica-desktop-${versionTag}`)); return { diff --git a/scripts/release/desktop-targets.mjs b/scripts/release/desktop-targets.mjs index ea94400..70c0509 100644 --- a/scripts/release/desktop-targets.mjs +++ b/scripts/release/desktop-targets.mjs @@ -3,6 +3,7 @@ export const desktopTargets = [ platform: "darwin", arch: "x64", osToken: "macos", + artifactNameToken: "mac", artifactFormat: "dmg", signingRequirements: ["apple-codesign", "apple-notarization"], }, @@ -10,6 +11,7 @@ export const desktopTargets = [ platform: "darwin", arch: "arm64", osToken: "macos", + artifactNameToken: "mac", artifactFormat: "dmg", signingRequirements: ["apple-codesign", "apple-notarization"], }, @@ -17,6 +19,7 @@ export const desktopTargets = [ platform: "win32", arch: "x64", osToken: "windows", + artifactNameToken: "win", artifactFormat: "exe", signingRequirements: ["authenticode"], }, @@ -24,6 +27,7 @@ export const desktopTargets = [ platform: "win32", arch: "arm64", osToken: "windows", + artifactNameToken: "win", artifactFormat: "exe", signingRequirements: ["authenticode"], }, @@ -31,6 +35,7 @@ export const desktopTargets = [ platform: "linux", arch: "x64", osToken: "linux", + artifactNameToken: "linux", artifactFormat: "AppImage", signingRequirements: ["cosign"], }, @@ -38,6 +43,7 @@ export const desktopTargets = [ platform: "linux", arch: "arm64", osToken: "linux", + artifactNameToken: "linux", artifactFormat: "AppImage", signingRequirements: ["cosign"], }, @@ -78,10 +84,10 @@ export const desktopCertificationGates = [ export function getDesktopUpdaterArtifacts(target) { if (target.platform === "darwin") { - return ["latest-mac.yml", `ica-desktop--macos-${target.arch}.${target.artifactFormat}.blockmap`]; + return ["latest-mac.yml", `ica-desktop--mac-${target.arch}.${target.artifactFormat}.blockmap`]; } if (target.platform === "win32") { - return ["latest.yml", `ica-desktop--windows-${target.arch}.${target.artifactFormat}.blockmap`]; + return ["latest.yml", `ica-desktop--win-${target.arch}.${target.artifactFormat}.blockmap`]; } return ["latest-linux.yml", `ica-desktop--linux-${target.arch}.${target.artifactFormat}.blockmap`]; } diff --git a/tests/installer/desktop-release-packaging.test.ts b/tests/installer/desktop-release-packaging.test.ts index 5eee574..e1c8ac5 100644 --- a/tests/installer/desktop-release-packaging.test.ts +++ b/tests/installer/desktop-release-packaging.test.ts @@ -50,6 +50,14 @@ test("release workflow builds signed desktop artifacts on platform runners and p assert.match(workflow, /ubuntu-latest/); assert.match(workflow, /windows-latest/); assert.match(workflow, /macos-latest/); + assert.match(workflow, /arch:\s*x64/); + assert.match(workflow, /arch:\s*arm64/); + assert.match(workflow, /--mac dmg --x64/); + assert.match(workflow, /--mac dmg --arm64/); + assert.match(workflow, /--win nsis --x64/); + assert.match(workflow, /--win nsis --arm64/); + assert.match(workflow, /--linux AppImage --x64/); + assert.match(workflow, /--linux AppImage --arm64/); assert.match(workflow, /npm run build:desktop:release/); assert.match(workflow, /\.dmg/); assert.match(workflow, /\.exe/); diff --git a/tests/installer/desktop-rollout-validation.test.ts b/tests/installer/desktop-rollout-validation.test.ts index fd2d0cc..de25f0a 100644 --- a/tests/installer/desktop-rollout-validation.test.ts +++ b/tests/installer/desktop-rollout-validation.test.ts @@ -76,7 +76,7 @@ test("desktop validation matrix defines required acceptance and certification ch assert.ok(validationMatrix.certificationGates.some((gate) => gate.validationSource === "ci")); for (const target of validationMatrix.targets) { - assert.match(target.packageArtifactName, /^ica-desktop-v12\.3\.0-(macos|windows|linux)-(x64|arm64)\.(dmg|exe|AppImage)$/); + assert.match(target.packageArtifactName, /^ica-desktop-v12\.3\.0-(mac|win|linux)-(x64|arm64)\.(dmg|exe|AppImage)$/); assert.ok(target.packageFormat.length > 0); assert.ok(target.updaterFeedPath.startsWith("desktop/stable/")); assert.ok(target.updaterArtifacts.length > 0);