From 19b0ac250490f03869258d4d8eeb37260f0a79ba Mon Sep 17 00:00:00 2001 From: Jun Heider Date: Wed, 21 Oct 2026 15:19:58 -0400 Subject: [PATCH] Release tested starter zips by hand A manually run Release workflow takes a version, runs pnpm check, builds bumpers-vX.Y.Z.zip with git archive, smoke-tests it by following the README quick start in a clean folder, and only then tags the commit and publishes a GitHub Release with the zip. Release-only files are export-ignored. README gains a "Get it" section; RELEASING.md and decision record 0005 describe the process. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QDhuY8My9jmziKrHogpQWV --- .gitattributes | 6 ++ .github/workflows/release.yml | 56 +++++++++++++++ README.md | 9 ++- RELEASING.md | 34 +++++++++ .../decisions/0005-releases-as-tested-zips.md | 24 +++++++ scripts/build-starter.ts | 70 +++++++++++++++++++ 6 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/release.yml create mode 100644 RELEASING.md create mode 100644 docs/decisions/0005-releases-as-tested-zips.md create mode 100644 scripts/build-starter.ts diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9960425 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..630bbcc --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/README.md b/README.md index 4743728..5a29afa 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..89b39cb --- /dev/null +++ b/RELEASING.md @@ -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`. diff --git a/docs/decisions/0005-releases-as-tested-zips.md b/docs/decisions/0005-releases-as-tested-zips.md new file mode 100644 index 0000000..c63dcbe --- /dev/null +++ b/docs/decisions/0005-releases-as-tested-zips.md @@ -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. diff --git a/scripts/build-starter.ts b/scripts/build-starter.ts new file mode 100644 index 0000000..2ebf0e2 --- /dev/null +++ b/scripts/build-starter.ts @@ -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( + (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).`);