Fix Windows release verification portability
Windows CI / verify (push) Failing after 2m35s

Canonicalize shortcut targets across Windows short and long path forms while preserving exact target validation. Keep Gitea 1.22 push verification parser-compatible and harden CI contracts against skipped or weakened live gates.
This commit is contained in:
Sucukdeluxe
2026-08-13 23:45:53 +02:00
parent 9f1b052afd
commit 9e8a3a0f4d
4 changed files with 210 additions and 205 deletions
-169
View File
@@ -4,32 +4,6 @@ on:
push: push:
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
inputs:
live_gate:
description: Optional live gate to run after verification
required: true
default: none
type: choice
options:
- none
- twitch
- updater-postpublish
source_version:
description: Published source version for the updater gate
required: false
type: string
source_sha256:
description: SHA-256 of the published source installer
required: false
type: string
update_version:
description: Newly published target version for the updater gate
required: false
type: string
update_sha512:
description: SHA-512 from the newly published latest.yml
required: false
type: string
permissions: permissions:
contents: read contents: read
@@ -123,146 +97,3 @@ jobs:
- name: Installer smoke - name: Installer smoke
run: npm run test:installer run: npm run test:installer
timeout-minutes: 10 timeout-minutes: 10
twitch-live:
if: github.event_name == 'workflow_dispatch' && github.event.inputs.live_gate == 'twitch'
needs: verify
runs-on: windows-latest
timeout-minutes: 60
env:
CI: 'true'
steps:
- uses: actions/checkout@v4
timeout-minutes: 10
- uses: actions/setup-node@v4
timeout-minutes: 10
with:
node-version: '24.11.1'
cache: npm
- name: Clean install
run: npm ci
timeout-minutes: 10
- name: Build
run: npm run build
timeout-minutes: 10
- name: Verify live integration contract
run: npm run test:live-integration-contract
timeout-minutes: 10
- name: Provision pinned media tools
run: |
$env:TWITCH_VOD_MANAGER_LIVE_TOOL_ROOT = Join-Path $env:RUNNER_TEMP "tvm-live-tools-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT"
@'
const fs = require('node:fs');
const path = require('node:path');
const tools = require('./dist/tools.js');
(async () => {
const root = process.env.TWITCH_VOD_MANAGER_LIVE_TOOL_ROOT;
const streamlinkDirectory = path.join(root, 'streamlink');
const ffmpegDirectory = path.join(root, 'ffmpeg');
const temporaryDirectory = path.join(root, 'temporary');
fs.mkdirSync(temporaryDirectory, { recursive: true });
tools.initToolDirs(streamlinkDirectory, ffmpegDirectory, () => temporaryDirectory);
const result = await tools.repairManagedTools();
if (!result.success) throw new Error(`Pinned media tool provisioning failed: ${JSON.stringify(result.statuses)}`);
if (!process.env.GITHUB_ENV) throw new Error('Actions environment export file is unavailable');
fs.appendFileSync(process.env.GITHUB_ENV, [
`TWITCH_VOD_MANAGER_LIVE_STREAMLINK_PATH=${tools.getStreamlinkPath()}`,
`TWITCH_VOD_MANAGER_LIVE_FFPROBE_PATH=${tools.getFFprobePath()}`,
''
].join('\n'));
})().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
'@ | node
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
timeout-minutes: 10
- name: Twitch provider OAuth, Helix and bounded VOD gate
env:
TWITCH_VOD_MANAGER_LIVE_INTEGRATION: '1'
TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_ID: ${{ secrets.TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_ID }}
TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET: ${{ secrets.TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET }}
TWITCH_VOD_MANAGER_LIVE_TWITCH_LOGIN: ${{ secrets.TWITCH_VOD_MANAGER_LIVE_TWITCH_LOGIN }}
TWITCH_VOD_MANAGER_LIVE_TWITCH_VOD_ID: ${{ secrets.TWITCH_VOD_MANAGER_LIVE_TWITCH_VOD_ID }}
run: npm run test:live:twitch
timeout-minutes: 10
updater-live-postpublish:
if: github.event_name == 'workflow_dispatch' && github.event.inputs.live_gate == 'updater-postpublish'
needs: verify
runs-on: windows-latest
timeout-minutes: 60
env:
CI: 'true'
TWITCH_VOD_MANAGER_LIVE_INTEGRATION: '1'
TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION: ${{ github.event.inputs.source_version }}
TWITCH_VOD_MANAGER_LIVE_SOURCE_SHA256: ${{ github.event.inputs.source_sha256 }}
TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION: ${{ github.event.inputs.update_version }}
TWITCH_VOD_MANAGER_LIVE_UPDATE_SHA512: ${{ github.event.inputs.update_sha512 }}
TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA: ${{ github.sha }}
steps:
- uses: actions/checkout@v4
timeout-minutes: 10
with:
ref: ${{ github.sha }}
- uses: actions/setup-node@v4
timeout-minutes: 10
with:
node-version: '24.11.1'
cache: npm
- name: Clean install
run: npm ci
timeout-minutes: 10
- name: Require explicit post-publish updater inputs
run: |
$required = @(
'TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION',
'TWITCH_VOD_MANAGER_LIVE_SOURCE_SHA256',
'TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION',
'TWITCH_VOD_MANAGER_LIVE_UPDATE_SHA512',
'TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA'
)
foreach ($name in $required) {
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) {
throw "Missing required post-publish updater input: $name"
}
}
if ($env:TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA -notmatch '^[0-9a-fA-F]{40}$') {
throw 'Pinned updater commit provenance must be a 40-character hexadecimal SHA'
}
if (-not [string]::Equals($env:TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA, $env:GITHUB_SHA, [StringComparison]::OrdinalIgnoreCase)) {
throw 'Pinned updater commit provenance must match GITHUB_SHA'
}
$sourceVersionText = $env:TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION.Trim()
$updateVersionText = $env:TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION.Trim()
$versionPattern = '^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})$'
if ($sourceVersionText -notmatch $versionPattern) {
throw 'Pinned source version must be an exact three-segment numeric release version'
}
if ($updateVersionText -notmatch $versionPattern) {
throw 'Pinned update version must be an exact three-segment numeric release version'
}
$sourceVersion = [version]$sourceVersionText
$updateVersion = [version]$updateVersionText
if ($sourceVersion -ge $updateVersion) {
throw 'Pinned source version must be older than the update version'
}
$packageVersion = (Get-Content -Raw -LiteralPath package.json | ConvertFrom-Json).version
if ($updateVersionText -ne $packageVersion) {
throw "Pinned update version must match package.json version $packageVersion"
}
$expectedRef = "refs/tags/v$updateVersionText"
if ($env:GITHUB_REF -ne $expectedRef) {
throw "Post-publish updater gate must run from release tag $expectedRef"
}
timeout-minutes: 10
- name: Build
run: npm run build
timeout-minutes: 10
- name: Verify live integration contract
run: npm run test:live-integration-contract
timeout-minutes: 10
- name: Verify published updater path
run: npm run test:live:updater-postpublish
timeout-minutes: 25
+161 -3
View File
@@ -1,5 +1,7 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { isDeepStrictEqual } = require('node:util');
const yaml = require('js-yaml');
const root = path.resolve(__dirname, '..'); const root = path.resolve(__dirname, '..');
const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
@@ -147,9 +149,13 @@ function parseWorkflow(source) {
const onEndOffset = lines.slice(onStart + 1).findIndex((line) => /^\S/.test(line)); const onEndOffset = lines.slice(onStart + 1).findIndex((line) => /^\S/.test(line));
const onEnd = onEndOffset < 0 ? lines.length : onStart + 1 + onEndOffset; const onEnd = onEndOffset < 0 ? lines.length : onStart + 1 + onEndOffset;
const dispatchStart = lines.findIndex((line, index) => index > onStart && index < onEnd && line === ' workflow_dispatch:'); 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:'); const dispatchEndOffset = dispatchStart < 0
? -1
: lines.slice(dispatchStart + 1, onEnd).findIndex((line) => /^ {2}\S/.test(line));
const dispatchEnd = dispatchEndOffset < 0 ? onEnd : dispatchStart + 1 + dispatchEndOffset;
const inputsStart = dispatchStart < 0 ? -1 : lines.findIndex((line, index) => index > dispatchStart && index < dispatchEnd && line === ' inputs:');
if (dispatchStart >= 0 && inputsStart >= 0) { if (dispatchStart >= 0 && inputsStart >= 0) {
for (let index = inputsStart + 1; index < onEnd; index += 1) { for (let index = inputsStart + 1; index < dispatchEnd; index += 1) {
const field = parseMappingField(lines[index], 6); const field = parseMappingField(lines[index], 6);
if (field?.[1] === '') addField(dispatchInputs, field[0], ''); if (field?.[1] === '') addField(dispatchInputs, field[0], '');
} }
@@ -160,6 +166,97 @@ function parseWorkflow(source) {
return { dispatchInputs, duplicateKeys: [...duplicateKeys], jobs }; return { dispatchInputs, duplicateKeys: [...duplicateKeys], jobs };
} }
function parseWorkflowDocument(source, label, errors) {
try {
const document = yaml.load(source, { schema: yaml.JSON_SCHEMA });
if (!document || typeof document !== 'object' || Array.isArray(document)) {
errors.push(`${label} must contain one YAML mapping document`);
return undefined;
}
return document;
} catch (error) {
const reason = error && typeof error === 'object' && 'reason' in error ? error.reason : 'invalid YAML';
errors.push(`${label} cannot be parsed as YAML: ${reason}`);
return undefined;
}
}
function sortedKeys(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? Object.keys(value).sort() : [];
}
function collectYamlNamesAndValues(value, values = [], visited = new Set()) {
if (typeof value === 'string') {
values.push(value);
return values;
}
if (!value || typeof value !== 'object' || visited.has(value)) return values;
visited.add(value);
if (Array.isArray(value)) {
for (const item of value) collectYamlNamesAndValues(item, values, visited);
return values;
}
for (const [key, item] of Object.entries(value)) {
values.push(key);
collectYamlNamesAndValues(item, values, visited);
}
return values;
}
function expressionUsesRoot(value, root) {
if (typeof value !== 'string') return false;
return [...value.matchAll(/\$\{\{([\s\S]*?)\}\}/g)].some((match) => {
const normalized = match[1]
.replace(/\[\s*(['"])([A-Za-z_][A-Za-z0-9_-]*)\1\s*\]/g, '.$2')
.replace(/\s+/g, '')
.toLowerCase();
if (root === 'secrets') return /(?:^|[^a-z0-9_])secrets(?:$|[^a-z0-9_])/.test(normalized);
if (root === 'github.event.inputs') return /(?:^|[^a-z0-9_])github\.event\.inputs(?:$|[^a-z0-9_])/.test(normalized);
return false;
});
}
function validateRequiredDocumentSteps(job, label, errors) {
for (const step of Array.isArray(job?.steps) ? job.steps : []) {
const stepLabel = step.name || step.run || step.uses || 'unnamed step';
if (Object.hasOwn(step, 'if')) errors.push(`${label} ${stepLabel} must not be conditionally skipped`);
if (Object.hasOwn(step, 'continue-on-error')) errors.push(`${label} ${stepLabel} must not ignore failures`);
}
}
function validateWorkflowCompatibility(githubSource, giteaSource) {
const errors = [];
const githubDocument = parseWorkflowDocument(githubSource, 'GitHub workflow', errors);
const giteaDocument = parseWorkflowDocument(giteaSource, 'Gitea workflow', errors);
if (!githubDocument || !giteaDocument) return errors;
const githubTriggers = githubDocument.on;
const giteaTriggers = giteaDocument.on;
const expectedTriggers = ['pull_request', 'push', 'workflow_dispatch'];
if (!isDeepStrictEqual(sortedKeys(githubTriggers), expectedTriggers)) errors.push('GitHub workflow must define exactly push, pull_request and workflow_dispatch triggers');
if (!isDeepStrictEqual(sortedKeys(giteaTriggers), expectedTriggers)) errors.push('Gitea workflow must define exactly push, pull_request and workflow_dispatch triggers');
if (githubTriggers?.push !== null) errors.push('GitHub push trigger must remain unfiltered');
if (githubTriggers?.pull_request !== null) errors.push('GitHub pull_request trigger must remain unfiltered');
if (!isDeepStrictEqual(giteaTriggers?.push, githubTriggers?.push)) errors.push('GitHub and Gitea push triggers must be structurally equivalent');
if (!isDeepStrictEqual(giteaTriggers?.pull_request, githubTriggers?.pull_request)) errors.push('GitHub and Gitea pull_request triggers must be structurally equivalent');
if (giteaTriggers?.workflow_dispatch !== null) errors.push('Gitea workflow_dispatch must remain empty');
const githubJobs = githubDocument.jobs;
const giteaJobs = giteaDocument.jobs;
if (!isDeepStrictEqual(sortedKeys(githubJobs), ['twitch-live', 'updater-live-postpublish', 'verify'])) errors.push('GitHub workflow must define exactly verify, twitch-live and updater-live-postpublish jobs');
if (!isDeepStrictEqual(sortedKeys(giteaJobs), ['verify'])) errors.push('Gitea workflow must define exactly the verify job');
if (!isDeepStrictEqual(giteaJobs?.verify, githubJobs?.verify)) errors.push('GitHub and Gitea verify jobs must be structurally equivalent');
for (const jobName of ['verify', 'twitch-live', 'updater-live-postpublish']) validateRequiredDocumentSteps(githubJobs?.[jobName], `GitHub ${jobName}`, errors);
validateRequiredDocumentSteps(giteaJobs?.verify, 'Gitea verify', errors);
const giteaNamesAndValues = collectYamlNamesAndValues(giteaDocument);
if (giteaNamesAndValues.some((value) => expressionUsesRoot(value, 'secrets'))) errors.push('Gitea workflow must not reference secrets');
if (giteaNamesAndValues.some((value) => expressionUsesRoot(value, 'github.event.inputs'))) errors.push('Gitea workflow must not reference github.event.inputs');
if (giteaNamesAndValues.some((value) => /^TWITCH_VOD_MANAGER_LIVE_/i.test(value))) errors.push('Gitea workflow must not contain live integration bindings');
return errors;
}
function positiveTimeout(fields) { function positiveTimeout(fields) {
const values = fields.get('timeout-minutes') || []; const values = fields.get('timeout-minutes') || [];
return values.length === 1 && /^\d+$/.test(values[0]) && Number(values[0]) > 0; return values.length === 1 && /^\d+$/.test(values[0]) && Number(values[0]) > 0;
@@ -390,6 +487,58 @@ for (const command of ['npx install-electron --no', 'npm run pack', 'npm run dis
check(!hasSingleConditionalRetry(`${command}\n${command}`, command), `CI retry contract accepts an unconditional ${command} retry`); check(!hasSingleConditionalRetry(`${command}\n${command}`, command), `CI retry contract accepts an unconditional ${command} retry`);
} }
const compatibilityGithubFixture = `on:
push:
pull_request:
workflow_dispatch:
inputs: {}
jobs:
verify:
runs-on: windows-latest
twitch-live:
runs-on: windows-latest
updater-live-postpublish:
runs-on: windows-latest`;
const compatibilityGiteaFixture = `on:
push:
pull_request:
workflow_dispatch:
jobs:
verify:
runs-on: windows-latest`;
check(validateWorkflowCompatibility(compatibilityGithubFixture, compatibilityGiteaFixture).length === 0, 'Workflow compatibility contract rejects a compatible Gitea workflow');
const quotedJobCompatibilityErrors = validateWorkflowCompatibility(compatibilityGithubFixture, compatibilityGiteaFixture.replace('jobs:', `jobs:
"extra":
runs-on: windows-latest`));
check(quotedJobCompatibilityErrors.includes('Gitea workflow must define exactly the verify job'), 'Gitea compatibility contract accepts a quoted additional job');
const filteredTriggerCompatibilityErrors = validateWorkflowCompatibility(compatibilityGithubFixture, compatibilityGiteaFixture.replace(' push:', ` push:
branches-ignore:
- '**'`));
check(filteredTriggerCompatibilityErrors.includes('GitHub and Gitea push triggers must be structurally equivalent'), 'Gitea compatibility contract accepts a disabling push filter');
const forbiddenExpressionCompatibilityErrors = validateWorkflowCompatibility(compatibilityGithubFixture, compatibilityGiteaFixture.replace(' runs-on: windows-latest', ` runs-on: windows-latest
env:
FIRST: \${{secrets.UNTRACKED_LIVE_TOKEN}}
SECOND: \${{ github['event']['inputs']['live_gate'] }}`));
check(forbiddenExpressionCompatibilityErrors.includes('Gitea workflow must not reference secrets'), 'Gitea compatibility contract accepts an alternative secrets expression');
check(forbiddenExpressionCompatibilityErrors.includes('Gitea workflow must not reference github.event.inputs'), 'Gitea compatibility contract accepts an indexed github.event.inputs expression');
const skippedLiveStepGithubFixture = compatibilityGithubFixture.replace(` twitch-live:
runs-on: windows-latest`, ` twitch-live:
runs-on: windows-latest
steps:
- name: provider
"if": false
run: npm run provider
- name: ignored
"continue-on-error": true
run: npm run ignored`);
const skippedLiveStepErrors = validateWorkflowCompatibility(skippedLiveStepGithubFixture, compatibilityGiteaFixture);
check(skippedLiveStepErrors.includes('GitHub twitch-live provider must not be conditionally skipped'), 'GitHub live gate contract accepts if: false on a required step');
check(skippedLiveStepErrors.includes('GitHub twitch-live ignored must not ignore failures'), 'GitHub live gate contract accepts continue-on-error on a required step');
const requiredScripts = { const requiredScripts = {
lint: 'eslint .', lint: 'eslint .',
'security:check': 'node scripts/security-check.js && node scripts/smoke-test-public-release-config.js', 'security:check': 'node scripts/security-check.js && node scripts/smoke-test-public-release-config.js',
@@ -450,14 +599,20 @@ for (const relativePath of ['.github/workflows/windows-ci.yml', '.gitea/workflow
'npm run test:installer' 'npm run test:installer'
]; ];
if (relativePath === '.github/workflows/windows-ci.yml') {
check(workflow.jobs.size === 3 && verifyJob && twitchLiveJob && updaterLiveJob, `${relativePath} must define exactly verify, twitch-live and updater-live-postpublish jobs`); check(workflow.jobs.size === 3 && verifyJob && twitchLiveJob && updaterLiveJob, `${relativePath} must define exactly verify, twitch-live and updater-live-postpublish jobs`);
} else {
check(workflow.jobs.size === 1 && verifyJob, `${relativePath} must define exactly the verify job`);
}
check(workflow.duplicateKeys.length === 0, `${relativePath} contains duplicate YAML keys: ${workflow.duplicateKeys.join(', ')}`); check(workflow.duplicateKeys.length === 0, `${relativePath} contains duplicate YAML keys: ${workflow.duplicateKeys.join(', ')}`);
validateTimeouts(workflow, relativePath, failures); validateTimeouts(workflow, relativePath, failures);
validateCheckouts(workflow, relativePath, failures); validateCheckouts(workflow, relativePath, failures);
validateSetupNode(workflow, relativePath, failures); validateSetupNode(workflow, relativePath, failures);
if (relativePath === '.github/workflows/windows-ci.yml') {
for (const input of ['source_version', 'source_sha256', 'update_version', 'update_sha512']) { 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}`); 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 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) { for (const command of requiredCommands) {
check(stepIndexByRun(verifyJob, command) >= 0, `${relativePath} verify job is missing an exact ${command} run step`); check(stepIndexByRun(verifyJob, command) >= 0, `${relativePath} verify job is missing an exact ${command} run step`);
@@ -471,6 +626,7 @@ for (const relativePath of ['.github/workflows/windows-ci.yml', '.gitea/workflow
} }
check(hasSingleConditionalRetry(verifyJob?.steps.map((step) => step.raw).join('\n') || '', 'npx install-electron --no'), `${relativePath} does not retry Electron binary provisioning exactly once`); 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`); check(findSecretLeaks(verifyJob, undefined, secretNames).length === 0, `${relativePath} exposes Twitch live inputs to normal CI`);
if (relativePath === '.github/workflows/windows-ci.yml') {
validateManualGate(twitchLiveJob, 'twitch', relativePath, failures); validateManualGate(twitchLiveJob, 'twitch', relativePath, failures);
const twitchProviderStep = stepByName(twitchLiveJob, 'Twitch provider OAuth, Helix and bounded VOD gate'); const twitchProviderStep = stepByName(twitchLiveJob, 'Twitch provider OAuth, Helix and bounded VOD gate');
for (const name of secretNames) { for (const name of secretNames) {
@@ -496,9 +652,11 @@ for (const relativePath of ['.github/workflows/windows-ci.yml', '.gitea/workflow
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`); 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'); 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(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 giteaCompatibilitySource = workflowSources.get('.gitea/workflows/windows-ci.yml') || '';
for (const error of validateWorkflowCompatibility(workflowSources.get('.github/workflows/windows-ci.yml') || '', giteaCompatibilitySource)) failures.push(error);
const liveIntegrationSource = fs.readFileSync(path.join(root, 'scripts/smoke-test-live-integration.js'), 'utf8'); 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(/dist['"],\s*['"]main['"],\s*['"]twitch['"]/.test(liveIntegrationSource), 'Twitch provider live gate does not load the built Twitch product module');
+10 -2
View File
@@ -62,6 +62,14 @@ function normalizeWindowsPath(candidate) {
return path.win32.resolve(String(candidate)).replaceAll('/', '\\').toLowerCase(); return path.win32.resolve(String(candidate)).replaceAll('/', '\\').toLowerCase();
} }
function canonicalWindowsPath(candidate) {
try {
return normalizeWindowsPath(fs.realpathSync.native(candidate));
} catch {
return normalizeWindowsPath(candidate);
}
}
function assertPathInside(targetPath, parentPath) { function assertPathInside(targetPath, parentPath) {
const relative = path.win32.relative(path.win32.resolve(parentPath), path.win32.resolve(targetPath)); const relative = path.win32.relative(path.win32.resolve(parentPath), path.win32.resolve(targetPath));
if (!relative || relative.startsWith('..\\') || relative === '..' || path.win32.isAbsolute(relative)) { if (!relative || relative.startsWith('..\\') || relative === '..' || path.win32.isAbsolute(relative)) {
@@ -80,10 +88,10 @@ function assertFile(filePath, label) {
function assertShortcutDetails(details, { expectedIcon, expectedTarget, pathExists = fs.existsSync }) { function assertShortcutDetails(details, { expectedIcon, expectedTarget, pathExists = fs.existsSync }) {
const targetPath = String(details.targetPath || ''); const targetPath = String(details.targetPath || '');
const iconPath = String(details.iconLocation || '').replace(/,\s*-?\d+$/, ''); const iconPath = String(details.iconLocation || '').replace(/,\s*-?\d+$/, '');
if (normalizeWindowsPath(targetPath) !== normalizeWindowsPath(expectedTarget)) { if (canonicalWindowsPath(targetPath) !== canonicalWindowsPath(expectedTarget)) {
throw new Error(`Shortcut target mismatch: ${JSON.stringify({ actual: targetPath, expected: expectedTarget })}`); throw new Error(`Shortcut target mismatch: ${JSON.stringify({ actual: targetPath, expected: expectedTarget })}`);
} }
if (normalizeWindowsPath(iconPath) !== normalizeWindowsPath(expectedIcon)) { if (canonicalWindowsPath(iconPath) !== canonicalWindowsPath(expectedIcon)) {
throw new Error(`Shortcut icon mismatch: ${JSON.stringify({ actual: iconPath, expected: expectedIcon })}`); throw new Error(`Shortcut icon mismatch: ${JSON.stringify({ actual: iconPath, expected: expectedIcon })}`);
} }
if (!pathExists(targetPath)) throw new Error(`Shortcut target is missing: ${targetPath}`); if (!pathExists(targetPath)) throw new Error(`Shortcut target is missing: ${targetPath}`);
+12 -4
View File
@@ -130,19 +130,27 @@ test('shortcut inspection reads a real temporary Windows link', { skip: process.
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-link-contract-')); const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-link-contract-'));
const targetPath = path.join(temporaryRoot, 'Twitch VOD Manager.exe'); const targetPath = path.join(temporaryRoot, 'Twitch VOD Manager.exe');
const iconPath = path.join(temporaryRoot, 'icon.ico'); const iconPath = path.join(temporaryRoot, 'icon.ico');
const wrongTargetPath = path.join(temporaryRoot, 'Wrong Twitch VOD Manager.exe');
const shortcutPath = path.join(temporaryRoot, 'Twitch VOD Manager.lnk'); const shortcutPath = path.join(temporaryRoot, 'Twitch VOD Manager.lnk');
try { try {
fs.writeFileSync(targetPath, 'target'); fs.writeFileSync(targetPath, 'target');
fs.writeFileSync(iconPath, 'icon'); fs.writeFileSync(iconPath, 'icon');
const result = spawnSync('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', "$shortcut = (New-Object -ComObject WScript.Shell).CreateShortcut($env:TVM_TEST_SHORTCUT); $shortcut.TargetPath = $env:TVM_TEST_TARGET; $shortcut.IconLocation = $env:TVM_TEST_ICON; $shortcut.Save()"], { fs.writeFileSync(wrongTargetPath, 'wrong target');
const result = spawnSync('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', "$shortcut = (New-Object -ComObject WScript.Shell).CreateShortcut($env:TVM_TEST_SHORTCUT); $shortcut.TargetPath = $env:TVM_TEST_TARGET; $shortcut.IconLocation = $env:TVM_TEST_ICON; $shortcut.Save(); $folder = (New-Object -ComObject Scripting.FileSystemObject).GetFolder($env:TVM_TEST_ROOT); [Console]::Write($folder.ShortPath)"], {
encoding: 'utf8', encoding: 'utf8',
env: { ...process.env, TVM_TEST_ICON: iconPath, TVM_TEST_SHORTCUT: shortcutPath, TVM_TEST_TARGET: targetPath }, env: { ...process.env, TVM_TEST_ICON: iconPath, TVM_TEST_ROOT: temporaryRoot, TVM_TEST_SHORTCUT: shortcutPath, TVM_TEST_TARGET: targetPath },
windowsHide: true windowsHide: true
}); });
assert.strictEqual(result.status, 0, result.stderr); assert.strictEqual(result.status, 0, result.stderr);
const details = readShortcutDetails(shortcutPath); const details = readShortcutDetails(shortcutPath);
assert.strictEqual(details.targetPath.toLowerCase(), targetPath.toLowerCase()); const shortRoot = result.stdout.trim();
assert.strictEqual(details.iconLocation.replace(/,\s*0$/, '').toLowerCase(), iconPath.toLowerCase()); const expectedTarget = path.join(shortRoot, path.basename(targetPath));
const expectedIcon = path.join(shortRoot, path.basename(iconPath));
assert.doesNotThrow(() => assertShortcutDetails(details, { expectedIcon, expectedTarget }));
assert.throws(() => assertShortcutDetails(details, {
expectedIcon,
expectedTarget: wrongTargetPath
}), /target mismatch/i);
} finally { } finally {
fs.rmSync(temporaryRoot, { force: true, recursive: true }); fs.rmSync(temporaryRoot, { force: true, recursive: true });
} }