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
48 changes: 24 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,47 +5,47 @@
# sprout

Every pull request gets its own preview: an isolated database on one shared
instance plus an optional live app, for teams that review running code instead
of diffs. Self-hosted, no platform lock-in — everything is torn down when the
PR closes.
instance plus an optional live app, for teams that review running code
instead of diffs. Self-hosted, no platform lock-in — everything is torn down
when the PR closes.

```text
PR opened / push PR closed
│ │
▼ ▼
sprout deploy ──► gateway ──► sprout teardown
│ │
├─ CREATE DATABASE ├─ stop containers
├─ start app (+ Traefik) └─ DROP DATABASE
└─ optional seed image
│
▼
preview URL for this PR
│ │
▼ ▼
sprout deploy ──► gateway ──► sprout teardown
│ │
├─ CREATE DATABASE ├─ stop containers
├─ start app (+ Traefik) └─ DROP DATABASE
└─ optional seed image
│
▼
preview URL for this PR
```

## Adopt in three files

`.sprout.yaml`, a CI include, and the `Dockerfile` your app already ships —
plus two CI variables from your operator. The published gateway image
`.sprout.yaml`, a CI include, and the `Dockerfile` the app already ships —
plus two CI variables from the operator. The published gateway image
reports anonymous [install telemetry](docs/telemetry.md) by default
(`SPROUT_TELEMETRY=off` stops it). Humans:
(`SPROUT_TELEMETRY=off` stops it). Humans start at
[Getting started](docs/getting-started.md). Agents: paste the
[Onboarding prompt](docs/onboarding-prompt.md) into your coding harness and it
wires the repo for you. Live pitch:
[Onboarding prompt](docs/onboarding-prompt.md) into a coding harness and it
wires the repo. Live pitch:
[the marketing page](https://simpros.github.io/sprout/).

## Docs

- **[Public docs](https://simpros.github.io/sprout/docs/index.html)** — concept, guides, reference
- **[Public docs](https://simpros.github.io/sprout/docs/index.html)** — guides and reference
([source](docs/site/index.html) · [llms.txt](llms.txt))
- [Getting started](docs/getting-started.md) — first preview
- [Adopting a repo](docs/adopting-a-repo.md) — `.sprout.yaml` manifest reference
- [Getting started](docs/getting-started.md) — first preview in one sitting
- [Adopting a repo](docs/adopting-a-repo.md) — write `.sprout.yaml`, migrate at startup
- [CI integration](docs/ci-integration.md) — GitHub / GitLab wiring, reset
- [Previews](docs/previews.md) — databases, seeding, services, mail
- [Operator deploy](docs/operator-deploy.md) — compose stack, env, Traefik
- [CLI reference](docs/cli-reference.md) — every command
- [Troubleshooting](docs/troubleshooting.md) — error catalogue
- [Telemetry](docs/telemetry.md) — anonymous install reporting, off switch
- [CLI reference](docs/cli-reference.md) — every command and flag
- [Troubleshooting](docs/troubleshooting.md) — symptom → fix catalogue
- [Telemetry](docs/telemetry.md) — what is sent, what it costs, off switch
- [Onboarding prompt](docs/onboarding-prompt.md) — paste into a coding harness
- [`examples/adopting-repo/README.md`](examples/adopting-repo/README.md) — copy-paste starter
- [`CONTEXT.md`](CONTEXT.md) — domain vocabulary
Expand Down
152 changes: 79 additions & 73 deletions docs/adopting-a-repo.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,25 @@
# Adopting a repo

`.sprout.yaml` is the config-as-code file in an adopting repo: slug, preview
hostname template, optional health-check settings, optional companion service
routing metadata, optional `build` / `seed` image blocks, optional `db`
provider block, and computed env values. This page is the contract.
Write the `.sprout.yaml` config-as-code file at the repo root and the app
entrypoint that runs inside the preview. Each section below is one task.

- First preview? Start in [Getting started](getting-started.md).
- CI wiring for your forge? See [CI integration](ci-integration.md).
- Lifecycle (databases, seeding, services, mail)? See [Previews](previews.md).
- Commands and debugging? See [CLI reference](cli-reference.md).
- First preview: [Getting started](getting-started.md).
- CI wiring for the forge: [CI integration](ci-integration.md).
- Lifecycle (databases, seeding, services, mail): [Previews](previews.md).
- Commands and debugging: [CLI reference](cli-reference.md).

Copy-paste app files live in
[`examples/adopting-repo/README.md`](../examples/adopting-repo/README.md).

## Manifest keys (`.sprout.yaml`)

The CLI reads this file locally and sends parsed values to the gateway.
Unknown keys are rejected (`unknown key: <path>`). This table is the
contract; the notes directly below it (connection env, value grammar, merge
order) are part of the contract. Seeding order lives in
Look up any key here. The CLI reads this file locally and sends parsed
values to the gateway. Unknown keys are rejected (`unknown key: <path>`).
The table plus the three subsections directly below it (connection env,
value grammar, merge order) are the contract. Seeding order lives in
[Previews](previews.md#seed-run-order-and-resume), service merge rules in
[Previews](previews.md#service-images-merge-leave-clear-lifecycle), mail in
[Previews](previews.md#email-from-a-preview). Test
pointers live in [CLI reference](cli-reference.md#test-coverage-maintainers).
[Previews](previews.md#email-from-a-preview).

| Key | Required | Default | Purpose |
|---|---|---|---|
Expand Down Expand Up @@ -61,8 +58,9 @@ pointers live in [CLI reference](cli-reference.md#test-coverage-maintainers).

### Connection env: names, roles, reservation, port

The gateway injects these connection variables into preview app, service,
and seed containers. Which set you get follows `db.provider`:
Read this before naming a connection variable. The gateway injects these
connection variables into preview app, service, and seed containers. Which
set arrives follows `db.provider`:

Postgres (`db.provider: postgres`, the default):

Expand All @@ -72,15 +70,12 @@ PGAPPUSER PGAPPPASSWORD (dual only — see db.roles below)
```

`db.roles` selects the Postgres credential axis (`single` | `dual`,
Postgres only; rejected on `sqlite` / `none`). `dual` provisions the
restricted companion role and injects `PGAPPUSER` / `PGAPPPASSWORD`
exactly as before; `single` provisions no companion and injects neither
name anywhere. The default is derived: `dual` when `preview.env`
remaps `PGAPPUSER` or `PGAPPPASSWORD`, otherwise `single` — an adopter
who never mentions the companion never gets one, and existing remap
users keep working with zero config change. An explicit `db.roles`
wins over the derivation, and explicit `db.roles: single` plus a
companion remap fails fast at manifest parse and at the deploy route
Postgres only; rejected on `sqlite` / `none`). The default is derived:
`dual` when `preview.env` remaps `PGAPPUSER` or `PGAPPPASSWORD`,
otherwise `single` — a repo that never mentions the companion gets none,
and existing remap users keep working with no config change. An explicit
`db.roles` wins over the derivation, and explicit `db.roles: single` plus
a companion remap fails fast at manifest parse and at the deploy route
(`preview.env.<KEY> conflicts with db.roles single`).

SQLite (`db.provider: sqlite`):
Expand All @@ -94,12 +89,12 @@ at all — every `preview.env` entry is rejected at manifest parse
(`preview.env.PGHOST requires db.provider postgres`) and at the gateway
deploy route, and a `seed:` block is rejected the same way
(`seed requires db.provider postgres or sqlite (db.provider is none)`).
See [Previews](previews.md#no-database-previews) for the lifecycle.
See [Previews](previews.md#no-database-previews).

No `PG*` keys are injected for a SQLite preview, and no `DATABASE_URL`
for a Postgres one — `preview.env` entries for the other backend fail at
manifest parse (`preview.env.PGHOST requires db.provider postgres`).
See [Previews](previews.md#sqlite-previews) for the volume behaviour.
See [Previews](previews.md#sqlite-previews).

- **Owner** (`PGUSER` / `PGPASSWORD`): the static preview login
(`SPROUT_PG_USER`). Owns each preview database — use this for migrations.
Expand Down Expand Up @@ -135,77 +130,91 @@ example, and the opt-out live in exactly one place —

### Env value grammar

`preview.app_env` / `seed.env` values are a plain string, `{ generate:
stable_per_pr }`, or `{ required: true }`. Strings may interpolate
`{hostname}`, `{pr_id}`, `{commit_sha}` (`{commit_sha}` follows the forge
SHA, `CI_COMMIT_SHA` on GitLab / `GITHUB_SHA` on GitHub). `generate`
derives a per-MR secret (HMAC of repo, MR, key, keyed by the deploy token
— keep the token stable for the MR lifetime). `required` must be supplied
by CI; missing keys fail before the gateway call naming the key.
Write `preview.app_env` / `seed.env` values in one of three forms: a plain
string, `{ generate: stable_per_pr }`, or `{ required: true }`. Strings
may interpolate `{hostname}`, `{pr_id}`, `{commit_sha}` (`{commit_sha}`
follows the forge SHA, `CI_COMMIT_SHA` on GitLab / `GITHUB_SHA` on
GitHub). `generate` derives a per-MR secret (HMAC of repo, MR, key, keyed
by the deploy token — keep the token stable for the MR lifetime).
`required` must be supplied by CI; missing keys fail before the gateway
call naming the key.

### Env merge order

App: yaml first, then `SPROUT_APP_ENV` / each `--app-env-file` in order,
then `--app-env` flags (later wins per key). Seed: yaml first, then
`SPROUT_SEED_ENV` / each `--seed-env-file` in order, then `--seed-env`
flags. Seed args: yaml `seed.args` first, then `--seed-arg` flags
appended.
Layer env so later wins per key. App: yaml first, then `SPROUT_APP_ENV` /
each `--app-env-file` in order, then `--app-env` flags. Seed: yaml first,
then `SPROUT_SEED_ENV` / each `--seed-env-file` in order, then
`--seed-env` flags. Seed args: yaml `seed.args` first, then `--seed-arg`
flags appended.

Minimal app-only manifest (defaults apply):
Write a minimal app-only manifest (defaults apply):

```yaml
slug: myapp
preview:
hostname: "pr-{pr_id}.myapp.preview.example.com"
```

Seeded manifest (as in the quickstart): add `health:` + `seed:`. `seed: {}`
alone enables seeding with the conventional `Dockerfile.seed`.
Write a seeded manifest: add `health:` + `seed:` beside the app-only
manifest above. `health:` is required when seeding; `seed: {}` alone
enables seeding with the conventional `Dockerfile.seed`.

## App image: migrate at startup
```yaml
slug: myapp
preview:
hostname: "pr-{pr_id}.myapp.preview.example.com"
health:
path: /health
interval: 2s
timeout: 120s
expect: 200
seed:
dockerfile: Dockerfile.seed
```

Contract above is normative; this section is entrypoint examples only.
Connection names, owner/companion roles, and the port rule live in
Connection env — snippets below assume the default `PG*` map (`dual`
adds `PGAPP*`; `single` omits them).
## App image: migrate at startup

Your app image must:
Make the app entrypoint do these three things in order. The connection
names, owner/companion roles, and the port rule live in Connection env —
snippets below assume the default `PG*` map (`dual` adds `PGAPP*`;
`single` omits them):

1. Wait until Postgres accepts connections.
2. Run migrations as the **owner** against the injected database name
(default `PGDATABASE`); `GRANT` to the companion role for RLS instead
of creating roles.
3. Start the web server, connecting runtime queries as `PGAPPUSER` when
you need RLS.
RLS scoping is needed.

There is **no mandatory wrapper image** from sprout. Copy an entrypoint
that fits your stack.
There is no mandatory wrapper image from sprout. Copy an entrypoint that
fits the stack. Migrations must be **idempotent** — synchronize
re-deploys keep the same database and re-run migrate on every container
start.

### Dual-role (RLS) previews

Set `db.roles: dual` for product databases that use a privileged owner
+ restricted RLS role — previews then work without cluster `CREATEROLE`
on the preview login (without the flag there is no companion role and
no `PGAPP*` names to read):
Scope product databases that use a privileged owner + restricted RLS role.
Set `db.roles: dual` so previews work without cluster `CREATEROLE` on the
preview login (without it there is no companion role and no `PGAPP*` names
to read):

1. Migrate with `PGUSER` / `PGPASSWORD` (owner).
2. `GRANT` the needed table/sequence privileges to the role in `PGAPPUSER`
(and enable RLS / policies as in production).
3. Open the app pool with `PGAPPUSER` / `PGAPPPASSWORD` (remap via
`preview.env` if that matches your product env names).
`preview.env` when the product env names differ).

### Extra app env (non-connection)

Example only — grammar, merge order, and the gateway reservation rule
live above. Adopters often need runtime env beyond the connection fields
(`BETTER_AUTH_SECRET`, app URLs, trusted origins, etc.): pass those as
`preview.app_env` / `seed.env` in `.sprout.yaml`, a masked file-type
`SPROUT_APP_ENV` / `SPROUT_SEED_ENV` dotenv blob, repeatable
`--app-env-file` / `--seed-env-file`, or repeatable `--app-env KEY=VALUE` /
`--seed-env KEY=VALUE`. Forge File-var wiring lives in
[`templates/README.md`](../templates/README.md); placeholders expand in
every layer. File-type CI variables do NOT survive component `inputs:`
expansion — map file-type blobs at job runtime via `variables:`
Pass runtime env beyond the connection fields (`BETTER_AUTH_SECRET`, app
URLs, trusted origins) as `preview.app_env` / `seed.env` in
`.sprout.yaml`, a masked file-type `SPROUT_APP_ENV` / `SPROUT_SEED_ENV`
dotenv blob, repeatable `--app-env-file` / `--seed-env-file`, or
repeatable `--app-env KEY=VALUE` / `--seed-env KEY=VALUE`. Grammar, merge
order, and the gateway reservation rule live above. Forge File-var wiring
lives in [`templates/README.md`](../templates/README.md); placeholders
expand in every layer. File-type CI variables do NOT survive component
`inputs:` expansion — map file-type blobs at job runtime via `variables:`
(`SPROUT_APP_ENV: $MY_ENV_FILE`), never as `app_env_file:` inputs.

Example:
Expand All @@ -225,7 +234,7 @@ preview:

### Shell entrypoint (any runtime)

See [`examples/adopting-repo/docker-entrypoint.sh`](../examples/adopting-repo/docker-entrypoint.sh)
Copy [`examples/adopting-repo/docker-entrypoint.sh`](../examples/adopting-repo/docker-entrypoint.sh)
(default `PG*` names):

```bash
Expand All @@ -246,12 +255,11 @@ bun run db:migrate
exec bun run start
```

Migrations must be **idempotent** — synchronize re-deploys keep the same
database and re-run migrate on every container start.

## Deploy token setup

One-time per adopting repo (operator or lead dev with admin token):
Mint one deploy token per adopting repo (operator or lead dev with the
admin token), then store the returned token as the repo's `SPROUT_TOKEN`
secret:

```bash
export SPROUT_URL=https://sprout.example.com
Expand All @@ -261,8 +269,6 @@ sprout admin token create \
--repo "https://github.com/${GITHUB_REPOSITORY}"
```

Add the returned token to the repo's `SPROUT_TOKEN` secret.

Reviewers may see brief 502 responses while the app migrates and starts —
Traefik routes exist before the app is healthy.

Expand Down
Loading
Loading