From e681338631df77a6c2e8a7b818404f2fed7dc936 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:06:29 +0200 Subject: [PATCH] Harden public release gates --- .gitea/workflows/ci.yml | 1 + .github/workflows/ci.yml | 1 + package.json | 1 + scripts/release-plan.mjs | 13 +++++- scripts/verify-public-release.mjs | 58 ++++++++++++++++++++---- tests/public-release-verifier.test.js | 65 +++++++++------------------ tests/release-plan.test.js | 43 ++++++++++++++++++ 7 files changed, 129 insertions(+), 53 deletions(-) create mode 100644 tests/release-plan.test.js diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 10851ce..593fe9a 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -15,6 +15,7 @@ jobs: with: node-version: 24 cache: npm + - run: npm run verify:public-source - run: npm ci - run: npm run verify env: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10851ce..593fe9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,7 @@ jobs: with: node-version: 24 cache: npm + - run: npm run verify:public-source - run: npm ci - run: npm run verify env: diff --git a/package.json b/package.json index bc8d71b..76c140c 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "test:unit": "node --test tests/*.test.js", "test:ui": "node --test tests/ui-smoke.js", "test:backup-api": "npm --prefix services/backup-api test", + "verify:public-source": "node scripts/verify-public-release.mjs --source-only --tracked --package-version", "verify": "npm run lint && npm test && npm run test:backup-api && npm audit --omit=dev", "lint": "eslint .", "dist": "electron-builder --publish never --win", diff --git a/scripts/release-plan.mjs b/scripts/release-plan.mjs index 8bc00eb..09ddec7 100644 --- a/scripts/release-plan.mjs +++ b/scripts/release-plan.mjs @@ -1,6 +1,14 @@ const PRODUCT_NAME = 'Multi Hoster Uploader'; const ARTIFACT_NAME = 'Multi-Hoster-Upload'; +function requireEnglishReleaseNotes(value) { + const notes = typeof value === 'string' ? value.trim() : ''; + if (!/[A-Za-z]/.test(notes)) { + throw new Error('English release notes are required'); + } + return notes; +} + export function parseReleaseArgs(args) { const version = Array.isArray(args) ? args[0] : ''; if (!/^\d+\.\d+\.\d+$/.test(version || '')) { @@ -14,11 +22,12 @@ export function parseReleaseArgs(args) { } const excludedIndexes = new Set([0, transportTagIndex, transportTagIndex + 1]); - const notes = args.filter((arg, index) => !excludedIndexes.has(index) && arg !== '--dry-run').join(' '); + const notes = requireEnglishReleaseNotes(args.filter((arg, index) => !excludedIndexes.has(index) && arg !== '--dry-run').join(' ')); return { version, transportTag, notes, dryRun: args.includes('--dry-run') }; } export function createReleasePlan(options) { + const releaseBody = requireEnglishReleaseNotes(options.notes); const releaseTitle = `${PRODUCT_NAME} v${options.version}`; const setupName = `${ARTIFACT_NAME} Setup ${options.version}.exe`; const portableName = `${ARTIFACT_NAME} ${options.version}.exe`; @@ -30,7 +39,7 @@ export function createReleasePlan(options) { ...options, tag: options.transportTag, releaseTitle, - releaseBody: options.notes || releaseTitle, + releaseBody, setupName, portableName, blockmapName, diff --git a/scripts/verify-public-release.mjs b/scripts/verify-public-release.mjs index e023987..af90237 100644 --- a/scripts/verify-public-release.mjs +++ b/scripts/verify-public-release.mjs @@ -1,11 +1,14 @@ +import { execFile } from 'node:child_process'; import { lstat, readFile, readdir } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; +import { promisify } from 'node:util'; import { fileURLToPath } from 'node:url'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const args = process.argv.slice(2); const failures = new Map(); +const execFileAsync = promisify(execFile); const publicActionsDir = `.${['git', 'hub'].join('')}`; const privateActionsDir = `.${['gi', 'tea'].join('')}`; const sourceFiles = [ @@ -128,6 +131,7 @@ const sourceFiles = [ 'tests/orphan-tmp.test.js', 'tests/package-build-files.test.js', 'tests/public-release-verifier.test.js', + 'tests/release-plan.test.js', 'tests/queue-dedup-property.test.js', 'tests/queue-dedup.test.js', 'tests/queue-persistence-scenario.test.js', @@ -184,6 +188,7 @@ const expectedScripts = { 'test:unit': 'node --test tests/*.test.js', 'test:ui': 'node --test tests/ui-smoke.js', 'test:backup-api': 'npm --prefix services/backup-api test', + 'verify:public-source': 'node scripts/verify-public-release.mjs --source-only --tracked --package-version', verify: 'npm run lint && npm test && npm run test:backup-api && npm audit --omit=dev', lint: 'eslint .', dist: 'electron-builder --publish never --win', @@ -267,18 +272,26 @@ function isDeniedBasename(basename) { || /\.(?:bak|db|log|sqlite|sqlite3|tmp)$/i.test(basename); } -function parseArguments() { +function parseArguments(packageVersion) { const sourceOnlyCount = args.filter((arg) => arg === '--source-only').length; + const trackedCount = args.filter((arg) => arg === '--tracked').length; + const packageVersionCount = args.filter((arg) => arg === '--package-version').length; const versionFlagIndexes = args.map((arg, index) => arg === '--version' ? index : -1).filter((index) => index >= 0); const versionIndex = versionFlagIndexes[0] ?? -1; - const expectedVersion = versionIndex >= 0 ? args[versionIndex + 1] : ''; + const explicitVersion = versionIndex >= 0 ? args[versionIndex + 1] : ''; + const expectedVersion = packageVersionCount === 1 ? packageVersion : explicitVersion; const consumed = new Set(); if (sourceOnlyCount === 1) consumed.add(args.indexOf('--source-only')); if (sourceOnlyCount > 1) addFailure('scripts/verify-public-release.mjs', 'duplicate-source-only'); - if (versionFlagIndexes.length !== 1 || !/^\d+\.\d+\.\d+$/.test(expectedVersion || '')) { + if (trackedCount === 1) consumed.add(args.indexOf('--tracked')); + if (trackedCount > 1 || (trackedCount === 1 && sourceOnlyCount !== 1)) { + addFailure('scripts/verify-public-release.mjs', 'tracked-source-argument'); + } + if (packageVersionCount === 1) consumed.add(args.indexOf('--package-version')); + if (packageVersionCount > 1 || packageVersionCount + versionFlagIndexes.length !== 1 || !/^\d+\.\d+\.\d+$/.test(expectedVersion || '')) { addFailure('scripts/verify-public-release.mjs', 'expected-version-argument'); - } else { + } else if (versionFlagIndexes.length === 1) { consumed.add(versionIndex); consumed.add(versionIndex + 1); } @@ -287,7 +300,7 @@ function parseArguments() { if (!consumed.has(index)) addFailure('scripts/verify-public-release.mjs', 'argument-allowlist'); } - return { sourceOnly: sourceOnlyCount === 1, expectedVersion }; + return { sourceOnly: sourceOnlyCount === 1, tracked: trackedCount === 1, expectedVersion }; } async function enumerate(directory = root, relativeDirectory = '') { @@ -327,6 +340,35 @@ async function enumerate(directory = root, relativeDirectory = '') { return files; } +async function enumerateTracked() { + let stdout = ''; + try { + ({ stdout } = await execFileAsync('git', ['ls-files', '-z'], { + cwd: root, + encoding: 'utf8', + maxBuffer: 8 * 1024 * 1024 + })); + } catch { + addFailure('.git', 'tracked-source-enumeration'); + return []; + } + + const files = stdout.split('\0').filter(Boolean).map(normalizeRelative); + for (const relativePath of files) { + let stats; + try { + stats = await lstat(path.join(root, relativePath)); + } catch { + addFailure(relativePath, 'required-source-file'); + continue; + } + if (!stats.isFile() || stats.isSymbolicLink()) addFailure(relativePath, 'unsupported-file-type'); + if (isDeniedBasename(path.basename(relativePath))) addFailure(relativePath, 'denied-basename'); + if (!allowedFiles.has(relativePath)) addFailure(relativePath, 'source-layout-allowlist'); + } + return files; +} + async function readJson(relativePath, rule) { try { return JSON.parse(await readFile(path.join(root, relativePath), 'utf8')); @@ -432,15 +474,15 @@ function printFailures() { } async function main() { - const { sourceOnly, expectedVersion } = parseArguments(); - const files = await enumerate(); + const packageJson = await readJson('package.json', 'package-json'); + const { sourceOnly, tracked, expectedVersion } = parseArguments(packageJson?.version); + const files = tracked ? await enumerateTracked() : await enumerate(); const requiredFiles = sourceOnly ? sourceFiles : [...sourceFiles, ...screenshotFiles]; for (const requiredFile of requiredFiles) { if (!files.includes(requiredFile)) addFailure(requiredFile, 'required-source-file'); } await validateTextFiles(files); - const packageJson = await readJson('package.json', 'package-json'); const packageLock = await readJson('package-lock.json', 'package-lock-json'); const servicePackage = await readJson('services/backup-api/package.json', 'service-package-json'); const serviceLock = await readJson('services/backup-api/package-lock.json', 'service-package-lock-json'); diff --git a/tests/public-release-verifier.test.js b/tests/public-release-verifier.test.js index 8a30d29..01dfb18 100644 --- a/tests/public-release-verifier.test.js +++ b/tests/public-release-verifier.test.js @@ -6,19 +6,6 @@ const path = require('node:path'); const { spawnSync } = require('node:child_process'); const root = path.resolve(__dirname, '..'); -const rootFiles = [ - '.gitignore', - 'README.md', - 'SECURITY.md', - 'eslint.config.mjs', - 'main.js', - 'package-lock.json', - 'package.json', - 'preload-drop-target.js', - 'preload.js' -]; -const directoryRoots = [`.${['gi', 'tea'].join('')}`, `.${['git', 'hub'].join('')}`, 'assets', 'docs', 'lib', 'renderer', 'services/backup-api', 'tests']; -const scriptFiles = ['scripts/afterPack.cjs', 'scripts/dev-runner.cjs', 'scripts/release-plan.mjs', 'scripts/verify-public-release.mjs']; const screenshotFiles = [ 'assets/product-overview.png', 'docs/screenshots/upload-workspace.png', @@ -28,42 +15,19 @@ const screenshotFiles = [ ]; const currentVersion = require('../package.json').version; -function copyDirectory(source, destination) { - fs.mkdirSync(destination, { recursive: true }); - for (const entry of fs.readdirSync(source, { withFileTypes: true })) { - if (/^_ui-inject\..+\.tmp\.js$/.test(entry.name)) continue; - const sourcePath = path.join(source, entry.name); - const destinationPath = path.join(destination, entry.name); - if (entry.isDirectory()) copyDirectory(sourcePath, destinationPath); - else if (entry.isFile()) fs.copyFileSync(sourcePath, destinationPath); - } -} - -function copyDocumentationScreenshots(stage) { - for (const relativePath of screenshotFiles.slice(1)) { - const destination = path.join(stage, relativePath); - fs.mkdirSync(path.dirname(destination), { recursive: true }); - fs.copyFileSync(path.join(root, screenshotFiles[0]), destination); - } -} - function createStage() { const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-public-verifier-')); - for (const relativePath of rootFiles) { + const tracked = spawnSync('git', ['ls-files', '-z'], { + cwd: root, + encoding: 'buffer' + }); + assert.equal(tracked.status, 0, tracked.stderr?.toString('utf8')); + const trackedFiles = tracked.stdout.toString('utf8').split('\0').filter(Boolean); + for (const relativePath of trackedFiles) { const destination = path.join(stage, relativePath); fs.mkdirSync(path.dirname(destination), { recursive: true }); fs.copyFileSync(path.join(root, relativePath), destination); } - for (const relativePath of directoryRoots) { - if (relativePath === 'docs') copyDocumentationScreenshots(stage); - else copyDirectory(path.join(root, relativePath), path.join(stage, relativePath)); - } - for (const relativePath of scriptFiles) { - const destination = path.join(stage, relativePath); - fs.mkdirSync(path.dirname(destination), { recursive: true }); - fs.copyFileSync(path.join(root, relativePath), destination); - } - fs.rmSync(path.join(stage, 'assets', 'product-overview.png'), { force: true }); return stage; } @@ -102,6 +66,21 @@ test('public release verifier accepts only the exact source manifest and target assert.match(wrongVersion.stderr, /package\.json\tpackage-version-target/); }); +test('public source verification runs against the actual checkout at its package version', () => { + const executable = process.platform === 'win32' ? process.env.ComSpec : 'npm'; + const args = process.platform === 'win32' + ? ['/d', '/s', '/c', 'npm run --silent verify:public-source'] + : ['run', '--silent', 'verify:public-source']; + const result = spawnSync(executable, args, { + cwd: root, + encoding: 'utf8' + }); + + assert.equal(result.status, 0, result.error?.message || result.stderr || result.stdout); + assert.match(result.stdout, new RegExp(`version=${currentVersion.replaceAll('.', '\\.')}\\b`)); + assert.match(result.stdout, /layout=exact/); +}); + test('public release verifier requires and validates every approved screenshot', (t) => { const stage = createStage(); t.after(() => fs.rmSync(stage, { recursive: true, force: true })); diff --git a/tests/release-plan.test.js b/tests/release-plan.test.js new file mode 100644 index 0000000..0288fb8 --- /dev/null +++ b/tests/release-plan.test.js @@ -0,0 +1,43 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); + +const releasePlanUrl = pathToFileURL(path.resolve(__dirname, '../scripts/release-plan.mjs')).href; + +test('release planning rejects omitted or blank English release notes', async () => { + const { parseReleaseArgs } = await import(releasePlanUrl); + + assert.throws( + () => parseReleaseArgs(['2.1.20', '--transport-tag', 'v2.1.20']), + /English release notes are required/ + ); + assert.throws( + () => parseReleaseArgs(['2.1.20', '--transport-tag', 'v2.1.20', ' ']), + /English release notes are required/ + ); +}); + +test('release planning preserves dual-host asset names with English release notes', async () => { + const { createReleasePlan, parseReleaseArgs } = await import(releasePlanUrl); + const plan = createReleasePlan(parseReleaseArgs([ + '2.1.20', + '--transport-tag', + 'v2.1.20', + 'Security hardening and reliability fixes.' + ])); + + assert.equal(plan.releaseBody, 'Security hardening and reliability fixes.'); + assert.deepEqual(plan.expectedArtifacts, [ + 'Multi-Hoster-Upload Setup 2.1.20.exe', + 'Multi-Hoster-Upload 2.1.20.exe', + 'Multi-Hoster-Upload Setup 2.1.20.exe.blockmap', + 'latest.yml' + ]); + assert.deepEqual(plan.githubExpectedArtifacts, [ + 'Multi-Hoster-Upload.Setup.2.1.20.exe', + 'Multi-Hoster-Upload.2.1.20.exe', + 'Multi-Hoster-Upload.Setup.2.1.20.exe.blockmap', + 'latest.yml' + ]); +});