Repository navigation
ci: interaction and native Swift decide their own scope #8917
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
74f21d5
bc9ce0e
56ea52e
c4a1ead
45ae187
4b483b1
241a0f5
3ac9e70
1db319e
8a703de
9e59b89
a57a2f0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,189 @@ | ||
| # Copyright (c) Meta Platforms, Inc. and affiliates. | ||
|
|
||
| # Decides, inside the job that owns the work, whether a pull request changes | ||
| # anything in that job's scope (spec:AST-030). There is no separate scope job: | ||
| # each `test-<scope>` job runs this step right after its checkout and skips | ||
| # its remaining steps, reporting success, when nothing in scope changed. | ||
| # | ||
| # - The PR's own change comes from GitHub's file list for the pull request | ||
| # (its diff from the merge base), never from main's history since an old | ||
| # base, so a long-lived branch does not inherit main's changes (#8812) and | ||
| # no history is fetched or deepened (#8826). | ||
| # - The classifier (change-scope.cjs and its two helpers) is read from the PR's | ||
| # base commit, so a pull request cannot loosen its own classification. | ||
| # - Anything this step cannot decide (not a pull request, a missing trusted | ||
| # classifier, an API error, an empty file list or one GitHub truncates) | ||
| # puts everything in scope (spec:AST-030 FR3, FR4). A classifier that rejects the change (a spec-only PR with a | ||
| # Changeset) fails the step, as the old scope job failed its dependents. | ||
| # - A release-branch pull request is the generated version bump: spec-only and | ||
| # tooling-only, with `release_bump` true. | ||
| # | ||
| # The job must be checked out first: this is a local action. | ||
|
|
||
| name: Test scope | ||
| description: Whether this pull request changes anything in the calling job's scope. | ||
|
|
||
| inputs: | ||
| paths: | ||
| description: >- | ||
| Optional regular expression over changed file paths (old and new names | ||
| of a rename). When given, `touches` says whether any changed path | ||
| matches; when empty, `touches` is true. | ||
| required: false | ||
| default: '' | ||
| outside-pull-request: | ||
| description: >- | ||
| `applies` outside a pull request (a merge-queue entry): `run` (the | ||
| default) or `skip`, for evidence that already ran on the pull request. | ||
| required: false | ||
| default: run | ||
| requires-head-file: | ||
| description: >- | ||
| Optional path the work needs at the PR head. A head cut before that file | ||
| existed, whose diff does not touch it, does not apply (with a notice). | ||
| required: false | ||
| default: '' | ||
|
|
||
| outputs: | ||
| spec_only: | ||
| description: The PR changes only spec records. | ||
| value: ${{ steps.scope.outputs.spec_only }} | ||
| docsite_only: | ||
| description: The PR changes only the docsite. | ||
| value: ${{ steps.scope.outputs.docsite_only }} | ||
| tooling_only: | ||
| description: The PR changes only Node tooling. | ||
| value: ${{ steps.scope.outputs.tooling_only }} | ||
| full_suite: | ||
| description: The import graph cannot scope this change; run every test. | ||
| value: ${{ steps.scope.outputs.full_suite }} | ||
| release_bump: | ||
| description: The PR targets a release branch (the generated version bump). | ||
| value: ${{ steps.scope.outputs.release_bump }} | ||
| touches: | ||
| description: A changed path matches `paths` (true when `paths` is empty or undecidable). | ||
| value: ${{ steps.scope.outputs.touches }} | ||
| applies: | ||
| description: >- | ||
| The job's work applies: not a specialized lane (spec, docsite or tooling | ||
| only), `touches` holds, and any `requires-head-file` is present. | ||
| value: ${{ steps.scope.outputs.applies }} | ||
|
|
||
| runs: | ||
| using: composite | ||
| steps: | ||
| - id: scope | ||
| uses: actions/github-script@v9 | ||
| env: | ||
| SCOPE_PATHS: ${{ inputs.paths }} | ||
| SCOPE_OUTSIDE_PR: ${{ inputs.outside-pull-request }} | ||
| SCOPE_HEAD_FILE: ${{ inputs.requires-head-file }} | ||
| with: | ||
| retries: 3 | ||
| script: | | ||
| const fs = require('node:fs'); | ||
| const os = require('node:os'); | ||
| const path = require('node:path'); | ||
| const {execFileSync} = require('node:child_process'); | ||
| const everything = { | ||
| spec_only: 'false', | ||
| docsite_only: 'false', | ||
| tooling_only: 'false', | ||
| full_suite: 'true', | ||
| release_bump: 'false', | ||
| touches: 'true', | ||
| applies: 'true', | ||
| }; | ||
| const report = values => { | ||
| for (const [key, value] of Object.entries(values)) core.setOutput(key, value); | ||
| core.info(Object.entries(values).map(([k, v]) => `${k}=${v}`).join(' ')); | ||
| }; | ||
| if (context.eventName !== 'pull_request') { | ||
| core.info(`${context.eventName}: not a pull request, so everything is in scope.`); | ||
| report({...everything, applies: process.env.SCOPE_OUTSIDE_PR === 'skip' ? 'false' : 'true'}); | ||
| return; | ||
| } | ||
| const pr = context.payload.pull_request; | ||
| if (pr.base.ref.startsWith('release/v')) { | ||
| core.info('Release-branch pull request: the generated version bump.'); | ||
| report({ | ||
| ...everything, | ||
| spec_only: 'true', | ||
| tooling_only: 'true', | ||
| release_bump: 'true', | ||
| touches: 'false', | ||
| applies: 'false', | ||
| }); | ||
| return; | ||
| } | ||
| let files; | ||
| let root; | ||
| try { | ||
| files = await github.paginate(github.rest.pulls.listFiles, { | ||
| ...context.repo, | ||
| pull_number: pr.number, | ||
| per_page: 100, | ||
| }); | ||
| // GitHub lists at most 3000 files for a pull request. | ||
| if (files.length >= 3000) throw new Error('GitHub truncated the file list at 3000'); | ||
| if (files.length === 0) throw new Error('GitHub listed no changed files'); | ||
| root = fs.mkdtempSync(path.join(os.tmpdir(), 'test-scope-')); | ||
| for (const file of [ | ||
| '.github/scripts/change-scope.cjs', | ||
| '.github/scripts/knowledge-paths.cjs', | ||
| 'scripts/component-packages.cjs', | ||
| ]) { | ||
| const {data} = await github.rest.repos.getContent({...context.repo, path: file, ref: pr.base.sha}); | ||
| fs.mkdirSync(path.join(root, path.dirname(file)), {recursive: true}); | ||
| fs.writeFileSync(path.join(root, file), Buffer.from(data.content, 'base64')); | ||
| } | ||
| } catch (error) { | ||
| core.warning(`Scope could not be decided (${error.message}); everything is in scope.`); | ||
| report(everything); | ||
| return; | ||
| } | ||
| const STATUS = {added: 'A', removed: 'D', modified: 'M', changed: 'M', copied: 'C', unchanged: 'M'}; | ||
| const nameStatus = files | ||
| .map(f => f.status === 'renamed' | ||
| ? `R100\t${f.previous_filename}\t${f.filename}` | ||
| : `${STATUS[f.status] ?? 'M'}\t${f.filename}`) | ||
| .join('\n'); | ||
| const outputFile = path.join(root, 'classification'); | ||
| fs.writeFileSync(outputFile, ''); | ||
| try { | ||
| execFileSync('node', [path.join(root, '.github/scripts/change-scope.cjs'), '--github-output'], { | ||
| input: `${nameStatus}\n`, | ||
| env: {...process.env, GITHUB_OUTPUT: outputFile}, | ||
| stdio: ['pipe', 'inherit', 'inherit'], | ||
| }); | ||
| } catch (error) { | ||
| core.setFailed(`The trusted classifier rejected this change: ${error.message}`); | ||
| return; | ||
| } | ||
| const classified = Object.fromEntries( | ||
| fs.readFileSync(outputFile, 'utf8').split('\n').filter(Boolean).map(line => line.split('=')), | ||
| ); | ||
| let touches = 'true'; | ||
| if (process.env.SCOPE_PATHS) { | ||
| const pattern = new RegExp(process.env.SCOPE_PATHS); | ||
| touches = String(files.some(f => pattern.test(f.filename) || (f.previous_filename != null && pattern.test(f.previous_filename)))); | ||
| } | ||
| const specialized = ['spec_only', 'docsite_only', 'tooling_only'].some(key => classified[key] === 'true'); | ||
| let applies = String(!specialized && touches === 'true'); | ||
| const headFile = process.env.SCOPE_HEAD_FILE; | ||
| if (applies === 'true' && headFile && !files.some(f => f.filename === headFile || f.previous_filename === headFile)) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
| try { | ||
| await github.rest.repos.getContent({...context.repo, path: headFile, ref: pr.head.sha}); | ||
| } catch (error) { | ||
| if (error.status === 404) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Only a 404 should turn the proofs off here, and today that holds, but no test pins it. The |
||
| core.notice(`This pull request's head has no ${headFile}; merge main to run this check.`); | ||
| applies = 'false'; | ||
| } | ||
| } | ||
| } | ||
| report({...everything, ...classified, release_bump: 'false', touches, applies}); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This
R100line is what keeps a rename's old path in front of the classifier, but no test depends on it: sending renames asM\t<new>keeps all 16 cases green, because "matches the old name of a rename" only checkstouches. AST-030 FR9 asks for a case where, say,packages/core/src/Button/Button.tsxis renamed to a spec path andspec_onlystays'false'withapplies'true'.