diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 1dc020d8..0cc34ff9 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -12,7 +12,7 @@ on: - '**/*.tsx' - package.json - package-lock.json - - .github/workflows/codeql.yml + - '.github/workflows/**' pull_request: branches: [main] paths: @@ -24,7 +24,7 @@ on: - '**/*.tsx' - package.json - package-lock.json - - .github/workflows/codeql.yml + - '.github/workflows/**' schedule: - cron: '31 7 * * 1' @@ -32,23 +32,32 @@ permissions: contents: read # Not a required status check, so it is safe to skip entirely (rather than -# just cancel) when a PR touches no JavaScript/TypeScript source: an -# unmatched path means the workflow never starts, and nothing here gates the -# merge queue on it. The weekly schedule run still scans the full tree -# regardless of what changed most recently. +# just cancel) when a PR touches neither JavaScript/TypeScript source nor a +# workflow: an unmatched path means the workflow never starts, and nothing +# here gates the merge queue on it. The weekly schedule run still scans the +# full tree regardless of what changed most recently. concurrency: group: codeql-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: analyze: - name: Analyze JavaScript + name: Analyze ${{ matrix.language }} runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: contents: read security-events: write actions: read + strategy: + # One language failing must not hide the other language's results. + fail-fast: false + matrix: + # `actions` analyses the workflow files themselves: script injection + # through ${{ github.event.* }} in `run:` blocks, over-broad + # GITHUB_TOKEN permissions, un-pinned third-party actions. Nothing else + # in this repository reads the workflows statically. + language: ['javascript-typescript', 'actions'] steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -57,9 +66,9 @@ jobs: - name: Initialize CodeQL uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: - languages: javascript-typescript + languages: ${{ matrix.language }} build-mode: none - name: Perform CodeQL analysis uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: - category: '/language:javascript-typescript' + category: '/language:${{ matrix.language }}' diff --git a/tests/codeql-contract.test.mjs b/tests/codeql-contract.test.mjs index 3ace740e..6bdc94e4 100644 --- a/tests/codeql-contract.test.mjs +++ b/tests/codeql-contract.test.mjs @@ -97,9 +97,7 @@ test('required returns a present value and names the fault in the rest', () => { }); test('the analyze job may upload its results to the security tab', () => { - const jobs = Object.entries(required(workflow.jobs, 'jobs')); - assert.equal(jobs.length, 1, 'codeql.yml declares more than one job'); - const [jobName, job] = jobs[0]; + const [jobName, job] = analyzeJob(); const permissions = required( job.permissions, `permissions for job ${jobName}`, @@ -125,18 +123,58 @@ test('init and analyze come from the same codeql-action release', () => { ); }); -test('the analysis declares a language set and the category that names it', () => { +// The languages CodeQL must analyse here. `javascript-typescript` is the site +// and its scripts; `actions` is the workflow files themselves, which nothing +// else in this repository reads statically. Dropping either from the matrix +// silently narrows the scan, so both are pinned by name. +const REQUIRED_LANGUAGES = ['actions', 'javascript-typescript']; + +function analyzeJob() { + const jobs = Object.entries(required(workflow.jobs, 'jobs')); + assert.equal(jobs.length, 1, 'codeql.yml declares more than one job'); + return jobs[0]; +} + +test('the analysis fans out over every required language', () => { + const [jobName, job] = analyzeJob(); + const strategy = required(job.strategy, `strategy for job ${jobName}`); + const languages = required( + required(strategy.matrix, `matrix for job ${jobName}`).language, + `matrix.language for job ${jobName}`, + ); + assert.ok( + Array.isArray(languages), + `codeql.yml matrix.language is not a list: ${JSON.stringify(languages)}`, + ); + const missing = REQUIRED_LANGUAGES.filter( + (language) => !languages.includes(language), + ); + assert.deepEqual( + missing, + [], + `codeql.yml matrix omits languages the scan must cover: ${missing.join(', ')}`, + ); + // A failure in one language's analysis must not cancel the other's upload. + assert.equal( + strategy['fail-fast'], + false, + 'codeql.yml matrix does not set fail-fast: false, so one language failing hides the other', + ); +}); + +test('init and analyze read the language from the matrix and name it in the category', () => { const languages = required( required(codeqlStep('init').with, '`with` block for codeql-action/init') .languages, 'languages for codeql-action/init', ); assert.equal( - typeof languages, - 'string', - 'codeql-action/init declares no languages', + languages, + '${{ matrix.language }}', + `codeql-action/init languages ${JSON.stringify( + languages, + )} does not read the matrix, so the fan-out above analyses nothing`, ); - assert.ok(languages.length > 0, 'codeql-action/init languages is empty'); const category = required( codeqlStep('analyze').with, '`with` block for codeql-action/analyze', @@ -146,7 +184,7 @@ test('the analysis declares a language set and the category that names it', () = `/language:${languages}`, `codeql-action/analyze category ${JSON.stringify( category, - )} does not name the analysed languages ${JSON.stringify(languages)}`, + )} does not name the analysed language ${JSON.stringify(languages)}`, ); }); @@ -179,11 +217,14 @@ test('every scanned source extension in the tree is matched by the path filters' ); }); -test('a change to the scanner configuration triggers a scan', () => { +test('a change to any workflow, including the scanner itself, triggers a scan', () => { + // `actions` analyses the workflow files, so the filter has to match all of + // them rather than only codeql.yml; the glob also covers codeql.yml, so + // narrowing the scan still runs it. const paths = pathFilters('push'); assert.ok( - paths.includes('.github/workflows/codeql.yml'), - 'codeql.yml path filters omit codeql.yml, so narrowing the scan does not run it', + paths.includes('.github/workflows/**'), + 'codeql.yml path filters omit .github/workflows/**, so a workflow edit is never analysed and narrowing the scan does not run it', ); });