diff --git a/.github/scripts/verify-release-artifacts.sh b/.github/scripts/verify-release-artifacts.sh index b35a74651..4aef28d4a 100644 --- a/.github/scripts/verify-release-artifacts.sh +++ b/.github/scripts/verify-release-artifacts.sh @@ -13,27 +13,29 @@ if [[ ! -d "$artifact_root" ]]; then exit 1 fi +# Require exactly one regular file matching a path-scoped release artifact pattern. require_exactly_one_path() { - local path_pattern="$1" label="$2" count=0 matched_path="" - while IFS= read -r -d '' matched_path; do count=$((count + 1)); done < <(find "$artifact_root" -type f -path "$path_pattern" -print0) + local path_pattern="$1" label="$2" count=0 + while IFS= read -r -d '' _; do count=$((count + 1)); done < <(find "$artifact_root" -type f -path "$path_pattern" -print0) if [[ $count -ne 1 ]]; then printf 'Expected exactly one %s, found %s.\n' "$label" "$count" >&2 exit 1 fi } +# Require exactly one named operational artifact inside its platform-scoped directory. require_exactly_one_file() { - local file_name="$1" count=0 matched_path="" - while IFS= read -r -d '' matched_path; do count=$((count + 1)); done < <(find "$artifact_root" -type f -name "$file_name" -print0) + local directory="$1" file_name="$2" count=0 + while IFS= read -r -d '' _; do count=$((count + 1)); done < <(find "$artifact_root/$directory" -type f -name "$file_name" -print0) if [[ $count -ne 1 ]]; then - printf 'Expected exactly one release artifact named %s, found %s.\n' "$file_name" "$count" >&2 + printf 'Expected exactly one release artifact named %s in %s, found %s.\n' "$file_name" "$directory" "$count" >&2 exit 1 fi } expected_dirs=( "release-disksage-ubuntu-22.04-${run_attempt}" - "release-disksage-windows-latest-${run_attempt}" + "release-disksage-windows-2022-${run_attempt}" "release-disksage-macos-latest-${run_attempt}" ) @@ -55,22 +57,24 @@ if [[ -n "$unexpected_entry" ]]; then exit 1 fi -require_exactly_one_path '*/bundle/deb/*.deb' 'Debian bundle' -require_exactly_one_path '*/bundle/appimage/*.AppImage' 'AppImage bundle' -require_exactly_one_path '*/bundle/msi/*.msi' 'Windows MSI bundle' -require_exactly_one_path '*/bundle/nsis/*.exe' 'Windows NSIS bundle' -require_exactly_one_path '*/bundle/dmg/*.dmg' 'macOS DMG bundle' +require_exactly_one_path "$artifact_root/${expected_dirs[0]}/bundle/deb/*.deb" 'Debian bundle' +require_exactly_one_path "$artifact_root/${expected_dirs[0]}/bundle/appimage/*.AppImage" 'AppImage bundle' +require_exactly_one_path "$artifact_root/${expected_dirs[1]}/bundle/msi/*.msi" 'Windows MSI bundle' +require_exactly_one_path "$artifact_root/${expected_dirs[1]}/bundle/nsis/*.exe" 'Windows NSIS bundle' +require_exactly_one_path "$artifact_root/${expected_dirs[2]}/bundle/dmg/*.dmg" 'macOS DMG bundle' -for required_name in \ - disksage-cloud-plan-linux-x86_64 \ - disksage-duplicate-audit-linux-x86_64 \ - disksage-cloud-plan-windows-x86_64.exe \ - disksage-duplicate-audit-windows-x86_64.exe \ - disksage-cloud-plan-macos-arm64 \ - disksage-duplicate-audit-macos-arm64; do - require_exactly_one_file "$required_name" - require_exactly_one_file "$required_name.sha256" -done +require_exactly_one_file "${expected_dirs[0]}" disksage-cloud-plan-linux-x86_64 +require_exactly_one_file "${expected_dirs[0]}" disksage-cloud-plan-linux-x86_64.sha256 +require_exactly_one_file "${expected_dirs[0]}" disksage-duplicate-audit-linux-x86_64 +require_exactly_one_file "${expected_dirs[0]}" disksage-duplicate-audit-linux-x86_64.sha256 +require_exactly_one_file "${expected_dirs[1]}" disksage-cloud-plan-windows-x86_64.exe +require_exactly_one_file "${expected_dirs[1]}" disksage-cloud-plan-windows-x86_64.exe.sha256 +require_exactly_one_file "${expected_dirs[1]}" disksage-duplicate-audit-windows-x86_64.exe +require_exactly_one_file "${expected_dirs[1]}" disksage-duplicate-audit-windows-x86_64.exe.sha256 +require_exactly_one_file "${expected_dirs[2]}" disksage-cloud-plan-macos-arm64 +require_exactly_one_file "${expected_dirs[2]}" disksage-cloud-plan-macos-arm64.sha256 +require_exactly_one_file "${expected_dirs[2]}" disksage-duplicate-audit-macos-arm64 +require_exactly_one_file "${expected_dirs[2]}" disksage-duplicate-audit-macos-arm64.sha256 checksum_files=() checksum_file="" @@ -113,8 +117,7 @@ for checksum_file in "${checksum_files[@]}"; do done regular_file_count=0 -matched_path="" -while IFS= read -r -d '' matched_path; do regular_file_count=$((regular_file_count + 1)); done < <(find "$artifact_root" -type f -print0) +while IFS= read -r -d '' _; do regular_file_count=$((regular_file_count + 1)); done < <(find "$artifact_root" -type f -print0) if [[ $regular_file_count -ne 17 ]]; then printf 'Unexpected release artifact entries: expected exactly 17 regular files, found %s.\n' "$regular_file_count" >&2 exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 980672cd7..30a69e363 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -259,6 +259,10 @@ jobs: path: release-artifacts merge-multiple: false + - name: Verify downloaded release artifact contract + shell: bash + run: bash .github/scripts/verify-release-artifacts.sh release-artifacts "${{ github.run_attempt }}" + - name: Generate and validate source-bound SBOM shell: bash run: | diff --git a/src/lib/releaseArtifactVerifierDirectoryContract.test.ts b/src/lib/releaseArtifactVerifierDirectoryContract.test.ts new file mode 100644 index 000000000..e15cd8a43 --- /dev/null +++ b/src/lib/releaseArtifactVerifierDirectoryContract.test.ts @@ -0,0 +1,131 @@ +import { createHash } from 'node:crypto'; +import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const runAttempt = '1'; +const platformDirectories = { + linux: `release-disksage-ubuntu-22.04-${runAttempt}`, + windows: `release-disksage-windows-2022-${runAttempt}`, + macos: `release-disksage-macos-latest-${runAttempt}`, +} as const; + +/** Writes one fixture file, creating only the parent directories required by that fixture. */ +function write(path: string, bytes: Buffer | string) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, bytes); +} + +/** Adds one operational CLI fixture together with its adjacent SHA-256 receipt. */ +function addCli(artifactRoot: string, directory: string, name: string) { + const bytes = Buffer.from(`cli:${name}`); + const assetPath = join(artifactRoot, directory, name); + write(assetPath, bytes); + write( + `${assetPath}.sha256`, + `${createHash('sha256').update(bytes).digest('hex')} ${name}\n`, + ); +} + +/** Materializes the exact 17-file Linux, Windows, and macOS release artifact contract. */ +function materializeExactArtifactSet(artifactRoot: string) { + write(join(artifactRoot, platformDirectories.linux, 'bundle/deb/disksage.deb'), 'deb'); + write(join(artifactRoot, platformDirectories.linux, 'bundle/appimage/disksage.AppImage'), 'appimage'); + write(join(artifactRoot, platformDirectories.windows, 'bundle/msi/disksage.msi'), 'msi'); + write(join(artifactRoot, platformDirectories.windows, 'bundle/nsis/disksage-setup.exe'), 'nsis'); + write(join(artifactRoot, platformDirectories.macos, 'bundle/dmg/disksage.dmg'), 'dmg'); + + addCli(artifactRoot, platformDirectories.linux, 'disksage-cloud-plan-linux-x86_64'); + addCli(artifactRoot, platformDirectories.linux, 'disksage-duplicate-audit-linux-x86_64'); + addCli(artifactRoot, platformDirectories.windows, 'disksage-cloud-plan-windows-x86_64.exe'); + addCli(artifactRoot, platformDirectories.windows, 'disksage-duplicate-audit-windows-x86_64.exe'); + addCli(artifactRoot, platformDirectories.macos, 'disksage-cloud-plan-macos-arm64'); + addCli(artifactRoot, platformDirectories.macos, 'disksage-duplicate-audit-macos-arm64'); +} + +/** Runs the repository-owned verifier against one isolated downloaded-artifact fixture. */ +function verify(artifactRoot: string) { + return spawnSync( + 'bash', + [ + resolve(repositoryRoot, '.github/scripts/verify-release-artifacts.sh'), + artifactRoot, + runAttempt, + ], + { cwd: repositoryRoot, encoding: 'utf8' }, + ); +} + +describe('release artifact verifier directory contract', () => { + it.runIf(process.platform !== 'win32')( + 'accepts the exact platform namespaces uploaded by the release matrix', + () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), 'disksage-release-artifact-verifier-')); + const artifactRoot = join(fixtureRoot, 'release-artifacts'); + try { + materializeExactArtifactSet(artifactRoot); + + const result = verify(artifactRoot); + + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toBe(''); + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }); + } + }, + ); + + it.runIf(process.platform !== 'win32')( + 'rejects a Windows bundle that escaped its Windows artifact directory', + () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), 'disksage-release-artifact-verifier-')); + const artifactRoot = join(fixtureRoot, 'release-artifacts'); + try { + materializeExactArtifactSet(artifactRoot); + const source = join(artifactRoot, platformDirectories.windows, 'bundle/msi/disksage.msi'); + const misplaced = join(artifactRoot, platformDirectories.linux, 'bundle/msi/disksage.msi'); + mkdirSync(dirname(misplaced), { recursive: true }); + renameSync(source, misplaced); + + const result = verify(artifactRoot); + + expect(result.status).not.toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('Windows MSI bundle'); + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }); + } + }, + ); + + it.runIf(process.platform !== 'win32')( + 'rejects a Windows operational CLI and checksum outside the Windows artifact directory', + () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), 'disksage-release-artifact-verifier-')); + const artifactRoot = join(fixtureRoot, 'release-artifacts'); + try { + materializeExactArtifactSet(artifactRoot); + const cliName = 'disksage-cloud-plan-windows-x86_64.exe'; + const source = join(artifactRoot, platformDirectories.windows, cliName); + const sourceChecksum = `${source}.sha256`; + const misplaced = join(artifactRoot, platformDirectories.linux, cliName); + const misplacedChecksum = `${misplaced}.sha256`; + renameSync(source, misplaced); + renameSync(sourceChecksum, misplacedChecksum); + + const result = verify(artifactRoot); + + expect(result.status).not.toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain(cliName); + expect(result.stderr).toContain(platformDirectories.windows); + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }); + } + }, + ); +}); \ No newline at end of file diff --git a/src/lib/releaseTagArtifactVerifierContract.test.ts b/src/lib/releaseTagArtifactVerifierContract.test.ts new file mode 100644 index 000000000..c24e4368a --- /dev/null +++ b/src/lib/releaseTagArtifactVerifierContract.test.ts @@ -0,0 +1,29 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + +/** Reads the source-controlled release workflow with normalized line endings for stable assertions. */ +function readReleaseWorkflow(): string { + return readFileSync(resolve(repositoryRoot, '.github/workflows/release.yml'), 'utf8').replace(/\r\n?/g, '\n'); +} + +describe('tag release artifact verifier contract', () => { + it('runs the shared exact build-artifact verifier before generating the SBOM', () => { + const workflow = readReleaseWorkflow(); + const attestStart = workflow.indexOf('\n attest-release:\n'); + const publishStart = workflow.indexOf('\n publish-release:\n', attestStart); + expect(attestStart).toBeGreaterThanOrEqual(0); + expect(publishStart).toBeGreaterThan(attestStart); + + const attestJob = workflow.slice(attestStart, publishStart); + const sharedVerifier = 'bash .github/scripts/verify-release-artifacts.sh release-artifacts "${{ github.run_attempt }}"'; + const verifierOffset = attestJob.indexOf(sharedVerifier); + const sbomOffset = attestJob.indexOf('- name: Generate and validate source-bound SBOM'); + + expect(verifierOffset).toBeGreaterThanOrEqual(0); + expect(sbomOffset).toBeGreaterThan(verifierOffset); + }); +}); \ No newline at end of file