The Reepolee render pipeline is a set of TypeScript files. bun scripts/ssg.ts. bun scripts/generate_sitemap.ts. bun scripts/prepare_images.ts. bun scripts/git_commit_push.ts. The prod target, which takes a project from source to deployed, chains them together in three commands: bun rel && bun ssg && bun git:commit-push. No Makefile, no YAML pipeline that runs locally, no task runner configuration with a plugin system and its own versioning concerns. Just scripts, in the same language as everything else, doing the work directly.

People sometimes ask why not a task runner, and there is a perfectly good ecosystem of them, mature and well understood, so the question is fair. But the comparison is not really the interesting part, because the choice of scripts over a task runner is a small consequence of a larger preference, and the larger preference is this: we want the awkward, platform-specific parts of a build to live inside code we can read, in the same language as everything else, rather than leaking out into configuration, plugin behavior, or the assumption that a particular binary happens to be installed on the machine. A script is worth reaching for not because it has fewer moving parts than a task runner, although it does, but because it is the place where a platform quirk can be absorbed, named, and made to behave the same everywhere, which is the thing I find myself valuing more the longer a project lives.


The Quirks Live in Code, Where You Can See Them

Every build eventually collides with something that is not the same on every machine, because the shell is different on Windows than it is on a Mac, because a path separator is a backslash in one place and a slash in another, because an image encoder that was fast on the developer's laptop turns out to depend on a native binary that the CI runner does not have, and because the version of a tool that was installed globally last year is not the version installed globally this year. These are the parts of a build that quietly consume an afternoon, and the question that matters is not whether they exist, because they always do, but where they are allowed to live.

When the build is a task runner with plugins, the quirk has to be handled somewhere outside the code you wrote, which usually means a plugin's own assumptions, a configuration flag that papers over the difference, or a note in a README telling the next person to install something first. When the build is a script, the quirk lives in a function you can open and read, given a name that says what it is working around, so that the workaround is visible instead of implicit and can be changed by the same person who found the problem.

This is the real reason we use Bun's native APIs throughout, and not merely to avoid a dependency. Bun.$ is not a wrapper around the system shell but its own parser and interpreter, so the shell grammar we write, the redirects and pipes and quoting and variable expansion, is the same on Windows as it is on a Linux runner, and a handful of the usual Unix commands like ls, rm, mkdir, cat and which are built into it, which means the scripts do not reach for rimraf or cross-env or assume coreutils is present to do ordinary file wrangling. This does not make every command portable, because the moment a script shells out to a genuinely platform-specific external binary it is as platform-dependent as it would be anywhere else, so the win is bounded but real: the shell language itself, and the everyday commands, stop being a source of difference between machines. Bun.file and Bun.write read and write without a stream-handling library and without the differences between how one platform buffers and another flushes. Bun.Image powers the image pipeline that used to need sharp and the native binary it drags along, which is the exact kind of dependency that works everywhere until the one time it does not, on a new architecture or an unfamiliar CI image, at which point the failure is somebody else's compiled code and not something you can read. Folding the encoder into the runtime means the pipeline runs anywhere Bun runs, and when it behaves oddly the behavior is in a .ts file we own rather than behind a binary we do not.

The gain is not that the quirks disappear, because they do not, but that they are collected into readable code in one place, named for what they are, and owned by the people who maintain the rest of the application, instead of being scattered across plugin defaults and setup instructions where nobody quite remembers why they are there.


Simplicity Is the Wrong Word, But It Points at the Right Thing

When people say a script is "simpler" than a task runner, what they usually mean is that a script has fewer concepts in it, and that is true but not quite the point. A task runner has a model of its own, whether that is a configuration object with a particular shape that its plugins expect, or a pipeline of sources and sinks and transforms chained together, and while none of these are complicated in themselves, they are different, different enough that when you come back to a project after six months you are not just reading code but decoding the runner's model before you can understand what the build actually does.

A TypeScript script has no such model. It is just code, and the code does what it says. The git_commit_push.ts script stages everything, checks if there is anything staged, and either commits and pushes or exits cleanly, because that is what those thirty lines say when you read them top to bottom, without knowing anything about the tool that runs them except that it is Bun. The grammar is the language you already know, which means the script is legible to anyone who can read TypeScript and is running for the first time in the same amount of time it would take for someone who has been maintaining it for years.


Updates Are Yours to Make

The same legibility matters most at the moment something needs to change, and something always needs to change, eventually, because the build needs a new step, or an existing step needs a different flag, or a script that was correct for one environment needs to be correct for two. When the build lives in a configuration layer that is not quite code, the change is a change to that layer, and the review becomes a review of intent rather than behavior, because tracing exactly what will happen means also knowing the behavior of whatever runs the configuration, which usually lives somewhere the reviewer has not read.

With a script, a change to the build is a change to TypeScript. The diff is TypeScript. The reviewer reads TypeScript. When the generate_sitemap.ts script needed to handle the localize: false frontmatter key to skip alternate-language <url> entries for non-localized pages, the change was two dozen lines of TypeScript in the script itself, the diff was exactly what it looks like, and anyone who has read the sitemap spec knows whether the behavior is correct just by reading the code that produces it.

There is a subtler version of this that matters during development: scripts can be run directly, with arguments, from the terminal, because they are just programs. bun scripts/generate_sitemap.ts --site-url https://staging.example.com --dist ./dist-staging is a command you can run when you're debugging the sitemap output for a new deployment environment. No task runner invocation, no intermediate configuration layer, just the script with the flags it already knows about because they are parsed in a function at the top of the file.


Scripts Tell You What They Did

There is another reason, about verifiability, and I think it is the one that is hardest to articulate until you've been burned by its absence.

A build step that succeeds silently has told you nothing. A build step that prints what it did has given you a log you can reason about, share with a colleague, and compare against the last run to understand what changed. Every script in the Reepolee pipeline prints its work: the sitemap script prints the number of URLs included and the number of pages skipped and why, the RSS script prints the language and the number of items for each feed, the image script prints every file it processed and whether it was a cache hit or a full re-encode. The ═══ separator and the final summary block are not decoration but a deliberate choice to make the output readable in a terminal, skimmable in a CI log, and easy to parse by eye when something unexpected happens.

The vendor_check.ts script extends this further. Vendored JavaScript libraries, such as a validation library or a syntax highlighter, are committed to the repository as static files, which means the version that runs in production is pinned to exactly what was reviewed and committed. The vendor check reads the version embedded in each file's header, reads the version recorded in the package.json download script, and tells you whether they match, and it does not fix anything but reports and leaves the human to decide, which is the distinction that makes the script verifiable rather than merely automated.

The engine drift check is the most elaborate version of this pattern we have. The template engine is shared upstream code, and the script that checks for drift does something that I find genuinely elegant: it transpiles both the local copy and the canonical copy with Bun's built-in TypeScript transpiler, strips whitespace and trailing commas, and compares the results, which means two files that are formatted differently but logically identical produce the same fingerprint and are not flagged as drifted, while two files that have diverged in behavior are caught even if the divergence is subtle. When there is a difference, the script writes a unified diff to engine_drift.diff and exits nonzero, so the CI fails and the developer reads the diff, because the tool found something a human needs to look at and handed it off rather than trying to resolve it on its own.

When a tool decides on its own, you inherit whatever it decided without ever seeing it; when it reports and stops, the decision stays with you, and that is the difference we keep reaching for.


Scripts Know the Domain

One thing that would be lost with an external task runner is the ability for scripts to import from the application's own libraries, and that ability turns out to matter more than it sounds.

The generate_sitemap.ts script is not a standalone tool that treats the application as a black box. It imports $lib/static_site to enumerate the page files. It imports $lib/i18n to load translations and resolve localized route names. It imports $lib/content_visibility to apply the same draft and future-date filtering rules that the dev server applies, so the sitemap sees the same pages the site actually serves. The RSS generator does the same, calling into $lib/collect_records with the same arguments the blog index page uses to sort and filter posts.

This means the scripts and the application share a model of what the project is, and changes to that model, whether a new frontmatter field, a new language, or a change to how routes are resolved, propagate to the scripts through the same import, automatically, without a separate integration layer. The scripts are not shims around the application but participants in it, which is only possible because they are written in the same language, import from the same paths, and run in the same runtime.

Anything that drives the build from outside the application loses this, because an external tool runs commands across a process boundary, which means whatever the application knows has to be re-expressed as flags or configuration the tool can read. We have seen projects where the list of supported languages appears in three places, in the application code, in the i18n build script, and in a CI matrix, all of which need to stay in sync and all of which drifted at some point, because there is no single source they all read from. With scripts that import from the application, the list appears once, in the configuration module, and everything that needs it reads it from there.


The Generator Is a Script Too

The clearest illustration of all of this is not in the build pipeline at all but in the code generator, which is the tool that scaffolds a CRUD resource, a route, or a page from the database, and which is itself nothing more than bun generator/reeman.ts. It opens as a terminal menu, but underneath it is the same shape as everything else, a thin entry point that delegates to small flow modules, one per thing it knows how to do, with no framework underneath and nothing to learn beyond the code that is there.

What makes the generator worth describing here is what it takes as input, because it does not read a schema file that someone wrote down and hopes is current, it reads the live database, introspecting every table, column, and foreign key at startup so that what it generates matches the database that actually exists rather than a description of it that may have drifted. This is the same idea as the sitemap script importing the application's own libraries, carried to its natural end: the tool's input is the running system, so the generated CRUD, the route wiring, and the translation scaffolding are correct by construction, and when the schema changes the generator sees the change the next time it runs because it looked at the database, not at a copy of what the database used to be. The flows even reach back into the application afterwards, importing the same live-reload notify that the dev server uses, so a freshly generated route shows up in the running site without a restart, which is only possible because the generator is a participant in the application and not a separate program pointed at it from outside.

The maintenance tools in the same menu are where the "reports, human decides" discipline shows up again, and this time with the database as the thing at stake. The translation prune tool scans every .ree template for the keys they actually reference, compares that against the keys in the database, and writes a timestamped .sql file of DELETE statements for you to read before anything is removed, while the domain-compliance check introspects the live schema, flags the columns that do not match the canonical type taxonomy, and offers to generate an ALTER TABLE script rather than altering anything itself. In both cases the script does the tedious, error-prone scanning and comparison that a human should not do by hand, and then it stops at exactly the point where judgment is required, handing over a reviewable artifact instead of quietly changing the database, because a tool that rewrites your schema while you are not looking is not a tool you can trust, and a tool that hands you the SQL and lets you read it is.


The prod Pipeline in Practice

What this looks like in practice is a prod target that reads: bun rel && bun ssg && bun git:commit-push. The ssg step runs the image pipeline, the CSS compilation, the static site generator, the sitemap, and the RSS feeds in sequence. The git:commit-push step stages all changes, checks whether there is anything to commit, exits cleanly if the working tree is already clean so the command is safe to run repeatedly without producing empty commits, and otherwise commits and pushes.

Three commands. Each does one clearly named thing. Each is a TypeScript file you can open, read from top to bottom, and understand without consulting documentation beyond what TypeScript itself requires. When the sitemap is wrong, you open generate_sitemap.ts. When the build is slow, you open ssg/pipeline.ts. When the git step fails, you read the thirty lines of git_commit_push.ts and the answer is in there.

That legibility, the ability to read the tool and understand it, is what we mean when we say scripting is verifiable. Not just that the output is honest, but that the mechanism is open, in the same place as everything else, maintained by the same people, and subject to the same review process as the application itself. There is no build system to upgrade, no plugin compatibility matrix to manage, no documentation to consult that lives somewhere other than the source file you already have open.

It is a small preference with a large surface area. We have found it works well.