Skip to content
Draft
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
39 changes: 39 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2888,6 +2888,45 @@ jobs:
fi
echo "all required work jobs succeeded (or correctly skipped per their setup flag)"


backstop:
name: backstop
needs: rollup
if: ${{ !cancelled() && contains(fromJSON('["push","schedule"]'), github.event_name) }}
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
concurrency:
group: ci-backstop
cancel-in-progress: false
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31
with:
extra_nix_config: |
experimental-features = nix-command flakes
extra-substituters = https://devenv.cachix.org https://cachix.cachix.org
extra-trusted-public-keys = devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= cachix.cachix.org-1:eWNHQldwUO7G2VkjpnjDbWwy4KQ/HNxht7H4SSoMckM=
- name: Put the pinned bun toolchain on PATH
# bun comes from the nix-pinned toolchain, never setup-bun, so it is the
# same derivation as the dev shell's. `jq -r` renders a missing `.store`
# as the literal `null`, so guard for it explicitly.
run: |
set -euo pipefail
store=$(nix eval --json -f tools/toolchain/gate-tools.nix langs.bun | jq -r '.store')
if [ -z "$store" ] || [ "$store" = null ]; then
echo "::error::gate-tools.nix langs.bun produced no store path" >&2
exit 1
fi
nix build --no-link "$store"
echo "$store/bin" >>"$GITHUB_PATH"
- name: Route failed full sweep
env:
ROLLUP_RESULT: ${{ needs.rollup.result }}
GH_TOKEN: ${{ github.token }}
run: bun tools/ci-backstop/index.ts

regen-forge-fixtures:
name: regen-fixtures
runs-on: ubuntu-latest
Expand Down
2 changes: 2 additions & 0 deletions .moon/workspace.yml
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,8 @@ projects:
# The Bun advisory check is a dedicated, always-run workflow job; this project
# keeps its typecheck and tests in the affected Moon matrix.
bun-audit-gate: 'tools/bun-audit-gate'
# The full-sweep failure backstop: a pure issue decision plus its tests.
ci-backstop: 'tools/ci-backstop'

# File hashing uses moon's in-process native walker (the v2.5+ default), NOT the
# git shell-out. This is load-bearing: the git hasher (`git ls-files` then `git
Expand Down
10 changes: 10 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions tools/ci-backstop/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# ci-backstop

Routes failed full CI sweeps to one `ci-backstop` issue. A green full sweep
closes that issue. `core.ts` holds the pure action decision; `index.ts` reads
GitHub Actions data and applies it through `gh`.

## Run

```sh
bun tools/ci-backstop/index.ts [--dry-run]
moon run ci-backstop:ci
```

The `backstop` job in `.github/workflows/ci.yml` runs on push and schedule
results after `rollup`. `GH_TOKEN` must have issue write access. `--dry-run`
prints the action and does not create the label or change an issue.
5 changes: 5 additions & 0 deletions tools/ci-backstop/biome.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"root": false,
"extends": "//",
"linter": { "rules": { "suspicious": { "noConsole": "off" } } }
}
92 changes: 92 additions & 0 deletions tools/ci-backstop/core.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, test } from "bun:test";
import { backstopAction } from "./core.ts";

const runUrl = "https://github.com/RigelBuild/compass/actions/runs/123";
const failedJobs = ["moon (bun)", "pgtest"];

const cases = [
{
name: "failure without an issue opens one",
result: "failure",
openIssue: null,
newerSweep: false,
kind: "open",
},
{
name: "failure with an issue comments",
result: "failure",
openIssue: 42,
newerSweep: false,
kind: "comment",
},
{
name: "success without an issue does nothing",
result: "success",
openIssue: null,
newerSweep: false,
kind: "none",
},
{
name: "success with an issue closes it",
result: "success",
openIssue: 42,
newerSweep: false,
kind: "close",
},
{
name: "newer sweep supersedes failure without an issue",
result: "failure",
openIssue: null,
newerSweep: true,
kind: "none",
},
{
name: "newer sweep supersedes failure with an issue",
result: "failure",
openIssue: 42,
newerSweep: true,
kind: "none",
},
{
name: "newer sweep supersedes success without an issue",
result: "success",
openIssue: null,
newerSweep: true,
kind: "none",
},
{
name: "newer sweep supersedes success with an issue",
result: "success",
openIssue: 42,
newerSweep: true,
kind: "none",
},
] as const;

describe("backstopAction", () => {
for (const scenario of cases) {
test(scenario.name, () => {
const action = backstopAction({
result: scenario.result,
event: "push",
runUrl,
failedJobs,
openIssue: scenario.openIssue,
runStartedAt: "2026-10-15T10:00:00Z",
newestSweepStartedAt: scenario.newerSweep
? "2026-10-15T10:00:01Z"
: "2026-10-15T10:00:00Z",
});

expect(action.kind).toBe(scenario.kind);
if (action.kind !== "none") {
expect(action.body).toContain(runUrl);
expect(action.body).toContain("moon (bun), pgtest");
expect(action.body.split("\n")).toHaveLength(2);
}
if (action.kind === "open") {
expect(action.title).toBe("CI push full sweep failed");
}
});
}
});
40 changes: 40 additions & 0 deletions tools/ci-backstop/core.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
export type BackstopAction =
| { kind: "open"; title: string; body: string }
| { kind: "comment" | "close"; issue: number; body: string }
| { kind: "none" };

export function backstopAction(input: {
result: "success" | "failure";
event: "push" | "schedule";
runUrl: string;
failedJobs: readonly string[];
openIssue: number | null;
runStartedAt: string;
newestSweepStartedAt: string;
}): BackstopAction {
if (Date.parse(input.newestSweepStartedAt) > Date.parse(input.runStartedAt)) {
return { kind: "none" };
}

const failedJobs =
input.failedJobs.length > 0 ? input.failedJobs.join(", ") : "none";
const body = [
`CI ${input.event} full sweep: ${input.result}. Run: ${input.runUrl}`,
`Failed jobs: ${failedJobs}`,
].join("\n");

if (input.result === "failure") {
if (input.openIssue !== null) {
return { kind: "comment", issue: input.openIssue, body };
}
return {
kind: "open",
title: `CI ${input.event} full sweep failed`,
body,
};
}

return input.openIssue === null
? { kind: "none" }
: { kind: "close", issue: input.openIssue, body };
}
Loading
Loading