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
6 changes: 6 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Files left out of the starter zip and GitHub's "Source code" downloads. They're only for
# publishing Bumpers itself. See RELEASING.md.
.gitattributes export-ignore
RELEASING.md export-ignore
.github/workflows/release.yml export-ignore
scripts/build-starter.ts export-ignore
56 changes: 56 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: Release

# Promote a version by hand: Actions tab > Release > Run workflow, or
# gh workflow run release.yml -f version=1.2.0
# Checks main, builds the starter zip, tests it the way the README says to use it, then tags the
# commit and publishes a GitHub Release with the zip. Nothing is tagged unless every step passes.
# See RELEASING.md.

on:
workflow_dispatch:
inputs:
version:
description: "Version to release, as X.Y.Z (no leading v)"
required: true

permissions:
contents: write

concurrency:
group: release
cancel-in-progress: false

jobs:
release:
# Only in the Bumpers repo itself: a project created from the template doesn't release Bumpers.
if: github.repository == 'coderjun/ts-bumpers'
runs-on: ubuntu-latest
env:
VERSION: ${{ inputs.version }}
steps:
- name: Release from main only
run: |
if [ "$GITHUB_REF" != "refs/heads/main" ]; then
echo "Releases come from main, not $GITHUB_REF. Pick main under 'Use workflow from'." >&2
exit 1
fi
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm check
- name: Build and smoke-test the starter zip
run: node scripts/build-starter.ts "$VERSION"
- name: Tag and publish the release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "v$VERSION" "dist/bumpers-v$VERSION.zip" \
--target "$GITHUB_SHA" \
--title "Bumpers v$VERSION" \
--generate-notes
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ A TypeScript starter for AI-agent development that keeps you out of trouble.

Like the bumpers in a bowling lane, it keeps you and your agents out of the gutter without taking over the throw. The guardrails are deterministic and enforced, not advisory. Formatting, linting, types, tests, and secret scanning run as hooks and CI checks that block, so an agent (or a person) can't skip them by forgetting.

## Get it

- **Download a release:** the latest `bumpers-vX.Y.Z.zip` from [Releases](https://github.com/coderjun/ts-bumpers/releases/latest). It's a versioned snapshot with no git history, tested to pass the quick start below before it was published.
- **Use the template:** click "Use this template" on [GitHub](https://github.com/coderjun/ts-bumpers) to create your own repository from the latest `main`.

Then follow the [quick start](#quick-start).

## Guardrails

| Guardrail | When it runs | What it catches |
Expand Down Expand Up @@ -33,7 +40,7 @@ New to TypeScript or to working with coding agents? Each step says what it does.
3. **Install the project**:

```bash
git init # skip if you cloned the repo
git init # only for the zip; a template or clone is already a repo
pnpm install # dependencies, git hooks, and the turn history
pnpm setup:tools # offers to install gitleaks and the GitHub CLI
pnpm check # lint, typecheck, check diagrams, and test
Expand Down
34 changes: 34 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Releasing Bumpers

How a version of Bumpers gets published. This file, the release workflow, and its build script are left out of the starter zip (see `.gitattributes`).

## Promote a version

1. Make sure `main` has what you want to release and its CI is green.
2. Pick the version, using [semantic versioning](https://semver.org/):
- patch (`1.0.1`): fixes, nothing a user has to change;
- minor (`1.1.0`): new guardrails or features that existing projects can adopt;
- major (`2.0.0`): changes a project made from an older version would have to migrate to, such as a new Node version.
3. Run the release: on GitHub, Actions > Release > Run workflow, from `main`, with the version. Or:

```bash
gh workflow run release.yml -f version=1.2.0
```

The workflow runs `pnpm check`, builds `bumpers-v1.2.0.zip`, and tests it the way the README says to use it: unzip, `git init`, `pnpm install`, `pnpm check`. Only then does it tag the commit `v1.2.0` and publish a GitHub Release with the zip and release notes generated from the merged pull requests. If any step fails, nothing is tagged or published.

## Try it first

Build and test the zip locally without publishing anything:

```bash
node scripts/build-starter.ts 1.2.0 # builds from the last commit; leaves dist/bumpers-v1.2.0.zip
```

## What's in the zip

Every committed file except the ones marked `export-ignore` in `.gitattributes`. Uncommitted and gitignored files (`.env`, `node_modules`, the turn history) can't get in, because the zip is built with `git archive` from the commit. To leave another file out, add it to `.gitattributes`, and to the check in `scripts/build-starter.ts`.

## The template repository

The repo is also a GitHub template, so people can click "Use this template" instead of downloading the zip. A template copies every file, including the release machinery, so the release workflow only runs in `coderjun/ts-bumpers`.
24 changes: 24 additions & 0 deletions docs/decisions/0005-releases-as-tested-zips.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# 0005: Releases are tested starter zips, promoted by hand

Status: accepted, 2026-10-21

## Context

People should be able to start a project from a known-good version of Bumpers without cloning its history. GitHub already attaches a "Source code (zip)" to every release, and can mark a repository as a template ("Use this template"). Neither checks that what people get actually works.

## Decision

A manually run workflow (`.github/workflows/release.yml`) takes a version, runs `pnpm check`, builds `bumpers-vX.Y.Z.zip` with `git archive`, and smoke-tests it by following the README quick start in a clean folder. Only if all of that passes does it tag the commit and publish a GitHub Release with the zip. Release-only files are marked `export-ignore` in `.gitattributes`, so they're left out of the zip and of GitHub's own source downloads. The repository is also a template, for people who'd rather start from `main` on GitHub.

## Why

- Promoting by hand keeps releases deliberate; every merge to `main` isn't a release.
- Running the workflow, rather than pushing a tag, means nothing is tagged unless the zip passes. A pushed tag would exist even when the build failed.
- `git archive` only includes committed files, so `.env`, `node_modules`, and the turn history can't leak into a release.
- The smoke test catches what the repo's own CI can't: a starter that works as a clone but not from a zip. For example, the diagram check needs a git repository, which the quick start's `git init` provides.

Not a reason: hiding Bumpers' history. The decision records and diagram changelog stay in the zip because they explain the guardrails.

## Consequences

Versions follow semantic versioning, described in `RELEASING.md`. Template users get every file, including the release workflow, so the workflow only runs in `coderjun/ts-bumpers`. To publish from a fork or a renamed repository, change that condition.
70 changes: 70 additions & 0 deletions scripts/build-starter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Builds the starter zip for a Bumpers release, then checks that it works the way the README says
// to use it: unzip, git init, pnpm install, pnpm check. Used by .github/workflows/release.yml. Run
// it locally to try a release without publishing anything:
// node scripts/build-starter.ts 1.2.0
// Builds from the last commit (HEAD), not uncommitted changes, and leaves dist/bumpers-v1.2.0.zip.
// Files marked export-ignore in .gitattributes (release machinery) are left out of the zip.

import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";

const version = process.argv[2] ?? "";
const tag = `v${version}`;
const name = `bumpers-${tag}`;
const zip = resolve("dist", `${name}.zip`);

function fail(message: string): never {
console.error(`Release ${tag}: ${message}`);
process.exit(1);
}

function run(command: string, args: string[], cwd = process.cwd()): string {
return execFileSync(command, args, { cwd, encoding: "utf8" }).trim();
}

function step(title: string, command: string, args: string[], cwd: string): void {
console.log(`\n▶ ${title}: ${command} ${args.join(" ")}`);
execFileSync(command, args, { cwd, stdio: "inherit" });
}

// 1. The version is X.Y.Z, unused, and higher than every earlier release.
if (!/^\d+\.\d+\.\d+$/.test(version)) fail(`"${version}" isn't a version like 1.2.0.`);
const parse = (v: string) => v.replace(/^v/, "").split(".").map(Number);
const newer = (a: number[], b: number[]) => {
for (let i = 0; i < 3; i++) {
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) > (b[i] ?? 0);
}
return false;
};
const released = run("git", ["tag", "--list", "v*.*.*"]).split("\n").filter(Boolean);
if (released.includes(tag)) fail("that version is already released.");
const latest = released.reduce<string | undefined>(
(best, t) => (!best || newer(parse(t), parse(best)) ? t : best),
undefined,
);
if (latest && !newer(parse(version), parse(latest))) {
fail(`the latest release is ${latest}; a new release has to be higher.`);
}

// 2. Build the zip from the last commit. Only committed files go in, so nothing local can leak.
mkdirSync("dist", { recursive: true });
run("git", ["archive", "--format=zip", `--prefix=${name}/`, "-o", zip, "HEAD"]);
const contents = run("unzip", ["-Z1", zip]).split("\n");
const leaked = contents.filter((f) =>
/\/(\.env|\.gitattributes|RELEASING\.md|\.github\/workflows\/release\.yml|scripts\/build-starter\.ts)$/.test(
f,
),
);
if (leaked.length > 0) fail(`the zip contains files it shouldn't:\n ${leaked.join("\n ")}`);

// 3. Use it the way a newcomer would, following the README quick start.
const workspace = mkdtempSync(join(tmpdir(), "bumpers-smoke-"));
run("unzip", ["-q", zip, "-d", workspace]);
const starter = join(workspace, name);
step("Start a repository", "git", ["init", "--quiet"], starter);
step("Install", "pnpm", ["install", "--frozen-lockfile"], starter);
step("Check", "pnpm", ["check"], starter);

console.log(`\nBuilt and checked ${zip} (${contents.length} entries).`);
Loading