Release Twitch VOD Manager 1.0.18
Harden update, system-check, queue, cutter, streamer and shutdown state transitions. Add multi-user installer recovery, secret-safe config migration, provider fallback handling, managed-tool validation and real media export coverage. Refresh the English public documentation, release notes and 1.0.18 product screenshot.
This commit is contained in:
@@ -9,12 +9,401 @@ function check(condition, message) {
|
||||
if (!condition) failures.push(message);
|
||||
}
|
||||
|
||||
function addField(fields, key, value) {
|
||||
if (!fields.has(key)) fields.set(key, []);
|
||||
fields.get(key).push(value);
|
||||
}
|
||||
|
||||
function singleField(fields, key) {
|
||||
const values = fields.get(key) || [];
|
||||
return values.length === 1 ? values[0] : undefined;
|
||||
}
|
||||
|
||||
function recordDuplicateFields(duplicates, fields, prefix) {
|
||||
for (const [key, values] of fields) {
|
||||
if (values.length > 1) duplicates.add(`${prefix}.${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseMappingField(line, indentation, listItem = false) {
|
||||
let leadingSpaces = 0;
|
||||
while (line[leadingSpaces] === ' ') leadingSpaces += 1;
|
||||
if (leadingSpaces !== indentation) return undefined;
|
||||
let body = line.slice(indentation);
|
||||
if (listItem) {
|
||||
if (!body.startsWith('- ')) return undefined;
|
||||
body = body.slice(2);
|
||||
}
|
||||
const colon = body.indexOf(':');
|
||||
if (colon <= 0) return undefined;
|
||||
const key = body.slice(0, colon);
|
||||
if (![...key].every((character) => /[A-Za-z0-9_-]/.test(character))) return undefined;
|
||||
return [key, body.slice(colon + 1).trimStart()];
|
||||
}
|
||||
|
||||
function parseWorkflow(source) {
|
||||
const lines = source.split(/\r?\n/);
|
||||
const duplicateKeys = new Set();
|
||||
const topLevelFields = new Map();
|
||||
for (const line of lines) {
|
||||
const field = parseMappingField(line, 0);
|
||||
if (field) addField(topLevelFields, field[0], field[1]);
|
||||
}
|
||||
recordDuplicateFields(duplicateKeys, topLevelFields, 'workflow');
|
||||
const jobsStart = lines.findIndex((line) => line === 'jobs:');
|
||||
const jobs = new Map();
|
||||
if (jobsStart >= 0) {
|
||||
const jobsEndOffset = lines.slice(jobsStart + 1).findIndex((line) => /^\S/.test(line));
|
||||
const jobsEnd = jobsEndOffset < 0 ? lines.length : jobsStart + 1 + jobsEndOffset;
|
||||
const jobStarts = [];
|
||||
for (let index = jobsStart + 1; index < jobsEnd; index += 1) {
|
||||
const field = parseMappingField(lines[index], 2);
|
||||
if (field?.[1] === '') jobStarts.push({ index, name: field[0] });
|
||||
}
|
||||
const jobNames = new Map();
|
||||
for (const jobStart of jobStarts) addField(jobNames, jobStart.name, '');
|
||||
recordDuplicateFields(duplicateKeys, jobNames, 'jobs');
|
||||
for (let jobIndex = 0; jobIndex < jobStarts.length; jobIndex += 1) {
|
||||
const start = jobStarts[jobIndex].index;
|
||||
const end = jobStarts[jobIndex + 1]?.index || jobsEnd;
|
||||
const fields = new Map();
|
||||
for (let index = start + 1; index < end; index += 1) {
|
||||
const field = parseMappingField(lines[index], 4);
|
||||
if (field) addField(fields, field[0], field[1]);
|
||||
}
|
||||
const jobPath = `jobs.${jobStarts[jobIndex].name}`;
|
||||
recordDuplicateFields(duplicateKeys, fields, jobPath);
|
||||
const env = new Map();
|
||||
const envStart = lines.findIndex((line, index) => index > start && index < end && line === ' env:');
|
||||
if (envStart >= 0) {
|
||||
for (let index = envStart + 1; index < end; index += 1) {
|
||||
if (lines[index].trim() && !lines[index].startsWith(' ')) break;
|
||||
const field = parseMappingField(lines[index], 6);
|
||||
if (field) addField(env, field[0], field[1]);
|
||||
}
|
||||
}
|
||||
recordDuplicateFields(duplicateKeys, env, `${jobPath}.env`);
|
||||
const stepsStart = lines.findIndex((line, index) => index > start && index < end && line === ' steps:');
|
||||
const steps = [];
|
||||
if (stepsStart >= 0) {
|
||||
const stepStarts = [];
|
||||
for (let index = stepsStart + 1; index < end; index += 1) {
|
||||
if (/^ {6}-\s+/.test(lines[index])) stepStarts.push(index);
|
||||
}
|
||||
for (let stepIndex = 0; stepIndex < stepStarts.length; stepIndex += 1) {
|
||||
const stepStart = stepStarts[stepIndex];
|
||||
const stepEnd = stepStarts[stepIndex + 1] || end;
|
||||
const stepFields = new Map();
|
||||
const firstField = parseMappingField(lines[stepStart], 6, true);
|
||||
if (firstField) addField(stepFields, firstField[0], firstField[1]);
|
||||
for (let index = stepStart + 1; index < stepEnd; index += 1) {
|
||||
const field = parseMappingField(lines[index], 8);
|
||||
if (field) addField(stepFields, field[0], field[1]);
|
||||
}
|
||||
const stepPath = `${jobPath}.steps[${stepIndex}]`;
|
||||
recordDuplicateFields(duplicateKeys, stepFields, stepPath);
|
||||
const stepEnv = new Map();
|
||||
const stepEnvStart = lines.findIndex((line, index) => index > stepStart && index < stepEnd && line === ' env:');
|
||||
if (stepEnvStart >= 0) {
|
||||
for (let index = stepEnvStart + 1; index < stepEnd; index += 1) {
|
||||
if (lines[index].trim() && !lines[index].startsWith(' ')) break;
|
||||
const field = parseMappingField(lines[index], 10);
|
||||
if (field) addField(stepEnv, field[0], field[1]);
|
||||
}
|
||||
}
|
||||
recordDuplicateFields(duplicateKeys, stepEnv, `${stepPath}.env`);
|
||||
const stepWith = new Map();
|
||||
const stepWithStart = lines.findIndex((line, index) => index > stepStart && index < stepEnd && line === ' with:');
|
||||
if (stepWithStart >= 0) {
|
||||
for (let index = stepWithStart + 1; index < stepEnd; index += 1) {
|
||||
if (lines[index].trim() && !lines[index].startsWith(' ')) break;
|
||||
const field = parseMappingField(lines[index], 10);
|
||||
if (field) addField(stepWith, field[0], field[1]);
|
||||
}
|
||||
}
|
||||
recordDuplicateFields(duplicateKeys, stepWith, `${stepPath}.with`);
|
||||
steps.push({
|
||||
env: stepEnv,
|
||||
fields: stepFields,
|
||||
name: singleField(stepFields, 'name'),
|
||||
raw: lines.slice(stepStart, stepEnd).join('\n'),
|
||||
with: stepWith
|
||||
});
|
||||
}
|
||||
}
|
||||
jobs.set(jobStarts[jobIndex].name, {
|
||||
env,
|
||||
fields,
|
||||
header: lines.slice(start + 1, stepsStart >= 0 ? stepsStart : end).join('\n'),
|
||||
name: jobStarts[jobIndex].name,
|
||||
steps
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const dispatchInputs = new Map();
|
||||
const onStart = lines.findIndex((line) => line === 'on:');
|
||||
if (onStart >= 0) {
|
||||
const onEndOffset = lines.slice(onStart + 1).findIndex((line) => /^\S/.test(line));
|
||||
const onEnd = onEndOffset < 0 ? lines.length : onStart + 1 + onEndOffset;
|
||||
const dispatchStart = lines.findIndex((line, index) => index > onStart && index < onEnd && line === ' workflow_dispatch:');
|
||||
const inputsStart = lines.findIndex((line, index) => index > dispatchStart && index < onEnd && line === ' inputs:');
|
||||
if (dispatchStart >= 0 && inputsStart >= 0) {
|
||||
for (let index = inputsStart + 1; index < onEnd; index += 1) {
|
||||
const field = parseMappingField(lines[index], 6);
|
||||
if (field?.[1] === '') addField(dispatchInputs, field[0], '');
|
||||
}
|
||||
recordDuplicateFields(duplicateKeys, dispatchInputs, 'on.workflow_dispatch.inputs');
|
||||
}
|
||||
}
|
||||
|
||||
return { dispatchInputs, duplicateKeys: [...duplicateKeys], jobs };
|
||||
}
|
||||
|
||||
function positiveTimeout(fields) {
|
||||
const values = fields.get('timeout-minutes') || [];
|
||||
return values.length === 1 && /^\d+$/.test(values[0]) && Number(values[0]) > 0;
|
||||
}
|
||||
|
||||
function validateTimeouts(workflow, label, errors) {
|
||||
for (const job of workflow.jobs.values()) {
|
||||
if (!positiveTimeout(job.fields)) errors.push(`${label} job ${job.name} must define exactly one positive job-level timeout-minutes`);
|
||||
for (let index = 0; index < job.steps.length; index += 1) {
|
||||
const step = job.steps[index];
|
||||
const executionFields = (step.fields.get('run') || []).length + (step.fields.get('uses') || []).length;
|
||||
if (executionFields === 0) continue;
|
||||
const stepLabel = step.name || `unnamed step ${index + 1}`;
|
||||
if (executionFields !== 1) errors.push(`${label} ${job.name} ${stepLabel} must define exactly one of run or uses`);
|
||||
if (!positiveTimeout(step.fields)) errors.push(`${label} ${job.name} ${stepLabel} must define exactly one positive timeout-minutes`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateCheckouts(workflow, label, errors) {
|
||||
for (const job of workflow.jobs.values()) {
|
||||
const checkouts = job.steps.filter((step) => /^actions\/checkout@/.test(singleField(step.fields, 'uses') || ''));
|
||||
if (checkouts.length !== 1 || singleField(checkouts[0].fields, 'uses') !== 'actions/checkout@v4') {
|
||||
errors.push(`${label} job ${job.name} must define exactly one actions/checkout@v4 step`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateSetupNode(workflow, label, errors) {
|
||||
for (const job of workflow.jobs.values()) {
|
||||
const setupSteps = job.steps.filter((step) => /^actions\/setup-node@/.test(singleField(step.fields, 'uses') || ''));
|
||||
if (setupSteps.length !== 1 || singleField(setupSteps[0].fields, 'uses') !== 'actions/setup-node@v4' || singleField(setupSteps[0].with, 'node-version') !== "'24.11.1'") {
|
||||
errors.push(`${label} job ${job.name} must define exactly one actions/setup-node@v4 step pinned to Node 24.11.1`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stepByName(job, name) {
|
||||
const matches = job?.steps.filter((step) => step.name === name) || [];
|
||||
return matches.length === 1 ? matches[0] : undefined;
|
||||
}
|
||||
|
||||
function stepIndexByRun(job, command) {
|
||||
return job?.steps.findIndex((step) => singleField(step.fields, 'run') === command) ?? -1;
|
||||
}
|
||||
|
||||
function hasTrimmedLine(step, expected) {
|
||||
return step?.raw.split(/\r?\n/).some((line) => line.trim() === expected) || false;
|
||||
}
|
||||
|
||||
const strictVersionPattern = "'^(0|[1-9][0-9]{0,8})\\.(0|[1-9][0-9]{0,8})\\.(0|[1-9][0-9]{0,8})$'";
|
||||
|
||||
function hasStrictUpdaterVersions(step) {
|
||||
return hasTrimmedLine(step, '$sourceVersionText = $env:TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION.Trim()')
|
||||
&& hasTrimmedLine(step, '$updateVersionText = $env:TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION.Trim()')
|
||||
&& hasTrimmedLine(step, `$versionPattern = ${strictVersionPattern}`)
|
||||
&& hasTrimmedLine(step, 'if ($sourceVersionText -notmatch $versionPattern) {')
|
||||
&& hasTrimmedLine(step, 'if ($updateVersionText -notmatch $versionPattern) {')
|
||||
&& hasTrimmedLine(step, '$sourceVersion = [version]$sourceVersionText')
|
||||
&& hasTrimmedLine(step, '$updateVersion = [version]$updateVersionText')
|
||||
&& !step.raw.includes('TrimStart');
|
||||
}
|
||||
|
||||
function hasExactChainedCommand(script, expected) {
|
||||
if (typeof script !== 'string') return false;
|
||||
return script.split(/\s*&&\s*/).filter((command) => command === expected).length === 1;
|
||||
}
|
||||
|
||||
function validateManualGate(job, gate, label, errors) {
|
||||
const expected = `github.event_name == 'workflow_dispatch' && github.event.inputs.live_gate == '${gate}'`;
|
||||
if (singleField(job?.fields || new Map(), 'if') !== expected) errors.push(`${label} ${job?.name || gate} must be manual-only for ${gate}`);
|
||||
if (singleField(job?.fields || new Map(), 'needs') !== 'verify') errors.push(`${label} ${job?.name || gate} must require verify`);
|
||||
}
|
||||
|
||||
function findSecretLeaks(job, allowedStep, secretNames) {
|
||||
const leaks = [];
|
||||
for (const name of secretNames) {
|
||||
if (job.env.has(name) || job.header.includes(`secrets.${name}`)) leaks.push(`job:${name}`);
|
||||
for (let index = 0; index < job.steps.length; index += 1) {
|
||||
const step = job.steps[index];
|
||||
if (step === allowedStep) continue;
|
||||
if (step.env.has(name) || step.raw.includes(`secrets.${name}`)) leaks.push(`step:${index + 1}:${name}`);
|
||||
}
|
||||
}
|
||||
return leaks;
|
||||
}
|
||||
|
||||
const parserFixture = parseWorkflow(`jobs:
|
||||
fixture:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: named
|
||||
uses: actions/checkout@v4
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 11
|
||||
- run: npm test`);
|
||||
const parserFixtureErrors = [];
|
||||
validateTimeouts(parserFixture, 'fixture', parserFixtureErrors);
|
||||
check(parserFixture.jobs.get('fixture')?.steps.length === 2, 'CI parser does not recognize an unnamed step boundary');
|
||||
check(parserFixtureErrors.includes('fixture fixture named must define exactly one positive timeout-minutes'), 'CI timeout contract accepts duplicate step timeouts');
|
||||
check(parserFixtureErrors.includes('fixture fixture unnamed step 2 must define exactly one positive timeout-minutes'), 'CI timeout contract accepts a missing timeout hidden by another step');
|
||||
|
||||
const gateFixture = parseWorkflow(`jobs:
|
||||
updater-live-postpublish:
|
||||
if: false
|
||||
needs: verify
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- run: Write-Output "github.event_name == 'workflow_dispatch' && github.event.inputs.live_gate == 'updater-postpublish'"
|
||||
timeout-minutes: 10`);
|
||||
const gateFixtureErrors = [];
|
||||
validateManualGate(gateFixture.jobs.get('updater-live-postpublish'), 'updater-postpublish', 'fixture', gateFixtureErrors);
|
||||
check(gateFixtureErrors.includes('fixture updater-live-postpublish must be manual-only for updater-postpublish'), 'CI manual gate contract accepts a condition embedded in run text');
|
||||
|
||||
const secretNames = ['TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_ID', 'TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET', 'TWITCH_VOD_MANAGER_LIVE_TWITCH_LOGIN', 'TWITCH_VOD_MANAGER_LIVE_TWITCH_VOD_ID'];
|
||||
const secretFixture = parseWorkflow(`jobs:
|
||||
twitch-live:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: provider
|
||||
run: npm run provider
|
||||
timeout-minutes: 10
|
||||
- run: npm run unrelated
|
||||
env:
|
||||
TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET: \${{ secrets.TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET }}
|
||||
timeout-minutes: 10`);
|
||||
const secretFixtureJob = secretFixture.jobs.get('twitch-live');
|
||||
check(findSecretLeaks(secretFixtureJob, stepByName(secretFixtureJob, 'provider'), secretNames).includes('step:2:TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET'), 'CI secret scope contract misses a leak in an unnamed following step');
|
||||
|
||||
const releaseCommandPrefixFixture = 'npm run build && npm run test:live-integration-contract-shadow';
|
||||
check(!hasExactChainedCommand(releaseCommandPrefixFixture, 'npm run test:live-integration-contract'), 'Release command contract accepts a longer command with the required command as a prefix');
|
||||
check(hasExactChainedCommand('npm run build && npm run test:live-integration-contract', 'npm run test:live-integration-contract'), 'Release command contract rejects an exact required command');
|
||||
check(!hasExactChainedCommand('npm run build && npm run test:unit', 'npm run test:live-integration-contract'), 'Release command contract accepts a missing required command');
|
||||
check(!hasExactChainedCommand('npm run test:live-integration-contract && npm run test:live-integration-contract', 'npm run test:live-integration-contract'), 'Release command contract accepts a duplicate required command');
|
||||
|
||||
const duplicateKeyFixture = parseWorkflow(`jobs:
|
||||
fixture:
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
CI: 'true'
|
||||
CI: 'false'
|
||||
env:
|
||||
OTHER: value
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: first
|
||||
ref: second
|
||||
with:
|
||||
fetch-depth: 1
|
||||
timeout-minutes: 10`);
|
||||
for (const duplicatePath of ['jobs.fixture.env', 'jobs.fixture.env.CI', 'jobs.fixture.steps[0].with', 'jobs.fixture.steps[0].with.ref']) {
|
||||
check(duplicateKeyFixture.duplicateKeys?.includes(duplicatePath), `CI parser does not report duplicate YAML key ${duplicatePath}`);
|
||||
}
|
||||
|
||||
const duplicateJobFixture = parseWorkflow(`jobs:
|
||||
fixture:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: npm test
|
||||
timeout-minutes: 10
|
||||
fixture:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: npm test
|
||||
timeout-minutes: 10`);
|
||||
check(duplicateJobFixture.duplicateKeys?.includes('jobs.fixture'), 'CI parser does not report a duplicate job key');
|
||||
|
||||
const permissiveVersionPreflightFixture = {
|
||||
raw: `$sourceVersion = [version]($env:TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION.TrimStart('v'))
|
||||
$updateVersion = [version]($env:TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION.TrimStart('v'))`
|
||||
};
|
||||
check(!hasStrictUpdaterVersions(permissiveVersionPreflightFixture), 'CI version preflight contract accepts prefixes, suffixes or leading zeroes');
|
||||
|
||||
const missingCheckoutFixture = parseWorkflow(`jobs:
|
||||
fixture:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
timeout-minutes: 10`);
|
||||
const missingCheckoutErrors = [];
|
||||
validateCheckouts(missingCheckoutFixture, 'fixture', missingCheckoutErrors);
|
||||
check(missingCheckoutErrors.includes('fixture job fixture must define exactly one actions/checkout@v4 step'), 'CI job action contract accepts a job without checkout');
|
||||
|
||||
const extraCheckoutFixture = parseWorkflow(`jobs:
|
||||
fixture:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
timeout-minutes: 10
|
||||
- uses: actions/checkout@v3
|
||||
timeout-minutes: 10`);
|
||||
const extraCheckoutErrors = [];
|
||||
validateCheckouts(extraCheckoutFixture, 'fixture', extraCheckoutErrors);
|
||||
check(extraCheckoutErrors.includes('fixture job fixture must define exactly one actions/checkout@v4 step'), 'CI job action contract accepts an additional checkout version');
|
||||
|
||||
const extraSetupNodeFixture = parseWorkflow(`jobs:
|
||||
fixture:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
timeout-minutes: 10
|
||||
with:
|
||||
node-version: '24.11.1'
|
||||
- uses: actions/setup-node@v3
|
||||
timeout-minutes: 10`);
|
||||
const extraSetupNodeErrors = [];
|
||||
validateSetupNode(extraSetupNodeFixture, 'fixture', extraSetupNodeErrors);
|
||||
check(extraSetupNodeErrors.includes('fixture job fixture must define exactly one actions/setup-node@v4 step pinned to Node 24.11.1'), 'CI job action contract accepts an additional setup-node version');
|
||||
|
||||
function hasSingleConditionalRetry(source, command) {
|
||||
const lines = source.split(/\r?\n/);
|
||||
const commandIndexes = lines
|
||||
.map((line, index) => line.trim() === command ? index : -1)
|
||||
.filter((index) => index >= 0);
|
||||
if (commandIndexes.length !== 2) return false;
|
||||
const [first, second] = commandIndexes;
|
||||
return second === first + 2
|
||||
&& lines[first + 1]?.trim() === 'if ($LASTEXITCODE -ne 0) {'
|
||||
&& lines[first + 3]?.trim() === 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }'
|
||||
&& lines[first + 4]?.trim() === '}';
|
||||
}
|
||||
|
||||
const retryFixture = (command) => `${command}\nif ($LASTEXITCODE -ne 0) {\n ${command}\n if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }\n}`;
|
||||
for (const command of ['npx install-electron --no', 'npm run pack', 'npm run dist:ci']) {
|
||||
check(hasSingleConditionalRetry(retryFixture(command), command), `CI retry contract rejects a valid ${command} retry`);
|
||||
check(!hasSingleConditionalRetry(`${retryFixture(command)}\n${command}`, command), `CI retry contract accepts more than one ${command} retry`);
|
||||
check(!hasSingleConditionalRetry(`${command}\n${command}`, command), `CI retry contract accepts an unconditional ${command} retry`);
|
||||
}
|
||||
|
||||
const requiredScripts = {
|
||||
lint: 'eslint .',
|
||||
'security:check': 'node scripts/security-check.js && node scripts/smoke-test-public-release-config.js',
|
||||
'test:security': 'node --test scripts/security-check.test.js',
|
||||
'test:lint-config': 'node --test scripts/lint-config.test.mjs',
|
||||
'test:ci-contract': 'node scripts/smoke-test-ci-contract.js',
|
||||
'test:installer-contract': 'node --test scripts/smoke-test-installer.test.js',
|
||||
'test:managed-tools-contract': 'node --test scripts/smoke-test-managed-tools-live.test.js',
|
||||
'test:cutter-matrix-contract': 'node --test scripts/smoke-test-cutter-media-matrix.test.js',
|
||||
'test:managed-tools-live': 'node scripts/smoke-test-managed-tools-live.js',
|
||||
'test:live-integration-contract': 'npm run build && node --test scripts/smoke-test-live-integration.test.js',
|
||||
'test:live:twitch': 'node scripts/smoke-test-live-integration.js twitch',
|
||||
'test:live:updater-postpublish': 'node scripts/smoke-test-live-integration.js updater',
|
||||
'test:e2e:cutter-matrix': 'npm run build && node scripts/smoke-test-cutter-media-matrix.js',
|
||||
'test:e2e:focused': 'npm run test:e2e:isolation && npm run test:e2e:workspace-ui',
|
||||
'test:packaged-launch': 'node scripts/smoke-test-packaged-launch.js',
|
||||
'test:installer': 'node scripts/smoke-test-installer.js',
|
||||
@@ -25,50 +414,110 @@ for (const [name, command] of Object.entries(requiredScripts)) {
|
||||
check(packageJson.scripts?.[name] === command, `package script ${name} is missing or changed`);
|
||||
}
|
||||
|
||||
for (const contract of ['test:installer-contract', 'test:managed-tools-contract', 'test:cutter-matrix-contract', 'test:live-integration-contract']) {
|
||||
check(hasExactChainedCommand(packageJson.scripts?.['test:e2e:release'], `npm run ${contract}`), `release verification does not include exactly one ${contract} command`);
|
||||
}
|
||||
|
||||
const workflowSources = new Map();
|
||||
for (const relativePath of ['.github/workflows/windows-ci.yml', '.gitea/workflows/windows-ci.yml']) {
|
||||
const absolutePath = path.join(root, relativePath);
|
||||
check(fs.existsSync(absolutePath), `${relativePath} is missing`);
|
||||
if (!fs.existsSync(absolutePath)) continue;
|
||||
|
||||
const source = fs.readFileSync(absolutePath, 'utf8');
|
||||
workflowSources.set(relativePath, source);
|
||||
const workflow = parseWorkflow(source);
|
||||
const verifyJob = workflow.jobs.get('verify');
|
||||
const twitchLiveJob = workflow.jobs.get('twitch-live');
|
||||
const updaterLiveJob = workflow.jobs.get('updater-live-postpublish');
|
||||
const requiredCommands = [
|
||||
'npm ci',
|
||||
'npx install-electron --no',
|
||||
'npm run lint',
|
||||
'npm run test:lint-config',
|
||||
'npm run security:check',
|
||||
'npm run test:security',
|
||||
'npm run test:ci-contract',
|
||||
'npm run test:installer-contract',
|
||||
'npm run test:managed-tools-contract',
|
||||
'npm run test:cutter-matrix-contract',
|
||||
'npm run test:live-integration-contract',
|
||||
'npm run test:unit',
|
||||
'npm run test:e2e:focused',
|
||||
'npm run build',
|
||||
'npm run pack',
|
||||
'node scripts/smoke-test-cutter-media-matrix.js',
|
||||
'npm run test:managed-tools-live',
|
||||
'npm run test:packaged-launch',
|
||||
'npm run dist:ci',
|
||||
'npm run test:installer'
|
||||
];
|
||||
|
||||
check(/runs-on:\s*windows-latest/.test(source), `${relativePath} does not use a Windows runner`);
|
||||
check(/node-version:\s*['"]?24\.11\.1['"]?/.test(source), `${relativePath} does not pin Node 24.11.1`);
|
||||
check(workflow.jobs.size === 3 && verifyJob && twitchLiveJob && updaterLiveJob, `${relativePath} must define exactly verify, twitch-live and updater-live-postpublish jobs`);
|
||||
check(workflow.duplicateKeys.length === 0, `${relativePath} contains duplicate YAML keys: ${workflow.duplicateKeys.join(', ')}`);
|
||||
validateTimeouts(workflow, relativePath, failures);
|
||||
validateCheckouts(workflow, relativePath, failures);
|
||||
validateSetupNode(workflow, relativePath, failures);
|
||||
for (const input of ['source_version', 'source_sha256', 'update_version', 'update_sha512']) {
|
||||
check((workflow.dispatchInputs.get(input) || []).length === 1, `${relativePath} workflow_dispatch must define explicit ${input}`);
|
||||
}
|
||||
for (const job of workflow.jobs.values()) check(singleField(job.fields, 'runs-on') === 'windows-latest', `${relativePath} ${job.name} does not use a Windows runner`);
|
||||
for (const command of requiredCommands) {
|
||||
check(source.includes(command), `${relativePath} is missing ${command}`);
|
||||
check(stepIndexByRun(verifyJob, command) >= 0, `${relativePath} verify job is missing an exact ${command} run step`);
|
||||
}
|
||||
const verifyBuildIndex = stepIndexByRun(verifyJob, 'npm run build');
|
||||
const verifyLiveContractIndex = stepIndexByRun(verifyJob, 'npm run test:live-integration-contract');
|
||||
check(verifyBuildIndex >= 0 && verifyBuildIndex < verifyLiveContractIndex, `${relativePath} verify must build before the live integration contract`);
|
||||
check(verifyBuildIndex < stepIndexByRun(verifyJob, 'npm run test:managed-tools-live'), `${relativePath} runs the live managed-tools check before build`);
|
||||
for (const command of ['npm run pack', 'npm run dist:ci']) {
|
||||
check(source.match(new RegExp(command.replace(/[:]/g, '\\:'), 'g'))?.length >= 2, `${relativePath} does not retry transient ${command} failures`);
|
||||
check(hasSingleConditionalRetry(verifyJob?.steps.map((step) => step.raw).join('\n') || '', command), `${relativePath} does not retry transient ${command} failures exactly once`);
|
||||
}
|
||||
check(source.match(/npx install-electron --no/g)?.length >= 2, `${relativePath} does not retry Electron binary provisioning`);
|
||||
const runSteps = source.split(/\r?\n/).filter((line) => /^\s+run:\s+/.test(line));
|
||||
const timeoutSteps = source.split(/\r?\n/).filter((line) => /^\s+timeout-minutes:\s*10\s*$/.test(line));
|
||||
check(timeoutSteps.length >= runSteps.length, `${relativePath} does not cap every command at ten minutes`);
|
||||
check(!/test:[^\s]*authenticated|TWITCH_CLIENT_SECRET|DISCORD_WEBHOOK/i.test(source), `${relativePath} includes authenticated integration inputs`);
|
||||
check(hasSingleConditionalRetry(verifyJob?.steps.map((step) => step.raw).join('\n') || '', 'npx install-electron --no'), `${relativePath} does not retry Electron binary provisioning exactly once`);
|
||||
check(findSecretLeaks(verifyJob, undefined, secretNames).length === 0, `${relativePath} exposes Twitch live inputs to normal CI`);
|
||||
validateManualGate(twitchLiveJob, 'twitch', relativePath, failures);
|
||||
const twitchProviderStep = stepByName(twitchLiveJob, 'Twitch provider OAuth, Helix and bounded VOD gate');
|
||||
for (const name of secretNames) {
|
||||
check(singleField(twitchProviderStep?.env || new Map(), name) === `\${{ secrets.${name} }}`, `${relativePath} Twitch live ${name.toLowerCase()} is not scoped to the final provider step`);
|
||||
}
|
||||
check(findSecretLeaks(twitchLiveJob, twitchProviderStep, secretNames).length === 0, `${relativePath} exposes Twitch secrets outside the final provider step`);
|
||||
check(singleField(twitchProviderStep?.env || new Map(), 'TWITCH_VOD_MANAGER_LIVE_INTEGRATION') === "'1'" && singleField(twitchProviderStep?.fields || new Map(), 'run') === 'npm run test:live:twitch', `${relativePath} Twitch live gate is not explicitly opted in at the final provider step`);
|
||||
check(twitchLiveJob?.steps.some((step) => step.name === 'Provision pinned media tools' && step.raw.includes('TWITCH_VOD_MANAGER_LIVE_STREAMLINK_PATH') && step.raw.includes('TWITCH_VOD_MANAGER_LIVE_FFPROBE_PATH')), `${relativePath} Twitch live gate does not provision its real media tools`);
|
||||
check(stepIndexByRun(twitchLiveJob, 'npm run build') >= 0 && stepIndexByRun(twitchLiveJob, 'npm run build') < stepIndexByRun(twitchLiveJob, 'npm run test:live-integration-contract'), `${relativePath} Twitch live gate must build before its integration contract`);
|
||||
validateManualGate(updaterLiveJob, 'updater-postpublish', relativePath, failures);
|
||||
for (const input of ['source_version', 'source_sha256', 'update_version', 'update_sha512']) {
|
||||
check(singleField(updaterLiveJob?.env || new Map(), `TWITCH_VOD_MANAGER_LIVE_${input.toUpperCase()}`) === `\${{ github.event.inputs.${input} }}`, `${relativePath} updater live gate does not bind explicit ${input}`);
|
||||
}
|
||||
check(singleField(updaterLiveJob?.env || new Map(), 'TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA') === '${{ github.sha }}', `${relativePath} updater live gate does not bind provenance to github.sha`);
|
||||
const updaterCheckout = updaterLiveJob?.steps.filter((step) => singleField(step.fields, 'uses') === 'actions/checkout@v4') || [];
|
||||
check(updaterCheckout.length === 1 && singleField(updaterCheckout[0].with, 'ref') === '${{ github.sha }}', `${relativePath} updater checkout is not explicitly bound to github.sha`);
|
||||
check(singleField(updaterLiveJob?.env || new Map(), 'TWITCH_VOD_MANAGER_LIVE_INTEGRATION') === "'1'" && stepIndexByRun(updaterLiveJob, 'npm run test:live:updater-postpublish') >= 0, `${relativePath} updater live gate is not explicitly opted in`);
|
||||
check(stepIndexByRun(updaterLiveJob, 'npm run build') >= 0 && stepIndexByRun(updaterLiveJob, 'npm run build') < stepIndexByRun(updaterLiveJob, 'npm run test:live-integration-contract'), `${relativePath} updater live gate must build before its integration contract`);
|
||||
const updaterPreflight = stepByName(updaterLiveJob, 'Require explicit post-publish updater inputs');
|
||||
check(updaterPreflight && secretNames.every((name) => !updaterPreflight.raw.includes(name)), `${relativePath} updater preflight references Twitch credentials`);
|
||||
check(hasTrimmedLine(updaterPreflight, "'TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA'") && hasTrimmedLine(updaterPreflight, "if ($env:TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA -notmatch '^[0-9a-fA-F]{40}$') {") && hasTrimmedLine(updaterPreflight, 'if (-not [string]::Equals($env:TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA, $env:GITHUB_SHA, [StringComparison]::OrdinalIgnoreCase)) {'), `${relativePath} updater preflight does not verify commit provenance against GITHUB_SHA`);
|
||||
check(hasStrictUpdaterVersions(updaterPreflight) && hasTrimmedLine(updaterPreflight, 'if ($sourceVersion -ge $updateVersion) {'), `${relativePath} updater preflight does not require exact source_version < update_version`);
|
||||
check(hasTrimmedLine(updaterPreflight, '$packageVersion = (Get-Content -Raw -LiteralPath package.json | ConvertFrom-Json).version') && hasTrimmedLine(updaterPreflight, 'if ($updateVersionText -ne $packageVersion) {') && hasTrimmedLine(updaterPreflight, '$expectedRef = "refs/tags/v$updateVersionText"') && hasTrimmedLine(updaterPreflight, 'if ($env:GITHUB_REF -ne $expectedRef) {'), `${relativePath} updater preflight does not bind the exact update version text to package.json and its release tag`);
|
||||
const updaterExecutionStep = stepByName(updaterLiveJob, 'Verify published updater path');
|
||||
check(singleField(updaterExecutionStep?.fields || new Map(), 'timeout-minutes') === '25', `${relativePath} updater live gate does not allow 25 minutes for a real installer download`);
|
||||
}
|
||||
|
||||
check(workflowSources.get('.github/workflows/windows-ci.yml') === workflowSources.get('.gitea/workflows/windows-ci.yml'), 'GitHub and Gitea workflows are not byte-identical');
|
||||
|
||||
const liveIntegrationSource = fs.readFileSync(path.join(root, 'scripts/smoke-test-live-integration.js'), 'utf8');
|
||||
check(/dist['"],\s*['"]main['"],\s*['"]twitch['"]/.test(liveIntegrationSource), 'Twitch provider live gate does not load the built Twitch product module');
|
||||
check(liveIntegrationSource.includes('TwitchAppTokenService') && liveIntegrationSource.includes('requestTwitchAppAccessToken'), 'Twitch provider live gate bypasses the product token service');
|
||||
|
||||
for (const relativePath of [
|
||||
'scripts/security-check.js',
|
||||
'scripts/security-check.test.js',
|
||||
'scripts/lint-config.test.mjs',
|
||||
'scripts/smoke-test-packaged-launch.js',
|
||||
'scripts/smoke-test-installer.js'
|
||||
'scripts/smoke-test-installer.js',
|
||||
'scripts/smoke-test-installer.test.js',
|
||||
'scripts/smoke-test-managed-tools-live.js',
|
||||
'scripts/smoke-test-managed-tools-live.test.js',
|
||||
'scripts/smoke-test-live-integration-contract.js',
|
||||
'scripts/smoke-test-live-integration.js',
|
||||
'scripts/smoke-test-live-integration.test.js',
|
||||
'scripts/smoke-test-cutter-media-matrix.js',
|
||||
'scripts/smoke-test-cutter-media-matrix.test.js'
|
||||
]) {
|
||||
check(fs.existsSync(path.join(root, relativePath)), `${relativePath} is missing`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user