470 lines
20 KiB
TypeScript
470 lines
20 KiB
TypeScript
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<string | undefined> {
|
|
const lines = workflow.split(/\r?\n/);
|
|
const versions: Array<string | undefined> = [];
|
|
|
|
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");
|
|
});
|
|
});
|