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
7 changes: 7 additions & 0 deletions adapters/laravel-inertia/adapter.env
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,10 @@ ADAPTER_FAMILY="laravel"
# a cold cache — it reads as a hang. Widened, not disabled, so a real hang still
# ends the test.
ADAPTER_GENERATOR='SHELL_VERBOSITY=-1 COMPOSER_PROCESS_TIMEOUT=900 composer create-project "laravel/vue-starter-kit:dev-main#11a7368240d7d9146415aa6bfc858a90b6ce8490" "$APP_DIR" --no-interaction --prefer-dist'
# The vue starter kit ships its own .github/, and composer create-project
# brings it along into apps/app/. At that path github ignores both files in
# it, so they configure nothing — but zizmor still audits every workflow and
# dependabot file in the tree, and the kit's dependabot.yml sets
# `cooldown.default-days: 5`, which fails the security gate with exit 13 on
# the first pull request. Inert config whose only effect is a red check.
ADAPTER_POST_GENERATE='rm -rf .github'
13 changes: 13 additions & 0 deletions adapters/laravel-inertia/mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,23 @@ node = "24.15.0"

[tasks.install]
# mise installs composer as composer.phar, not composer
# `npm run build` belongs here, not in check or test, and it is not optional.
# Two artifacts it produces are gitignored and generated by nothing else:
# resources/js/{actions,routes}, written by the vite plugin's
# `php artisan wayfinder:generate --with-form`, which types:check compiles
# against; and public/build/manifest.json, without which every test that
# renders an inertia page 500s. Neither is visible locally, because the
# pre-push checklist runs build and leaves both on disk — so the app passed
# every local run and could never pass CI, which checks out clean.
#
# This is the order the starter kit's own CI uses: `composer setup` ends with
# `npm run build`, and only then does `composer ci:check` run types:check and
# the tests.
run = [
"php -r 'exit(version_compare(PHP_VERSION, \"8.3.0\", \">=\") ? 0 : 1);' || { echo \"laravel-inertia requires system php >= 8.3.0 (found $(php -r 'echo PHP_VERSION;' 2>/dev/null || echo 'no php on PATH')); install php 8.3 or newer via your OS package manager (e.g. apt install php8.3-cli) or https://php.net, then re-run mise install\" >&2; exit 1; }",
"composer.phar install --no-interaction --prefer-dist",
"npm ci",
"npm run build",
]

# the starter kit's frontend toolchain is vite-plus, whose `vp check` covers
Expand Down
5 changes: 5 additions & 0 deletions adapters/nestjs/.prettierignore
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
pnpm-lock.yaml
# `nest build` writes here, and format is `prettier --check .`. Within one
# checklist run format precedes build, so the first run is clean and every
# run after it fails on the previous run's output — a task list that poisons
# itself. CI never saw it: a fresh checkout has no dist.
dist
6 changes: 5 additions & 1 deletion adapters/nextjs/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
USER node
EXPOSE 3000
# `/`, not `/api/health`: create-next-app generates no health route, so every
# image built from here reported unhealthy from first boot until somebody
# noticed and wrote one. An app that adds a real health endpoint should point
# this at it.
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget -qO- http://localhost:3000/api/health || exit 1
CMD wget -qO- http://localhost:3000/ || exit 1
CMD ["node", "server.js"]
6 changes: 5 additions & 1 deletion adapters/nextjs/Dockerfile.workspace
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ COPY --from=build /app/apps/@APP_FILTER@/.next/static ./apps/@APP_FILTER@/.next/
COPY --from=build /app/apps/@APP_FILTER@/public ./apps/@APP_FILTER@/public
USER node
EXPOSE 3000
# `/`, not `/api/health`: create-next-app generates no health route, so every
# image built from here reported unhealthy from first boot until somebody
# noticed and wrote one. An app that adds a real health endpoint should point
# this at it.
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget -qO- http://localhost:3000/api/health || exit 1
CMD wget -qO- http://localhost:3000/ || exit 1
CMD ["node", "apps/@APP_FILTER@/server.js"]
2 changes: 1 addition & 1 deletion common/.github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
push:
branches: [main]
schedule:
- cron: "17 3 * * 1"
- cron: '17 3 * * 1'
permissions: {}
jobs:
security:
Expand Down
11 changes: 11 additions & 0 deletions common/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# The pre-commit hook runs `prettier --write` over every staged file matching
# its glob, and `yaml` is in that glob. Left to itself prettier reformats
# pnpm-lock.yaml — it grew by 2-4 KB the one time it happened — and rewrites
# CHANGELOG.md's bullets from `*` to `-` on every release, fighting Release
# Please for a file Release Please owns. Neither file is written by hand, so
# neither is prettier's to format.
pnpm-lock.yaml
**/pnpm-lock.yaml
package-lock.json
**/package-lock.json
CHANGELOG.md
2 changes: 1 addition & 1 deletion common/commitlint.config.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export default {
extends: ["@commitlint/config-conventional"],
extends: ['@commitlint/config-conventional'],
};
2 changes: 1 addition & 1 deletion common/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,4 @@ services:
required: false
restart: always
ports:
- "${APP_PORT:-8080}:8080"
- '${APP_PORT:-8080}:8080'
2 changes: 1 addition & 1 deletion common/lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ pre-commit:
parallel: true
commands:
prettier:
glob: "*.{ts,tsx,js,mjs,cjs,json,md,yml,yaml,css}"
glob: '*.{ts,tsx,js,mjs,cjs,json,md,yml,yaml,css}'
run: mise exec -- pnpm exec prettier --write {staged_files}
stage_fixed: true
gitleaks:
Expand Down
2 changes: 1 addition & 1 deletion common/pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ packages:
allowBuilds:
unrs-resolver: false
esbuild: true
"@parcel/watcher": false
'@parcel/watcher': false
49 changes: 40 additions & 9 deletions docs/runbook/first-project-walkthrough.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ Then read what it made before doing anything else:
```sh
cd demo-app
git log --oneline # one commit, "chore: scaffold project"
cat mise.toml # config_roots: apps/web, apps/api, docs
cat mise.toml # [monorepo] config_roots = apps/web, apps/api, docs
# [vars] database = "postgres", cache = "redis"
ls .github/workflows # five call sites
grep -l database compose*.yaml # all three: compose.yaml, .dev.yaml, .test.yaml
Expand Down Expand Up @@ -133,6 +133,16 @@ mv /tmp/env-aside apps/api/.env
Expect: still pass. A failure here is a real finding — it means a check depends
on a file that is not committed, and CI will fail where you succeeded.

Which files those are depends on the adapter, and this is the step where
guessing costs you a red pull request. Move aside everything the app's
`.gitignore` excludes that some task writes, not just the two named above.
For `laravel-inertia` that is `apps/app/resources/js/actions`,
`apps/app/resources/js/routes` and `apps/app/public/build` — the first two
are written by the vite build's wayfinder plugin and compiled against by
`types:check`, the third is the vite manifest, without which every test that
renders an inertia page returns 500. Running the checks with those in place
is not the same experiment as CI runs.

A `nestjs` app's `check` and `build` also depend on a `prisma` task now
(`prisma generate` first, when the project has a database). Moving its `.env`
aside the same way still passes — `prisma generate` only parses the schema,
Expand Down Expand Up @@ -200,12 +210,19 @@ gh pr create --fill
gh pr checks --watch
```

The first `gh pr checks --watch` usually exits 1 straight away with `no checks
reported on the 'feat/health' branch`. Nothing is wrong: GitHub has not
registered the check runs yet, and `--watch` does not wait for a first one to
appear. Give it twenty seconds and issue it again.

Expect: `CI / ci / ci (apps/api)` runs, because `apps/api` changed. Expect
`CI / ci / changes` to skip roots that did not. Expect `commitlint` to run —
it only ever runs on a pull request. Expect all seven checks green, including
`security / codeql`, `security / gitleaks` and `security / zizmor` — on a
private repository those three each need something the workflow grants them
explicitly, so a failure there is a finding, not the normal state.
it only ever runs on a pull request. Expect every check green, including `security / codeql`,
`security / gitleaks` and `security / zizmor` — on a private repository those
three each need something the workflow grants them explicitly, so a failure
there is a finding, not the normal state. How many checks there are depends on
the project: six named jobs plus one `ci (<root>)` for each config root the
commit touched, so this project has seven and an api-only one has six.

The `gh api` call is required, not optional: without it Release Please cannot
open its pull request later, and the failure appears several steps away from
Expand All @@ -225,6 +242,13 @@ If that pull request's checks sit at `Action required`, the repository has no
`RELEASE_APP_ID`/`RELEASE_APP_PRIVATE_KEY`; a pull request opened with
`GITHUB_TOKEN` starts no workflow. Merging it directly still releases.

Those runs do not stay amber. Once the approval window passes, or once the
squash merge deletes the head branch, they end as `failure`, and `gh run view`
explains them with `This run likely failed because of a workflow file issue`.
There is no workflow file issue. Expect to be left with three red runs in the
history that the release did not depend on and that say nothing true about
your project.

```sh
gh pr merge <number> --squash
gh release list
Expand All @@ -249,10 +273,17 @@ git status
```

Expect: `apps/worker` exists, `mise.toml` gained a config root, `ci.yml`'s
`roots:` gained an entry, and `lefthook.yml`, `pnpm-workspace.yaml` and
`pnpm-lock.yaml` also changed — the new app's own hooks, `allowBuilds` and
`minimumReleaseAgeExclude` entries, and dependencies. Expect the changes to
be left uncommitted for review.
`roots:` gained an entry, and `lefthook.yml` and `pnpm-lock.yaml` also
changed. Expect the changes to be left uncommitted for review.

Two of those are conditional, and a diff that does not show them is not a
finding. `pnpm-workspace.yaml` changes only when the new app needs an
`allowBuilds` or `minimumReleaseAgeExclude` entry the project does not
already carry — adding a second `nestjs` app to a project that already has
one leaves it untouched, because `apps/*` already matched and prisma was
already decided. And `lefthook.yml` changes without gaining a hook: `nestjs`
contributes none, since prettier in the common layer already covers
typescript sources.

The new application is wired to the database and cache this project already
recorded, not asked again: `apps/worker/.env.example` names a `DATABASE_URL`
Expand Down
7 changes: 6 additions & 1 deletion lib/adapter.sh
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,12 @@ merge_lefthook_fragment() {
# cleaned up on both paths: under `set -e` a yq failure leaves immediately
# and the file survives the run. Not a RETURN trap — that fires again in
# callers, where $rendered is out of scope and the shell aborts.
if ! yq eval-all --inplace 'select(fileIndex==0) * select(fileIndex==1)' \
# -P (block style) because yq propagates the *fragment's* style to the whole
# merged document, and an adapter contributing no hook ships `{}` — one flow
# mapping is enough to collapse all 26 lines of lefthook.yml onto a single
# line and replace every comment in it. The hooks still run; the file a
# human has to read and review does not survive.
if ! yq eval-all --inplace -P 'select(fileIndex==0) * select(fileIndex==1)' \
"${project}/lefthook.yml" "$rendered"; then
rm -f "$rendered"
die "failed to merge the lefthook fragment for ${rel}"
Expand Down
7 changes: 2 additions & 5 deletions lib/project.sh
Original file line number Diff line number Diff line change
Expand Up @@ -251,20 +251,17 @@ sync_standalone_build_policy() {
"$file" "${project}/pnpm-workspace.yaml"
}

# pnpm_install <dir> <what-for> [ceiling]
# pnpm_install <dir> <what-for>
# pnpm reports its failures on stdout, so silencing the install leaves a `die`
# that names the step and proves nothing — a CI failure here was unreadable
# until this kept the output. Shown only on failure; a successful install is
# still quiet.
pnpm_install() {
local dir="$1" what="$2" ceiling="${3:-}" log status=0
local dir="$1" what="$2" log status=0
log="$(mktemp)"

(
cd "$dir"
# exported inside the subshell, not written as a `VAR=x cmd` prefix: the
# prefix has to be literal, and an expanded one is read as a command name.
[ -z "$ceiling" ] || export MISE_CEILING_PATHS="$ceiling"
# --no-frozen-lockfile because pnpm turns frozen on by itself when CI=true,
# and this install exists precisely to rewrite the lockfile a generator just
# produced. Without it the step is a contradiction that only fails on a
Expand Down
26 changes: 22 additions & 4 deletions scaffold
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,21 @@ cmd_new() {
*) target="$(pwd)/${target}" ;;
esac
local name; name="$(basename "$target")"

# `mise exec` walks upward for config and trusts what it finds — persistently,
# in ~/.local/state/mise, and it loads the file it trusts, which means running
# any `_.source` or `{{ exec(...) }}` in that config's [env]. Generate a
# project inside a tree you did not write and someone else's mise.toml is
# executed and permanently trusted, with none of the asking mise exists to do.
# `mise trust -C` and `mise lock -C` do not walk; only `mise exec` does.
#
# Set once here rather than threaded through the one function somebody
# remembered: this run makes mise subprocesses from pnpm_install,
# resolve_minimum_release_age, sync_workspace_lockfile and every adapter
# generator, and the guard has to cover all of them or it covers nothing.
MISE_CEILING_PATHS="$(dirname "$target")"
export MISE_CEILING_PATHS

local -a requested=()
local database="" cache=""

Expand Down Expand Up @@ -279,10 +294,7 @@ cmd_new() {
# backs lefthook's commit-msg hook, which must work in a php-only
# project (docs/decisions/0007).
#
# MISE_CEILING_PATHS because `mise exec` walks upward and would otherwise
# trust a mise.toml above $target; `mise trust -C` does not cover that.
pnpm_install "$target" "installing the project root's own tooling dependencies" \
"$(dirname "$target")"
pnpm_install "$target" "installing the project root's own tooling dependencies"
# pnpm_install above installed at full strength (minimum-release-age=0),
# so a violation among commitlint's own dependencies never surfaced —
# the loop below resolves each app's lockfile but never the root's, and
Expand Down Expand Up @@ -368,6 +380,12 @@ cmd_add() {
{ [ -f "${project}/mise.toml" ] && grep -q '^monorepo_root = true$' "${project}/mise.toml"; } \
|| die "not a scaffold project (${project}): run 'scaffold add' from inside a project created by 'scaffold new'"

# Same reason as cmd_new's: every mise subprocess this run makes would
# otherwise walk above the project and permanently trust — and execute —
# whatever mise.toml it found there.
MISE_CEILING_PATHS="$(dirname "$project")"
export MISE_CEILING_PATHS

# Read back rather than asked for: an application added in month six is
# wired to the database the project has had since month one, and a second
# answer to a question already settled is a way for the two to disagree.
Expand Down
2 changes: 1 addition & 1 deletion services/mongodb/compose.dev.fragment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ services:
# loopback, not 0.0.0.0: docker publishes to every interface by default
# and its iptables rules bypass most host firewalls, so the bare form
# exposes this password-is-"app" database to the whole local network.
- "127.0.0.1:27017:27017"
- '127.0.0.1:27017:27017'
2 changes: 1 addition & 1 deletion services/mongodb/compose.fragment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ services:
# mongosh, not a tcp probe: the port is open well before the server will
# accept a query, and a check that passes early is worse than none —
# depends_on releases the application into a database that is not ready.
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')"]
test: ['CMD', 'mongosh', '--quiet', '--eval', "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
8 changes: 6 additions & 2 deletions services/mongodb/drivers/nest.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
# shellcheck shell=bash
# shellcheck disable=SC2034 # read by services/shared/nest.sh, sourced below
PRISMA_PROVIDER="mongodb"
# directConnection, because prisma's mongodb provider otherwise expects a
# replica set and a single-node container is not one.
# authSource=admin is the parameter this actually depends on: the container
# creates the user in `admin`, and without it prisma authenticates against
# `app` and fails with a SCRAM error. directConnection is kept for prisma's
# mongodb provider, which otherwise expects a replica set — measured against a
# single-node container it changes nothing for `db push`, so treat it as
# unproven for anything but transactional writes.
PRISMA_URL="mongodb://app:app@localhost:27017/app?authSource=admin&directConnection=true"
# shellcheck source=/dev/null
. "${SCAFFOLD_ROOT}/services/shared/nest.sh"
2 changes: 1 addition & 1 deletion services/mysql/compose.dev.fragment.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
services:
database:
ports:
- "127.0.0.1:3306:3306"
- '127.0.0.1:3306:3306'
2 changes: 1 addition & 1 deletion services/mysql/compose.fragment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ services:
MYSQL_PASSWORD: ${DB_PASSWORD:-app}
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-app}
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "--silent"]
test: ['CMD', 'mysqladmin', 'ping', '-h', '127.0.0.1', '--silent']
interval: 10s
timeout: 5s
retries: 5
2 changes: 1 addition & 1 deletion services/postgres/compose.dev.fragment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ services:
# loopback, not 0.0.0.0: docker publishes to every interface by default
# and its iptables rules bypass most host firewalls, so the bare form
# exposes this password-is-"app" database to the whole local network.
- "127.0.0.1:5432:5432"
- '127.0.0.1:5432:5432'
2 changes: 1 addition & 1 deletion services/postgres/compose.fragment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ services:
POSTGRES_USER: ${DB_USERNAME:-app}
POSTGRES_PASSWORD: ${DB_PASSWORD:-app}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-app}"]
test: ['CMD-SHELL', 'pg_isready -U ${DB_USERNAME:-app}']
interval: 10s
timeout: 5s
retries: 5
2 changes: 1 addition & 1 deletion services/redis/compose.dev.fragment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ services:
# loopback, not 0.0.0.0: docker publishes to every interface by default
# and its iptables rules bypass most host firewalls, so the bare form
# exposes this password-is-"app" cache to the whole local network.
- "127.0.0.1:6379:6379"
- '127.0.0.1:6379:6379'
12 changes: 10 additions & 2 deletions services/redis/compose.fragment.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
services:
cache:
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:-app}"]
# block style, and it has to be block *here*: yq's merge keeps the style of
# its first input, so the prod fragment's own command inherits whatever
# this one uses. As a flow sequence the assembled prod command runs past
# prettier's print width, and the project's pre-commit hook then rewrote
# the compose.yaml scaffold had just handed it.
command:
- redis-server
- --requirepass
- '${REDIS_PASSWORD:-app}'
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-app}", "ping"]
test: ['CMD', 'redis-cli', '-a', '${REDIS_PASSWORD:-app}', 'ping']
interval: 10s
timeout: 5s
retries: 5
10 changes: 8 additions & 2 deletions services/redis/compose.prod.fragment.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
services:
cache:
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:-changeme}", "--appendonly", "yes"]
# block style, matching the base fragment — see the comment there.
command:
- redis-server
- --requirepass
- ${REDIS_PASSWORD:-changeme}
- --appendonly
- 'yes'
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-changeme}", "ping"]
test: ['CMD', 'redis-cli', '-a', '${REDIS_PASSWORD:-changeme}', 'ping']
interval: 10s
timeout: 5s
retries: 5
Expand Down
Loading