Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .github/actions/ci-changes/action.yml
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[@]}"
203 changes: 203 additions & 0 deletions .github/scripts/ci-changes.cjs
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\//;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

// 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();
7 changes: 7 additions & 0 deletions .github/scripts/thread-transfer-report.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,13 @@ async function resolve({ github, context, core }) {

const sourceArtifacts = await artifactsForRun(github, owner, repo, source.id);
const sourceArtifact = findResultArtifact(sourceArtifacts);
// CI requires the artifact whenever the server tests run, so a passing run
// without one skipped them because the PR cannot affect the server.
if (!sourceArtifact && source.conclusion === "success") {
core.info("CI skipped the server tests for this change; nothing to report.");
core.setOutput("publish", "false");
return;
}
const workflowRuns = await github.paginate(github.rest.actions.listWorkflowRuns, {
owner,
repo,
Expand Down
42 changes: 42 additions & 0 deletions .github/scripts/thread-transfer-report.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,48 @@ test("does not guess when a fallback commit belongs to multiple PRs", async () =
assert.equal(fetchedPull, false);
});

test("does not report a passing run that skipped the server tests", async () => {
const outputs = {};
const listWorkflowRunArtifacts = () => {};
await resolve({
github: {
paginate: async (method) => {
assert.equal(method, listWorkflowRunArtifacts);
return [];
},
rest: {
actions: { listWorkflowRunArtifacts },
pulls: {
get: async () => ({
data: { head: { sha: "head-sha" }, base: { sha: "base-sha", ref: "main" } },
}),
},
},
},
context: {
repo: { owner: "pingdotgg", repo: "t3code" },
payload: {
workflow_run: {
id: 2,
event: "pull_request",
workflow_id: 3,
head_sha: "head-sha",
conclusion: "success",
pull_requests: [{ number: 5350 }],
},
},
},
core: {
info: () => {},
setOutput: (key, value) => {
outputs[key] = value;
},
},
});

assert.equal(outputs.publish, "false");
});

test("does not publish a stale result after the PR head advances", async () => {
let listedComments = false;
const info = [];
Expand Down
Loading
Loading