From 2dd2112e1bb9d59ce9e8cc67a61b70a352992ef0 Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 15 Jul 2026 22:19:40 +0700 Subject: [PATCH 1/5] fix(release): make RC updater proof bash 3.2 safe --- .github/workflows/evaos-beta-rc-canary.yml | 18 ++++++++++-------- scripts/evaosBetaReleaseGate.js | 11 +++++++++++ .../unit/process/evaosBetaReleaseGate.test.ts | 7 +++++++ 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/.github/workflows/evaos-beta-rc-canary.yml b/.github/workflows/evaos-beta-rc-canary.yml index 9c283cd2eae..a63df485aaf 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, asset.sha256, '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..5a840d079d4 100644 --- a/scripts/evaosBetaReleaseGate.js +++ b/scripts/evaosBetaReleaseGate.js @@ -922,6 +922,17 @@ function collectReleaseDistributeWorkflowIssues(workflow) { function collectRcCanaryWorkflowIssues(workflow) { const issues = []; + const workflowText = String(workflow || ''); + if ( + workflowText.includes("ZIP_NAME=$(node - release-assets/latest-arm64-mac.yml <<'NODE'") || + workflowText.includes( + "EXPECTED_SHA=$(node - release-assets/evaos-beta-release-manifest.json \"$ZIP_NAME\" <<'NODE'" + ) + ) { + 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..859ebdf22d9 100644 --- a/tests/unit/process/evaosBetaReleaseGate.test.ts +++ b/tests/unit/process/evaosBetaReleaseGate.test.ts @@ -1136,12 +1136,19 @@ 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( + 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' From 0df83dff77270a455a21eacf42bf066f9d5d0873 Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 15 Jul 2026 22:21:10 +0700 Subject: [PATCH 2/5] chore(release): format RC shell guard --- scripts/evaosBetaReleaseGate.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/evaosBetaReleaseGate.js b/scripts/evaosBetaReleaseGate.js index 5a840d079d4..eed04735d58 100644 --- a/scripts/evaosBetaReleaseGate.js +++ b/scripts/evaosBetaReleaseGate.js @@ -926,7 +926,7 @@ function collectRcCanaryWorkflowIssues(workflow) { if ( workflowText.includes("ZIP_NAME=$(node - release-assets/latest-arm64-mac.yml <<'NODE'") || workflowText.includes( - "EXPECTED_SHA=$(node - release-assets/evaos-beta-release-manifest.json \"$ZIP_NAME\" <<'NODE'" + 'EXPECTED_SHA=$(node - release-assets/evaos-beta-release-manifest.json "$ZIP_NAME" <<\'NODE\'' ) ) { issues.push( From 485237b4a5669eb7c4e21e50b7ca03407acbad9a Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 15 Jul 2026 22:22:55 +0700 Subject: [PATCH 3/5] test(release): cover both RC heredoc guards --- tests/unit/process/evaosBetaReleaseGate.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/unit/process/evaosBetaReleaseGate.test.ts b/tests/unit/process/evaosBetaReleaseGate.test.ts index 859ebdf22d9..aa76189fec7 100644 --- a/tests/unit/process/evaosBetaReleaseGate.test.ts +++ b/tests/unit/process/evaosBetaReleaseGate.test.ts @@ -1149,6 +1149,11 @@ describe('evaOS beta release gate', () => { `${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); const drifted = workflow.replace( ' hdiutil detach "$mount_dir" -quiet', ' rm -rf "$extract_dir"\n hdiutil detach "$mount_dir" -quiet' From 86316cfe8dc457b8569238d31315d725c7df23ff Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 15 Jul 2026 22:29:03 +0700 Subject: [PATCH 4/5] test(release): harden RC heredoc regression guard --- scripts/evaosBetaReleaseGate.js | 9 +++------ tests/unit/process/evaosBetaReleaseGate.test.ts | 5 +++++ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/scripts/evaosBetaReleaseGate.js b/scripts/evaosBetaReleaseGate.js index eed04735d58..5b4659fd354 100644 --- a/scripts/evaosBetaReleaseGate.js +++ b/scripts/evaosBetaReleaseGate.js @@ -923,12 +923,9 @@ function collectReleaseDistributeWorkflowIssues(workflow) { function collectRcCanaryWorkflowIssues(workflow) { const issues = []; const workflowText = String(workflow || ''); - if ( - workflowText.includes("ZIP_NAME=$(node - release-assets/latest-arm64-mac.yml <<'NODE'") || - workflowText.includes( - 'EXPECTED_SHA=$(node - release-assets/evaos-beta-release-manifest.json "$ZIP_NAME" <<\'NODE\'' - ) - ) { + const unsafeUpdaterCommandSubstitution = + /(?:^|\n)\s*(?:ZIP_NAME|EXPECTED_SHA)\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' ); diff --git a/tests/unit/process/evaosBetaReleaseGate.test.ts b/tests/unit/process/evaosBetaReleaseGate.test.ts index aa76189fec7..374a2fce816 100644 --- a/tests/unit/process/evaosBetaReleaseGate.test.ts +++ b/tests/unit/process/evaosBetaReleaseGate.test.ts @@ -1154,6 +1154,11 @@ describe('evaOS beta release gate', () => { `${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); const drifted = workflow.replace( ' hdiutil detach "$mount_dir" -quiet', ' rm -rf "$extract_dir"\n hdiutil detach "$mount_dir" -quiet' From d9521980cff9e4d6c955a02e2b60a90cbce6d73f Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 15 Jul 2026 22:32:11 +0700 Subject: [PATCH 5/5] fix(release): close RC proof edge cases --- .github/workflows/evaos-beta-rc-canary.yml | 2 +- scripts/evaosBetaReleaseGate.js | 2 +- tests/unit/process/evaosBetaReleaseGate.test.ts | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/evaos-beta-rc-canary.yml b/.github/workflows/evaos-beta-rc-canary.yml index a63df485aaf..f207034b269 100644 --- a/.github/workflows/evaos-beta-rc-canary.yml +++ b/.github/workflows/evaos-beta-rc-canary.yml @@ -339,7 +339,7 @@ jobs: if (!asset || !/^[0-9a-f]{64}$/i.test(String(asset.sha256 || ''))) { throw new Error(`Trusted manifest does not bind updater ZIP ${assetName}.`); } - fs.writeFileSync(outputPath, asset.sha256, 'utf8'); + 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}') diff --git a/scripts/evaosBetaReleaseGate.js b/scripts/evaosBetaReleaseGate.js index 5b4659fd354..7ab83af8edc 100644 --- a/scripts/evaosBetaReleaseGate.js +++ b/scripts/evaosBetaReleaseGate.js @@ -924,7 +924,7 @@ function collectRcCanaryWorkflowIssues(workflow) { const issues = []; const workflowText = String(workflow || ''); const unsafeUpdaterCommandSubstitution = - /(?:^|\n)\s*(?:ZIP_NAME|EXPECTED_SHA)\s*=\s*\$\(\s*node(?:\s|$)[\s\S]{0,500}?<<-?\s*['"]?[A-Za-z_][A-Za-z0-9_]*['"]?/; + /(?:^|\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' diff --git a/tests/unit/process/evaosBetaReleaseGate.test.ts b/tests/unit/process/evaosBetaReleaseGate.test.ts index 374a2fce816..a0b4c2c8c91 100644 --- a/tests/unit/process/evaosBetaReleaseGate.test.ts +++ b/tests/unit/process/evaosBetaReleaseGate.test.ts @@ -1144,6 +1144,7 @@ describe('evaOS beta release gate', () => { '.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` @@ -1159,6 +1160,11 @@ describe('evaOS beta release gate', () => { `${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'