import { readdirSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; const repoRoot = resolve(__dirname, "../../.."); const externalPnpmWorkflowFiles = new Set([".github/workflows/notify-docs.yml"]); function readRepoFile(path: string): string { return readFileSync(resolve(repoRoot, path), "utf8"); } function workflowFiles(): string[] { const workflowDir = resolve(repoRoot, ".github/workflows"); return readdirSync(workflowDir) .filter((name) => name.endsWith(".yml") || name.endsWith(".yaml")) .map((name) => `.github/workflows/${name}`); } function extractDockerPnpmVersion(dockerfile: string): string | undefined { return dockerfile.match(/^ARG PNPM_VERSION=([0-9]+\.[0-9]+\.[0-9]+)$/m)?.[1]; } function extractDockerNodeMajor(dockerfile: string): string | undefined { return dockerfile.match(/^ARG NODE_MAJOR=([0-9]+)$/m)?.[1]; } function extractPackageManagerPnpmVersion(packageJSON: string): string { const packageManager = (JSON.parse(packageJSON) as { packageManager?: string }).packageManager; const version = packageManager?.match(/^pnpm@([0-9]+\.[0-9]+\.[0-9]+)$/)?.[1]; expect(version, "apps/package.json: packageManager must pin pnpm").toBeDefined(); if (version === undefined) { throw new Error("apps/package.json: packageManager must pin pnpm"); } return version; } function indentation(line: string): number { return line.search(/\S/); } function isBlankOrComment(line: string): boolean { const trimmed = line.trim(); return trimmed.length === 0 || trimmed.startsWith("#"); } function parseVersionLine(line: string): string | undefined { const match = line.match(/^\s*version:\s*(?:"([^"]+)"|'([^']+)'|([^"'\s#]+))\s*(?:#.*)?$/); return match?.[1] ?? match?.[2] ?? match?.[3]; } function findVersionInWithBlock( lines: string[], start: number, withIndent: number, ): string | undefined { for (let index = start; index < lines.length; index += 1) { const line = lines[index]; if (isBlankOrComment(line)) { continue; } if (indentation(line) <= withIndent) { return undefined; } const version = parseVersionLine(line); if (version !== undefined) { return version; } } return undefined; } function findPnpmSetupVersion( lines: string[], start: number, stepIndent: number, ): string | undefined { for (let index = start; index < lines.length; index += 1) { const line = lines[index]; if (isBlankOrComment(line)) { continue; } if (indentation(line) <= stepIndent) { return undefined; } const withMatch = line.match(/^(\s*)with:\s*(?:#.*)?$/); if (withMatch !== null) { return findVersionInWithBlock(lines, index + 1, withMatch[1].length); } } return undefined; } function matchPnpmSetupUsesLine(line: string): RegExpMatchArray | null { const match = line.match( /^(\s*)(?:-\s*)?uses:\s*["']?pnpm\/action-setup@([^"'\s#]+)["']?\s*(#.*)?$/, ); if (match === null) return null; const ref = match[2]; const comment = match[3] ?? ""; const versionTagPattern = /^v[0-9]+(?:\.[0-9]+\.[0-9]+)?$/; const versionTag = versionTagPattern.test(ref); const shaPinned = /^[0-9a-f]{40}$/.test(ref) && /^#\s*v[0-9]+(?:\.[0-9]+\.[0-9]+)?$/.test(comment); expect( versionTag || shaPinned, "pnpm/action-setup must use a version tag or a 40-character SHA with the version tag in a comment", ).toBe(true); return match; } function findStepIndent(lines: string[], usesLineIndex: number, usesIndent: number): number { if (lines[usesLineIndex].trimStart().startsWith("- ")) { return usesIndent; } for (let index = usesLineIndex - 1; index >= 0; index -= 1) { const line = lines[index]; if (isBlankOrComment(line)) { continue; } const lineIndent = indentation(line); if (lineIndent < usesIndent && line.slice(lineIndent).startsWith("- ")) { return lineIndent; } } return Math.max(0, usesIndent - 2); } function extractWorkflowPnpmVersions(workflow: string): Array { const lines = workflow.split(/\r?\n/); const versions: Array = []; for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; const setupMatch = matchPnpmSetupUsesLine(line); if (setupMatch !== null) { const stepIndent = findStepIndent(lines, index, setupMatch[1].length); versions.push(findPnpmSetupVersion(lines, index + 1, stepIndent)); } } return versions; } function assertWorkflowPnpmVersions(file: string, expectedVersion?: string): number { const versions = extractWorkflowPnpmVersions(readRepoFile(file)); for (const version of versions) { if (version === undefined) { expect(version, `${file}: pnpm/action-setup step is missing a version pin`).toBeDefined(); continue; } if (expectedVersion !== undefined) { expect(version, `${file}: pnpm/action-setup version must match apps/package.json`).toBe( expectedVersion, ); } } return versions.length; } describe("release runtime tooling configuration", () => { it("rejects malformed SHA version comments for pnpm/action-setup", () => { expect(() => matchPnpmSetupUsesLine( "uses: pnpm/action-setup@0123456789abcdef0123456789abcdef01234567 # v4-old", ), ).toThrow(/40-character SHA with the version tag/); }); it("pins runtime tooling consistently across Docker and GitHub Actions", () => { const packagePnpmVersion = extractPackageManagerPnpmVersion(readRepoFile("apps/package.json")); const dockerfile = readRepoFile("Dockerfile"); const universalDockerfile = readRepoFile("Dockerfile.universal"); const dockerPnpmVersion = extractDockerPnpmVersion(dockerfile); const universalDockerPnpmVersion = extractDockerPnpmVersion(universalDockerfile); const dockerNodeMajor = extractDockerNodeMajor(dockerfile); expect(dockerfile).not.toContain("pnpm@latest"); expect(dockerNodeMajor, "Dockerfile: NODE_MAJOR must stay on the release Node line").toBe("24"); expect(dockerfile).toContain("AS apt-keys"); expect(dockerfile).toContain( "COPY --from=apt-keys /nodesource.gpg /usr/share/keyrings/nodesource.gpg", ); expect(dockerfile).toContain( "COPY --from=apt-keys /microsoft.gpg /usr/share/keyrings/microsoft.gpg", ); expect(dockerfile).toContain("https://deb.nodesource.com/node_${NODE_MAJOR}.x"); expect(dockerfile).toContain("/etc/apt/sources.list.d/azure-cli.sources"); expect(dockerfile).not.toContain("InstallAzureCLIDeb"); expect(dockerfile).not.toContain(" gnupg \\"); expect(dockerfile).toMatch( /rm -f[\s\\]+\/etc\/apt\/sources\.list\.d\/nodesource\.list[\s\\]+\/etc\/apt\/sources\.list\.d\/azure-cli\.sources[\s\\]+\/usr\/share\/keyrings\/nodesource\.gpg[\s\\]+\/usr\/share\/keyrings\/microsoft\.gpg/, ); expect(dockerfile).toMatch(/\bnodejs\b/); expect(dockerfile).toMatch(/\bazure-cli\b/); if (dockerPnpmVersion !== undefined) { expect(dockerPnpmVersion, "Dockerfile: PNPM_VERSION must match apps/package.json").toBe( packagePnpmVersion, ); } expect( universalDockerPnpmVersion, "Dockerfile.universal: PNPM_VERSION must match apps/package.json", ).toBe(packagePnpmVersion); expect(universalDockerfile).not.toContain("pnpm@latest"); expect(universalDockerfile).not.toContain("corepack enable"); expect(universalDockerfile).not.toContain("corepack prepare"); expect(universalDockerfile).toContain( 'npm install -g --prefix /usr/local "pnpm@${PNPM_VERSION}"', ); const workflowSetupCount = workflowFiles().reduce((count, file) => { const expectedVersion = externalPnpmWorkflowFiles.has(file) ? undefined : packagePnpmVersion; return count + assertWorkflowPnpmVersions(file, expectedVersion); }, 0); expect(workflowSetupCount).toBeGreaterThan(0); }); }); describe("release npm publishing", () => { it("skips packages that are already published on npm", () => { const script = readRepoFile("scripts/release/publish-npm.sh"); expect(script).toContain('npm view "${pkg}@${VERSION}" version --silent'); expect(script).toMatch( /if package_already_published "\$pkg"; then\s+record_already_published "\$pkg"\s+continue\s+fi/, ); expect(script).toMatch( /if package_already_published "kandev"; then\s+record_already_published "kandev"/, ); expect(script).toContain("EPUBLISHCONFLICT"); expect(script).toContain("treated as idempotent success"); }); }); describe("release desktop artifacts", () => { const desktopPlatforms = ["macos-arm64", "macos-x64", "linux-x64", "linux-arm64", "windows-x64"]; function releaseWorkflow(): string { return readRepoFile(".github/workflows/release.yml"); } it("builds desktop artifacts for every supported runtime platform", () => { const workflow = releaseWorkflow(); expect(workflow).toMatch(/\n build-desktop:\n/); expect(workflow).toContain("needs: [prepare, build-bundles]"); expect(workflow).toContain("scripts/release/prepare-desktop-runtime.sh"); expect(workflow).toContain('--platform "${{ matrix.platform }}"'); expect(workflow).toContain( 'rustup toolchain install stable --profile minimal --target "${{ matrix.rust_target }}"', ); expect(workflow).toMatch(/Swatinem\/rust-cache@[0-9a-f]{40}\s+# v2/); for (const platform of desktopPlatforms) { expect(workflow, `release.yml must include desktop platform ${platform}`).toContain( `platform: ${platform}`, ); } }); it("publishes desktop artifacts while leaving npm and Homebrew tied to runtime tarballs", () => { const workflow = releaseWorkflow(); const checksumScript = readRepoFile("scripts/release/write-sha256.sh"); const verifyAssetsScript = readRepoFile("scripts/release/verify-desktop-assets.sh"); const publishNpmScript = readRepoFile("scripts/release/publish-npm.sh"); const homebrewScript = readRepoFile("scripts/release/update-homebrew-tap.sh"); expect(workflow).toContain( "needs: [prepare, build-bundles, build-desktop, docker-universal-manifest]", ); expect(workflow).toContain("pattern: desktop-*"); expect(workflow).toContain("scripts/release/verify-desktop-assets.sh"); expect(workflow).toContain("dist/release-assets/kandev-desktop-*"); expect(workflow).toContain('scripts/release/write-sha256.sh "$dest" "$dest.sha256"'); expect(workflow).not.toContain('shasum -a 256 "$(basename "$dest")"'); expect(checksumScript).toContain("command -v shasum"); expect(checksumScript).toContain("command -v sha256sum"); expect(verifyAssetsScript).toContain("command -v shasum"); expect(verifyAssetsScript).toContain("command -v sha256sum"); expect(publishNpmScript).toContain('asset="kandev-${platform}.tar.gz"'); expect(publishNpmScript).not.toContain("kandev-desktop-"); expect(homebrewScript).toContain('local sha_file="kandev-${platform}.tar.gz.sha256"'); expect(homebrewScript).not.toContain("kandev-desktop-"); }); it("bumps desktop package and Tauri versions during release preparation", () => { const workflow = releaseWorkflow(); expect(workflow).toContain('(cd apps/desktop && npm version --no-git-tag-version "$NEXT")'); expect(workflow).toContain("tauriConfig.version = next"); expect(workflow).toContain('cargoToml.replace(/^version = ".*"$/m, `version = "${next}"`)'); expect(workflow).toContain('name = "kandev-desktop"'); expect(workflow).toContain("apps/desktop/src-tauri/tauri.conf.json"); expect(workflow).toContain("apps/desktop/src-tauri/Cargo.toml"); expect(workflow).toContain("apps/desktop/src-tauri/Cargo.lock"); }); it("pins linux x64 desktop release builds to Ubuntu 22.04", () => { const workflow = releaseWorkflow(); expect(workflow).toContain(`- os: ubuntu-22.04 platform: linux-x64 goos: linux goarch: amd64`); expect(workflow).toContain(`- platform: linux-x64 os: ubuntu-22.04 rust_target: x86_64-unknown-linux-gnu tauri_bundles: appimage,deb,rpm`); }); it("builds and publishes a complete signed Tauri updater feed", () => { const workflow = releaseWorkflow(); const tauriConfig = JSON.parse(readRepoFile("apps/desktop/src-tauri/tauri.conf.json")); const manifestScript = readRepoFile("scripts/release/updater-manifest.mjs"); const verifyAssetsScript = readRepoFile("scripts/release/verify-desktop-assets.sh"); expect(tauriConfig.bundle.createUpdaterArtifacts).toBe(false); expect(workflow).toContain( "TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}", ); expect(workflow).toContain( "TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}", ); expect(workflow).toContain('createUpdaterArtifacts":"v1Compatible"'); expect(workflow).toContain("scripts/release/updater-manifest.mjs generate"); expect(workflow).toContain("scripts/release/updater-manifest.mjs verify"); expect(workflow).toContain("dist/release-assets/latest.json"); for (const target of [ "darwin-aarch64", "darwin-x86_64", "linux-aarch64", "linux-x86_64", "windows-x86_64", ]) { expect(manifestScript, `updater manifest must include ${target}`).toContain(`"${target}"`); } for (const extension of ["*.app.tar.gz", "*.AppImage.tar.gz", "*.nsis.zip", "*.sig"]) { expect(workflow, `desktop collector must retain ${extension}`).toContain( `-name '${extension}'`, ); expect(verifyAssetsScript, `asset verification must recognize ${extension}`).toContain( extension, ); } }); it("keeps updater signing independent from platform identity signing", () => { const workflow = releaseWorkflow(); const signingDocs = readRepoFile("docs/desktop-tauri-signing.md"); const updaterSigningScript = readRepoFile("scripts/release/updater-signing-ready.sh"); for (const key of ["TAURI_SIGNING_PRIVATE_KEY", "TAURI_SIGNING_PRIVATE_KEY_PASSWORD"]) { expect(workflow).toContain(key); expect(signingDocs).toContain(key); } expect(signingDocs).toContain("distinct from macOS and Windows code-signing credentials"); expect(signingDocs).toContain("Installers remain available"); expect(signingDocs).toContain("`latest.json` is not"); expect(workflow).toContain('bash "$helper" "${{ matrix.platform }}"'); expect(updaterSigningScript).not.toContain("MACOS_SIGNING_ENABLED"); expect(updaterSigningScript).not.toContain("WINDOWS_SIGNING_ENABLED"); expect(signingDocs).toContain("Missing OS credentials do not block Tauri-signed updater"); expect(workflow).not.toContain( "Refusing to build an updater artifact that is not also signed for its operating system.", ); }); it("builds unsigned desktop releases when signing inputs are incomplete", () => { const workflow = releaseWorkflow(); const signingDocs = readRepoFile("docs/desktop-tauri-signing.md"); const tauriConfig = readRepoFile("apps/desktop/src-tauri/tauri.conf.json"); const windowsSignScript = readRepoFile("apps/desktop/src-tauri/windows-sign.ps1"); expect(workflow).not.toContain("allow_unsigned_desktop"); expect(workflow).not.toContain("ALLOW_UNSIGNED_DESKTOP"); expect(workflow).toContain("desktop_validation_only"); expect(workflow).toContain("ref: ${{ needs.prepare.outputs.ref }}"); expect(workflow).toContain("persist-credentials: false"); expect(workflow).toContain("Desktop validation summary"); expect(workflow).toContain("No release PR, tag, GitHub release, public container tags"); expect(workflow).toContain("if: ${{ !inputs.dry_run && !inputs.desktop_validation_only }}"); expect(workflow).toContain('if [ "$DESKTOP_VALIDATION_ONLY" = "true" ]; then'); expect(workflow).toContain("scripts/release/desktop-signing-ready.sh macos"); expect(workflow).toContain("scripts/release/desktop-signing-ready.sh windows"); expect(workflow).toContain("MACOS_SIGNING_ENABLED=false"); expect(workflow).toContain("MACOS_SIGNING_ENABLED=true"); expect(workflow).toContain("WINDOWS_SIGNING_ENABLED=false"); expect(workflow).toContain("WINDOWS_SIGNING_ENABLED=true"); expect(workflow).toContain( "macOS signing/notarization inputs are incomplete; building unsigned desktop artifact.", ); expect(workflow).toContain( "Windows signing inputs are incomplete; building unsigned desktop artifact.", ); expect(workflow).toContain("Add unsigned desktop warning to release notes"); expect(workflow).toContain( "desktop installers in this release are unsigned development builds.", ); expect(workflow).toContain("docker-amd64:"); expect(workflow).toContain("docker-universal-manifest:"); expect(workflow).toContain("unset APPLE_CERTIFICATE"); expect(workflow).toContain("unset APPLE_ID"); expect(workflow).toContain("unset WINDOWS_CERTIFICATE"); expect(workflow).toContain("unset WINDOWS_CERTIFICATE_PASSWORD"); expect(workflow).toContain("unset WINDOWS_TIMESTAMP_URL"); expect(workflow).toContain("unset WINDOWS_SIGNTOOL_PATH"); for (const key of [ "APPLE_CERTIFICATE", "APPLE_CERTIFICATE_PASSWORD", "KEYCHAIN_PASSWORD", "APPLE_SIGNING_IDENTITY", "APPLE_API_KEY_P8", "APPLE_API_KEY_PATH", "APPLE_TEAM_ID", ]) { expect(workflow, `release.yml must wire ${key}`).toContain(key); } for (const key of ["WINDOWS_CERTIFICATE", "WINDOWS_CERTIFICATE_PASSWORD", "signtool sign"]) { expect(workflow, `release.yml must wire ${key}`).toContain(key); } expect(workflow).toContain("Invoke-SignTool"); expect(workflow).toContain("$LASTEXITCODE -ne 0"); expect(workflow).toContain("Sign macOS desktop runtime binaries"); expect(workflow).toContain("resources/kandev/bin"); expect(workflow).toContain( "for binary in kandev agentctl agentctl-darwin-arm64 agentctl-darwin-amd64; do", ); expect(workflow).toContain( 'codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY"', ); expect(workflow).toContain("codesign --verify --strict"); expect(workflow).toContain("Sign Windows desktop runtime binaries"); expect(workflow).toContain('foreach ($binary in @("kandev.exe", "agentctl.exe"))'); expect(workflow).toContain("Remove-Item -LiteralPath $certificatePath"); expect(tauriConfig).toContain('"publisher": "Kandev"'); expect(tauriConfig).toContain('"timestampUrl"'); expect(tauriConfig).toContain('"timestampUrl": "https://timestamp.digicert.com"'); expect(tauriConfig).toContain('"signCommand"'); expect(tauriConfig).toContain("windows-sign.ps1"); expect(tauriConfig).not.toContain('"csp": null'); expect(windowsSignScript).toContain('"https://timestamp.digicert.com"'); expect(windowsSignScript).toContain('WINDOWS_SIGNING_ENABLED -eq "false"'); expect(windowsSignScript).not.toContain("ALLOW_UNSIGNED_DESKTOP"); expect(windowsSignScript).toContain("Skipping Windows signing for unsigned desktop artifact"); expect(windowsSignScript).toContain("Remove-Item -LiteralPath $certificatePath"); expect(signingDocs).toContain("signs desktop artifacts opportunistically"); expect(signingDocs).toContain("Missing or incomplete inputs do not block the release"); expect(signingDocs).not.toContain("allow_unsigned_desktop"); expect(signingDocs).toContain("desktop_validation_only"); expect(signingDocs).toContain("does not publish a GitHub release"); expect(signingDocs).toContain("public container tags"); expect(signingDocs).toContain("Ubuntu 22.04"); }); });