Repository navigation
ci: skip jobs a pull request cannot affect #19587
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
Merged
Merged
Changes from 3 commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
53b24aa
ci: skip jobs a pull request cannot affect
maria-rcks 3e051c1
ci: cover undeclared test inputs in the change gate
maria-rcks cac33e9
ci: server shards gate themselves instead of waiting for changes
maria-rcks 0ffecae
ci: read changed files from git instead of the REST API
maria-rcks File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| name: CI changes | ||
| description: Decide which CI jobs a change needs. See .github/scripts/ci-changes.cjs. | ||
| outputs: | ||
| typecheck: | ||
| value: ${{ steps.detect.outputs.typecheck }} | ||
| build: | ||
| value: ${{ steps.detect.outputs.build }} | ||
| test: | ||
| value: ${{ steps.detect.outputs.test }} | ||
| test_packages: | ||
| value: ${{ steps.detect.outputs.test_packages }} | ||
| test_web: | ||
| value: ${{ steps.detect.outputs.test_web }} | ||
| test_server: | ||
| value: ${{ steps.detect.outputs.test_server }} | ||
| rust: | ||
| value: ${{ steps.detect.outputs.rust }} | ||
| release_smoke: | ||
| value: ${{ steps.detect.outputs.release_smoke }} | ||
| mobile_native: | ||
| value: ${{ steps.detect.outputs.mobile_native }} | ||
| runs: | ||
| using: composite | ||
| steps: | ||
| - id: detect | ||
| shell: bash | ||
| env: | ||
| GH_TOKEN: ${{ github.token }} | ||
| PR_NUMBER: ${{ github.event.pull_request.number }} | ||
| BEFORE_SHA: ${{ github.event.before }} | ||
| run: | | ||
| set -uo pipefail | ||
|
|
||
| decide() { | ||
| node .github/scripts/ci-changes.cjs "$@" | tee -a "$GITHUB_OUTPUT" | ||
| exit "${PIPESTATUS[0]}" | ||
| } | ||
|
|
||
| fail_open() { | ||
| echo "$* Running every job." | ||
| decide | ||
| } | ||
|
|
||
| count_rows() { | ||
| printf '%s\n' "$1" | grep -c . || true | ||
| } | ||
|
|
||
| # One row per changed file, holding the new path and, for a rename, | ||
| # the path it replaced: renaming a file out of a package changes that | ||
| # package just like editing it. | ||
| row='[.filename, (.previous_filename // empty)] | @tsv' | ||
|
|
||
| if [[ -n "${PR_NUMBER}" ]]; then | ||
| # The PR files endpoint stops at 3000 files and pagination cannot | ||
| # extend it, so cross-check against the count the PR itself reports. | ||
| expected=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.changed_files') \ | ||
| || fail_open "Could not read the pull request." | ||
| rows=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" --paginate --jq ".[] | ${row}") \ | ||
| || fail_open "Could not resolve changed files." | ||
|
|
||
| listed=$(count_rows "$rows") | ||
| if [[ "$listed" -lt "$expected" ]]; then | ||
| fail_open "GitHub listed only ${listed} of ${expected} changed files." | ||
| fi | ||
| mode=() | ||
| else | ||
| rows=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${BEFORE_SHA}...${GITHUB_SHA}" --jq ".files[]? | ${row}") \ | ||
| || fail_open "Could not resolve changed files." | ||
|
|
||
| # The compare endpoint reports at most 300 files and pagination does | ||
| # not extend that list, so a full list may be hiding changes. | ||
| listed=$(count_rows "$rows") | ||
| if [[ "$listed" -ge 300 ]]; then | ||
| fail_open "GitHub listed ${listed} changed files, the compare endpoint maximum." | ||
| fi | ||
| # main keeps a full run as the backstop for anything the gate misses. | ||
| mode=(--full) | ||
| fi | ||
|
|
||
| tr '\t' '\n' <<< "$rows" > "$RUNNER_TEMP/changed-paths" | ||
| decide --paths "$RUNNER_TEMP/changed-paths" "${mode[@]}" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,203 @@ | ||
| // Decides which CI jobs a change needs. Prints `key=value` lines for | ||
| // $GITHUB_OUTPUT; a job runs unless its key is "false". | ||
| // | ||
| // node ci-changes.cjs --paths <file> gate by the listed paths | ||
| // node ci-changes.cjs --paths <file> --full run every Linux job, gate only the macOS lint | ||
| // node ci-changes.cjs changed paths unknown: run everything | ||
| // | ||
| // Every changed path maps to workspace packages, which then pull in their | ||
| // dependents from package.json. A path this file does not recognize runs | ||
| // everything, so a new root file or directory can only cost minutes. | ||
| const fs = require("node:fs"); | ||
| const path = require("node:path"); | ||
|
|
||
| const root = path.resolve(__dirname, "../.."); | ||
|
|
||
| // Never read by a build, typecheck, or test. Lint still formats them. | ||
| const INERT = [ | ||
| /^docs\//, | ||
| /^\.(agents|cursor|vscode|devcontainer|macroscope|repos)\//, | ||
| /^[^/]+\.md$/, | ||
| /^LICENSE$/, | ||
| ]; | ||
|
|
||
| // Run everything: the root vite.config.ts loads the first as setup for every | ||
| // package's tests, and the vendored mobile tarballs are installed dependencies. | ||
| const RUNS_EVERYTHING = [ | ||
| /^packages\/shared\/src\/(HostProcess|testing\/longTempDir)\.ts$/, | ||
| /^apps\/mobile\/deps\//, | ||
| ]; | ||
|
|
||
| // Paths outside a package, or files inside one that other packages read | ||
| // through relative imports rather than a package.json dependency. | ||
| const EXTRA_OWNERS = [ | ||
| // Vite configs and tsconfigs of these apps import scripts/lib directly. | ||
| [/^scripts\/lib\//, ["@t3tools/desktop", "@t3tools/mobile", "@t3tools/web", "t3"]], | ||
| // scripts/build-desktop-artifact.ts reads the GNOME extension manifest. | ||
| [/^apps\/desktop\/gnome-extension\//, ["@t3tools/scripts"]], | ||
| [/^native\/browser-secret\//, ["@t3tools/desktop"]], | ||
| [/^native\/libghostty-vt\//, ["@t3tools/web"]], | ||
| ]; | ||
| const RUST = /^native\/(resource-monitor|kde-snap-shot|hyprland-snap-shot)\//; | ||
|
|
||
| // Tests that read another package's files without declaring it. The scripts | ||
| // tests read app manifests, assets, and sources (cursor-sdk-packaging, | ||
| // third-party-licenses, brand-assets, build-desktop-artifact). | ||
| const TEST_ONLY_DEPS = { | ||
| "@t3tools/provider-pi": ["@t3tools/client-runtime"], | ||
| "@t3tools/scripts": [ | ||
| "@t3tools/desktop", | ||
| "@t3tools/mobile", | ||
| "@t3tools/web", | ||
| "t3", | ||
| "@t3tools/provider-cursor", | ||
| ], | ||
| }; | ||
| // Declared dependencies that only feed a build step: the server bundles the | ||
| // built web app (apps/server/vite.config.ts) but never imports it. | ||
| const BUILD_ONLY_DEPS = { t3: ["@t3tools/web"] }; | ||
|
|
||
| // Sources scripts/mobile-native-static-check.ts lints, plus the tool and rule | ||
| // configuration that decides how it lints them, plus the root package.json | ||
| // that defines the lint:mobile command. | ||
| const MOBILE_NATIVE = | ||
| /^apps\/mobile\/.*\.(swift|kt|kts)$|^apps\/mobile\/(\.swiftlint\.yml|detekt\.yml|\.editorconfig|Brewfile)$|^scripts\/mobile-native-static-check\.ts$|^package\.json$|^\.github\/workflows\/ci\.yml$|^\.github\/scripts\/ci-changes\.cjs$|^\.github\/actions\/ci-changes\//; | ||
|
|
||
| // Jobs that test every package except these, which have jobs of their own. | ||
| const OWN_TEST_JOB = new Set(["t3", "@t3tools/web", "@t3tools/monorepo"]); | ||
|
|
||
| function readWorkspace() { | ||
| const patterns = []; | ||
| let inPackages = false; | ||
| for (const line of fs.readFileSync(path.join(root, "pnpm-workspace.yaml"), "utf8").split("\n")) { | ||
| if (/^\S/.test(line)) inPackages = line.startsWith("packages:"); | ||
| const entry = inPackages && line.match(/^\s+-\s+["']?([^"'\s]+)/); | ||
| if (entry) patterns.push(entry[1]); | ||
| } | ||
|
|
||
| const dirs = patterns.flatMap((pattern) => { | ||
| if (!pattern.endsWith("/*")) return [pattern]; | ||
| const parent = pattern.slice(0, -2); | ||
| return fs.readdirSync(path.join(root, parent)).map((name) => `${parent}/${name}`); | ||
| }); | ||
|
|
||
| const packages = []; | ||
| for (const dir of dirs) { | ||
| const manifestPath = path.join(root, dir, "package.json"); | ||
| if (!fs.existsSync(manifestPath)) continue; | ||
| const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); | ||
| const deps = { | ||
| ...manifest.dependencies, | ||
| ...manifest.devDependencies, | ||
| ...manifest.peerDependencies, | ||
| }; | ||
| packages.push({ | ||
| name: manifest.name, | ||
| dir, | ||
| hasTest: Boolean(manifest.scripts?.test), | ||
| deps: Object.keys(deps).filter( | ||
| (dep) => | ||
| String(deps[dep]).startsWith("workspace:") && | ||
| !BUILD_ONLY_DEPS[manifest.name]?.includes(dep), | ||
| ), | ||
| }); | ||
| } | ||
| return packages; | ||
| } | ||
|
|
||
| // Returns the packages a path touches, [] for an inert path, or null when the | ||
| // path is not understood and everything must run. | ||
| function ownersOf(file, packages) { | ||
| if (INERT.some((pattern) => pattern.test(file))) return []; | ||
| // A manifest can change dependencies or versions other packages read. | ||
| if (path.posix.basename(file) === "package.json") return null; | ||
| if (RUNS_EVERYTHING.some((pattern) => pattern.test(file))) return null; | ||
| // scripts/build-desktop-artifact.test.ts reads the crates' sources. | ||
| if (RUST.test(file)) return ["@t3tools/scripts"]; | ||
|
|
||
| const extra = EXTRA_OWNERS.flatMap(([pattern, names]) => (pattern.test(file) ? names : [])); | ||
| const owner = packages | ||
| .filter((pkg) => file.startsWith(`${pkg.dir}/`)) | ||
| .sort((a, b) => b.dir.length - a.dir.length)[0]; | ||
| if (owner) return [owner.name, ...extra]; | ||
| return extra.length > 0 ? extra : null; | ||
| } | ||
|
|
||
| function decide(files, packages, full) { | ||
| const all = new Set(packages.map((pkg) => pkg.name)); | ||
| let affected = new Set(); | ||
| let everything = full || files === null; | ||
| let rust = false; | ||
|
|
||
| for (const file of files ?? []) { | ||
| if (RUST.test(file)) rust = true; | ||
| const owners = ownersOf(file, packages); | ||
| if (owners === null) { | ||
| everything = true; | ||
| console.error(`Runs everything: ${file}`); | ||
| } else { | ||
| for (const owner of owners) affected.add(owner); | ||
| } | ||
| } | ||
|
|
||
| // Release smoke reads scripts/ itself, not what the scripts tests read. | ||
| let scriptsChanged = everything; | ||
| if (everything) { | ||
| affected = all; | ||
| } else { | ||
| // Add dependents until nothing changes. | ||
| let grew = true; | ||
| while (grew) { | ||
| grew = false; | ||
| for (const pkg of packages) { | ||
| if (!affected.has(pkg.name) && pkg.deps.some((dep) => affected.has(dep))) { | ||
| affected.add(pkg.name); | ||
| grew = true; | ||
| } | ||
| } | ||
| } | ||
| scriptsChanged = affected.has("@t3tools/scripts"); | ||
| for (const [name, deps] of Object.entries(TEST_ONLY_DEPS)) { | ||
| if (deps.some((dep) => affected.has(dep))) affected.add(name); | ||
| } | ||
| } | ||
|
|
||
| const tested = packages.filter( | ||
| (pkg) => pkg.hasTest && !OWN_TEST_JOB.has(pkg.name) && affected.has(pkg.name), | ||
| ); | ||
| const has = (name) => affected.has(name); | ||
|
|
||
| return { | ||
| affected: [...affected].sort(), | ||
| outputs: { | ||
| typecheck: affected.size > 0, | ||
| build: has("@t3tools/desktop") || has("t3") || has("@t3tools/web"), | ||
| test: tested.length > 0, | ||
| // Empty means every package the Test job covers. | ||
| test_packages: everything ? "" : tested.map((pkg) => pkg.name).join(" "), | ||
| test_web: has("@t3tools/web"), | ||
| test_server: has("t3"), | ||
| rust: everything || rust, | ||
| release_smoke: scriptsChanged, | ||
| mobile_native: files === null || files.some((file) => MOBILE_NATIVE.test(file)), | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| function main() { | ||
| const args = process.argv.slice(2); | ||
| const pathsIndex = args.indexOf("--paths"); | ||
| const files = | ||
| pathsIndex === -1 | ||
| ? null | ||
| : fs | ||
| .readFileSync(args[pathsIndex + 1], "utf8") | ||
| .split("\n") | ||
| .filter(Boolean); | ||
| const { affected, outputs } = decide(files, readWorkspace(), args.includes("--full")); | ||
|
|
||
| console.error(`Affected packages: ${affected.join(", ") || "none"}`); | ||
| for (const [key, value] of Object.entries(outputs)) console.log(`${key}=${value}`); | ||
| } | ||
|
|
||
| main(); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.