diff --git a/.github/workflows/evaos-beta-rc-canary.yml b/.github/workflows/evaos-beta-rc-canary.yml index 9c283cd2eae..f207034b269 100644 --- a/.github/workflows/evaos-beta-rc-canary.yml +++ b/.github/workflows/evaos-beta-rc-canary.yml @@ -305,9 +305,10 @@ jobs: TAG_COMMIT: ${{ steps.provenance.outputs.tag_commit }} run: | set -euo pipefail - ZIP_NAME=$(node - release-assets/latest-arm64-mac.yml <<'NODE' + ZIP_NAME_PATH="$RUNNER_TEMP/evaos-updater-zip-name.txt" + node - release-assets/latest-arm64-mac.yml "$ZIP_NAME_PATH" <<'NODE' const fs = require('fs'); - const metadataPath = process.argv[2]; + const [metadataPath, outputPath] = process.argv.slice(2); const text = fs.readFileSync(metadataPath, 'utf8'); const refs = []; for (const line of text.split(/\r?\n/)) { @@ -321,25 +322,26 @@ jobs: if (unique.length !== 1 || !/arm64/i.test(unique[0])) { throw new Error('latest-arm64-mac.yml must identify exactly one arm64 updater ZIP.'); } - process.stdout.write(unique[0]); + fs.writeFileSync(outputPath, unique[0], 'utf8'); NODE - ) + ZIP_NAME=$(cat "$ZIP_NAME_PATH") UPDATER_ZIP="release-assets/$ZIP_NAME" if [ ! -f "$UPDATER_ZIP" ]; then echo "::error::Updater metadata references missing arm64 ZIP: $ZIP_NAME" exit 1 fi - EXPECTED_SHA=$(node - release-assets/evaos-beta-release-manifest.json "$ZIP_NAME" <<'NODE' + EXPECTED_SHA_PATH="$RUNNER_TEMP/evaos-updater-zip-expected-sha.txt" + node - release-assets/evaos-beta-release-manifest.json "$ZIP_NAME" "$EXPECTED_SHA_PATH" <<'NODE' const fs = require('fs'); - const [manifestPath, assetName] = process.argv.slice(2); + const [manifestPath, assetName, outputPath] = process.argv.slice(2); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); const asset = (manifest.assets || []).find((candidate) => candidate.name === assetName); if (!asset || !/^[0-9a-f]{64}$/i.test(String(asset.sha256 || ''))) { throw new Error(`Trusted manifest does not bind updater ZIP ${assetName}.`); } - process.stdout.write(asset.sha256); + fs.writeFileSync(outputPath, String(asset.sha256).toLowerCase(), 'utf8'); NODE - ) + EXPECTED_SHA=$(cat "$EXPECTED_SHA_PATH") ACTUAL_SHA=$(shasum -a 256 "$UPDATER_ZIP" | awk '{print $1}') if [ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]; then echo "::error::Updater ZIP checksum does not match the trusted release manifest." diff --git a/scripts/evaosBetaReleaseGate.js b/scripts/evaosBetaReleaseGate.js index 39724d948b6..7ab83af8edc 100644 --- a/scripts/evaosBetaReleaseGate.js +++ b/scripts/evaosBetaReleaseGate.js @@ -922,6 +922,14 @@ function collectReleaseDistributeWorkflowIssues(workflow) { function collectRcCanaryWorkflowIssues(workflow) { const issues = []; + const workflowText = String(workflow || ''); + const unsafeUpdaterCommandSubstitution = + /(?:^|\n)\s*(?:ZIP_NAME|EXPECTED_SHA)\s*=\s*['"]?\s*\$\(\s*node(?:\s|$)[\s\S]{0,500}?<<-?\s*['"]?[A-Za-z_][A-Za-z0-9_]*['"]?/; + if (unsafeUpdaterCommandSubstitution.test(workflowText)) { + issues.push( + '.github/workflows/evaos-beta-rc-canary.yml: macOS Bash 3.2 must not wrap updater ZIP Node heredocs in command substitution' + ); + } const installerBlocks = Array.from( String(workflow || '').matchAll(/^ {10}install_app_from_dmg\(\) \{\n([\s\S]*?)^ {10}\}$/gm), (match) => match[1] diff --git a/tests/unit/process/evaosBetaReleaseGate.test.ts b/tests/unit/process/evaosBetaReleaseGate.test.ts index 4053559bc90..a0b4c2c8c91 100644 --- a/tests/unit/process/evaosBetaReleaseGate.test.ts +++ b/tests/unit/process/evaosBetaReleaseGate.test.ts @@ -1136,12 +1136,35 @@ describe('evaOS beta release gate', () => { it('keeps RC DMG installation and installed local-control proof fail closed', () => { if (process.platform === 'win32') return; const workflow = fs.readFileSync(path.join(repoRoot, '.github/workflows/evaos-beta-rc-canary.yml'), 'utf8'); + const commandSubstitutionHeredocIssue = + '.github/workflows/evaos-beta-rc-canary.yml: macOS Bash 3.2 must not wrap updater ZIP Node heredocs in command substitution'; const expectedIssue = '.github/workflows/evaos-beta-rc-canary.yml: install_app_from_dmg must not reference the ZIP-only extract_dir variable under nounset'; const controlStartIssue = '.github/workflows/evaos-beta-rc-canary.yml: installed candidate must run the operator-acknowledged local control_start suite'; expect(releaseGate.collectRcCanaryWorkflowIssues(workflow)).toEqual([]); + expect(workflow).toContain("fs.writeFileSync(outputPath, String(asset.sha256).toLowerCase(), 'utf8');"); + expect( + releaseGate.collectRcCanaryWorkflowIssues( + `${workflow}\n ZIP_NAME=$(node - release-assets/latest-arm64-mac.yml <<'NODE'\n NODE\n )\n` + ) + ).toContain(commandSubstitutionHeredocIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues( + `${workflow}\n EXPECTED_SHA=$(node - release-assets/evaos-beta-release-manifest.json "$ZIP_NAME" <<'NODE'\n NODE\n )\n` + ) + ).toContain(commandSubstitutionHeredocIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues( + `${workflow}\n ZIP_NAME=$( node - release-assets/latest-arm64-mac.yml << 'NODE'\n NODE\n )\n` + ) + ).toContain(commandSubstitutionHeredocIssue); + expect( + releaseGate.collectRcCanaryWorkflowIssues( + `${workflow}\n ZIP_NAME="$(node - release-assets/latest-arm64-mac.yml <<'NODE'\n NODE\n )"\n` + ) + ).toContain(commandSubstitutionHeredocIssue); const drifted = workflow.replace( ' hdiutil detach "$mount_dir" -quiet', ' rm -rf "$extract_dir"\n hdiutil detach "$mount_dir" -quiet'