diff --git a/.gitea/workflows/windows-ci.yml b/.gitea/workflows/windows-ci.yml index b3f7b85..b1923ef 100644 --- a/.gitea/workflows/windows-ci.yml +++ b/.gitea/workflows/windows-ci.yml @@ -4,32 +4,6 @@ on: push: pull_request: 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: contents: read @@ -123,146 +97,3 @@ jobs: - name: Installer smoke run: npm run test:installer 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 diff --git a/scripts/smoke-test-ci-contract.js b/scripts/smoke-test-ci-contract.js index 8092bc3..29518dc 100644 --- a/scripts/smoke-test-ci-contract.js +++ b/scripts/smoke-test-ci-contract.js @@ -1,5 +1,7 @@ const fs = require('fs'); const path = require('path'); +const { isDeepStrictEqual } = require('node:util'); +const yaml = require('js-yaml'); const root = path.resolve(__dirname, '..'); 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 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:'); + 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) { - for (let index = inputsStart + 1; index < onEnd; index += 1) { + for (let index = inputsStart + 1; index < dispatchEnd; index += 1) { const field = parseMappingField(lines[index], 6); if (field?.[1] === '') addField(dispatchInputs, field[0], ''); } @@ -160,6 +166,97 @@ function parseWorkflow(source) { 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) { const values = fields.get('timeout-minutes') || []; 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`); } +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 = { lint: 'eslint .', 'security:check': 'node scripts/security-check.js && node scripts/smoke-test-public-release-config.js', @@ -450,13 +599,19 @@ for (const relativePath of ['.github/workflows/windows-ci.yml', '.gitea/workflow 'npm run test:installer' ]; - check(workflow.jobs.size === 3 && verifyJob && twitchLiveJob && updaterLiveJob, `${relativePath} must define exactly verify, twitch-live and updater-live-postpublish jobs`); + 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`); + } 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(', ')}`); 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}`); + if (relativePath === '.github/workflows/windows-ci.yml') { + 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) { @@ -471,34 +626,37 @@ 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(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`); + if (relativePath === '.github/workflows/windows-ci.yml') { + 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(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 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'); check(/dist['"],\s*['"]main['"],\s*['"]twitch['"]/.test(liveIntegrationSource), 'Twitch provider live gate does not load the built Twitch product module'); diff --git a/scripts/smoke-test-installer.js b/scripts/smoke-test-installer.js index d9256ee..c68634b 100644 --- a/scripts/smoke-test-installer.js +++ b/scripts/smoke-test-installer.js @@ -62,6 +62,14 @@ function normalizeWindowsPath(candidate) { 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) { const relative = path.win32.relative(path.win32.resolve(parentPath), path.win32.resolve(targetPath)); 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 }) { const targetPath = String(details.targetPath || ''); 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 })}`); } - if (normalizeWindowsPath(iconPath) !== normalizeWindowsPath(expectedIcon)) { + if (canonicalWindowsPath(iconPath) !== canonicalWindowsPath(expectedIcon)) { throw new Error(`Shortcut icon mismatch: ${JSON.stringify({ actual: iconPath, expected: expectedIcon })}`); } if (!pathExists(targetPath)) throw new Error(`Shortcut target is missing: ${targetPath}`); diff --git a/scripts/smoke-test-installer.test.js b/scripts/smoke-test-installer.test.js index 44c2afa..40d4125 100644 --- a/scripts/smoke-test-installer.test.js +++ b/scripts/smoke-test-installer.test.js @@ -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 targetPath = path.join(temporaryRoot, 'Twitch VOD Manager.exe'); 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'); try { fs.writeFileSync(targetPath, 'target'); 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', - 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 }); assert.strictEqual(result.status, 0, result.stderr); const details = readShortcutDetails(shortcutPath); - assert.strictEqual(details.targetPath.toLowerCase(), targetPath.toLowerCase()); - assert.strictEqual(details.iconLocation.replace(/,\s*0$/, '').toLowerCase(), iconPath.toLowerCase()); + const shortRoot = result.stdout.trim(); + 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 { fs.rmSync(temporaryRoot, { force: true, recursive: true }); }