'use strict'; const assert = require('assert'); const fs = require('fs'); const path = require('path'); const repoRoot = path.resolve(__dirname, '..', '..'); const workflowPaths = [ '.github/workflows/release.yml', '.github/workflows/reusable-release.yml', ]; const lifecycleRunnerSource = load('tests/ci/packed-artifact-lifecycle.js'); let passed = 0; let failed = 0; function test(name, fn) { try { fn(); console.log(` ✓ ${name}`); passed += 1; } catch (error) { console.log(` ✗ ${name}`); console.log(` Error: ${error.message}`); failed += 1; } } function load(relativePath) { return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8').replace(/\r\n/g, '\n'); } function jobBlock(source, jobName, nextJobName) { const startMarker = `\n ${jobName}:\n`; const start = source.indexOf(startMarker); assert.ok(start >= 0, `missing ${jobName} job`); if (!nextJobName) { return source.slice(start); } const end = source.indexOf(`\n ${nextJobName}:\n`, start + startMarker.length); assert.ok(end > start, `missing ${nextJobName} job after ${jobName}`); return source.slice(start, end); } console.log('\n=== Testing packed-artifact release workflows ===\n'); for (const workflowPath of workflowPaths) { const source = load(workflowPath); test(`${workflowPath} packs once and exports the package name and SHA-256`, () => { assert.strictEqual( (source.match(/npm pack --json/g) || []).length, 1, 'release workflow must pack exactly once' ); assert.match(source, /package_sha256:\s*\$\{\{ steps\.pack\.outputs\.package_sha256 \}\}/); assert.match(source, /createHash\(['"]sha256['"]\)/); assert.match(source, /package_sha256=['"]? \+ digest/); }); test(`${workflowPath} invokes only test files present in the release source`, () => { const referencedTests = [...source.matchAll(/\bnode (tests\/[A-Za-z0-9_./-]+\.js)\b/g)] .map(match => match[1]); assert.ok(referencedTests.length > 0, 'release workflow should run repository tests'); for (const testPath of referencedTests) { assert.ok(fs.existsSync(path.join(repoRoot, testPath)), `missing workflow test: ${testPath}`); } }); test(`${workflowPath} uploads the one packed tgz as the release artifact`, () => { const verify = jobBlock(source, 'verify', 'lifecycle'); const packIndex = verify.indexOf('name: Pack npm artifact'); const uploadIndex = verify.indexOf('name: Upload release artifacts'); assert.ok(packIndex >= 0, 'missing pack step'); assert.ok(uploadIndex > packIndex, 'artifact upload must happen after pack and hash'); assert.match(verify, /name:\s*ecc-release-artifacts/); assert.match(verify, /\$\{\{ steps\.pack\.outputs\.package_file \}\}/); assert.match(verify, /tests\/ci\/packed-artifact-lifecycle\.js/); }); test(`${workflowPath} fails retries when npm already has different bytes`, () => { const verify = jobBlock(source, 'verify', 'lifecycle'); assert.match(verify, /name:\s*Verify existing npm artifact matches candidate/); assert.match(verify, /if:\s*steps\.npm_publish_state\.outputs\.already_published == 'true'/); assert.match(verify, /npm view "\$\{PACKAGE_NAME\}@\$\{PACKAGE_VERSION\}" dist\.integrity/); assert.match(verify, /createHash\(['"]sha512['"]\)/); assert.match(verify, /Existing npm artifact does not match tested candidate/); }); test(`${workflowPath} verifies the same tgz on Node 20 across three operating systems`, () => { const lifecycle = jobBlock(source, 'lifecycle', 'publish'); assert.match(lifecycle, /needs:\s*verify/); assert.match(lifecycle, /os:\s*\[ubuntu-latest, macos-latest, windows-latest\]/); assert.match(lifecycle, /runs-on:\s*\$\{\{ matrix\.os \}\}/); assert.match(lifecycle, /node-version:\s*['"]20\.x['"]/); assert.match(lifecycle, /uses:\s*actions\/download-artifact@/); assert.match(lifecycle, /name:\s*ecc-release-artifacts/); assert.match(lifecycle, /ECC_RELEASE_PACKAGE:\s*release-artifacts\/\$\{\{ needs\.verify\.outputs\.package_file \}\}/); assert.match(lifecycle, /ECC_RELEASE_SHA256:\s*\$\{\{ needs\.verify\.outputs\.package_sha256 \}\}/); assert.match(lifecycle, /node release-artifacts\/tests\/ci\/packed-artifact-lifecycle\.js/); assert.doesNotMatch(lifecycle, /actions\/checkout@/); assert.doesNotMatch(lifecycle, /\bsecrets\s*:/, 'lifecycle job must not receive secrets'); assert.doesNotMatch(lifecycle, /\$\{\{\s*secrets\./, 'lifecycle job must not reference secrets'); }); test(`${workflowPath} blocks publishing on packed-artifact lifecycle success`, () => { const publish = jobBlock(source, 'publish'); assert.match(publish, /needs:\s*\[verify, lifecycle\]/); assert.match(publish, /ECC_RELEASE_PACKAGE:\s*\$\{\{ needs\.verify\.outputs\.package_file \}\}/); assert.match(publish, /npm publish "\.\/\$\{ECC_RELEASE_PACKAGE\}"/); assert.match(publish, /name:\s*Verify artifact before publish/); assert.match(publish, /ECC_RELEASE_SHA256:\s*\$\{\{ needs\.verify\.outputs\.package_sha256 \}\}/); assert.match(publish, /createHash\(['"]sha256['"]\)/); assert.match(publish, /ecc-universal-\[0-9A-Za-z\.\+-\]/); assert.ok( publish.indexOf('name: Verify artifact before publish') < publish.indexOf('name: Create GitHub Release'), 'publish must verify the independently downloaded archive before creating the release' ); }); } test('reusable release requires its input to resolve through the tag namespace', () => { const source = load('.github/workflows/reusable-release.yml'); const verify = jobBlock(source, 'verify', 'lifecycle'); assert.match(verify, /ref:\s*refs\/tags\/\$\{\{ inputs\.tag \}\}/); }); test('pull-request CI packs once and exports the exact installer artifact identity', () => { const source = load('.github/workflows/ci.yml'); const pack = jobBlock(source, 'pack-installer', 'packed-install-lifecycle'); assert.strictEqual((pack.match(/npm pack --json/g) || []).length, 1); assert.match(pack, /package_file:\s*\$\{\{ steps\.pack\.outputs\.package_file \}\}/); assert.match(pack, /package_sha256:\s*\$\{\{ steps\.pack\.outputs\.package_sha256 \}\}/); assert.match(pack, /createHash\(['"]sha256['"]\)/); assert.match(pack, /name:\s*ecc-ci-installer-artifact/); }); test('pull-request CI runs the same packed installer on Linux, macOS, and Windows', () => { const source = load('.github/workflows/ci.yml'); const lifecycle = jobBlock(source, 'packed-install-lifecycle', 'validate'); assert.match(lifecycle, /needs:\s*pack-installer/); assert.match(lifecycle, /os:\s*\[ubuntu-latest, macos-latest, windows-latest\]/); assert.match(lifecycle, /node-version:\s*['"]20\.x['"]/); assert.match(lifecycle, /name:\s*ecc-ci-installer-artifact/); assert.match(lifecycle, /ECC_RELEASE_PACKAGE:\s*release-artifacts\/\$\{\{ needs\.pack-installer\.outputs\.package_file \}\}/); assert.match(lifecycle, /ECC_RELEASE_SHA256:\s*\$\{\{ needs\.pack-installer\.outputs\.package_sha256 \}\}/); assert.match(lifecycle, /node tests\/ci\/packed-artifact-lifecycle\.js/); assert.doesNotMatch(lifecycle, /\$\{\{\s*secrets\./); }); test('packed lifecycle invokes installed public bins, including setup help', () => { assert.match(lifecycleRunnerSource, /getNpmExecInvocation/); assert.match(lifecycleRunnerSource, /\['ecc-universal', 'setup', '--help'\]/); assert.match(lifecycleRunnerSource, /\['ecc', \.\.\.args\]/); assert.doesNotMatch(lifecycleRunnerSource, /node_modules.*scripts.*ecc\.js/); }); console.log(`\nPassed: ${passed}`); console.log(`Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0);