Harden public release gates
This commit is contained in:
@@ -15,6 +15,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: npm
|
cache: npm
|
||||||
|
- run: npm run verify:public-source
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run verify
|
- run: npm run verify
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: npm
|
cache: npm
|
||||||
|
- run: npm run verify:public-source
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run verify
|
- run: npm run verify
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"test:unit": "node --test tests/*.test.js",
|
"test:unit": "node --test tests/*.test.js",
|
||||||
"test:ui": "node --test tests/ui-smoke.js",
|
"test:ui": "node --test tests/ui-smoke.js",
|
||||||
"test:backup-api": "npm --prefix services/backup-api test",
|
"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",
|
"verify": "npm run lint && npm test && npm run test:backup-api && npm audit --omit=dev",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"dist": "electron-builder --publish never --win",
|
"dist": "electron-builder --publish never --win",
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
const PRODUCT_NAME = 'Multi Hoster Uploader';
|
const PRODUCT_NAME = 'Multi Hoster Uploader';
|
||||||
const ARTIFACT_NAME = 'Multi-Hoster-Upload';
|
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) {
|
export function parseReleaseArgs(args) {
|
||||||
const version = Array.isArray(args) ? args[0] : '';
|
const version = Array.isArray(args) ? args[0] : '';
|
||||||
if (!/^\d+\.\d+\.\d+$/.test(version || '')) {
|
if (!/^\d+\.\d+\.\d+$/.test(version || '')) {
|
||||||
@@ -14,11 +22,12 @@ export function parseReleaseArgs(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const excludedIndexes = new Set([0, transportTagIndex, transportTagIndex + 1]);
|
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') };
|
return { version, transportTag, notes, dryRun: args.includes('--dry-run') };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createReleasePlan(options) {
|
export function createReleasePlan(options) {
|
||||||
|
const releaseBody = requireEnglishReleaseNotes(options.notes);
|
||||||
const releaseTitle = `${PRODUCT_NAME} v${options.version}`;
|
const releaseTitle = `${PRODUCT_NAME} v${options.version}`;
|
||||||
const setupName = `${ARTIFACT_NAME} Setup ${options.version}.exe`;
|
const setupName = `${ARTIFACT_NAME} Setup ${options.version}.exe`;
|
||||||
const portableName = `${ARTIFACT_NAME} ${options.version}.exe`;
|
const portableName = `${ARTIFACT_NAME} ${options.version}.exe`;
|
||||||
@@ -30,7 +39,7 @@ export function createReleasePlan(options) {
|
|||||||
...options,
|
...options,
|
||||||
tag: options.transportTag,
|
tag: options.transportTag,
|
||||||
releaseTitle,
|
releaseTitle,
|
||||||
releaseBody: options.notes || releaseTitle,
|
releaseBody,
|
||||||
setupName,
|
setupName,
|
||||||
portableName,
|
portableName,
|
||||||
blockmapName,
|
blockmapName,
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
import { execFile } from 'node:child_process';
|
||||||
import { lstat, readFile, readdir } from 'node:fs/promises';
|
import { lstat, readFile, readdir } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const failures = new Map();
|
const failures = new Map();
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
const publicActionsDir = `.${['git', 'hub'].join('')}`;
|
const publicActionsDir = `.${['git', 'hub'].join('')}`;
|
||||||
const privateActionsDir = `.${['gi', 'tea'].join('')}`;
|
const privateActionsDir = `.${['gi', 'tea'].join('')}`;
|
||||||
const sourceFiles = [
|
const sourceFiles = [
|
||||||
@@ -128,6 +131,7 @@ const sourceFiles = [
|
|||||||
'tests/orphan-tmp.test.js',
|
'tests/orphan-tmp.test.js',
|
||||||
'tests/package-build-files.test.js',
|
'tests/package-build-files.test.js',
|
||||||
'tests/public-release-verifier.test.js',
|
'tests/public-release-verifier.test.js',
|
||||||
|
'tests/release-plan.test.js',
|
||||||
'tests/queue-dedup-property.test.js',
|
'tests/queue-dedup-property.test.js',
|
||||||
'tests/queue-dedup.test.js',
|
'tests/queue-dedup.test.js',
|
||||||
'tests/queue-persistence-scenario.test.js',
|
'tests/queue-persistence-scenario.test.js',
|
||||||
@@ -184,6 +188,7 @@ const expectedScripts = {
|
|||||||
'test:unit': 'node --test tests/*.test.js',
|
'test:unit': 'node --test tests/*.test.js',
|
||||||
'test:ui': 'node --test tests/ui-smoke.js',
|
'test:ui': 'node --test tests/ui-smoke.js',
|
||||||
'test:backup-api': 'npm --prefix services/backup-api test',
|
'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',
|
verify: 'npm run lint && npm test && npm run test:backup-api && npm audit --omit=dev',
|
||||||
lint: 'eslint .',
|
lint: 'eslint .',
|
||||||
dist: 'electron-builder --publish never --win',
|
dist: 'electron-builder --publish never --win',
|
||||||
@@ -267,18 +272,26 @@ function isDeniedBasename(basename) {
|
|||||||
|| /\.(?:bak|db|log|sqlite|sqlite3|tmp)$/i.test(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 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 versionFlagIndexes = args.map((arg, index) => arg === '--version' ? index : -1).filter((index) => index >= 0);
|
||||||
const versionIndex = versionFlagIndexes[0] ?? -1;
|
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();
|
const consumed = new Set();
|
||||||
|
|
||||||
if (sourceOnlyCount === 1) consumed.add(args.indexOf('--source-only'));
|
if (sourceOnlyCount === 1) consumed.add(args.indexOf('--source-only'));
|
||||||
if (sourceOnlyCount > 1) addFailure('scripts/verify-public-release.mjs', 'duplicate-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');
|
addFailure('scripts/verify-public-release.mjs', 'expected-version-argument');
|
||||||
} else {
|
} else if (versionFlagIndexes.length === 1) {
|
||||||
consumed.add(versionIndex);
|
consumed.add(versionIndex);
|
||||||
consumed.add(versionIndex + 1);
|
consumed.add(versionIndex + 1);
|
||||||
}
|
}
|
||||||
@@ -287,7 +300,7 @@ function parseArguments() {
|
|||||||
if (!consumed.has(index)) addFailure('scripts/verify-public-release.mjs', 'argument-allowlist');
|
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 = '') {
|
async function enumerate(directory = root, relativeDirectory = '') {
|
||||||
@@ -327,6 +340,35 @@ async function enumerate(directory = root, relativeDirectory = '') {
|
|||||||
return files;
|
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) {
|
async function readJson(relativePath, rule) {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(await readFile(path.join(root, relativePath), 'utf8'));
|
return JSON.parse(await readFile(path.join(root, relativePath), 'utf8'));
|
||||||
@@ -432,15 +474,15 @@ function printFailures() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const { sourceOnly, expectedVersion } = parseArguments();
|
const packageJson = await readJson('package.json', 'package-json');
|
||||||
const files = await enumerate();
|
const { sourceOnly, tracked, expectedVersion } = parseArguments(packageJson?.version);
|
||||||
|
const files = tracked ? await enumerateTracked() : await enumerate();
|
||||||
const requiredFiles = sourceOnly ? sourceFiles : [...sourceFiles, ...screenshotFiles];
|
const requiredFiles = sourceOnly ? sourceFiles : [...sourceFiles, ...screenshotFiles];
|
||||||
for (const requiredFile of requiredFiles) {
|
for (const requiredFile of requiredFiles) {
|
||||||
if (!files.includes(requiredFile)) addFailure(requiredFile, 'required-source-file');
|
if (!files.includes(requiredFile)) addFailure(requiredFile, 'required-source-file');
|
||||||
}
|
}
|
||||||
|
|
||||||
await validateTextFiles(files);
|
await validateTextFiles(files);
|
||||||
const packageJson = await readJson('package.json', 'package-json');
|
|
||||||
const packageLock = await readJson('package-lock.json', 'package-lock-json');
|
const packageLock = await readJson('package-lock.json', 'package-lock-json');
|
||||||
const servicePackage = await readJson('services/backup-api/package.json', 'service-package-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');
|
const serviceLock = await readJson('services/backup-api/package-lock.json', 'service-package-lock-json');
|
||||||
|
|||||||
@@ -6,19 +6,6 @@ const path = require('node:path');
|
|||||||
const { spawnSync } = require('node:child_process');
|
const { spawnSync } = require('node:child_process');
|
||||||
|
|
||||||
const root = path.resolve(__dirname, '..');
|
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 = [
|
const screenshotFiles = [
|
||||||
'assets/product-overview.png',
|
'assets/product-overview.png',
|
||||||
'docs/screenshots/upload-workspace.png',
|
'docs/screenshots/upload-workspace.png',
|
||||||
@@ -28,42 +15,19 @@ const screenshotFiles = [
|
|||||||
];
|
];
|
||||||
const currentVersion = require('../package.json').version;
|
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() {
|
function createStage() {
|
||||||
const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-public-verifier-'));
|
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);
|
const destination = path.join(stage, relativePath);
|
||||||
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
||||||
fs.copyFileSync(path.join(root, relativePath), destination);
|
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;
|
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/);
|
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) => {
|
test('public release verifier requires and validates every approved screenshot', (t) => {
|
||||||
const stage = createStage();
|
const stage = createStage();
|
||||||
t.after(() => fs.rmSync(stage, { recursive: true, force: true }));
|
t.after(() => fs.rmSync(stage, { recursive: true, force: true }));
|
||||||
|
|||||||
@@ -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'
|
||||||
|
]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user