This workflow is valid and common:
name: ci
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: self-hosted
strategy:
fail-fast: false
matrix:
node: [20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
- run: npm testThe problem appears when several jobs repeat the same setup, a matrix must stay in sync with a list in package.json, or copied permissions: blocks drift. A workflow has control flow, environment, inputs, and outputs, but YAML has no functions, types, imports, or isolated tests. Copying the configuration creates multiple places that can diverge.
Keep the provider's YAML
One approach is a proprietary workflow language that runs on one runtime and exports YAML only when needed. That makes the checked-in workflow harder for reviewers to read and turns migration into a rewrite.
preloop takes a different approach. Write workflow source in TypeScript, Python, Go, or Rust, and generate the YAML your provider already runs. Both files stay in the repository: CI executes and reviewers read the YAML; the source produces it.
The generated YAML is the durable artifact. If the generator is removed, the pipeline still runs.
The same workflow as TypeScript
import { workflow, job, run, uses } from "@preloopdev/sdk";
const node = [20, 22];
export default workflow("ci", {
on: {
push: { branches: ["main"] },
pull_request: {},
},
permissions: { contents: "read" },
jobs: {
test: job({
"runs-on": "self-hosted",
strategy: { "fail-fast": false, matrix: { node } },
steps: [
uses("actions/checkout@v6"),
uses("actions/setup-node@v4", {
with: { "node-version": "${{ matrix.node }}", cache: "npm" },
}),
run("npm ci"),
run("npm test"),
],
}),
},
});A misspelled key is a type error instead of a problem discovered during a job. The matrix comes from one list, so adding Node 24 updates every job that references it.
For repeated setup, define it once:
const setup = (node: number) => [
uses("actions/checkout@v6"),
uses("actions/setup-node@v4", {
with: { "node-version": String(node), cache: "npm" },
}),
run("npm ci"),
];Nine jobs can import the same helper. The benefit is keeping required copies identical; the YAML remains the file the provider executes.
Detect drift
Generation refuses to overwrite a workflow edited outside the generator. It reports the workflow and its owning source. --force is available when an explicit overwrite is intended.
--check writes nothing and exits non-zero when a workflow is stale, missing, or edited. A pull request job can run this check and report the offending filename. A manifest maps each source file to the workflow it owns, so the tool does not infer ownership from a header comment.
Import existing workflows
Import converts existing YAML into editable source and leaves the original files untouched. The pipeline continues to use the original YAML while the source is introduced.
Import also refuses to overwrite a source file changed since it was imported. Existing edits are preserved.
Lint the generated output
Generation does not replace review. The linter checks the zizmor security audit catalog natively, plus comment-aware permission checks, unreachable steps, and references that are not pinned. Warnings exit 0 and errors exit 1, so the command can gate a pull request. A pedantic mode promotes additional policy checks.
When shellcheck is installed, run: blocks are checked as a separate program. The linter can also read a context file containing available runner labels and secret names. It reads names, never values, and can catch a runs-on label that no runner advertises.
Add languages through a plugin
TypeScript, Python, Go, and Rust are built in. Other languages use a versioned protocol with JSON on stdin, JSON on stdout, and three operations: describe, emit, and synth. A protocol version mismatch is a hard error. Plugins run with the repository root as their working directory and are never piped through a shell.
Scope
Generated workflows do not make CI faster or change what a workflow does. They do not replace the YAML file the provider executes, and they do not require preloop to run it.
They let a team review workflow source as code while keeping provider-compatible YAML as the executable artifact.