Notes
Our take on CI
Plain ships its own CI, analogous to GitHub Actions, but workflows are real code, the platform is the standard library, and calling a model is one function. Here's how that changes the feel of writing a pipeline, and what it does for supply-chain safety and reliability along the way.
Jamie Davenport11 min
Most CI is YAML: a config language pretending to be a programming language, with string interpolation standing in for control flow and a feedback loop that runs only after you push. You write a block, push, wait, watch it go red on a typo, fix the typo, push again. The language can't help you, because there is no language: just a document that a runner interprets somewhere else, later.
It works. Half the software in the world ships through it. But nobody likes the part where a ${{ }} is subtly wrong, or where the answer to "how do I reuse this across three jobs" is "copy the block." We took CI as a given for so long that we stopped noticing how much of it is friction we'd never accept anywhere else in our stack.
Plain takes a different path. CI is built into the platform, analogous to GitHub Actions, but with a few differences that change how it feels to use. None of them are individually radical. Together they add up to something that feels less like configuring a runner and more like writing the rest of your software.
Workflows are real code
A Plain workflow is a source file you author in a real language (TypeScript, Python, or Go), not a YAML document. Here's the CI that builds this very repo:
import { defineWorkflow, on, pm, sh } from "@plain/ci";
defineWorkflow("ci", {
on: [on.push({ branches: ["main"] })],
run: async () => {
await pm.install(); // auto-detects pnpm/npm/yarn/bun from the lockfile
await sh("pnpm run build");
},
});Triggers, steps, and shell calls are ordinary expressions. You get autocomplete, refactoring, jump-to-definition, and the option to factor common logic into functions instead of copy-pasting blocks between jobs. A matrix isn't a special YAML construct; it's a for loop. A conditional step isn't if: with a string expression; it's an if. The patterns you already know carry over, because it's the same language you write everything else in.
Because the workflow is just code, it's type-checked by the same toolchain as the rest of the project. A typo in a trigger or a bad argument to a step is an error you see in your editor, not a red build you discover ten minutes after pushing. And when you do want to run it, you don't have to push to find out what happens:
plain ci run <workflow>That command runs the workflow locally, against your working tree, the same way it'll run on the server. The feedback loop collapses from "push and wait" to "save and run." It's the difference between debugging by deployment and debugging by execution: the thing every other part of your toolchain figured out years ago, finally applied to CI.
Compose like the rest of your code
The first thing you reach for in any language is "extract the repeated part into a function." YAML can't, really (anchors and reusable-workflow indirection are a pale imitation), so CI pipelines drift into copy-paste. When the workflow is code, the repeated part is just a function in a file you import:
import { pm, sh } from "@plain/ci";
export async function buildAndTest() {
await pm.install();
await sh("pnpm run build");
await sh("pnpm test");
}import { defineWorkflow, on } from "@plain/ci";
import { buildAndTest } from "./lib";
defineWorkflow("ci", {
on: [on.push({ branches: ["main"] }), on.pr.opened()],
run: () => buildAndTest(),
});Steps run in sequence because await runs in sequence. Want two builds at once? That's not a strategy.matrix block; it's Promise.all over an array, the way you'd parallelize anything else:
await Promise.all(
[1, 2, 3, 4].map((shard) => sh(`pnpm test --shard=${shard}/4`)),
);There's no second mental model for "how CI does loops" or "how CI does shared logic." It's the model you already have.
The platform is the standard library
This is the part that doesn't map onto GitHub Actions. Your run function is handed a ctx, the context for the event that triggered it, and through it, the whole platform. No marketplace action, no GITHUB_TOKEN, no REST calls assembled by hand. ctx carries the trigger (ctx.pr, ctx.commit, ctx.git.branch, ctx.trigger) alongside typed methods to act on it.
Think about what a triage automation looks like on a conventional CI: a third-party action you found in a marketplace, pinned to a SHA you hope is safe, fed a personal access token through a secret, talking to a REST API whose response shape you're guessing at. Here it's a few method calls on an object your editor can describe to you:
import { defineWorkflow, on } from "@plain/ci";
defineWorkflow("triage", {
on: [on.issue.labelled("bug")],
run: async ({ ctx }) => {
if (!ctx.issue) return;
// Open a tracking doc and link it to the issue.
await ctx.docs.create({
title: `Investigation: issue #${ctx.issue.number}`,
body: "## Repro\n\n## Root cause\n\n## Fix",
});
await ctx.comments.create({
parentKind: "issue",
parentId: ctx.issue.id,
body: "Triage doc created, picking this up.",
});
},
});The same client exposes ctx.issues.create, ctx.labels.add, and ctx.relations.add, so a workflow can comment, file follow-ups, label, and wire up relationships across the platform, all from one place, with one set of credentials. There are no tokens to mint and no scopes to get wrong, because the workflow is already running inside the platform it's acting on. Permissions come from the workflow's identity, not from a secret you copied into a settings page and now have to remember to rotate.
That's the quiet payoff of CI living in the platform rather than next to it: the integration surface that usually eats the first day of any automation project simply isn't there.
Calling a model is one function
AI models aren't a bolt-on action you find in a marketplace and feed an API key. They're part of the SDK. The whole surface you need is one call:
import { ai } from "@plain/ci";
const summary = await ai.chat("Summarize this build failure:\n\n" + log);ai.chat runs against models hosted by the platform, so there's no key to manage and nothing to provision. That makes it cheap (in effort, not just money) to drop a model into the middle of an otherwise ordinary pipeline step. Summarize a flaky test log before posting it. Draft a changelog entry from a diff. Turn a stack trace into a plain-English first guess at the cause and leave it as a comment. None of these are worth standing up an API integration for. All of them are worth one function call.
Free text is fine for a comment, but pipelines usually want a decision. So when you need structured output, hand ai.extract a schema and you get back a typed value, validated, no parsing:
import { ai } from "@plain/ci";
import { z } from "zod";
const triage = await ai.extract(log, {
schema: z.object({
category: z.enum(["flaky", "real-failure", "infra"]),
summary: z.string(),
suspectFiles: z.array(z.string()),
}),
});
if (triage.category === "flaky") await sh("pnpm test --retry 2");triage is typed exactly as the schema says, so the if below it is something the compiler checks, not something you hope the model formatted right. The model's output becomes an ordinary value your control flow can branch on.
And when a task is open-ended enough that you'd rather describe the goal than script the steps, ai.agent gives the model the platform itself as tools. You pass it the slice of ctx it's allowed to use, and it decides which calls to make:
import { defineWorkflow, on, ai } from "@plain/ci";
defineWorkflow("autolink", {
on: [on.pr.opened()],
run: ({ ctx }) =>
ai.agent({
goal: "Find the issue this PR closes, label it 'fixed', and link it to the PR.",
tools: [ctx.issues, ctx.labels, ctx.relations],
}),
});Same credentials, same typed surface as the rest of ctx; the model just calls the functions instead of you. The point isn't that AI belongs in every pipeline. It's that the cost of trying it should be a single line, so the decision is always "is this useful here?" and never "is this worth the setup?"
Example: a reviewer that leaves inline comments
Here's where the three ideas (code, ctx, and ai) start compounding. A pull-request reviewer reads the diff, asks a model for findings in a fixed shape, and posts each one as an inline comment on the exact line:
import { defineWorkflow, on, ai } from "@plain/ci";
import { z } from "zod";
defineWorkflow("review", {
on: [on.pr.opened(), on.pr.synchronized()],
run: async ({ ctx }) => {
const diff = await ctx.pr.diff();
const findings = await ai.extract(
"Review this diff. Flag real bugs, missing error handling, and " +
"anything that looks unintentional. Skip style nits:\n\n" + diff,
{
schema: z.array(
z.object({
file: z.string(),
line: z.number(),
severity: z.enum(["blocker", "warning", "note"]),
comment: z.string(),
}),
),
},
);
// Post each finding as an inline comment on the PR.
for (const f of findings) {
await ctx.pr.comment({ path: f.file, line: f.line, body: f.comment });
}
// Block the merge only if the model found something serious.
if (findings.some((f) => f.severity === "blocker")) {
await ctx.pr.requestChanges(`${findings.length} issue(s) found.`);
}
},
});No webhook to register, no diff to fetch over HTTP, no comment API to authenticate against, no JSON to coax out of the model by hand. ctx.pr.diff() and ctx.pr.comment() are the platform; ai.extract is the model; the for loop and the if are just the language. The whole reviewer is the thing it does, with nothing in between.
Example: bump and publish
Put those pieces together (platform access through ctx, a model through ai, and the feedback loop of running locally first) and a release pipeline gets short. This workflow bumps the version, asks a model to write the release notes from the commit log, publishes the package to Plain's own registry, and records the release as a doc, all in one file:
import { defineWorkflow, on, sh, pm, ai } from "@plain/ci";
defineWorkflow("release", {
on: [on.manual()],
run: async ({ ctx }) => {
// Bump the patch version (writes package.json, makes a commit + tag).
await sh("npm version patch -m 'release: %s'");
const { stdout: version } = await sh(
"node -p \"require('./package.json').version\"",
);
// Summarize everything since the previous tag with a model.
const { stdout: log } = await sh(
"git log --oneline $(git describe --tags --abbrev=0 HEAD^)..HEAD^",
);
const notes = await ai.chat(
"Write release notes from these commits, grouped into " +
"Features / Fixes / Internal:\n\n" + log,
);
// Build, then publish the package to Plain's registry. No registry
// URL, no auth token: on-platform credentials are implicit.
await pm.install();
await sh("pnpm run build");
await ctx.artifacts.publish({
name: "@plain/ci",
version: version.trim(),
dir: "dist",
});
// Record the release as a doc on the platform.
await ctx.docs.create({
title: `Release v${version.trim()}`,
body: notes,
});
},
});Look at what isn't there. No separate release config to keep in sync with the workflow. No action versions to pin and later upgrade. No registry credentials threaded through a secret. No second file describing how to post the release notes somewhere people will see them. The shell results you destructure, the model call, the artifact payload, and the doc body are all type-checked together, in one place, by the same compiler that checks the package you're releasing.
And because it's just code, the whole thing runs locally with plain ci run release before it touches the real registry. You can watch it bump the version, generate the notes, and tell you exactly what it would publish: the rehearsal and the performance are the same script, which is the only way you ever fully trust a release pipeline.
A smaller attack surface
The supply-chain incidents of the last couple of years have mostly rhymed. A popular third-party action gets compromised, and because uses: some-org/some-action@v3 pulls code that runs inside your job with your credentials, the blast radius is every pipeline that depended on it. Secrets land in logs; tokens get exfiltrated; the mutable tag you trusted points somewhere new overnight. The standard advice, pin every action to a full commit SHA, is sound, and almost nobody follows it consistently.
The marketplace is the vector, and Plain doesn't have one. When the platform is the standard library, the things a workflow calls (ctx.docs.create, ai.chat, pm.install) are first-party surface, versioned with the SDK, not arbitrary code you pulled from a stranger and handed your token to. There's nothing to pin, because there's no @v3 to repoint.
Credentials follow the same line. There's no long-lived access token sitting in a secrets page waiting to leak: a workflow acts as itself, with permissions scoped to what it's for and expiring with the run. The thing an attacker most wants out of CI, a durable credential, mostly isn't there to take.
None of this makes your own dependency tree safe. pm.install() still pulls whatever your lockfile points at, and that's a supply chain like any other. What goes away is the CI-specific layer: the third-party action running beside your code with your keys. That's the part that's been burning people lately, and it's the part the model removes by construction.
For everything still on the inside, the run is sandboxed deny-by-default. A workflow starts with no network egress, no filesystem outside its own workspace, and no credentials it wasn't explicitly handed; anything beyond that is something you opt into in the workflow, in code, where a reviewer can see it. So when a dependency does turn out to be hostile, a postinstall script that tries to phone home or read a token finds the doors already locked. You can't drive supply-chain risk to zero, but you can make a compromised package's default capabilities close to nothing.
When the runner goes down
The day before this post went up, GitHub Actions was down for the better part of an hour. If you've lived on shared CI for any length of time, you know the shape of it: nothing you can do, no commit that fixes it, just a status page and a queue that isn't moving. CI sits on the critical path between writing code and shipping it, so when it stops, so do you.
We won't pretend a young platform has a longer uptime record than the incumbents; it doesn't, and anyone who claims otherwise on day one is selling something. What we can do is build for reliability from the start rather than bolt it on later, and the code-not-config model helps more than it looks. Because a workflow is an ordinary program, the exact same run happens on your machine:
plain ci run releaseAn outage of the hosted runner is a degraded state, not a wall. The pipeline you'd normally hand to the server is a script you can also run yourself, so "CI is down" stops meaning "we can't ship" and starts meaning "we ship the manual way for an hour." Reliability isn't only about how rarely the service falls over. It's also about how much you're stuck when it does.
The shape of the bet
Each of these on its own is a small thing. Code instead of YAML. A context object instead of a marketplace. One function instead of an integration. A local run instead of a push. But the YAML era trained us to expect friction at every one of those seams, and to build elaborate habits around routing past it.
The bet is that when CI is real code, with the platform as its standard library and a model one function away, all of that friction was incidental: not the cost of doing CI, just the cost of doing it in a config file bolted onto a service that didn't know about the rest of your work. Take those away and the release pipeline you'd dread writing in YAML becomes one short file you'd actually enjoy.
This is an early take, and the design is still moving. Plain is in early access, and we're looking for a handful of teams to partner with closely as we build it. If that's you, join the waitlist.