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
145 changes: 49 additions & 96 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,122 +2,76 @@

## Setup

```sh
mise install # every tool this repository uses, pinned in mise.toml
mise exec -- lefthook install # shellcheck, gitleaks, commit-message check
mise run lint # shellcheck + shfmt over every tracked shell file
mise run test-unit # the offline suites
```
1. `mise install` — every tool this repository uses, pinned in `mise.toml`.
2. `mise exec -- lefthook install` — the git hooks (`lefthook` is pinned, not on `PATH`).
3. `mise run lint`
4. `mise run test-unit`

`lefthook` is pinned here, not on your PATH, so the bare command works only if
your shell already runs `mise activate`. The hooks are deliberately cheap —
`lint` and a secret scan at `pre-commit`, a Conventional Commit check at
`commit-msg`, and no `pre-push` gate: the suites run in this repository's CI on
every push, and paying two minutes locally to learn the same thing is a tax.

`./scaffold` loads this repository's own pinned `jq` and `yq` before it does
anything else, so it needs no wrapper — from the clone, a symlink, or `PATH`.
| Hook | Runs |
| --- | --- |
| `pre-commit` | `mise run lint`, gitleaks on staged changes |
| `commit-msg` | Conventional Commit check (a grep; this repository has no node) |
| `pre-push` | nothing: CI runs the suites on every push |

## Tasks

| Task | What it runs |
| Task | Runs |
| --- | --- |
| `lint` | shellcheck + shfmt (`-i 2 -ci`) over every shell file the repository tracks |
| `test-unit` | the suites that never invoke an adapter's generator — offline and quick |
| `test-integration` | the suites that generate a real project as a fixture |
| `test` | every suite, including the per-adapter smoke tests |
| `test-runner` | `test-unit` and `test-integration` under the environment a GitHub runner has |
| `ci-unit` | `lint` + `test-unit` — what CI runs on a pull request |
| `checklist` | `lint` + `test` — the pre-push gate |

`mise run test-runner` matters more than its name suggests. `pnpm` turns on
`--frozen-lockfile` when `CI` is set, and `mise` trusts every config it finds
for the same reason; a suite that passes without those variables says nothing
about the runner. Several failures have only ever appeared there.
| `lint` | shellcheck + shfmt (`-i 2 -ci`) over every tracked shell file |
| `test-unit` | The offline suites, listed by name in `mise.toml` |
| `test-integration` | The suites that generate a real project as a fixture |
| `test` | Every suite in `tests/`, including the per-adapter smoke tests |
| `test-runner` | `test-unit` and `test-integration` with `CI`, `GITHUB_ACTIONS` and `MISE_YES` set, as on a runner |
| `ci-unit` | `lint` + `test-unit`: the `unit` job in `.github/workflows/ci.yml` |
| `checklist` | `lint` + `test` |

## Writing a test
`test-runner` matters: with `CI` set, pnpm uses `--frozen-lockfile`, so a suite can pass locally and fail on a runner.

Assert with `assert_ok` rather than `[ "$status" -eq 0 ]`. bats captures the
command's output into `$output` and prints none of it, so a bare status check
reports the line that failed and nothing about why — every diagnosis in this
repository has started by adding that output back by hand.
## Test lanes

Each test generates its own project. That is slow, and deliberately so: a
shared fixture makes one test's mess into the next test's failure, and a suite
whose result depends on execution order cannot say what broke. The suites run
with `--jobs` instead, which overlaps independent work without sharing any.
| Suite | Lane | Where CI runs it |
| --- | --- | --- |
| Listed in `test-unit` | unit | `.github/workflows/ci.yml`, `unit` job |
| Listed in `test-integration` | integration | `.github/workflows/ci.yml`, `integration` job |
| `tests/new-<adapter>.bats` | smoke, per adapter tier | `.github/workflows/adapters.yml` |
| `tests/provenance.bats` | provenance | `.github/workflows/provenance.yml` |

When a test cannot hold its own precondition — `mise` pre-trusts every config
on a runner, `gh` has no credentials there — `skip` with the reason rather than
failing. A red that is about the environment teaches nothing.
`tests/contract.bats` fails when a suite is in no lane, or when a `test-unit` suite runs an adapter generator.

## Adding an adapter
## Writing a test

See [docs/runbook/add-an-adapter.md](docs/runbook/add-an-adapter.md). In short:
an adapter invokes a framework's own generator and overlays its own files on
the result. It must ship `adapter.env`, `mise.toml`, `Dockerfile` and
`.env.example`, implement all nine contract tasks, and pass `scaffold lint`.
- Assert with `assert_ok` (`tests/helpers/setup.bash`), not `[ "$status" -eq 0 ]`: it prints the captured output on failure.
- Each test builds its own fixtures; no suite uses `setup_file`. Suites run in parallel with `--jobs`.
- When the environment cannot hold a precondition, `skip` with the reason instead of failing.

`format`, `lint` and `check` must report without repairing. The linter enforces
this by rejecting a writing flag in their `run` — see
[ADR-0011](docs/decisions/0011-task-contract-names-follow-immich.md).
## Adding an adapter

Nothing needs doing for the wizard: it builds its questions from `scaffold
list`, so a new adapter appears there as soon as `scaffold lint` passes —
see [09-wizard](docs/tour/09-wizard.md).
Follow [docs/runbook/add-an-adapter.md](docs/runbook/add-an-adapter.md). The wizard needs no change: it reads `scaffold list`.

## Adding a service

A service is a directory under `services/`, not an adapter — see
[ADR-0019](docs/decisions/0019-services-are-not-adapters.md). It must ship
six files: `service.env` (`SERVICE_NAME`, `SERVICE_KIND`, a digest-pinned
`SERVICE_IMAGE`), a shared `compose.fragment.yaml`, a `compose.prod.fragment.yaml`
/ `compose.dev.fragment.yaml` / `compose.test.fragment.yaml` delta per lane,
and an `env.fragment`. None of the compose fragments may carry their own
`image:` line — `assemble_compose` writes the digest in from `service.env`.

It must also ship a driver, `drivers/<family>.sh`, for every adapter family
whose role is `api` or `app` — `laravel` and `nest` today; `next` takes none,
because `nextjs`'s role is `web` and the presentation tier opens no
connection. `scaffold lint` derives that family list from the adapters
themselves and fails a service that is missing any one of them, by name. A
new adapter family is the same problem from the other direction: it cannot
merge until every existing service has a `drivers/<that-family>.sh`, which is
why the lint requires the full matrix rather than checking each service in
isolation.
A service is a directory under `services/` ([ADR-0019](docs/decisions/0019-services-are-not-adapters.md)).

## What a change reaches

A change to `common/` or to an adapter reaches a project that already exists
only when somebody runs `scaffold update` in it — see
[ADR-0023](docs/decisions/0023-a-project-records-what-generated-it.md). A
change to a reusable workflow in *you/.github* reaches every project the next
time it runs, once `v1` moves (ADR-0005). Knowing which of the two you are
writing decides whether anything has to be done afterwards.

## Commits

Conventional Commits, enforced by lefthook at `commit-msg`. `feat:` and `fix:`
move the version of a generated project; `chore:` and `docs:` do not.
1. Add `service.env` with `SERVICE_NAME`, `SERVICE_KIND` and a digest-pinned `SERVICE_IMAGE`.
2. Add `compose.fragment.yaml` and the per-lane `compose.prod.fragment.yaml`, `compose.dev.fragment.yaml`, `compose.test.fragment.yaml`, none with an `image:` line.
3. Add `env.fragment`.
4. Add `drivers/<family>.sh` for every family of an `api` or `app` adapter — today `laravel`, `nest`, `flask`.
5. Run `./scaffold lint`: it derives the families from the adapters and names any missing driver.

## Versions
A new adapter family is the same check from the other side: every service needs its driver before it merges.

This toolbox is versioned by git tag and nothing else — there is no package to
publish, and the tag is the artefact. `scaffold --version` is `git describe`
against the checkout, and a generated project records that same string in its
`.scaffold.toml`, which is what `scaffold update` later diffs from.
## What a change reaches

Cut one from `main` after a change worth telling somebody about:
| Change | Reaches an existing project |
| --- | --- |
| `common/` or an adapter | When someone runs `scaffold update` in it ([ADR-0023](docs/decisions/0023-a-project-records-what-generated-it.md)) |
| A reusable workflow in *you/.github* | On its next run, once `v1` moves (ADR-0005) |

```sh
git tag v0.2.0
git push origin v0.2.0
```
## Commits and versions

Tagging is deliberately manual. Release Please is not set up here the way it is
in a generated project, because nothing downstream installs this by version:
what a tag buys is a readable answer in `--version` and in every
`.scaffold.toml` written after it, not a distribution channel.
- Conventional Commits, checked at `commit-msg`.
- The toolbox is versioned by git tag only. `scaffold --version` is `git describe`, and `.scaffold.toml` records it.
- Tagging is manual: `git tag v0.2.0 && git push origin v0.2.0`.

## Before opening a pull request

Expand All @@ -126,5 +80,4 @@ mise run lint
mise run test-runner
```

`mise run test` additionally covers the per-adapter smoke tests, including the
tier B adapter, which takes about 25 minutes.
`mise run test` adds the per-adapter smoke tests; tier B's five tests take about five minutes each.
104 changes: 19 additions & 85 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,23 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)

A bash toolbox that generates fully configured client projects. Every generated
application implements the same nine-task contract — `install`, `format`,
`format-fix`, `lint`, `check`, `test`, `build`, `ci-unit`, `checklist` — so CI
runs one command per config root and never learns the language.
application implements the same nine-task contract, so CI runs one command per
config root and never learns the language.

## Install

The toolbox needs `git`, `mise`, `jq` and `yq`. `mise` supplies the last two,
so install it first ([instructions](https://mise.jdx.dev/getting-started.html)),
then:
Needs `git` and [`mise`](https://mise.jdx.dev/getting-started.html); `mise install` supplies `jq`, `yq` and the rest.

```sh
git clone https://github.com/ttncode/scaffold.git
cd scaffold
mise install # jq, yq, bats, shellcheck, zizmor, rush — pinned in mise.toml
mise install
./scaffold list
export PATH="$PWD:$PATH" # optional: run `scaffold` from anywhere
```

`scaffold` loads its own pinned `jq` and `yq` from this toolbox's `mise.toml`
before it does anything else, so it runs the same from a clone, from a symlink,
or from `PATH`:

```sh
ln -s "$PWD/scaffold" ~/.local/bin/scaffold
```

It reads that environment without changing directory, so a relative target is
always created where the command was run, not inside the toolbox. It still
refuses to run when `git` or `mise` itself is missing, and names which.
`scaffold` loads its pinned `jq` and `yq` itself, and creates a relative target where you run it.
Call it by its path or through `PATH`; a symlink to it does not work.

## Usage

Expand All @@ -41,99 +30,44 @@ scaffold new <name> [--web <adapter>] [--api <adapter>] [--app <adapter>]
[--db <service>] [--cache <service>]
scaffold add <dir> --adapter <adapter>
scaffold update [dir] [--dry-run]
scaffold publish [dir] [--public] [--no-protect] [--dry-run]
scaffold list
scaffold publish [dir] [--public | --private] [--no-protect] [--dry-run]
scaffold list [--adapters] [--services]
scaffold lint
scaffold --version
```

Run with no arguments in a terminal, `scaffold` walks you to a complete
`new` command instead of printing usage — see
[09-wizard](docs/tour/09-wizard.md). Anywhere else — a script, CI, no
terminal attached — it keeps exactly the behaviour below.

`new` creates a project. `add` installs another application into one that
already exists. `update` brings a project that already exists up to this
toolbox — it diffs `common/` and each adapter between the commit the project
records in its own `.scaffold.toml` and this one, and applies the result to
the project's own paths; `--dry-run` prints that patch instead. `publish` creates the GitHub
repository the project already names and applies the settings a generated
project needs but cannot carry in a file — see
[ADR-0024](docs/decisions/0024-publishing-a-project-is-part-of-generating-it.md).
`list` reports the adapters and their tiers. `lint` checks every adapter and every service
against the contract. `--version` reports
which commit of this toolbox is installed — `git describe`, so a working tree
with uncommitted edits says `-dirty`.

`new` prints one line per step rather than a package manager's output, and the
commands to run next when it finishes. `SCAFFOLD_VERBOSE=1` passes everything
through instead; a failing step prints its whole output either way.

`--db` and `--cache` select a database and a cache; each defaults to `none`
except `--db`, which defaults to `mysql` for a project with an `--api` or
`--app` adapter. Requesting either on a project with neither is refused —
the `web` tier has no driver, so nothing in the project could reach it. See
[ADR-0020](docs/decisions/0020-database-default-is-derived-from-requested-adapters.md).

The generated workflows call this account's reusable CI (`dot-github`) and
publish to its `ghcr.io` namespace. `scaffold new` resolves the account from
`SCAFFOLD_GITHUB_OWNER`, then `gh api user`, then `git config github.user`,
and refuses to generate if none of the three resolves.
What each command does, its defaults and its decision: [Commands](docs/README.md#commands).

## Adapter support tiers

"Supported" and "guaranteed" are different words. Tier membership is read
from each adapter's own `ADAPTER_TIER` (`adapters/*/adapter.env`) — see
[ADR-0012](docs/decisions/0012-tiered-adapter-support.md).
Each adapter's tier is `ADAPTER_TIER` in its `adapter.env` ([ADR-0012](docs/decisions/0012-tiered-adapter-support.md)).

| Tier | Adapters | CI runs it | Guarantee |
| --- | --- | --- | --- |
| A | `nextjs`, `nestjs`, `laravel-api`, `flask` | every pull request, and nightly | stays green through every dependency bump |
| B | `laravel-inertia` | when `adapters/laravel-inertia/**` changes, and weekly | verified regularly, not on every push — a full generation measures ~5 minutes per test |
| C | none currently | not automatically verified | may rot; no guarantee at all |
| B | `laravel-inertia` | a pull request that changes `adapters/laravel-inertia/`, weekly, or on manual dispatch | verified regularly, not on every push |
| C | none currently | not automatically verified | none |

## Services

A database or cache is a directory under `services/`, not an adapter — see
[ADR-0019](docs/decisions/0019-services-are-not-adapters.md). Each ships a
driver per adapter family (`laravel`, `nest`, `next`, `flask`); `scaffold lint`
requires the full matrix before an adapter in a new family can merge.
A database or cache is a directory under `services/`, not an adapter ([ADR-0019](docs/decisions/0019-services-are-not-adapters.md)).

| Slot | Services | Default |
| --- | --- | --- |
| `--db` | `mysql`, `postgres`, `mongodb`, `none` | `mysql` (with `--api` or `--app`), otherwise `none` |
| `--db` | `mysql`, `postgres`, `mongodb`, `none` | `mysql` with `--api` or `--app`, otherwise `none` ([ADR-0020](docs/decisions/0020-database-default-is-derived-from-requested-adapters.md)) |
| `--cache` | `redis`, `none` | `none` |

No DynamoDB: `compose.yaml` is attached to every release for a client to run
(ADR-0014), and the only DynamoDB that fits a compose file is an emulator
with no production counterpart in a self-hosted stack.
No DynamoDB: `compose.yaml` ships with every release for a client to run (ADR-0014), and the only DynamoDB that fits a compose file is an emulator with no production counterpart.

## Documentation

- [Start here](docs/README.md) — what the toolbox is, map, commands, glossary
- [Start here](docs/README.md) — what the toolbox is, map, commands, glossary, [reading path](docs/README.md#reading-path)
- [Tour](docs/tour/) — how the pieces fit, nine pages
- [Decisions](docs/decisions/) — why they fit that way
- [Runbooks](docs/runbook/) — what to do when something specific happens
- [Provenance](docs/PROVENANCE.md) — what is copied from immich, and where it drifted
- [Contributing](CONTRIBUTING.md) — the tasks, the tests, and how to add an adapter

### Reading path

New to this toolbox: [01-toolchain](docs/tour/01-toolchain.md) through
[03-ci](docs/tour/03-ci.md). That is day one — enough to generate a project and
understand what CI does with it.

Owning it for real, over the first week: the rest of the tour
([04-guardrails](docs/tour/04-guardrails.md) through
[09-wizard](docs/tour/09-wizard.md)), plus ADR-0001, ADR-0003 and ADR-0011.

`docs/runbook/` is not reading material — consult it when the situation that
names it actually arises.
- [Contributing](CONTRIBUTING.md) — tasks, tests, adding an adapter or a service

## Licence

MIT — see [LICENSE](LICENSE).

That covers this toolbox. A project it generates carries no licence of its own:
the toolbox does not write one, because who owns generated work and on what
terms is a question for the engagement it was generated for, not a default.
MIT — see [LICENSE](LICENSE). A generated project gets no licence file: its terms belong to the engagement it was generated for.
4 changes: 2 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Security policy

Report vulnerabilities privately rather than opening a public issue. Email the
maintainer listed in `CODEOWNERS` with a description and reproduction steps.
Report vulnerabilities privately, not in a public issue. Contact the owner in
`CODEOWNERS` (`@ttncode`) with a description and reproduction steps.
Expect an initial response within a few business days.
5 changes: 5 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ Measured on `scaffold new demo --api nestjs --web nextjs --db postgres`: 101 tra
| `scaffold --version` | also `-v` | `git describe` of this toolbox, `-dirty` for uncommitted edits | [ADR-0023](decisions/0023-a-project-records-what-generated-it.md) |
| `scaffold --help` | also `-h` | Prints usage | none |

| Variable | Effect |
| --- | --- |
| `SCAFFOLD_GITHUB_OWNER` | The account substituted for `you/`; otherwise `gh api user`, then `git config github.user`, else `scaffold new` refuses |
| `SCAFFOLD_VERBOSE=1` | Streams every step's output; by default a step's output is shown only when it fails |

## Glossary

| Term | Meaning | Where |
Expand Down
2 changes: 2 additions & 0 deletions docs/runbook/ci-is-red.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ means `scripts/check-provenance.sh` itself regressed, not that upstream
drifted; its `check` job means upstream actually moved — see
`docs/runbook/sync-with-upstream-immich.md`.

- `tests/contract.bats` fails with "<file> runs an adapter generator to completion", or the toolbox's `unit` job hits its 5-minute timeout: that `test-unit` suite runs an adapter generator. Move it to `test-integration` in `mise.toml`.

## Before you call a fix done

This project's own history has four separate fixes that each stopped the
Expand Down
Loading