mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
Keep development desktop artifacts limited to distributable archives and update metadata so macOS workflow downloads do not duplicate the packaged app bundle. The package smoke step still validates the unpacked app before upload. Tested: bun test scripts/pr/release-workflow.test.ts Tested: bun run check:policy Confidence: high Scope-risk: narrow
304 lines
13 KiB
TypeScript
304 lines
13 KiB
TypeScript
import { describe, expect, test } from 'bun:test'
|
|
import { readFileSync } from 'node:fs'
|
|
|
|
describe('release desktop workflow', () => {
|
|
function readReleaseWorkflow() {
|
|
return readFileSync('.github/workflows/release-desktop.yml', 'utf8')
|
|
}
|
|
|
|
function extractJob(workflow: string, jobName: string) {
|
|
return workflow.match(
|
|
new RegExp(`${jobName}:[\\s\\S]*?(?:\\n {2}[a-zA-Z0-9_-]+:|$)`),
|
|
)?.[0]
|
|
}
|
|
|
|
test('build job waits for a PR-quality preflight before packaging', () => {
|
|
const workflow = readReleaseWorkflow()
|
|
|
|
expect(workflow).toContain('quality-preflight:')
|
|
expect(workflow).toContain('run: bun run verify')
|
|
expect(workflow).toContain('- quality-preflight')
|
|
expect(workflow).toContain('name: Build (${{ matrix.label }})')
|
|
})
|
|
|
|
test('desktop build workflows keep Bun compile cache on the runner work drive', () => {
|
|
for (const workflowPath of [
|
|
'.github/workflows/build-desktop-dev.yml',
|
|
'.github/workflows/release-desktop.yml',
|
|
]) {
|
|
const workflow = readFileSync(workflowPath, 'utf8')
|
|
for (const stepName of ['Build sidecars']) {
|
|
const step = workflow.match(
|
|
new RegExp(`- name: ${stepName}[\\s\\S]*?(?:\\n\\s{6}- name:|\\n\\s*with:|$)`),
|
|
)?.[0]
|
|
|
|
expect(step, `${workflowPath} ${stepName}`).toContain(
|
|
'BUN_INSTALL_CACHE_DIR: ${{ runner.temp }}/bun-install-cache',
|
|
)
|
|
expect(step, `${workflowPath} ${stepName}`).toContain(
|
|
'SIDECAR_TARGET_TRIPLE: ${{ matrix.target_triple }}',
|
|
)
|
|
}
|
|
|
|
expect(workflow).toContain('Build Electron')
|
|
expect(workflow).toContain('smoke_platform')
|
|
expect(workflow).toContain('bun run test:package-smoke --platform ${{ matrix.smoke_platform }} --package-kind release --artifacts-dir desktop/build-artifacts/electron')
|
|
expect(workflow).not.toContain('tauri-apps/tauri-action@v0')
|
|
}
|
|
})
|
|
|
|
test('development desktop artifacts exclude unpacked macOS app bundles', () => {
|
|
const workflow = readFileSync('.github/workflows/build-desktop-dev.yml', 'utf8')
|
|
const collectStep = workflow.match(
|
|
/- name: Collect artifacts[\s\S]*?(?:\n\s{6}- name:|$)/,
|
|
)?.[0]
|
|
|
|
expect(collectStep).toContain('*.dmg')
|
|
expect(collectStep).toContain('*.zip')
|
|
expect(collectStep).toContain('*.blockmap')
|
|
expect(collectStep).toContain('*.yml')
|
|
expect(collectStep).not.toContain('-type d -name "*.app"')
|
|
})
|
|
|
|
test('desktop package includes Linux deb metadata required by electron-builder', () => {
|
|
const desktopPackage = JSON.parse(readFileSync('desktop/package.json', 'utf8')) as {
|
|
description?: string
|
|
homepage?: string
|
|
author?: {
|
|
name?: string
|
|
email?: string
|
|
}
|
|
build?: {
|
|
linux?: {
|
|
maintainer?: string
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(desktopPackage.description).toBeTruthy()
|
|
expect(desktopPackage.homepage).toBe('https://github.com/NanmiCoder/cc-haha')
|
|
expect(desktopPackage.author?.name).toBe('NanmiCoder')
|
|
expect(desktopPackage.author?.email).toBe('relakkes@gmail.com')
|
|
expect(desktopPackage.build?.linux?.maintainer).toBe('NanmiCoder <relakkes@gmail.com>')
|
|
})
|
|
|
|
test('release workflow requires macOS Gatekeeper launch approval before upload', () => {
|
|
const workflow = readReleaseWorkflow()
|
|
const gatekeeperStep = workflow.match(
|
|
/- name: Verify macOS launch policy[\s\S]*?(?:\n\s{6}- name:|$)/,
|
|
)?.[0]
|
|
|
|
expect(gatekeeperStep).toContain("if: matrix.smoke_platform == 'macos'")
|
|
expect(gatekeeperStep).toContain('bun run test:package-smoke --platform macos --package-kind release --artifacts-dir desktop/build-artifacts/electron --require-macos-gatekeeper')
|
|
expect(workflow.indexOf('Verify macOS launch policy')).toBeLessThan(workflow.indexOf('Upload release artifacts for final publish'))
|
|
})
|
|
|
|
test('release workflow blocks on macOS signing/notarization secrets and warns for Windows signing', () => {
|
|
const workflow = readReleaseWorkflow()
|
|
const signingJob = workflow.match(
|
|
/signing-preflight:[\s\S]*?(?:\n {2}[a-zA-Z0-9_-]+:|$)/,
|
|
)?.[0]
|
|
const buildJob = extractJob(workflow, 'build')
|
|
|
|
expect(signingJob).toContain('Validate release signing and notarization secrets')
|
|
for (const secret of [
|
|
'MACOS_CERTIFICATE',
|
|
'MACOS_CERTIFICATE_PASSWORD',
|
|
'APPLE_ID',
|
|
'APPLE_APP_SPECIFIC_PASSWORD',
|
|
'APPLE_TEAM_ID',
|
|
]) {
|
|
expect(signingJob).toContain(secret)
|
|
}
|
|
for (const secret of [
|
|
'WINDOWS_CERTIFICATE',
|
|
'WINDOWS_CERTIFICATE_PASSWORD',
|
|
]) {
|
|
expect(signingJob).toContain(secret)
|
|
}
|
|
expect(signingJob).toContain('Missing required macOS signing/notarization secrets')
|
|
expect(signingJob).toContain('Windows signing secrets missing')
|
|
expect(signingJob).toContain('::warning::Windows signing secrets missing')
|
|
|
|
const macRequiredBlock = signingJob?.match(
|
|
/missing=\(\)[\s\S]*?# Windows signing is optional:/,
|
|
)?.[0]
|
|
const windowsOptionalBlock = signingJob?.match(
|
|
/win_missing=\(\)[\s\S]*?fi\n/,
|
|
)?.[0]
|
|
expect(macRequiredBlock).toContain('exit 1')
|
|
expect(windowsOptionalBlock).toContain('::warning::')
|
|
expect(windowsOptionalBlock).not.toContain('exit 1')
|
|
expect(buildJob).toContain('- quality-preflight')
|
|
expect(buildJob).toContain('- signing-preflight')
|
|
expect(workflow.indexOf('signing-preflight:')).toBeLessThan(workflow.indexOf('build:'))
|
|
expect(workflow.indexOf('signing-preflight:')).toBeLessThan(workflow.indexOf('Upload release artifacts for final publish'))
|
|
})
|
|
|
|
test('release workflow avoids same-name updater metadata uploads from matrix builds', () => {
|
|
const workflow = readReleaseWorkflow()
|
|
const namespaceStep = workflow.match(
|
|
/- name: Namespace update metadata assets[\s\S]*?(?:\n\s{6}- name:|$)/,
|
|
)?.[0]
|
|
|
|
expect(namespaceStep).toContain('for file in latest*.yml')
|
|
expect(namespaceStep).toContain('"${file%.yml}-${{ matrix.label }}.yml"')
|
|
expect(workflow.indexOf('Namespace update metadata assets')).toBeLessThan(workflow.indexOf('Upload release artifacts for final publish'))
|
|
})
|
|
|
|
test('release workflow uploads only Actions artifacts from matrix builds', () => {
|
|
const workflow = readReleaseWorkflow()
|
|
const buildJob = extractJob(workflow, 'build')
|
|
|
|
expect(buildJob).toContain('Validate matrix release asset set')
|
|
for (const label of ['macOS-ARM64', 'macOS-x64', 'Linux-x64', 'Linux-ARM64', 'Windows-x64']) {
|
|
expect(buildJob).toContain(`${label})`)
|
|
}
|
|
expect(buildJob).toContain('Upload release artifacts for final publish')
|
|
expect(buildJob).toContain('actions/upload-artifact@v4')
|
|
expect(buildJob).toContain('name: desktop-release-artifacts-${{ matrix.label }}')
|
|
expect(buildJob).not.toContain('softprops/action-gh-release@v2')
|
|
expect(buildJob).not.toContain('Load release notes')
|
|
})
|
|
|
|
test('release workflow publishes all release assets only after all matrix builds pass', () => {
|
|
const workflow = readReleaseWorkflow()
|
|
const publishJob = extractJob(workflow, 'publish-release')
|
|
|
|
expect(workflow).toContain('name: desktop-update-metadata-${{ matrix.label }}')
|
|
expect(workflow).toContain('name: desktop-release-artifacts-${{ matrix.label }}')
|
|
expect(publishJob).toContain('needs: build')
|
|
expect(publishJob).toContain('actions/download-artifact@v4')
|
|
expect(publishJob).toContain('pattern: desktop-release-artifacts-*')
|
|
expect(publishJob).toContain('pattern: desktop-update-metadata-*')
|
|
expect(publishJob).toContain('Validate complete release asset set')
|
|
expect(publishJob).toContain('bun run scripts/release-update-metadata.ts --metadata-dir artifacts/update-metadata --out-dir artifacts/update-metadata-standard')
|
|
expect(publishJob).toContain('Validate standard update metadata set')
|
|
expect(publishJob).toContain('softprops/action-gh-release@v2')
|
|
expect(publishJob).toContain('artifacts/release-assets/**/*.dmg')
|
|
expect(publishJob).toContain('artifacts/release-assets/**/*.exe')
|
|
expect(publishJob).toContain('artifacts/release-assets/**/*.AppImage')
|
|
expect(publishJob).toContain('artifacts/update-metadata-standard/*.yml')
|
|
expect(publishJob).toContain('fail_on_unmatched_files: true')
|
|
expect(publishJob).toContain('Load release notes')
|
|
expect(workflow.indexOf('publish-release:')).toBeGreaterThan(workflow.indexOf('build:'))
|
|
})
|
|
|
|
test('release matrix asset basenames remain unique when final artifacts are flattened', () => {
|
|
const desktopPackage = JSON.parse(readFileSync('desktop/package.json', 'utf8')) as {
|
|
version: string
|
|
build: {
|
|
artifactName: string
|
|
}
|
|
}
|
|
const version = desktopPackage.version
|
|
expect(desktopPackage.build.artifactName).toBe('Claude-Code-Haha-${version}-${os}-${arch}.${ext}')
|
|
|
|
const expectedReleaseAssets = [
|
|
`Claude-Code-Haha-${version}-mac-arm64.dmg`,
|
|
`Claude-Code-Haha-${version}-mac-arm64.dmg.blockmap`,
|
|
`Claude-Code-Haha-${version}-mac-arm64.zip`,
|
|
`Claude-Code-Haha-${version}-mac-arm64.zip.blockmap`,
|
|
`Claude-Code-Haha-${version}-mac-x64.dmg`,
|
|
`Claude-Code-Haha-${version}-mac-x64.dmg.blockmap`,
|
|
`Claude-Code-Haha-${version}-mac-x64.zip`,
|
|
`Claude-Code-Haha-${version}-mac-x64.zip.blockmap`,
|
|
`Claude-Code-Haha-${version}-linux-x86_64.AppImage`,
|
|
`Claude-Code-Haha-${version}-linux-amd64.deb`,
|
|
`Claude-Code-Haha-${version}-linux-arm64.AppImage`,
|
|
`Claude-Code-Haha-${version}-linux-arm64.deb`,
|
|
`Claude-Code-Haha-${version}-win-x64.exe`,
|
|
`Claude-Code-Haha-${version}-win-x64.exe.blockmap`,
|
|
]
|
|
const namespacedMetadata = [
|
|
'latest-mac-macOS-ARM64.yml',
|
|
'latest-mac-macOS-x64.yml',
|
|
'latest-linux-Linux-x64.yml',
|
|
'latest-linux-Linux-ARM64.yml',
|
|
'latest-Windows-x64.yml',
|
|
]
|
|
const standardMetadata = [
|
|
'latest-mac.yml',
|
|
'latest-linux.yml',
|
|
'latest-linux-arm64.yml',
|
|
'latest.yml',
|
|
]
|
|
const flattenedNames = [
|
|
...expectedReleaseAssets,
|
|
...namespacedMetadata,
|
|
...standardMetadata,
|
|
]
|
|
|
|
expect(new Set(flattenedNames).size).toBe(flattenedNames.length)
|
|
expect(expectedReleaseAssets.filter((name) => name.endsWith('.dmg')).length).toBe(2)
|
|
expect(expectedReleaseAssets.filter((name) => name.endsWith('.zip')).length).toBe(2)
|
|
expect(expectedReleaseAssets.filter((name) => name.endsWith('.AppImage')).length).toBe(2)
|
|
expect(expectedReleaseAssets.filter((name) => name.endsWith('.deb')).length).toBe(2)
|
|
expect(expectedReleaseAssets.filter((name) => name.endsWith('.exe')).length).toBe(1)
|
|
expect(expectedReleaseAssets.some((name) => name.includes('-linux-') && name.endsWith('.blockmap'))).toBe(false)
|
|
for (const platform of ['mac', 'linux', 'win']) {
|
|
expect(expectedReleaseAssets.some((name) => name.includes(`-${platform}-`))).toBe(true)
|
|
}
|
|
expect(standardMetadata).toEqual([
|
|
'latest-mac.yml',
|
|
'latest-linux.yml',
|
|
'latest-linux-arm64.yml',
|
|
'latest.yml',
|
|
])
|
|
})
|
|
|
|
test('release workflow validates exact expected release assets and update metadata before publishing', () => {
|
|
const workflow = readReleaseWorkflow()
|
|
const buildJob = extractJob(workflow, 'build')
|
|
const publishJob = extractJob(workflow, 'publish-release')
|
|
const expectedFiles = [
|
|
'Claude-Code-Haha-${APP_VERSION}-mac-arm64.dmg',
|
|
'Claude-Code-Haha-${APP_VERSION}-mac-arm64.zip',
|
|
'Claude-Code-Haha-${APP_VERSION}-mac-x64.dmg',
|
|
'Claude-Code-Haha-${APP_VERSION}-mac-x64.zip',
|
|
'Claude-Code-Haha-${APP_VERSION}-linux-x86_64.AppImage',
|
|
'Claude-Code-Haha-${APP_VERSION}-linux-amd64.deb',
|
|
'Claude-Code-Haha-${APP_VERSION}-linux-arm64.AppImage',
|
|
'Claude-Code-Haha-${APP_VERSION}-linux-arm64.deb',
|
|
'Claude-Code-Haha-${APP_VERSION}-win-x64.exe',
|
|
]
|
|
|
|
for (const file of expectedFiles) {
|
|
expect(buildJob).toContain(file)
|
|
expect(publishJob).toContain(file)
|
|
}
|
|
for (const metadata of ['latest-mac.yml', 'latest-linux.yml', 'latest-linux-arm64.yml', 'latest.yml']) {
|
|
expect(publishJob).toContain(`artifacts/update-metadata-standard/$file`)
|
|
expect(publishJob).toContain(metadata)
|
|
}
|
|
expect(buildJob).not.toContain('linux-x64.AppImage.blockmap')
|
|
expect(buildJob).not.toContain('linux-arm64.AppImage.blockmap')
|
|
expect(buildJob).toContain('latest-linux-arm64.yml')
|
|
expect(buildJob).toContain('Missing release assets for %s')
|
|
expect(publishJob).toContain('Missing complete release assets')
|
|
expect(publishJob).toContain('Missing standard update metadata')
|
|
})
|
|
|
|
test('Electron Builder publish config does not rely on git remote autodetection', () => {
|
|
const desktopPackage = JSON.parse(readFileSync('desktop/package.json', 'utf8')) as {
|
|
build: {
|
|
publish?: Array<{ provider?: string, owner?: string, repo?: string }>
|
|
mac?: { publish?: unknown }
|
|
win?: { publish?: unknown }
|
|
linux?: { publish?: unknown }
|
|
}
|
|
}
|
|
|
|
expect(desktopPackage.build.publish).toEqual([
|
|
{
|
|
provider: 'github',
|
|
owner: 'NanmiCoder',
|
|
repo: 'cc-haha',
|
|
},
|
|
])
|
|
expect(desktopPackage.build.mac?.publish).toBeUndefined()
|
|
expect(desktopPackage.build.win?.publish).toBeUndefined()
|
|
expect(desktopPackage.build.linux?.publish).toBeUndefined()
|
|
})
|
|
})
|