Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
46 changes: 46 additions & 0 deletions .github/scripts/mutation-matrix.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { readFileSync } from "node:fs";

const shards = [
{
project: "domain",
shard: "domain-habit-freeze",
mutate: ["**/Entities/Habit.cs", "**/Entities/StreakFreeze.cs"],
},
{
project: "domain",
shard: "domain-user-invariants",
mutate: ["**/Entities/User.cs", "**/Entities/HabitInvariants.cs"],
},
{ project: "application" },
{ project: "infrastructure" },
];

const projects = {
domain: "Orbit.Domain",
application: "Orbit.Application",
infrastructure: "Orbit.Infrastructure",
};

let selected = shards;

if (process.argv[2] === "--stdin") {
const changed = readFileSync(0, "utf8")
.split("\0")
.filter(Boolean);
for (const path of changed) process.stderr.write(`Changed: ${JSON.stringify(path)}\n`);
const shared = changed.some((path) =>
/^Directory\..*\.(props|targets)$/.test(path)
|| /^[^/]+\.slnx?$/.test(path)
|| /^(global\.json|[Nn]u[Gg]et\.[Cc]onfig|\.config\/dotnet-tools\.json)$/.test(path)
|| path === ".github/workflows/mutation.yml"
|| path.startsWith(".github/scripts/mutation-matrix.")
);
selected = shards.filter(({ project }) => shared || changed.some((path) => !/\.(md|mdx|rst|adoc)$/i.test(path) && (
path.startsWith(`src/${projects[project]}/`)
|| path.startsWith(`tests/${projects[project]}.Tests/`)
|| path === `stryker-config.${project}.json`
)));
}

process.stdout.write(`matrix=${JSON.stringify({ include: selected })}\n`);
process.stdout.write(`has-shards=${selected.length > 0}\n`);
142 changes: 142 additions & 0 deletions .github/scripts/mutation-matrix.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";

const selector = fileURLToPath(new URL("./mutation-matrix.mjs", import.meta.url));
const allShards = ["domain-habit-freeze", "domain-user-invariants", "application", "infrastructure"];

function select(changed) {
const output = execFileSync(process.execPath, [selector, ...(changed === undefined ? [] : ["--stdin"])], {
input: changed,
encoding: "utf8",
});
const values = Object.fromEntries(output.trim().split("\n").map((line) => {
const separator = line.indexOf("=");
return [line.slice(0, separator), line.slice(separator + 1)];
}));
const matrix = JSON.parse(values.matrix);
assert.equal(values["has-shards"], String(matrix.include.length > 0));
return matrix.include;
}

function selectPaths(paths) {
return select(paths.map((path) => `${path}\0`).join(""));
}

function repository(t) {
const root = mkdtempSync(join(tmpdir(), "orbit-mutation-matrix-"));
t.after(() => rmSync(root, { recursive: true, force: true }));
const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8" });
git("init", "--quiet");
git("-c", "user.name=Mutation test", "-c", "user.email=mutation@example.com", "commit", "--quiet", "--allow-empty", "-m", "Base");
git("branch", "base");
const paths = new Set();
const write = (path) => {
mkdirSync(dirname(join(root, path)), { recursive: true });
writeFileSync(join(root, path), "changed\n");
paths.add(path);
};
const commit = (...extraPaths) => {
git("--literal-pathspecs", "add", "--", ...new Set([...paths, ...extraPaths]));
git("-c", "user.name=Mutation test", "-c", "user.email=mutation@example.com", "commit", "--quiet", "-m", "Change");
paths.clear();
};
const changed = () => git("diff", "--name-only", "--no-renames", "-z", "--end-of-options", "base...HEAD");
return { root, git, write, commit, changed };
}

function names(matrix) {
return matrix.map((entry) => entry.shard ?? entry.project);
}

test("an empty changed-file list selects no shards", () => {
assert.deepEqual(selectPaths([]), []);
});

test("documentation-only changes select no shards", () => {
assert.deepEqual(selectPaths([
"README.md",
"docs/mutation notes.md",
"src/Orbit.Domain/CLAUDE.md",
"tests/Orbit.Application.Tests/README.md",
"src/Orbit.Infrastructure/docs/design.MD",
]), []);
});

test("unrelated projects select no shards", () => {
assert.deepEqual(selectPaths(["src/Orbit.Api/Controllers/AuthController.cs", "tests/Orbit.Analyzers.Tests/RuleTests.cs"]), []);
});

for (const [project, expected] of [
["Domain", allShards.slice(0, 2)],
["Application", ["application"]],
["Infrastructure", ["infrastructure"]],
]) {
for (const path of [`src/Orbit.${project}/Entities/Changed.cs`, `tests/Orbit.${project}.Tests/ChangedTests.cs`]) {
test(`${path} selects only its project's shards`, () => {
assert.deepEqual(names(selectPaths([path])), expected);
});
}
}

test("changes across projects select their union without duplicate shards", () => {
assert.deepEqual(names(selectPaths([
"src/Orbit.Domain/Entities/Habit.cs",
"tests/Orbit.Domain.Tests/HabitTests.cs",
"src/Orbit.Infrastructure/Services/UserStreakService.cs",
])), [allShards[0], allShards[1], "infrastructure"]);
});

test("NUL separators preserve spaces and newlines within paths", () => {
assert.deepEqual(names(selectPaths(["src/Orbit.Domain/Entities/Habit notes\nREADME.md"])), []);
assert.deepEqual(names(selectPaths(["src/Orbit.Domain/Entities/Habit notes\nChanged.cs"])), allShards.slice(0, 2));
});

test("renames select both the old and new project, and deletions select the old project", (t) => {
const repo = repository(t);
const oldPath = "src/Orbit.Domain/Old.cs";
const newPath = "src/Orbit.Application/Moved.cs";
const deletedPath = "tests/Orbit.Infrastructure.Tests/RemovedTests.cs";
repo.write(oldPath);
repo.write(deletedPath);
repo.commit();
repo.git("branch", "--force", "base");
mkdirSync(dirname(join(repo.root, newPath)), { recursive: true });
renameSync(join(repo.root, oldPath), join(repo.root, newPath));
rmSync(join(repo.root, deletedPath));
repo.commit(oldPath, newPath, deletedPath);
assert.deepEqual(names(select(repo.changed())), allShards);
});

test("the diff uses the merge base rather than unrelated changes on the base branch", (t) => {
const repo = repository(t);
const head = repo.git("branch", "--show-current").trim();
repo.git("checkout", "--quiet", "base");
repo.write("src/Orbit.Infrastructure/BaseOnly.cs");
repo.commit();
repo.git("checkout", "--quiet", head);
repo.write("README.md");
repo.commit();
assert.deepEqual(select(repo.changed()), []);
});

for (const path of ["Directory.Build.props", ".config/dotnet-tools.json", ".github/workflows/mutation.yml", ".github/scripts/mutation-matrix.mjs"]) {
test(`${path} selects every shard`, () => {
assert.deepEqual(names(selectPaths([path])), allShards);
});
}

test("a project-specific Stryker config selects only that project", () => {
assert.deepEqual(names(selectPaths(["stryker-config.application.json"])), ["application"]);
});

test("nightly and manual runs keep all shards and their mutation scopes without input", () => {
const matrix = select();
assert.deepEqual(names(matrix), allShards);
assert.deepEqual(matrix[0].mutate, ["**/Entities/Habit.cs", "**/Entities/StreakFreeze.cs"]);
assert.deepEqual(matrix[1].mutate, ["**/Entities/User.cs", "**/Entities/HabitInvariants.cs"]);
});
12 changes: 12 additions & 0 deletions .github/workflows/arch-map.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,20 @@ name: arch-map

on:
pull_request:
paths:
- 'src/**/*.cs'
- 'src/**/*.csproj'
- 'tests/**/*.cs'
- 'tools/arch-map.mjs'
- '.github/workflows/arch-map.yml'
push:
branches: [main]
paths:
- 'src/**/*.cs'
- 'src/**/*.csproj'
- 'tests/**/*.cs'
- 'tools/arch-map.mjs'
- '.github/workflows/arch-map.yml'

concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}
Expand Down
13 changes: 10 additions & 3 deletions .github/workflows/gating-matrix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,18 @@ name: Gating matrix
on:
pull_request:
branches: [main]
paths:
- 'src/**/*.cs'
- 'tools/gating-matrix.mjs'
- 'tools/__tests__/gating-matrix.test.mjs'
- '.github/workflows/gating-matrix.yml'
push:
branches: [main]
paths:
- 'src/**/*.cs'
- 'tools/gating-matrix.mjs'
- 'tools/__tests__/gating-matrix.test.mjs'
- '.github/workflows/gating-matrix.yml'

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
Expand All @@ -25,9 +35,6 @@ jobs:
with:
node-version: 22

- name: Test the generator
run: node --test tools/__tests__/gating-matrix.test.mjs

- name: Generate the gating matrix
run: node tools/gating-matrix.mjs

Expand Down
104 changes: 44 additions & 60 deletions .github/workflows/guards.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,79 +18,63 @@ permissions:
pull-requests: read

jobs:
workflows:
name: Workflow Syntax
guards:
name: Guards
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Check workflow files
defaults:
run:
shell: bash
# https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency documents queue, which actionlint does not recognize.
run: |
lint_dir="$(mktemp -d)"
curl --proto '=https' --tlsv1.2 -fsSL https://raw.githubusercontent.com/rhysd/actionlint/v1.7.12/scripts/download-actionlint.bash -o "$lint_dir/download-actionlint.bash"
bash "$lint_dir/download-actionlint.bash" 1.7.12 "$lint_dir"
"$lint_dir/actionlint" -color -ignore '^unexpected key "queue" for "concurrency" section\. expected one of "cancel-in-progress", "group"$'

timeless:
name: Timeless Text
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Check changed text
if: github.event_name == 'pull_request'
run: node tools/check-timeless.mjs --base origin/${{ github.base_ref }}
- name: Check tracked text
if: github.event_name == 'push'
run: node tools/check-timeless.mjs --all

dashes:
name: Dash Ban
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Changed files carry no new dashes
if: github.event_name == 'pull_request'
- name: Workflow Syntax
if: ${{ !cancelled() }}
# https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency documents queue, which actionlint does not recognize.
run: |
lint_dir="$(mktemp -d)"
curl --proto '=https' --tlsv1.2 -fsSL https://raw.githubusercontent.com/rhysd/actionlint/v1.7.12/scripts/download-actionlint.bash -o "$lint_dir/download-actionlint.bash"
bash "$lint_dir/download-actionlint.bash" 1.7.12 "$lint_dir"
"$lint_dir/actionlint" -color -ignore '^unexpected key "queue" for "concurrency" section\. expected one of "cancel-in-progress", "group"$'

- name: Timeless Text
if: ${{ !cancelled() }}
env:
EVENT_NAME: ${{ github.event_name }}
BASE_REF: ${{ github.base_ref }}
run: |
git diff --name-only --diff-filter=ACMR origin/${{ github.base_ref }}...HEAD -z \
| xargs -0 --no-run-if-empty node tools/check-dashes.mjs --files
- name: Baseline only shrinks
run: node tools/check-dashes.mjs --check-baseline
- name: PR title and body carry no dashes
# Bot PR bodies quote upstream text that Orbit cannot edit.
# The file-level dash check still covers bot changes.
if: github.event_name == 'pull_request' && github.event.pull_request.user.type != 'Bot'
if [ "$EVENT_NAME" = pull_request ]; then
node tools/check-timeless.mjs --base "origin/$BASE_REF"
else
node tools/check-timeless.mjs --all
fi

- name: Dash Ban
if: ${{ !cancelled() }}
env:
EVENT_NAME: ${{ github.event_name }}
BASE_REF: ${{ github.base_ref }}
PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
run: node tools/check-dashes.mjs --text "$PR_TITLE $PR_BODY"
run: |
if [ "$EVENT_NAME" = pull_request ]; then
git diff --name-only --diff-filter=ACMR "origin/$BASE_REF...HEAD" -z \
| xargs -0 --no-run-if-empty node tools/check-dashes.mjs --files
fi
node tools/check-dashes.mjs --check-baseline
if [ "$EVENT_NAME" = pull_request ] && [ "$PR_AUTHOR_TYPE" != Bot ]; then
node tools/check-dashes.mjs --text "$PR_TITLE $PR_BODY"
fi

frontmatter:
name: Skill and Agent Frontmatter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
- run: node tools/check-frontmatter.mjs
- name: Skill and Agent Frontmatter
if: ${{ !cancelled() }}
run: node tools/check-frontmatter.mjs

root-allowlist:
name: Root Allowlist
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
- run: node tools/check-root-allowlist.mjs
- name: Root Allowlist
if: ${{ !cancelled() }}
run: node tools/check-root-allowlist.mjs
Loading
Loading