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
2 changes: 1 addition & 1 deletion .synced-from
Original file line number Diff line number Diff line change
@@ -1 +1 @@
96c1dbc9f7a27d368f79c17702c0fbb7a799da04
58a1034ccd38510d510ccf2f1e91e84769227c48
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
# bot-ci

<!-- owner: bot-ci-checks -->
The CI and guardrails for bot-built code in touchlesscode. Org rulesets make it apply to every repo's default branch without anyone adding files to the repo, and everything runs on Ubicloud runners.

| check | runs on | what it does |
|-------|---------|--------------|
| **PR Lint** | every PR; only checks the bot's PRs unless `PR_LINT_SCOPE=all` | exo's PR title and description rules |
| **PR Lint** | every PR; only checks the bot's PRs unless `PR_LINT_SCOPE=all` | exo's PR title and description rules, and no exo skip tokens in a bot PR's title |
| **AI Provenance** | every PR; human-made PRs pass at once | PRs made by a coding agent must come through the touchless-bot MCP (`open_pr` / `update_pr`) |
| **Build** | repos with no PR CI of their own | install, lint, typecheck, test, coverage, build; bundles Worker previews |
| **Previews** | after Build, when it bundled any | deploys staging previews to `<app>-<branch>-pr.touchlessapis.com` |
| **Previews** | after Build, when it bundled any | deploys staging previews to `<app>-<version>.touchlessapis.com` ("Previews" below) |
| **E2E** | after Previews, when the repo has `test:e2e` | the repo's Playwright tests against each preview it deployed |
| **Second Look (beta)** | only when someone asks | AI testers plus a context-aware reviewer; see [second-look](https://github.com/touchlesscode/second-look) |
| **Second Look (beta)** | when someone asks, and on every push in its beta scope | AI testers plus a context-aware reviewer. Never a required check; in the beta scope its approval gates AI Provenance and the bot's Slack *Approve*. See [second-look](https://github.com/touchlesscode/second-look) |

A second ruleset, `review-required`, needs one human approval given after the last push, and blocks force-pushes and deleting the default branch. The Touchless Bot App has no bypass, so it can open PRs but never merge one alone.

In practice Build and Previews only run in repos with no CI of their own, which today means the bot's `touchlesscode/apps` monorepo and repos made from `app-template`. exo and anything else with a `pull_request` workflow are left out of the `bot-ci-build` ruleset, so they never see them.
**What's required where.** The `bot-ci` ruleset requires PR Lint and AI Provenance on every repo. The `bot-ci-build` ruleset requires `ci.yml` (Plan, Build, Previews, E2E) only in repos with no CI of their own, which today means the bot's `touchlesscode/apps` monorepo and repos made from `app-template`. exo, exo-foundation, core-db and anything else with a `pull_request` workflow are left out of it, so they never see Build, Previews, E2E or the coverage gate: their own CI (exo's `cicd`) is what tests them, and bot-ci doesn't make it required. An org owner chooses each ruleset's enforcement: in evaluate mode the checks run and report but block no merge.

## PR Lint

Expand All @@ -32,6 +33,8 @@ EXO-1234 (or a browse link, N/A, NO-TICKET)
How a reviewer can check it by hand.
```

A bot PR's title can't carry exo's skip tokens, `[skip-checks]` or `[skip e2e]` (any case): they'd skip exo's quality checks or e2e. People's PRs may, and the draft `[e2e]` opt-in is fine.

Dependabot, merge-queue and revert PRs are skipped. **Editing the title or body doesn't re-run it**, because rulesets ignore the `edited` event: push a commit, or close and reopen the PR.

## AI Provenance
Expand All @@ -55,6 +58,7 @@ Before that, Plan reads which template the repo was made from. If it names a dif
- Node comes from `.nvmrc`, `.node-version` or `engines.node`, else 24.

### Coverage
<!-- owner: coverage-gate -->

After the checks, the coverage gate (`actions/coverage/coverageGate.ts`) reads every `coverage/lcov.info` the tests wrote (`app-template` runs vitest with the `lcov` reporter). It reads the reports itself, so lowering the thresholds in a vitest config doesn't get a PR past it.

Expand Down Expand Up @@ -86,6 +90,7 @@ The install step gets the org secret `PACKAGES_READ_TOKEN` (a classic PAT with o
- Yarn and bun installs don't get the token (their installs run repo code), so they only see packages that list the repo under the package's Actions access.

## Previews
<!-- owner: preview-urls -->

An affected app gets a preview when its `wrangler.jsonc` has an `env.feature` section, exactly as exo does it. `app-template` ships this layout:

Expand All @@ -98,7 +103,7 @@ An affected app gets a preview when its `wrangler.jsonc` has an `env.feature` se
}
```

- `VERSION` is exo's `getVersionFromBranch`: the last 20 characters of `<branch>-pr`, lowercased, with `.`, `/` and `_` turned into `-`.
- The host is `<app>-<version>.touchlessapis.com`. `<version>` is exo's `getVersionFromBranch`: `<branch>-pr`, lowercased, every character outside `a-z0-9-` turned into `-`, its last 20 characters, leading dashes dropped (`feat/EXO-1300_csv` → `feat-exo-1300-csv-pr`); a Dependabot branch keeps only its last segment, and `main` is `latest` (`actions/ci/deployPreviews.ts` `versionOf`).
- Secrets listed in the app's `package.json` under `config.exo.secrets` are uploaded with the deploy itself, via `wrangler deploy --secrets-file`, so a new Worker whose config declares required secrets deploys on its first run. Their values come from the app's (or the repo's) `secrets/preview.enc.yaml`, a sops + age file that Previews decrypts with the org secret `SOPS_AGE_KEY_PREVIEW` (`app-template/secrets/README.md`), else from the repo's GitHub secrets with the same names. A listed secret that's in neither fails that app's preview.
- The PR description gets the Preview Deployments table (🟢 deployed, 🔴 failed, ⚪ skipped) between its `deployment-links` markers, and a failed deploy also gets a comment.
- Dependabot and fork PRs don't deploy. Apps with no `env.feature`, or with a `wrangler.toml`, are listed as skipped.
Expand Down
2 changes: 1 addition & 1 deletion actions/ai-provenance/attestationOf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { summaryFieldsOf } from "./summaryFieldsOf.ts"
/** the check run the touchless-bot MCP (or a bot worker) puts on a head commit it submitted */
export const attestationName = "Touchless Bot MCP"

export type CheckRun = { name: string; head_sha: string; status?: string; conclusion: string | null; app?: { slug?: string; id?: number } | null; output?: { summary?: string | null } | null }
export type CheckRun = { name: string; head_sha: string; status?: string; conclusion: string | null; app?: { slug?: string; id?: number } | null; external_id?: string | null; output?: { summary?: string | null } | null }

export type Attestation = { attested: true; lane?: string; requester?: string; ticket?: string; via?: string } | { attested: false; reason: string }

Expand Down
2 changes: 1 addition & 1 deletion actions/ai-provenance/decideProvenance.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Attestation } from "./attestationOf.ts"
import type { SecondLookVerdict } from "./secondLookAdapter.ts"
import type { SecondLookVerdict } from "../second-look/betaScopeTypes.ts"

/** the failure text for a harness-made pr without a current attestation (exact; people search for it) */
export const outsideMcpFailure = "made by a personal harness outside the touchless-bot MCP: resubmit with open_pr / update_pr"
Expand Down
8 changes: 5 additions & 3 deletions actions/ai-provenance/gatherProvenance.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { betaLabel } from "../second-look/betaScopeNames.ts"
import type { BetaSignals, SecondLookVerdict } from "../second-look/betaScopeTypes.ts"
import { secondLookVerdictOf } from "../second-look/secondLookVerdictOf.ts"
import { attestationOf, type Attestation, type CheckRun } from "./attestationOf.ts"
import { everLabeledOf } from "./everLabeledOf.ts"
import type { GithubCall } from "./githubOf.ts"
import { harnessSignalsOf, type PullCommit } from "./harnessSignalsOf.ts"
import { secondLookVerdictOf, type BetaSignals, type SecondLookVerdict } from "./secondLookAdapter.ts"

export type Gathered = { headSha: string; author?: string; harnessSignals: string[]; attestation: Attestation; beta: BetaSignals; verdict: SecondLookVerdict }

Expand Down Expand Up @@ -38,10 +40,10 @@ export const gatherProvenance = async ({ github, repository, number, appSlug, ap

const reviewsResponse = await github("GET", `/repos/${repository}/pulls/${number}/reviews?per_page=100`)
const reviews = reviewsResponse.status === 200 ? (reviewsResponse.json as { user?: { login?: string }; state?: string; commit_id?: string }[]).map((review) => ({ user: review.user?.login ?? "", state: review.state ?? "", commit_id: review.commit_id ?? "" })) : []
const verdict = secondLookVerdictOf(checkRuns.map((run) => ({ name: run.name, conclusion: run.conclusion, head_sha: run.head_sha, app_slug: run.app?.slug })), reviews, headSha)
const verdict = secondLookVerdictOf(checkRuns.map((run) => ({ name: run.name, conclusion: run.conclusion, head_sha: run.head_sha, app_slug: run.app?.slug, external_id: run.external_id })), reviews, headSha)

const events = await github("GET", `/repos/${repository}/issues/${number}/events?per_page=100`)
const recorded = events.status === 200 && everLabeledOf(events.json as { event?: string; label?: { name?: string } }[], "second-look-beta")
const recorded = events.status === 200 && everLabeledOf(events.json as { event?: string; label?: { name?: string } }[], betaLabel)
let repoProperty = typeof repoProperties?.["second-look-mode"] === "string" ? (repoProperties["second-look-mode"] as string) : undefined
if (repoProperty === undefined) {
const properties = await github("GET", `/repos/${repository}/properties/values`)
Expand Down
2 changes: 1 addition & 1 deletion actions/ai-provenance/githubOf.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type GithubCall = (method: "GET" | "POST", path: string, body?: unknown) => Promise<{ status: number; json: any }>
export type GithubCall = (method: "GET" | "POST" | "PATCH" | "DELETE", path: string, body?: unknown) => Promise<{ status: number; json: any }>

/** a minimal github rest client with the job token; network failures resolve as status 0 */
export const githubOf = (token: string, apiUrl = "https://api.github.com", fetchImpl: typeof fetch = fetch): GithubCall => async (method, path, body) => {
Expand Down
2 changes: 1 addition & 1 deletion actions/ai-provenance/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { gatherProvenance } from "./gatherProvenance.ts"
import { githubOf } from "./githubOf.ts"
import { pullNumbersOf } from "./pullNumbersOf.ts"
import { reportOf } from "./reportOf.ts"
import { isBetaEnabled } from "./secondLookAdapter.ts"
import { isBetaEnabled } from "../second-look/isBetaEnabled.ts"

const sleep = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const oneLine = (text: string) => text.replace(/[\r\n]+/g, " ").replace(/::/g, ": :")
Expand Down
2 changes: 1 addition & 1 deletion actions/ai-provenance/reportOf.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Attestation } from "./attestationOf.ts"
import type { ProvenanceDecision, ProvenanceMode } from "./decideProvenance.ts"
import type { BetaSignals, SecondLookVerdict } from "./secondLookAdapter.ts"
import type { BetaSignals, SecondLookVerdict } from "../second-look/betaScopeTypes.ts"

const icons = { success: "✅", failure: "❌", neutral: "⚪", pending: "⏳" } as const

Expand Down
4 changes: 0 additions & 4 deletions actions/ai-provenance/secondLookAdapter.ts

This file was deleted.

15 changes: 11 additions & 4 deletions actions/pr-lint/lintPullRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@
* (apps/exo-cli/src/cicd/utils/runPrLinting.ts), without its dependencies:
* title `type(scope): description | EXO-1234` (or `| NO-TICKET`)
* body `## Summary`, `### Jira Ticket Reference` (a valid EXO key/link, N/A or NO-TICKET), `### Test Plan`
* plus one rule of its own: a pr the bot opened or drove can't carry exo's skip tokens (`[skip-checks]`, `[skip e2e]`)
*
* node lintPullRequest.ts (in the action; reads $GITHUB_EVENT_PATH)
*
* env: PR_LINT_SCOPE (`bot`, the default: only bot-authored PRs; `all`: every PR),
* PR_LINT_BOT_LOGINS (comma-separated, default touchless-bot[bot]).
*/
import { appendFileSync, readFileSync } from "node:fs"
import { appLogin, reviewerLogin } from "../second-look/betaScopeNames.ts"
import { skipTokensOf } from "./skipTokensOf.ts"

export type PullRequestText = { title: string; body: string; branch: string }
/** a pr's text; `bot` (default true) is whether the bot opened or drove it, which bans exo's skip tokens */
export type PullRequestText = { title: string; body: string; branch: string; bot?: boolean }

export type LintResult = { skipped?: string; errors: { title: string; message: string }[] }

Expand All @@ -22,7 +26,7 @@ const semanticTitle = /^(feat|fix|docs|style|refactor|test|chore|build|ci|perf)(
const ticketSuffix = /\| (EXO-\d+|NO-TICKET)$/
const jiraReference = /^(EXO-\d+|\[EXO-\d+\]\(https?:\/\/[^/\s]+\/browse\/EXO-\d+\)|https?:\/\/[^/\s]+\/browse\/EXO-\d+|N\/A|NO-TICKET)$/i
const harnessMarker = /<!--\s*harness:\s*(claude|codex)\s*-->/i
const defaultBotLogins = ["touchless-bot[bot]", "clanker-in-chief"]
const defaultBotLogins = [appLogin, reviewerLogin]

const sections: Section[] = [
{
Expand Down Expand Up @@ -57,14 +61,15 @@ export const sectionContentOf = (body: string, heading: string) =>
body.match(new RegExp(`^${heading}[ \\t]*$\\s*(?:<!--.*?-->\\s*)*([\\s\\S]*?)(?=^## |^### |\\s*$)`, "m"))?.[1]?.trim()

/** every title and description problem, or why the pr is exempt (dependabot, merge queue, reverts) */
export const lintPullRequest = ({ title, body, branch }: PullRequestText): LintResult => {
export const lintPullRequest = ({ title, body, branch, bot = true }: PullRequestText): LintResult => {
if (branch.startsWith("dependabot/")) return { skipped: "dependabot branch", errors: [] }
if (branch.startsWith("gh-readonly-queue/")) return { skipped: "merge queue", errors: [] }
if (branch.startsWith("revert") && body.includes("Reverts") && body.includes("#")) return { skipped: "revert", errors: [] }

const titleErrors = [
semanticTitle.test(title) ? undefined : { title: "PR Title Validation Error", message: "**PR Title Error**: PR title does not follow the semantic messaging format (e.g., \"feat: add new feature\")." },
ticketSuffix.test(title) ? undefined : { title: "PR Title Validation Error", message: "**PR Title Error**: PR title must end with \"| EXO-XXXX\", where \"XXXX\" is the ticket number. Example: \"feat: add new feature | EXO-1234\". If there is no ticket, use \"NO-TICKET\"." },
...(bot ? skipTokensOf(title) : []).map((token) => ({ title: "PR Title Validation Error", message: `**PR Title Error**: a bot PR can't skip checks: remove \`${token}\` from the title. Only a person may skip exo's checks.` })),
].filter((error) => error !== undefined)
if (titleErrors.length) return { errors: titleErrors }

Expand Down Expand Up @@ -96,7 +101,9 @@ if (import.meta.main) {
console.log(`pr-lint: not a bot pull request (author ${pull.user?.login}); set the org variable PR_LINT_SCOPE=all to lint every PR`)
process.exit(0)
}
const result = lintPullRequest({ title: pull.title ?? "", body, branch: pull.head?.ref ?? "" })
// without a scope, isLintInScope says whether the bot opened or drove the pr (PR_LINT_SCOPE=all lints people's prs too)
const bot = isLintInScope({ author: pull.user?.login, body, botLogins })
const result = lintPullRequest({ title: pull.title ?? "", body, branch: pull.head?.ref ?? "", bot })
if (result.skipped) {
console.log(`pr-lint: skipped (${result.skipped})`)
process.exit(0)
Expand Down
9 changes: 9 additions & 0 deletions actions/pr-lint/skipTokensOf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* title tokens that make exo's `cicd` workflow skip its own checks (exo `.github/workflows/cicd.yml`): `[skip-checks]`
* skips the quality checks (nx affected build/lint/test), `[skip e2e]` skips every e2e suite. github's `contains()`
* ignores case, so this does too. the draft `[e2e]` opt-in only adds checks and isn't one
*/
const skipTokens = ["[skip-checks]", "[skip e2e]"]

/** the exo skip tokens in a pr title, as written in exo's workflow; empty when it has none */
export const skipTokensOf = (title: string) => skipTokens.filter((token) => title.toLowerCase().includes(token))
10 changes: 8 additions & 2 deletions actions/second-look/betaScopeNames.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* the names second look's beta scope is built on, shared by the relay (scripts/bot-ci-relay), setup.sh's property
* the names second look's beta scope is built on, shared by the relay (factory/gate/src/bot-ci-relay), setup.sh's property
* definition, and anything gating on second look's verdict (AI Provenance, the Slack Approve button)
*/

Expand All @@ -22,5 +22,11 @@ export const betaExternalId = "second-look-beta"
/** the touchless bot github app, the only one whose check runs count */
export const appSlug = "touchless-bot"

/** the user second look reviews and replies as, the one people @mention or request as a reviewer */
export const reviewerLogin = "clanker-in-chief"

/** the App's own login, which posts in second look's place when clanker-in-chief's token is missing */
export const appLogin = "touchless-bot[bot]"

/** the accounts second look reviews as: clanker-in-chief, or the App when its token is missing */
export const reviewerLogins = ["clanker-in-chief", "touchless-bot[bot]"]
export const reviewerLogins = [reviewerLogin, appLogin]
5 changes: 3 additions & 2 deletions actions/second-look/checkRunBodyOf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,13 @@ export const checkStartOf = ({ headSha, detailsUrl, requestedBy, betaEnabled = f
/**
* the body that closes the check with the review's outcome. outside the beta scope the check is informational: a pass
* is green ("no changes requested", never an approval) and anything else neutral, never red. on a beta-enabled pr it
* enforces: a pass is an approval, and a block (the review requests changes either way) or a failed review is red
* enforces: a pass is an approval and a block (the review requests changes either way) is red. a review that
* couldn't finish is neutral in both, like an abandoned run: it judged nothing, and red would read as changes requested
*/
export const checkEndOf = ({ verdict, error, betaEnabled, commentUrl }: { verdict?: Verdict; error?: string; betaEnabled: boolean; commentUrl?: string }) => {
const link = commentUrl ? `\n\n[The review](${commentUrl})` : ""
const notPassing = betaEnabled ? "failure" : "neutral"
if (error || !verdict) return { status: "completed", conclusion: notPassing, output: { title: "Couldn't review", summary: `${error ?? "no verdict"}${link}` } }
if (error || !verdict) return { status: "completed", conclusion: "neutral", output: { title: "Couldn't review", summary: `${error ?? "no verdict"}${link}` } }
if (verdict.verdict === "pass") return { status: "completed", conclusion: "success", output: { title: betaEnabled ? "Approved" : "No changes requested", summary: `${verdict.summary}${link}` } }
return { status: "completed", conclusion: notPassing, output: { title: "Changes requested", summary: `${verdict.summary}${link}` } }
}
Expand Down
Loading