import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { executableRunLines, jobSteps, readWorkflow, stepRunsCommand, stepsMissingEnvDeclaration, templateExpressionsInRunBodies, } from './workflow-test-helpers'; const prereleaseWorkflowPath = resolve(__dirname, '../.github/workflows/prerelease.yml'); const prereleaseWorkflow = readFileSync(prereleaseWorkflowPath, 'utf8').replace(/\r\n/g, '\n'); const packageWorkflow = readFileSync( resolve(__dirname, '../.github/workflows/package-release.yml'), 'utf8', ); const parsedPrereleaseWorkflow = readWorkflow(prereleaseWorkflowPath); const parsedPackageWorkflow = readWorkflow( resolve(__dirname, '../.github/workflows/package-release.yml'), ); const packageJsonPath = resolve(__dirname, '../package.json'); const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { scripts: Record; }; test('prerelease workflow triggers on beta and rc tags only', () => { assert.match(prereleaseWorkflow, /name: Prerelease/); const tagsBlock = prereleaseWorkflow.match(/tags:\s*\n((?:\s*-\s*'[^']+'\s*\n?)+)/); assert.ok(tagsBlock, 'Workflow tags block not found'); const tagsText = tagsBlock[1]; assert.ok(tagsText, 'Workflow tags entries not found'); const tagPatterns = [...tagsText.matchAll(/-\s*'([^']+)'/g)].map(([, pattern]) => pattern); assert.deepEqual(tagPatterns, ['v*-beta.*', 'v*-rc.*']); }); test('package scripts expose prerelease notes generation separately from stable changelog build', () => { assert.equal( packageJson.scripts['changelog:prerelease-notes'], 'bun run scripts/build-changelog.ts prerelease-notes', ); }); test('prerelease workflow uses committed prerelease notes and never calls claude in CI', () => { assert.match(prereleaseWorkflow, /--notes-file release\/prerelease-notes\.md/); assert.doesNotMatch(prereleaseWorkflow, /run: bun run changelog:prerelease-notes/); assert.doesNotMatch(prereleaseWorkflow, /run: bun run changelog:build/); }); test('prerelease delegates its quality gate instead of duplicating quality steps', () => { assert.deepEqual(parsedPrereleaseWorkflow.jobs?.['quality-gate'], { permissions: { contents: 'read' }, uses: './.github/workflows/quality-gate.yml', }); }); test('prerelease workflow publishes GitHub prereleases and keeps them off latest', () => { assert.match(prereleaseWorkflow, /gh release edit[\s\S]*--prerelease/); assert.match(prereleaseWorkflow, /gh release create[\s\S]*--prerelease/); assert.match(prereleaseWorkflow, /gh release create[\s\S]*--latest=false/); }); test('prerelease packaging workflows scope dependency caches by runner architecture', () => { const archScopedCacheKeyMatches = (prereleaseWorkflow + packageWorkflow).match( /key:\s*\${{\s*runner\.os\s*}}-\${{\s*runner\.arch\s*}}-bun-/g, ); const archScopedRestoreKeyMatches = (prereleaseWorkflow + packageWorkflow).match( /\${{\s*runner\.os\s*}}-\${{\s*runner\.arch\s*}}-bun-/g, ); assert.equal(archScopedCacheKeyMatches?.length, 4); assert.ok((archScopedRestoreKeyMatches?.length ?? 0) >= 8); }); test('prerelease workflow builds and uploads all release platforms', () => { assert.deepEqual(Object.keys(parsedPrereleaseWorkflow.jobs ?? {}).sort(), [ 'package', 'quality-gate', 'release', ]); assert.equal( parsedPrereleaseWorkflow.jobs?.package?.uses, './.github/workflows/package-release.yml', ); assert.deepEqual(parsedPrereleaseWorkflow.jobs?.package?.needs, ['quality-gate']); assert.deepEqual(parsedPrereleaseWorkflow.jobs?.release?.needs, ['package']); assert.deepEqual(Object.keys(parsedPackageWorkflow.jobs ?? {}).sort(), [ 'build-linux', 'build-macos', 'build-windows', ]); for (const [job, name, paths] of [ ['build-linux', 'appimage', ['release/*.AppImage']], ['build-macos', 'macos', ['release/*.dmg', 'release/*.zip']], ['build-windows', 'windows', ['release/*.exe', 'release/*.zip']], ] as const) { const uploads = jobSteps(parsedPackageWorkflow, job).filter( (step) => step.uses === 'actions/upload-artifact@v4', ); assert.equal(uploads.length, 1); const upload = uploads[0]; assert.ok(upload); assert.equal(upload.with?.name, name); assert.equal(upload.with?.['if-no-files-found'], 'error'); const uploadPath = upload.with?.path; assert.ok(typeof uploadPath === 'string'); assert.deepEqual(uploadPath.trim().split('\n'), [ ...paths, 'release/latest*.yml', 'release/*.blockmap', 'release/package-size-*.json', ]); const download = jobSteps(parsedPrereleaseWorkflow, 'release').find( (step) => step.uses === 'actions/download-artifact@v4' && step.with?.name === name, ); assert.equal(download?.with?.path, 'release'); } const steps = jobSteps(parsedPrereleaseWorkflow, 'release'); const checksum = steps.find((step) => step.name === 'Generate checksums'); const publish = steps.find((step) => step.name === 'Publish Prerelease'); assert.ok(checksum); assert.ok(publish); assert.ok(executableRunLines(checksum).includes('files+=(release/package-size-*.json)')); assert.ok(executableRunLines(publish).includes('release/package-size-*.json')); }); test('release callers pass only the declared packaging secrets', () => { const signingSecrets = [ 'CSC_LINK', 'CSC_KEY_PASSWORD', 'APPLE_ID', 'APPLE_APP_SPECIFIC_PASSWORD', 'APPLE_TEAM_ID', ]; // The bundled TMDB key is optional: artifacts stay valid without it and // users fall back to their own tmdb.apiKey. const optionalSecrets = ['SUBMINER_TMDB_API_KEY']; assert.deepEqual(parsedPackageWorkflow.on?.workflow_call?.secrets, { ...Object.fromEntries(signingSecrets.map((name) => [name, { required: true }])), ...Object.fromEntries(optionalSecrets.map((name) => [name, { required: false }])), }); for (const workflow of [ parsedPrereleaseWorkflow, readWorkflow(resolve(__dirname, '../.github/workflows/release.yml')), ]) { assert.equal(workflow.jobs?.package?.uses, './.github/workflows/package-release.yml'); assert.deepEqual( workflow.jobs?.package?.secrets, Object.fromEntries( [...signingSecrets, ...optionalSecrets].map((name) => [ name, '${{ secrets.' + name + ' }}', ]), ), ); } }); test('prerelease workflow publishes both launcher wrappers with the platform packages', () => { assert.match( prereleaseWorkflow, /files=\(release\/\*\.AppImage release\/\*\.dmg release\/\*\.exe release\/\*\.zip release\/\*\.tar\.gz release\/latest\*\.yml release\/\*\.blockmap dist\/launcher\/subminer dist\/launcher\/subminer\.cmd\)/, ); assert.match( prereleaseWorkflow, /artifacts=\([\s\S]*release\/\*\.exe[\s\S]*release\/latest\*\.yml[\s\S]*release\/\*\.blockmap[\s\S]*release\/SHA256SUMS\.txt[\s\S]*dist\/launcher\/subminer[\s\S]*dist\/launcher\/subminer\.cmd[\s\S]*\)/, ); }); test('prerelease workflow uploads updater metadata without builder debug YAML files', () => { assert.match(prereleaseWorkflow, /release\/latest\*\.yml/); assert.doesNotMatch(prereleaseWorkflow, /release\/\*\.yml/); }); test('prerelease workflow writes checksum entries using release asset basenames', () => { assert.match(prereleaseWorkflow, /: > release\/SHA256SUMS\.txt/); assert.match(prereleaseWorkflow, /for file in "\$\{files\[@\]\}"; do/); assert.match(prereleaseWorkflow, /\$\{file##\*\/\}/); assert.doesNotMatch( prereleaseWorkflow, /sha256sum "\$\{files\[@\]\}" > release\/SHA256SUMS\.txt/, ); }); test('prerelease workflow relies on builder artifact names without post-build zip renames', () => { assert.doesNotMatch(prereleaseWorkflow, /Rename Windows ZIP artifacts/); assert.doesNotMatch(prereleaseWorkflow, /Rename-Item[\s\S]*-win\.zip/); }); test('prerelease workflow validates artifacts before publishing the release and only undrafts after upload', () => { const artifactsIndex = prereleaseWorkflow.indexOf('artifacts=('); const createIndex = prereleaseWorkflow.indexOf('gh release create'); const uploadIndex = prereleaseWorkflow.indexOf('gh release upload'); const undraftIndex = prereleaseWorkflow.indexOf('--draft=false'); assert.notEqual(artifactsIndex, -1); assert.notEqual(createIndex, -1); assert.notEqual(uploadIndex, -1); assert.notEqual(undraftIndex, -1); assert.ok(artifactsIndex < createIndex); assert.ok(uploadIndex < undraftIndex); assert.match(prereleaseWorkflow, /gh release create[\s\S]*--draft[\s\S]*--prerelease/); }); test('prerelease workflow does not publish to AUR', () => { assert.doesNotMatch(prereleaseWorkflow, /aur-publish:/); assert.doesNotMatch(prereleaseWorkflow, /AUR_SSH_PRIVATE_KEY/); assert.doesNotMatch(prereleaseWorkflow, /scripts\/update-aur-package\.sh/); }); test('prerelease workflow rejects committed notes generated for a different beta or rc', () => { assert.equal( packageJson.scripts['changelog:check-prerelease-notes'], 'bun run scripts/build-changelog.ts check-prerelease-notes', ); // Matched at command positions only, so commenting the check out or quoting it // inside an echo fails the test rather than silently satisfying it. const steps = jobSteps(parsedPrereleaseWorkflow, 'release'); const checkIndex = steps.findIndex((step) => stepRunsCommand( step, /^bun run changelog:check-prerelease-notes --version "\$RELEASE_VERSION"/, ), ); const publishIndex = steps.findIndex((step) => stepRunsCommand(step, /^gh release (create|edit)\b/), ); assert.notEqual(checkIndex, -1); assert.notEqual(publishIndex, -1); // Stale notes are already published if the check runs after the release. assert.ok(checkIndex < publishIndex); }); test('prerelease workflow keeps tag-derived values out of shell bodies', () => { assert.deepEqual(templateExpressionsInRunBodies(parsedPrereleaseWorkflow), []); assert.deepEqual(stepsMissingEnvDeclaration(parsedPrereleaseWorkflow, 'RELEASE_VERSION'), []); });