diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 3117494b2..f7bd69beb 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -8,11 +8,24 @@ on: branches: [main] paths: - .github/workflows/server-parity.yml + - .gitattributes + - .cargo/** + - Cargo.toml + - Cargo.lock + - scripts/conformance/** + - scripts/ci/** + - tests/Unit/ServerParity*.mjs + - tests/Support/ServerParity*.mjs - scripts/conformance/server-parity* - scripts/conformance/server-parity/** - scripts/ci/pull-images.sh - scripts/ci/patched-pip.sh - scripts/ci/sdk-adapter-cache-key.sh + - config/dw-contract.php + - tests/Fixtures/ActivityMetadataProjection/** + - tests/Fixtures/CodecRegression/** + - tests/Fixtures/HistoryBudget/** + - tests/Fixtures/ServerUpgrade/** - tests/Fixtures/ServerParity/** - tests/Fixtures/ServerParityBaselines/** - tests/Fixtures/ServerParityNormalization/** @@ -121,6 +134,9 @@ jobs: env: EVENT_NAME: ${{ github.event_name }} PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + MERGE_GROUP_BASE_REF: ${{ github.event.merge_group.base_ref }} + MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }} + MERGE_GROUP_HEAD_SHA: ${{ github.event.merge_group.head_sha }} run: bash scripts/conformance/server-parity/changes.sh >> "$GITHUB_OUTPUT" published-sdk-adapters: diff --git a/scripts/conformance/server-parity/changes.mjs b/scripts/conformance/server-parity/changes.mjs index 910b00d7e..05e6a7e86 100644 --- a/scripts/conformance/server-parity/changes.mjs +++ b/scripts/conformance/server-parity/changes.mjs @@ -3,9 +3,9 @@ // // Pull requests run the workflow without a path filter, so its terminal // result always reports. This script applies the workflow's own push path -// filter to the pull request's changes, so one list governs both triggers. -// Every other event runs every fixture job, and so does any pull request -// whose changes cannot be classified. +// filter to pull requests and verified merge groups. A merge group can skip +// only against current main, so an unmerged predecessor cannot hide inputs. +// Other events and any unclassifiable range run every fixture job. // // Writes `run=true` or `run=false` to stdout, for $GITHUB_OUTPUT. import {execFileSync} from 'node:child_process'; @@ -64,18 +64,49 @@ export function matchingFiles(patterns, files) { return files.filter(file => expressions.some(expression => expression.test(file))); } -function classify({event, headSha, workflowPath}) { - if (event !== 'pull_request') return {run: true, reason: `${event} runs every fixture job`}; - // The checkout is the pull request's merge commit: base, then head. Git runs - // without a shell, and its file list is read as bytes. +function classify({event, headSha, mergeBaseRef, mergeBaseSha, mergeHeadSha, workflowPath}) { + if (!['pull_request', 'merge_group'].includes(event)) return {run: true, reason: `${event} runs every fixture job`}; + // Git runs without a shell, and its file list is read as bytes. const git = (...args) => execFileSync('git', args); let files; let matched; try { - const parents = git('rev-list', '--parents', '-n', '1', 'HEAD').toString('utf8').trim().split(' '); - if (parents.length !== 3 || parents[2] !== headSha) throw new Error('HEAD is not this pull request\'s merge commit'); - files = nulSeparatedPaths(git('diff', '--name-only', '-z', '--no-renames', 'HEAD^1', 'HEAD')); + let base = 'HEAD^1'; + let head = 'HEAD'; + if (event === 'pull_request') { + const parents = git('rev-list', '--parents', '-n', '1', 'HEAD').toString('utf8').trim().split(' '); + if (parents.length !== 3 || parents[2] !== headSha) throw new Error('HEAD is not this pull request\'s merge commit'); + } else { + if (mergeBaseRef !== 'refs/heads/main' + || !/^[a-f0-9]{40}$/.test(mergeBaseSha) || !/^[a-f0-9]{40}$/.test(mergeHeadSha) + || mergeBaseSha === mergeHeadSha) throw new Error('The merge-group identity is incomplete or unsupported'); + if (git('rev-parse', 'HEAD').toString('utf8').trim() !== mergeHeadSha) { + throw new Error('HEAD is not this merge group'); + } + git('merge-base', '--is-ancestor', mergeBaseSha, mergeHeadSha); + base = mergeBaseSha; + head = mergeHeadSha; + } + files = nulSeparatedPaths(git('diff', '--name-only', '-z', '--no-renames', base, head)); matched = matchingFiles(pushPathPatterns(readFileSync(workflowPath, 'utf8')), files); + if (event === 'merge_group' && matched.length === 0) { + // A preceding queued group may be the candidate's parent without being + // main yet. Do not skip that predecessor's qualification. Reading the + // public remote ref adds no credential or mutable checkout dependency. + // A moving/unavailable main or a shallow missing base runs everything. + let currentMain; + try { + currentMain = execFileSync('git', ['ls-remote', '--exit-code', 'origin', mergeBaseRef], + {encoding: 'utf8', timeout: 30_000, stdio: ['ignore', 'pipe', 'pipe']}).trim().split(/\s+/); + } catch { + // Git errors may contain a remote URL. The decision only needs to + // explain that main could not be verified, not reproduce that URL. + throw new Error('Current main could not be verified'); + } + if (currentMain.length !== 2 || currentMain[0] !== mergeBaseSha || currentMain[1] !== mergeBaseRef) { + throw new Error('The merge-group base is not verified current main'); + } + } } catch (error) { return {run: true, reason: `changes could not be classified (${error.message}); running every fixture job`}; } @@ -88,6 +119,9 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) const decision = classify({ event: process.env.EVENT_NAME ?? '', headSha: process.env.PR_HEAD_SHA ?? '', + mergeBaseRef: process.env.MERGE_GROUP_BASE_REF ?? '', + mergeBaseSha: process.env.MERGE_GROUP_BASE_SHA ?? '', + mergeHeadSha: process.env.MERGE_GROUP_HEAD_SHA ?? '', workflowPath: '.github/workflows/server-parity.yml', }); console.error(decision.reason); diff --git a/tests/Unit/ServerParityAggregateTest.mjs b/tests/Unit/ServerParityAggregateTest.mjs index 73d4c6add..02f8b0e10 100644 --- a/tests/Unit/ServerParityAggregateTest.mjs +++ b/tests/Unit/ServerParityAggregateTest.mjs @@ -6,9 +6,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {execFileSync, spawnSync} from 'node:child_process'; -import {copyFileSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync} from 'node:fs'; +import {copyFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; -import {dirname, join, resolve} from 'node:path'; +import {dirname, join, relative, resolve} from 'node:path'; +import {pathToFileURL} from 'node:url'; import {matchingFiles, nulSeparatedPaths, patternRegExp, pushPathPatterns} from '../../scripts/conformance/server-parity/changes.mjs'; import {decide} from '../../scripts/conformance/server-parity/aggregate.mjs'; @@ -60,6 +61,43 @@ test('path patterns follow GitHub filter semantics for the forms in use', () => assert.deepEqual(matchingFiles(['rust/**', 'docs/x.md'], ['README.md', 'rust/Cargo.toml', 'docs/x.md']), ['rust/Cargo.toml', 'docs/x.md']); }); +test('literal native inputs and parity imports are covered by the shared input list', () => { + const sourceFiles = (directory, suffix) => readdirSync(directory, {withFileTypes: true}).flatMap(entry => { + const path = join(directory, entry.name); + return entry.isDirectory() ? sourceFiles(path, suffix) : entry.isFile() && path.endsWith(suffix) ? [path] : []; + }); + const patterns = pushPathPatterns(workflowText); + const manifest = resolve('rust'); + const inputs = new Set(); + for (const file of ['rust/src', 'rust/tests', 'rust/examples'].flatMap(directory => sourceFiles(directory, '.rs'))) { + const source = readFileSync(file, 'utf8'); + for (const match of source.matchAll(/include_(?:str|bytes)!\s*\(\s*"([^"]+)"/g)) { + inputs.add(relative('.', resolve(dirname(file), match[1])).replaceAll('\\', '/')); + } + // The config audit also reads a manifest-relative repository input. + for (const match of source.matchAll(/concat!\s*\(\s*env!\("CARGO_MANIFEST_DIR"\)\s*,\s*"([^"]+)"/g)) { + inputs.add(relative('.', resolve(`${manifest}${match[1]}`)).replaceAll('\\', '/')); + } + } + for (const file of ['scripts/conformance/server-parity.mjs', + ...sourceFiles('scripts/conformance/server-parity', '.mjs')]) { + for (const match of readFileSync(file, 'utf8').matchAll(/(?:from\s*|import\s*)['"](\.[^'"]+)['"]/g)) { + inputs.add(relative('.', resolve(dirname(file), match[1])).replaceAll('\\', '/')); + } + } + const uncovered = [...inputs].filter(path => matchingFiles(patterns, [path]).length === 0); + assert.deepEqual(uncovered, [], `Native inputs absent from the shared path list: ${uncovered.join(', ')}`); +}); + +test('literal workflow helper invocations are covered by input selection', () => { + const patterns = pushPathPatterns(workflowText); + const commands = workflowText.split('\n').filter(line => !line.trimStart().startsWith('#')).join('\n'); + const scripts = [...commands.matchAll(/\b(?:bash|sh|php|node|python[0-9.]*)\s+(scripts\/[A-Za-z0-9_./-]+)/g)] + .map(match => match[1]); + assert.ok(scripts.length > 0); + assert.deepEqual([...new Set(scripts)].filter(path => matchingFiles(patterns, [path]).length === 0), []); +}); + test('path patterns accept no regular-expression syntax and match every other character literally', () => { for (const pattern of ['rust\\**', 'rust/\\d', 'a+b', 'a?b', 'a(b)', 'a[b]', 'a|b', 'a^b', 'a$b', 'a{1}', 'docs/{a,b}/**', 'a b', '']) { assert.throws(() => patternRegExp(pattern), /Unsupported path filter pattern/, JSON.stringify(pattern)); @@ -201,6 +239,7 @@ function pullRequest(change) { mkdirSync(join(root, dirname(classifierWrapper)), {recursive: true}); for (const file of [classifierWrapper, 'scripts/conformance/server-parity/changes.mjs']) copyFileSync(file, join(root, file)); write('README.md', 'base\n'); + write('config/dw-contract.php', ' write('docs/note.md', 'x\n'), 'run=false'], ['a Rust change runs the fixtures', ({write}) => write('rust/src/lib.rs', 'x\n'), 'run=true'], + ['a configuration-audit contract edit runs the fixtures', ({write}) => + write('config/dw-contract.php', " ['env' => 'FIXTURE_OPTION']];\n"), 'run=true'], ['a Rust file whose name holds a newline runs the fixtures', ({write}) => write('rust/hidden\nfixture.rs', 'x\n'), 'run=true'], ['a Rust file whose name holds a tab runs the fixtures', ({write}) => write('rust/hidden\tfixture.rs', 'x\n'), 'run=true'], ['a fixture whose name holds a newline runs the fixtures', ({write}) => write('tests/Fixtures/ServerParity/a\nb.json', '{}\n'), 'run=true'], @@ -263,7 +304,7 @@ test('pull request classification runs every fixture when HEAD is not that pull } }); -for (const event of ['push', 'merge_group', 'workflow_dispatch', '']) { +for (const event of ['push', 'workflow_dispatch', '']) { test(`${event || 'an unnamed event'} runs every fixture job`, () => { const {root, head} = pullRequest(({write}) => write('docs/note.md', 'x\n')); try { @@ -273,3 +314,183 @@ for (const event of ['push', 'merge_group', 'workflow_dispatch', '']) { } }); } + +// A checked-out merge-group candidate and an independently readable main ref. +// The local origin models GitHub without network access or another checkout. +function mergeGroup(change, {nativePredecessor = false} = {}) { + const {root} = pullRequest(change); + const git = (...args) => execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@example.invalid', ...args], + {cwd: root, encoding: 'utf8'}).trim(); + const base = git('rev-parse', 'HEAD^1'); + const firstGroup = git('rev-parse', 'HEAD'); + let predecessor; + if (nativePredecessor) { + git('checkout', '-q', '-b', 'queued-native', base); + mkdirSync(join(root, 'rust'), {recursive: true}); + writeFileSync(join(root, 'rust/queued.rs'), 'native predecessor\n'); + git('add', 'rust'); + git('commit', '-q', '-m', 'queued native change'); + predecessor = git('rev-parse', 'HEAD'); + git('merge', '-q', '--no-ff', '-m', 'combined queue group', firstGroup); + } + const groupHead = git('rev-parse', 'HEAD'); + git('checkout', '-q', '--detach'); + git('update-ref', 'refs/heads/main', base); + git('remote', 'add', 'origin', root); + return {root, base, groupHead, predecessor, git}; +} + +function groupEvent(group, overrides = {}) { + return {EVENT_NAME: 'merge_group', MERGE_GROUP_BASE_REF: 'refs/heads/main', + MERGE_GROUP_BASE_SHA: group.base, MERGE_GROUP_HEAD_SHA: group.groupHead, ...overrides}; +} + +test('the workflow supplies the merge-group identity to the classifier', () => { + const changes = jobs(workflowText).changes; + for (const [name, field] of [['BASE_REF', 'base_ref'], ['BASE_SHA', 'base_sha'], ['HEAD_SHA', 'head_sha']]) { + assert.ok(changes.includes(`MERGE_GROUP_${name}: \${{ github.event.merge_group.${field} }}`)); + } +}); + +for (const [label, change, expected] of [ + ['PHP-only changes skip the fixtures', ({write}) => write('app/Controller.php', ' write('docs/note.md', 'x\n'), 'run=false'], + ['native changes run the fixtures', ({write}) => write('rust/src/lib.rs', 'x\n'), 'run=true'], + ['parent Cargo configuration runs the fixtures', ({write}) => write('.cargo/config.toml', '[build]\n'), 'run=true'], + ['parent Cargo manifests run the fixtures', ({write}) => write('Cargo.toml', '[workspace]\n'), 'run=true'], + ['parent Cargo locks run the fixtures', ({write}) => write('Cargo.lock', 'version = 3\n'), 'run=true'], + ['checkout attributes run the fixtures', ({write}) => write('.gitattributes', '*.json text\n'), 'run=true'], + ['CI helper changes run the fixtures', ({write}) => write('scripts/ci/run-composer.sh', '#!/bin/sh\n'), 'run=true'], + ['a transitive parity import runs the fixtures', ({write}) => write('scripts/conformance/proxy-target.mjs', 'export {};\n'), 'run=true'], + ['shared contract test changes run the fixtures', ({write}) => write('tests/Unit/ServerParityAggregateTest.mjs', 'export {};\n'), 'run=true'], + ['shared observation helper changes run the fixtures', ({write}) => write('tests/Support/ServerParityNewObservation.mjs', 'export {};\n'), 'run=true'], + ['the native configuration audit input runs the fixtures', ({write}) => write('config/dw-contract.php', ' write('tests/Fixtures/CodecRegression/value.json', '{}\n'), 'run=true'], + ['upgrade fixtures run the fixtures', ({write}) => write('tests/Fixtures/ServerUpgrade/run.json', '{}\n'), 'run=true'], + ['activity metadata fixtures run the fixtures', ({write}) => write('tests/Fixtures/ActivityMetadataProjection/v1.json', '{}\n'), 'run=true'], + ['history budget fixtures run the fixtures', ({write}) => write('tests/Fixtures/HistoryBudget/pressure.json', '{}\n'), 'run=true'], + ['shared fixtures run the fixtures', ({write}) => write('tests/Fixtures/ServerParity/new.json', '{}\n'), 'run=true'], + ['moving a fixture out runs the fixtures', ({root, write}) => { + write('docs/.keep', ''); + renameSync(join(root, 'tests/Fixtures/ServerParity/case.json'), join(root, 'docs/case.json')); + }, 'run=true'], + ['unclassifiable file names run the fixtures', ({root}) => { + mkdirSync(join(root, 'docs'), {recursive: true}); + writeFileSync(Buffer.concat([Buffer.from(join(root, 'docs/')), Buffer.from([0xff])]), 'x\n'); + }, 'run=true'], +]) { + test(`merge group classification: ${label}`, () => { + const group = mergeGroup(change); + try { + assert.equal(classify(group.root, groupEvent(group)), expected); + } finally { + rmSync(group.root, {recursive: true, force: true}); + } + }); +} + +for (const [label, overrides] of [ + ['missing metadata', {MERGE_GROUP_BASE_SHA: '', MERGE_GROUP_HEAD_SHA: '', MERGE_GROUP_BASE_REF: ''}], + ['a missing base', {MERGE_GROUP_BASE_SHA: ''}], + ['a missing head', {MERGE_GROUP_HEAD_SHA: ''}], + ['a different target branch', {MERGE_GROUP_BASE_REF: 'refs/heads/other'}], + ['a mismatched checkout', {MERGE_GROUP_HEAD_SHA: '0'.repeat(40)}], + ['an unavailable base object', {MERGE_GROUP_BASE_SHA: '0'.repeat(40)}], + ['a Git option in place of an object identity', {MERGE_GROUP_BASE_SHA: '--output=/tmp/not-an-object'}], +]) { + test(`merge group classification runs all fixtures with ${label}`, () => { + const group = mergeGroup(({write}) => write('app/Controller.php', ' { + const group = mergeGroup(({write}) => write('app/Controller.php', ' { + const group = mergeGroup(({write}) => write('app/Controller.php', ' { + const group = mergeGroup(({write}) => write('app/Controller.php', ' { + const group = mergeGroup(({write}) => write('app/Controller.php', ' { + const group = mergeGroup(({write}) => write('app/Controller.php', ' { + const group = mergeGroup(({write}) => write('app/Controller.php', ' { + const group = mergeGroup(({write}) => write('app/Controller.php', '