Skip to content
Closed
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
21 changes: 19 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# required on every repo's default-branch PRs by the org ruleset "bot-ci".
# rulesets ignore trigger filters: this always runs on opened / synchronize / reopened.
# Build is for the bot's repos: ones made from app-template, without their own CI. required on default-branch
# PRs by the org ruleset "bot-ci-build", which leaves out every repo setup.sh's scan found isn't one. rulesets
# ignore trigger filters: this always runs on opened / synchronize / reopened. the plan job decides on a light
# runner, so a human repo created since that scan never starts the build runner (a skipped job still passes).
# staging previews only, exo-style (wrangler env.feature, <app>-<version>.touchlessapis.com).
# the checks job runs the PR's code; its one secret, PACKAGES_READ_TOKEN (read:packages for
# @touchlesscode/*), only reaches the scripts-off install after npmrcGuard. it bundles the previews.
Expand All @@ -22,8 +24,23 @@ concurrency:
cancel-in-progress: true

jobs:
plan:
name: Plan
runs-on: ${{ vars.BOT_CI_LIGHT_RUNNER || vars.ORG_CI_LIGHT_RUNNER || 'ubicloud-standard-2' }}
timeout-minutes: 5
outputs:
build: ${{ steps.plan.outputs.build }}
steps:
- id: plan
uses: touchlesscode/bot-ci/actions/ci-plan@main
with:
github-token: ${{ github.token }}
template: touchlesscode/app-template

checks:
name: Build
needs: plan
if: needs.plan.outputs.build == 'true'
runs-on: ${{ vars.BOT_CI_RUNNER || vars.ORG_CI_RUNNER || 'ubicloud-standard-8' }}
timeout-minutes: 30
outputs:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pr-lint.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# required on every repo's default-branch PRs by the org ruleset "bot-ci".
# required on every repo's default-branch PRs by the org ruleset "bot-ci" (Build has its own, "bot-ci-build").
# rulesets ignore `types`, so an edited title or body doesn't re-run this; a push or reopen does.
name: PR Lint

Expand Down
2 changes: 1 addition & 1 deletion .synced-from
Original file line number Diff line number Diff line change
@@ -1 +1 @@
828a939b6052af3a708691fbe161d2df1bf9ef50
8ae6a0d9ddb1e2ff0f05f766007129720638fc66
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# bot-ci

The CI and guardrails for bot-built code in touchlesscode. Two org rulesets make it apply to every repo's default branch without anyone adding files to the repo, and everything runs on Ubicloud runners.
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 |
|-------|---------|--------------|
Expand All @@ -11,7 +11,7 @@ The CI and guardrails for bot-built code in touchlesscode. Two org rulesets make

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 do work 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 skip them.
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.

## PR Lint

Expand All @@ -34,7 +34,7 @@ Dependabot, merge-queue and revert PRs are skipped. **Editing the title or body

## Build

First it decides whether to run at all (`actions/ci/planChecks.ts`). It skips repos that already run a GitHub workflow on `pull_request` or use another CI system (Buildkite, CircleCI, GitLab, and so on), and repos with no root `package.json`. The job summary says which.
First a light **Plan** job decides whether to run at all (`actions/ci/planChecks.ts`, from a checkout of just the CI config and `package.json`). It skips repos that already run a GitHub workflow on `pull_request` or use another CI system (Buildkite, CircleCI, GitLab, and so on), and repos with no root `package.json`; then the Build job doesn't start, which still passes. The job summary says why. Repos known to skip are left out of the ruleset altogether, so they don't even get Plan.

- **NX workspaces** get exo's pipeline: `nrwl/nx-set-shas` finds the base, then `nx affected -t build`, `lint`, `typecheck` and `test`, with the local NX cache and the pnpm store cached.
- **Other Node repos** install with a frozen lockfile, then run whichever of `lint`, a typecheck script (`typecheck`, `type-check`, `check-types` or `tsc`), `test` and `build` they declare.
Expand Down Expand Up @@ -88,7 +88,7 @@ The guard doesn't check bindings: a feature env that binds a prod KV namespace,

## Opting a repo out

Add your own `pull_request` workflow and Build and Previews skip the repo. To leave a repo out of the rulesets entirely, an org owner adds it to the excludes (`setup.sh --exclude <repo>`).
Add your own `pull_request` workflow and Build and Previews skip the repo (and leave its required checks at the next `setup.sh` run). To leave a repo out of the rulesets entirely, an org owner adds it to the excludes (`setup.sh --exclude <repo>`).

## Settings

Expand Down
45 changes: 45 additions & 0 deletions actions/ci-plan/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: bot-ci plan
description: Whether Build has anything to do in this repo (actions/ci/planChecks.ts) — only repos made from app-template (the bot's repos), without their own CI, with a package.json — from the repo's template link and a sparse checkout of just its CI config and package.json, so any other repo costs a light runner for a few seconds and never starts Build. Keep the sparse list in step with planChecks' otherCiFiles (a test checks)
inputs:
github-token:
required: true
template:
description: "owner/name of the app template; repos made from it get Build"
required: true
outputs:
build:
description: "'true' when Build should run"
value: ${{ steps.plan.outputs.skip != 'true' }}
runs:
using: composite
steps:
- id: template
shell: bash
env:
GH_TOKEN: ${{ inputs.github-token }}
run: echo "of=$(gh api "repos/$GITHUB_REPOSITORY" --jq '.template_repository.full_name // ""')" >> "$GITHUB_OUTPUT"
- if: steps.template.outputs.of == inputs.template
uses: actions/checkout@v7
with:
persist-credentials: false
sparse-checkout-cone-mode: false
sparse-checkout: |
/.github/workflows/
/package.json
/.buildkite/
/.circleci/
/codefresh.yml
/.gitlab-ci.yml
/azure-pipelines.yml
/bitbucket-pipelines.yml
/.travis.yml
/Jenkinsfile
- uses: actions/setup-node@v6
with:
node-version: 24
- id: plan
shell: bash
env:
BOT_CI_TEMPLATE: ${{ inputs.template }}
BOT_CI_TEMPLATE_OF: ${{ steps.template.outputs.of }}
run: node "$GITHUB_ACTION_PATH/../ci/planChecks.ts"
23 changes: 18 additions & 5 deletions actions/ci/planChecks.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
#!/usr/bin/env node
/**
* org ci planner: decides what the org-wide required ci runs in a checked-out repo.
* nothing when the repo already has its own pull request ci or isn't a node project.
* nothing when the repo wasn't made from app-template (it's for the bot's repos), already has its own
* pull request ci, or isn't a node project.
* nx workspaces get exo's pipeline: `nx affected -t build|lint|typecheck|test` against
* nx-set-shas' base, with the local nx cache. other node repos get install plus the
* lint / typecheck / test / build scripts they declare.
Expand Down Expand Up @@ -43,7 +44,8 @@ export type RepoSnapshot = {
readText: (path: string) => string | undefined
}

const otherCiFiles = [".buildkite", ".circleci", "codefresh.yml", ".gitlab-ci.yml", "azure-pipelines.yml", "bitbucket-pipelines.yml", ".travis.yml", "Jenkinsfile"]
/** other ci systems' config at the repo root (actions/ci-plan's sparse checkout lists the same paths) */
export const otherCiFiles = [".buildkite", ".circleci", "codefresh.yml", ".gitlab-ci.yml", "azure-pipelines.yml", "bitbucket-pipelines.yml", ".travis.yml", "Jenkinsfile"]
const typecheckScripts = ["typecheck", "type-check", "check-types", "tsc"]
const nxTargets = ["build", "lint", "typecheck", "test"]
const placeholderTest = /no test specified/
Expand Down Expand Up @@ -113,8 +115,13 @@ export const nodeVersionOf = (repo: RepoSnapshot, packageJson: PackageJson): { n
return { nodeVersion: "24" }
}

/** the whole plan for one repo */
export const planChecks = (repo: RepoSnapshot): CheckPlan => {
/**
* the whole plan for one repo. `botBuilt` says whether the repo was made from app-template (the bot's repos and
* `apps`); Build is only for those, so anything else is skipped. undefined means it wasn't checked (the Build job,
* which only starts after the plan job said yes)
*/
export const planChecks = (repo: RepoSnapshot, { botBuilt }: { botBuilt?: boolean } = {}): CheckPlan => {
if (botBuilt === false) return { skip: true, reason: "not made from app-template; Build is only for the bot's repos" }
const ownCi = ownCiOf(repo)
if (ownCi) return { skip: true, reason: `the repo runs its own CI (${ownCi})` }
const text = repo.readText("package.json")
Expand Down Expand Up @@ -173,8 +180,14 @@ export const outputLinesOf = (plan: CheckPlan) => [
]),
]

/** BOT_CI_TEMPLATE_OF (the repo's template, blank for none) against BOT_CI_TEMPLATE; undefined when either isn't set */
export const botBuiltOf = (environment: NodeJS.ProcessEnv) => {
if (!environment.BOT_CI_TEMPLATE || environment.BOT_CI_TEMPLATE_OF === undefined) return undefined
return environment.BOT_CI_TEMPLATE_OF.toLowerCase() === environment.BOT_CI_TEMPLATE.toLowerCase()
}

if (import.meta.main) {
const plan = planChecks(directorySnapshot(process.cwd()))
const plan = planChecks(directorySnapshot(process.cwd()), { botBuilt: botBuiltOf(process.env) })
console.log(`Build: ${plan.skip ? "skipping — " : ""}${plan.reason}`)
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${outputLinesOf(plan).join("\n")}\n`)
if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, `### Build\n${plan.skip ? "Skipped" : "Plan"}: ${plan.reason}\n`)
Expand Down
7 changes: 5 additions & 2 deletions actions/pr-lint/lintPullRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,12 @@ const sections: Section[] = [
export const isLintInScope = ({ scope, author, body, botLogins = defaultBotLogins }: { scope?: string; author?: string; body?: string; botLogins?: string[] }) =>
scope === "all" || (author !== undefined && botLogins.includes(author)) || harnessMarker.test(body ?? "")

/** the content under a markdown heading, up to the next `##`/`###` heading (html comments after the heading are ignored) */
/**
* the content under a markdown heading, up to the next `##`/`###` heading (html comments after the heading are ignored).
* the heading must be a line of its own, so a mention of it in prose or backticks doesn't count
*/
export const sectionContentOf = (body: string, heading: string) =>
body.match(new RegExp(`${heading}\\s*(?:<!--.*?-->\\s*)*([\\s\\S]*?)(?=^## |^### |\\s*$)`, "m"))?.[1]?.trim()
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 => {
Expand Down
2 changes: 1 addition & 1 deletion actions/second-look/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ inputs:
description: "prepare, synthesize or abandon"
required: true
github-token:
description: "a Touchless Bot App token for the PR's repo only. prepare: contents + pull-requests read; synthesize: contents read, issues + pull-requests write (posting falls back to it when post-token is blank, and it re-requests clanker-in-chief after the inline review); abandon: issues write, for the didn't-finish reply when post-token is blank"
description: "a Touchless Bot App token for the PR's repo only. prepare: contents + pull-requests read; synthesize: contents read, issues + pull-requests write (posting falls back to it when post-token is blank, and it re-requests clanker-in-chief after the inline review); abandon: issues + pull-requests write, for the didn't-finish comment review when post-token is blank"
required: false
checks-token:
description: "a Touchless Bot App token with checks write on the PR's repo, minted on its own so a missing Checks permission only means no check. blank: no check"
Expand Down
11 changes: 11 additions & 0 deletions actions/second-look/approveHintOf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/** the comment that forces an approval; the relay (scripts/bot-ci-relay/requestOf.ts) acts on it */
export const approveCommand = "@clanker-in-chief approve"

/**
* the line under a review that didn't approve: tags the pr's author (not a bot, which can't read it) and tells them
* they can force an approval for now
*/
export const approveHintOf = (author?: string) => {
const who = author && !author.endsWith("[bot]") ? `@${author}: ` : ""
return `${who}if this shouldn't hold the PR up, comment \`${approveCommand}\` (add why after it) and I'll approve it anyway.`
}
14 changes: 14 additions & 0 deletions actions/second-look/changedLinesOf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/** second look reviews a pr only when github's additions plus deletions stay at or under this */
export const maxChangedLines = 5_000

/** lines a pr changes, github's additions plus deletions; undefined when github didn't report both */
export const changedLinesOf = ({ additions, deletions }: { additions?: number; deletions?: number }) =>
typeof additions === "number" && typeof deletions === "number" && Number.isInteger(additions) && Number.isInteger(deletions) && additions >= 0 && deletions >= 0
? additions + deletions
: undefined

/** the line count when it's over the limit; undefined when the pr is reviewable, or when its size isn't known */
export const overLimitOf = (pull: { additions?: number; deletions?: number }) => {
const changed = changedLinesOf(pull)
return changed !== undefined && changed > maxChangedLines ? changed : undefined
}
5 changes: 4 additions & 1 deletion actions/second-look/checkRun.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
#!/usr/bin/env node
/**
* second look's check on the pr, in a dispatched run: `start` opens it on the head while the
* review runs, `abandon` closes it when the run ended without a review. synthesize.ts closes it
* review runs, `abandon` closes it when the run ended without a review, unless something already did (the relay's
* forced approval turns it green and cancels the run). synthesize.ts closes it
* with the verdict. best effort: without the app's checks permission there's just no check,
* and the review comment still posts.
*
Expand All @@ -28,6 +29,8 @@ const main = async () => {
if (action === "abandon") {
const id = process.env.SECOND_LOOK_CHECK_RUN_ID
if (!id) return console.log("second look: no check to close")
const current = await fetch(`https://api.github.com/repos/${repository}/check-runs/${id}`, { headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" } })
if (current.ok && ((await current.json()) as { status?: string }).status === "completed") return console.log("second look: the check is already closed (a forced approval?), leaving it")
await sendCheckRun({ token, repository, id, body: checkAbandonedOf({ result: process.env.SECOND_LOOK_RESULT || "failure", detailsUrl }) })
return console.log(`second look: closed the check on ${repository} (${id})`)
}
Expand Down
6 changes: 5 additions & 1 deletion actions/second-look/fetchPull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
/**
* second look, in a dispatched run: fetches the pr it was asked to look at and writes it as a
* pull_request event (targetOf.ts reads it), so every later step sees the same pr. it refuses
* repos outside the run's own org and prs that aren't open. with SECOND_LOOK_HEAD_SHA (what
* repos outside the run's own org, prs that aren't open, and prs that change over 5,000 lines.
* with SECOND_LOOK_HEAD_SHA (what
* prepare checked out), a pr that has moved on since is still reviewed at that commit.
*
* node fetchPull.ts
Expand All @@ -13,13 +14,16 @@
*/
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"
import { dirname } from "node:path"
import { maxChangedLines, overLimitOf } from "./changedLinesOf.ts"
import type { PullRequest } from "./types.ts"

/** why second look won't look at this pr; undefined when it will */
export const refusalOf = ({ repository, owner, pull }: { repository: string; owner: string; pull?: PullRequest }) => {
if (!/^[\w.-]+\/[\w.-]+$/.test(repository)) return `"${repository}" isn't an owner/name repository`
if (repository.split("/")[0].toLowerCase() !== owner.toLowerCase()) return `${repository} isn't in ${owner}`
if (pull && pull.state !== "open") return `${repository}#${pull.number} is ${pull.state ?? "not open"}`
const over = pull ? overLimitOf(pull) : undefined
if (pull && over !== undefined) return `${repository}#${pull.number} changes ${over} lines, over the ${maxChangedLines} second look will review`
return undefined
}

Expand Down
26 changes: 17 additions & 9 deletions actions/second-look/notifyUnfinished.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,37 @@
#!/usr/bin/env node
/**
* second look's wrap-up when a run ended without a review (and wasn't cancelled): the relay already
* told the pr a review is coming, so this says it isn't, with a link to the run. best effort.
* told the pr a review is coming, so this says it isn't, with a link to the run, as a comment review
* that tags the author with the force-approval hint (a plain pr comment when a review can't be posted). best effort.
*
* node notifyUnfinished.ts
*
* env: SECOND_LOOK_POST_TOKEN (clanker-in-chief's PAT) or GITHUB_TOKEN (the app, issues write),
* env: SECOND_LOOK_POST_TOKEN (clanker-in-chief's PAT) or GITHUB_TOKEN (the app, issues + pull requests write),
* SECOND_LOOK_REPOSITORY (validated by prepare), SECOND_LOOK_NUMBER, SECOND_LOOK_RESULT, SECOND_LOOK_STAGE,
* GITHUB_SERVER_URL, GITHUB_REPOSITORY, GITHUB_RUN_ID.
*/
import { approveHintOf } from "./approveHintOf.ts"
import { unfinishedReplyOf } from "./unfinishedReplyOf.ts"

const main = async () => {
const token = process.env.SECOND_LOOK_POST_TOKEN || process.env.GITHUB_TOKEN || ""
const repository = process.env.SECOND_LOOK_REPOSITORY ?? ""
const number = process.env.SECOND_LOOK_NUMBER ?? ""
if (!token || !repository || !/^\d+$/.test(number)) return console.log("second look: no token or pr to tell, skipping the reply")
const api = (path: string, body?: unknown) =>
fetch(`https://api.github.com/repos/${repository}/${path}`, {
method: body === undefined ? "GET" : "POST",
headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "content-type": "application/json" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
})
const detailsUrl = `${process.env.GITHUB_SERVER_URL || "https://github.com"}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
const body = unfinishedReplyOf({ result: process.env.SECOND_LOOK_RESULT || "failure", stage: process.env.SECOND_LOOK_STAGE || "review", detailsUrl })
const response = await fetch(`https://api.github.com/repos/${repository}/issues/${number}/comments`, {
method: "POST",
headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "content-type": "application/json" },
body: JSON.stringify({ body }),
})
if (!response.ok) throw new Error(`replying answered ${response.status}`)
const pull = await api(`pulls/${number}`)
const author = pull.ok ? ((await pull.json()) as { user?: { login?: string } }).user?.login : undefined
const body = `${unfinishedReplyOf({ result: process.env.SECOND_LOOK_RESULT || "failure", stage: process.env.SECOND_LOOK_STAGE || "review", detailsUrl })}\n\n${approveHintOf(author)}`
const review = await api(`pulls/${number}/reviews`, { event: "COMMENT", body })
if (review.ok) return console.log(`second look: told ${repository}#${number} the run didn't finish (comment review)`)
const comment = await api(`issues/${number}/comments`, { body })
if (!comment.ok) throw new Error(`replying answered ${review.status}, then ${comment.status}`)
console.log(`second look: told ${repository}#${number} the run didn't finish`)
}

Expand Down
Loading