diff --git a/.github/workflows/adapters.yml b/.github/workflows/adapters.yml index 04f3a65..262edbc 100644 --- a/.github/workflows/adapters.yml +++ b/.github/workflows/adapters.yml @@ -25,12 +25,15 @@ jobs: tier-a: ${{ steps.tiers.outputs.tier-a }} tier-b: ${{ steps.tiers.outputs.tier-b }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - - id: tiers + - name: Setup mise + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + - name: Discover the adapter tiers + id: tiers env: EVENT_NAME: ${{ github.event_name }} SCHEDULE_CRON: ${{ github.event.schedule }} @@ -54,22 +57,28 @@ jobs: matrix: adapter: ${{ fromJson(needs.discover.outputs.tier-a) }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - - run: corepack enable - - id: language + - name: Setup mise + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + - name: Enable corepack + run: corepack enable + - name: Detect the adapter language + id: language env: ADAPTER: ${{ matrix.adapter }} run: | lang="$(grep '^ADAPTER_LANGUAGE=' "adapters/${ADAPTER}/adapter.env" | cut -d'"' -f2)" echo "value=${lang}" >> "$GITHUB_OUTPUT" - - if: steps.language.outputs.value == 'php' + - name: Setup PHP + if: steps.language.outputs.value == 'php' uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: "8.3" - - env: + - name: Run the tier-a smoke test + env: ADAPTER: ${{ matrix.adapter }} run: bats "tests/new-${ADAPTER}.bats" @@ -87,22 +96,28 @@ jobs: matrix: adapter: ${{ fromJson(needs.discover.outputs.tier-b) }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - - run: corepack enable - - id: language + - name: Setup mise + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + - name: Enable corepack + run: corepack enable + - name: Detect the adapter language + id: language env: ADAPTER: ${{ matrix.adapter }} run: | lang="$(grep '^ADAPTER_LANGUAGE=' "adapters/${ADAPTER}/adapter.env" | cut -d'"' -f2)" echo "value=${lang}" >> "$GITHUB_OUTPUT" - - if: steps.language.outputs.value == 'php' + - name: Setup PHP + if: steps.language.outputs.value == 'php' uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: "8.3" - - env: + - name: Run the tier-b smoke test + env: ADAPTER: ${{ matrix.adapter }} run: bats "tests/new-${ADAPTER}.bats" @@ -119,22 +134,28 @@ jobs: matrix: adapter: ${{ fromJson(needs.discover.outputs.tier-a) }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - - run: corepack enable - - id: language + - name: Setup mise + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + - name: Enable corepack + run: corepack enable + - name: Detect the adapter language + id: language env: ADAPTER: ${{ matrix.adapter }} run: | lang="$(grep '^ADAPTER_LANGUAGE=' "adapters/${ADAPTER}/adapter.env" | cut -d'"' -f2)" echo "value=${lang}" >> "$GITHUB_OUTPUT" - - if: steps.language.outputs.value == 'php' + - name: Setup PHP + if: steps.language.outputs.value == 'php' uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: "8.3" - - env: + - name: Run the deploy check + env: ADAPTER: ${{ matrix.adapter }} run: ./scripts/deploy-check.sh "$ADAPTER" @@ -151,22 +172,28 @@ jobs: matrix: adapter: ${{ fromJson(needs.discover.outputs.tier-b) }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - - run: corepack enable - - id: language + - name: Setup mise + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + - name: Enable corepack + run: corepack enable + - name: Detect the adapter language + id: language env: ADAPTER: ${{ matrix.adapter }} run: | lang="$(grep '^ADAPTER_LANGUAGE=' "adapters/${ADAPTER}/adapter.env" | cut -d'"' -f2)" echo "value=${lang}" >> "$GITHUB_OUTPUT" - - if: steps.language.outputs.value == 'php' + - name: Setup PHP + if: steps.language.outputs.value == 'php' uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: "8.3" - - env: + - name: Run the deploy check + env: ADAPTER: ${{ matrix.adapter }} run: ./scripts/deploy-check.sh "$ADAPTER" @@ -180,12 +207,16 @@ jobs: contents: read timeout-minutes: 30 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - - run: corepack enable - - run: ./scripts/deploy-check.sh nextjs nestjs --db postgres + - name: Setup mise + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + - name: Enable corepack + run: corepack enable + - name: Run the deploy check for two apps + run: ./scripts/deploy-check.sh nextjs nestjs --db postgres compose: # expensive relative to the other checks here, so gated on the weekly @@ -196,14 +227,18 @@ jobs: contents: read timeout-minutes: 10 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + - name: Setup mise + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + - name: Setup PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: "8.3" - - run: bats tests/compose.bats + - name: Run the compose tests + run: bats tests/compose.bats services: # Eight combinations per adapter at minutes each, so the full grid is @@ -220,22 +255,28 @@ jobs: db: [mysql, postgres, mongodb, none] cache: [none, redis] steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - - run: corepack enable - - id: language + - name: Setup mise + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + - name: Enable corepack + run: corepack enable + - name: Detect the adapter language + id: language env: ADAPTER: ${{ matrix.adapter }} run: | lang="$(grep '^ADAPTER_LANGUAGE=' "adapters/${ADAPTER}/adapter.env" | cut -d'"' -f2)" echo "value=${lang}" >> "$GITHUB_OUTPUT" - - if: steps.language.outputs.value == 'php' + - name: Setup PHP + if: steps.language.outputs.value == 'php' uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: "8.3" - - env: + - name: Generate the project and run its checklist + env: ADAPTER: ${{ matrix.adapter }} DB: ${{ matrix.db }} CACHE: ${{ matrix.cache }} @@ -266,7 +307,8 @@ jobs: issues: write timeout-minutes: 5 steps: - - env: + - name: Open an issue for the failed run + env: GH_TOKEN: ${{ github.token }} run: >- gh issue create --repo "${{ github.repository }}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f85748e..20a0bea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,11 +22,14 @@ jobs: # on a runner, which is what the timeout below leaves room around. timeout-minutes: 5 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - - run: mise run ci-unit + - name: Setup mise + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + - name: Run the unit lane + run: mise run ci-unit integration: runs-on: ubuntu-latest @@ -39,7 +42,8 @@ jobs: # tests, not these cross-cutting mechanics suites. timeout-minutes: 25 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # workflows.bats walks this repository's own history to find the # commit that introduced an adapter, then diffs against its parent. @@ -47,12 +51,16 @@ jobs: # on `unknown revision` rather than on anything it is testing. fetch-depth: 0 persist-credentials: false - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - - run: corepack enable - - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + - name: Setup mise + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + - name: Enable corepack + run: corepack enable + - name: Setup PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: "8.3" - - run: mise run test-integration + - name: Run the integration lane + run: mise run test-integration zizmor: runs-on: ubuntu-latest @@ -60,10 +68,12 @@ jobs: contents: read timeout-minutes: 5 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + - name: Setup mise + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 # Only this repository's own workflows. common/ holds templates whose # `uses: you/.github/...` names no real repository, so ref-confusion # cannot resolve it and the audit errors out rather than reporting a @@ -71,4 +81,5 @@ jobs: # What a generated project's call sites get instead is structural: # tests/workflows.bats asserts sha-pinned actions, closed permission # sets and shared-repository-only `uses:`. No audit runs on them. - - run: mise exec -- zizmor .github/workflows/ + - name: Run zizmor + run: mise exec -- zizmor .github/workflows/ diff --git a/.github/workflows/provenance.yml b/.github/workflows/provenance.yml index bd7b175..2d104e3 100644 --- a/.github/workflows/provenance.yml +++ b/.github/workflows/provenance.yml @@ -24,15 +24,19 @@ jobs: contents: read timeout-minutes: 15 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + - name: Setup mise + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 # tests/provenance.bats needs a local upstream clone the same as # check does below — its own copy here, the way adapters.yml's # tier-b matrix legs each do their own generation independently. - - run: git clone --filter=blob:none --no-checkout https://github.com/immich-app/immich.git "${RUNNER_TEMP}/immich" - - env: + - name: Clone upstream immich + run: git clone --filter=blob:none --no-checkout https://github.com/immich-app/immich.git "${RUNNER_TEMP}/immich" + - name: Run the provenance self-test + env: SCAFFOLD_UPSTREAM_CLONE: ${{ runner.temp }}/immich run: bats tests/provenance.bats @@ -44,14 +48,17 @@ jobs: issues: write timeout-minutes: 15 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # check-provenance.sh has no network fallback of its own (a denied # fetch must fail loudly, not read as "everything drifted"); a # partial clone gets the full commit graph without every blob. - - run: git clone --filter=blob:none --no-checkout https://github.com/immich-app/immich.git "${RUNNER_TEMP}/immich" - - id: check + - name: Clone upstream immich + run: git clone --filter=blob:none --no-checkout https://github.com/immich-app/immich.git "${RUNNER_TEMP}/immich" + - name: Check for upstream drift + id: check env: SCAFFOLD_UPSTREAM_CLONE: ${{ runner.temp }}/immich run: ./scripts/check-provenance.sh | tee "${RUNNER_TEMP}/report.txt" @@ -60,7 +67,8 @@ jobs: # this step would silently never run. failure() first makes the # step eligible; steps.check.outcome narrows it to the real check # specifically, not some other step failing. - - if: failure() && steps.check.outcome == 'failure' + - name: Open an issue for upstream drift + if: failure() && steps.check.outcome == 'failure' env: GH_TOKEN: ${{ github.token }} run: | diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index ada49a1..8bb7c19 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -30,10 +30,12 @@ jobs: contents: read timeout-minutes: 5 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - env: + - name: Check the pull request body against the template + env: BODY: ${{ github.event.pull_request.body }} run: | missing="" diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..4a3f1c2 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "timonwong.shellcheck", + "foxundermoon.shell-format", + "editorconfig.editorconfig" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..2230113 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "files.associations": { + "scaffold": "shellscript" + } +} diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..e49d0f0 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @ttncode diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d29158d --- /dev/null +++ b/SECURITY.md @@ -0,0 +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. +Expect an initial response within a few business days. diff --git a/adapters/laravel-api/.dockerignore b/adapters/laravel-api/.dockerignore index 8103920..acffaad 100644 --- a/adapters/laravel-api/.dockerignore +++ b/adapters/laravel-api/.dockerignore @@ -2,7 +2,6 @@ vendor node_modules -# .env holds this installation's real APP_KEY and database password .env .env.* !.env.example @@ -10,10 +9,7 @@ node_modules storage/logs storage/framework/cache -# laravel's own .gitignore excludes the generated manifests but keeps the -# directory, and both halves matter here. `composer create-project` writes -# them locally, and they name dev providers a --no-dev image does not install; -# copied in, artisan cannot boot. Excluding the directory itself fails the -# other way — laravel refuses to start without it. For laravel-inertia either -# failure surfaces in vite's wayfinder plugin, several stages from the cause. +# both the directory and its manifests must survive: composer writes them +# locally, naming dev providers a --no-dev image lacks, but artisan also +# refuses to boot without the directory itself present bootstrap/cache/*.php diff --git a/adapters/laravel-api/.env.example b/adapters/laravel-api/.env.example index 56efee2..cf699ce 100644 --- a/adapters/laravel-api/.env.example +++ b/adapters/laravel-api/.env.example @@ -1,8 +1,6 @@ APP_ENV=local APP_KEY= -# off by default: `cp .env.example .env` is how most deploys start, and -# Laravel's debug page renders the whole environment — APP_KEY and -# DB_PASSWORD included — to anyone who triggers a 500. Turn it on locally. +# off by default: Laravel's debug page leaks APP_KEY and DB_PASSWORD on a 500 APP_DEBUG=false APP_URL=http://localhost:8000 # the database variables are written by the selected service's driver diff --git a/adapters/laravel-api/adapter.env b/adapters/laravel-api/adapter.env index 297f7ad..3df312c 100644 --- a/adapters/laravel-api/adapter.env +++ b/adapters/laravel-api/adapter.env @@ -10,18 +10,11 @@ ADAPTER_FAMILY="laravel" # (13.x needs ^8.3, 12.x needs ^8.2). Pinned, with mise.toml's install guard # holding the same floor. See docs/decisions/0016. ADAPTER_GENERATOR='composer create-project laravel/laravel:^13.0 "$APP_DIR" --no-interaction --prefer-dist' -# the skeleton ships phpunit and pint, but nothing for the check task -# -# The sed call wires routes/health.php (copied in by apply_adapter's -# directory loop, not this adapter's flat file list) into bootstrap/app.php's -# routing: laravel only auto-loads routes/web.php and routes/console.php, so -# a file dropped at routes/health.php with nothing pointing at it 404s -# forever. The grep pair after it is not optional — `s|health: '/up',|...|` -# silently no-ops if the skeleton ever reformats that line, leaving the -# route unregistered with no build failure, only a 404 discovered in -# production. +# the skeleton ships phpunit and pint, but nothing for the check task. The sed +# wires routes/health.php (copied in separately by apply_adapter) into +# bootstrap/app.php, since laravel auto-loads neither; the grep pair after it +# turns a skeleton reformat that breaks the sed into a build failure instead +# of a silent 404. ADAPTER_POST_GENERATE='composer require --dev larastan/larastan phpstan/phpstan --no-interaction && sed -i "s|health: '"'"'/up'"'"',|health: '"'"'/up'"'"',\n then: function (): void {\n require __DIR__.'"'"'/../routes/health.php'"'"';\n },|" bootstrap/app.php && { grep -q "then: function (): void {" bootstrap/app.php && grep -q "require __DIR__.'"'"'/../routes/health.php'"'"';" bootstrap/app.php; } || { echo "post-generate: health route wiring missing from bootstrap/app.php — the laravel skeleton likely changed its withRouting shape; update the sed pattern in ADAPTER_POST_GENERATE to match" >&2; exit 1; }' -# /up ships with laravel since 11.x and deliberately touches nothing, which -# is why it cannot stand alone: a project with a wrong DATABASE_URL passes it. ADAPTER_LIVENESS_PATH="/up" ADAPTER_READINESS_PATH="/health/ready" diff --git a/adapters/laravel-inertia/.dockerignore b/adapters/laravel-inertia/.dockerignore index 48bbc59..2a52b6a 100644 --- a/adapters/laravel-inertia/.dockerignore +++ b/adapters/laravel-inertia/.dockerignore @@ -2,7 +2,6 @@ vendor node_modules -# .env holds this installation's real APP_KEY and database password .env .env.* !.env.example @@ -10,16 +9,12 @@ node_modules storage/logs storage/framework/cache -# laravel's own .gitignore excludes the generated manifests but keeps the -# directory, and both halves matter here. `composer create-project` writes -# them locally, and they name dev providers a --no-dev image does not install; -# copied in, artisan cannot boot. Excluding the directory itself fails the -# other way — laravel refuses to start without it. For laravel-inertia either -# failure surfaces in vite's wayfinder plugin, several stages from the cause. +# both the directory and its manifests must survive: composer writes them +# locally, naming dev providers a --no-dev image lacks, but artisan also +# refuses to boot without the directory itself present — for this adapter the +# failure surfaces in vite's wayfinder plugin, several stages from the cause bootstrap/cache/*.php -# vite writes this and .gitignore excludes it, but a build context is not a -# git tree: without this line a developer who has run `npm run build` ships -# their host copy over the one the assets stage just built. Same shape as the -# bootstrap/cache manifests two lines up. +# a build context is not a git tree: without this, a host `npm run build` +# ships over what the assets stage just built public/build diff --git a/adapters/laravel-inertia/.env.example b/adapters/laravel-inertia/.env.example index 71e8e3e..5f403cd 100644 --- a/adapters/laravel-inertia/.env.example +++ b/adapters/laravel-inertia/.env.example @@ -1,8 +1,6 @@ APP_ENV=local APP_KEY= -# off by default: `cp .env.example .env` is how most deploys start, and -# Laravel's debug page renders the whole environment — APP_KEY and -# DB_PASSWORD included — to anyone who triggers a 500. Turn it on locally. +# off by default: Laravel's debug page leaks APP_KEY and DB_PASSWORD on a 500 APP_DEBUG=false APP_URL=http://localhost:8000 # the database variables are written by the selected service's driver diff --git a/adapters/laravel-inertia/adapter.env b/adapters/laravel-inertia/adapter.env index be593e5..92801fd 100644 --- a/adapters/laravel-inertia/adapter.env +++ b/adapters/laravel-inertia/adapter.env @@ -1,38 +1,19 @@ ADAPTER_NAME="laravel-inertia" ADAPTER_ROLE="app" -# ~5 minutes per test; the five in tests/new-laravel-inertia.bats cost ~25 -# minutes together. See docs/decisions/0012. ADAPTER_TIER="B" ADAPTER_LANGUAGE="php" ADAPTER_FAMILY="laravel" -# Pinned to a commit, not the branch, so create-project resolves the same tree -# every run. Move to a tag once laravel/vue-starter-kit cuts one targeting -# laravel 13 — its releases still pin ^12.0. -# -# The starter kit runs `artisan install:features --ansi` on install, and -# composer's --no-interaction does not reach that script line. SHELL_VERBOSITY=-1 -# satisfies Symfony Console's own check instead, so the prompt takes defaults. -# -# COMPOSER_PROCESS_TIMEOUT because install:features exceeds the 300s default on -# a cold cache — it reads as a hang. Widened, not disabled, so a real hang still -# ends the test. +# pinned to a commit so create-project resolves the same tree every run; move +# to a tag once laravel/vue-starter-kit targets laravel 13. SHELL_VERBOSITY=-1 +# satisfies the starter kit's install:features prompt, which --no-interaction +# does not reach; COMPOSER_PROCESS_TIMEOUT=900 because a cold cache exceeds +# the 300s default — widened, not disabled, so a real hang still ends the run. 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. -# The sed call wires routes/health.php (copied in by apply_adapter's -# directory loop, not this adapter's flat file list) into bootstrap/app.php's -# routing: laravel only auto-loads routes/web.php and routes/console.php, so -# a file dropped at routes/health.php with nothing pointing at it 404s -# forever. The grep pair after it is not optional — `s|health: '/up',|...|` -# silently no-ops if the skeleton ever reformats that line, leaving the -# route unregistered with no build failure, only a 404 discovered in -# production. +# rm -rf .github: the starter kit's own inert dependabot.yml still fails +# zizmor's audit with exit 13. The sed wires routes/health.php (copied in +# separately by apply_adapter) into bootstrap/app.php, since laravel +# auto-loads neither; the grep pair after it turns a skeleton reformat that +# breaks the sed into a build failure instead of a silent 404. ADAPTER_POST_GENERATE='rm -rf .github && sed -i "s|health: '"'"'/up'"'"',|health: '"'"'/up'"'"',\n then: function (): void {\n require __DIR__.'"'"'/../routes/health.php'"'"';\n },|" bootstrap/app.php && { grep -q "then: function (): void {" bootstrap/app.php && grep -q "require __DIR__.'"'"'/../routes/health.php'"'"';" bootstrap/app.php; } || { echo "post-generate: health route wiring missing from bootstrap/app.php — the laravel skeleton likely changed its withRouting shape; update the sed pattern in ADAPTER_POST_GENERATE to match" >&2; exit 1; }' -# /up ships with laravel since 11.x and deliberately touches nothing, which -# is why it cannot stand alone: a project with a wrong DATABASE_URL passes it. ADAPTER_LIVENESS_PATH="/up" ADAPTER_READINESS_PATH="/health/ready" diff --git a/adapters/nestjs/.dockerignore b/adapters/nestjs/.dockerignore index 1ed51ea..06bd904 100644 --- a/adapters/nestjs/.dockerignore +++ b/adapters/nestjs/.dockerignore @@ -1,11 +1,9 @@ -# node_modules is copied from a pinned build stage; a host copy landing after -# it would overwrite the pinned one with whatever the developer has locally. +# copied from a pinned build stage; a host copy would overwrite it node_modules .next dist coverage -# never belongs in an image .env .env.* !.env.example diff --git a/adapters/nestjs/.prettierignore b/adapters/nestjs/.prettierignore index 94e72f3..0245695 100644 --- a/adapters/nestjs/.prettierignore +++ b/adapters/nestjs/.prettierignore @@ -1,6 +1,4 @@ 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. +# nest build writes here; unignored, format fails on the previous run's own +# output once build has run once dist diff --git a/adapters/nestjs/adapter.env b/adapters/nestjs/adapter.env index 00ebce0..5d55c19 100644 --- a/adapters/nestjs/adapter.env +++ b/adapters/nestjs/adapter.env @@ -8,19 +8,12 @@ ADAPTER_FAMILY="nest" # generated test fails on every fresh project. Before moving off this pin, # generate with the new major and confirm `//apps/api:ci-unit` passes clean. ADAPTER_GENERATOR='pnpm dlx @nestjs/cli@11 new "$APP_DIR" --package-manager pnpm --skip-git' -# main.ts's un-awaited bootstrap() trips --max-warnings 0, and the generator's -# own output is not prettier-formatted. Wiring HealthModule here too: the -# module lives in src/health/ (copied in by apply_adapter's directory loop, -# not this adapter's flat file list), and app.module.ts is the generator's -# own file, so it can only be edited after the generator has produced it. -# The grep pair after the two sed calls is not optional: `1i` always -# succeeds, and `s/imports: \[\]/…/` silently no-ops if the generator ever -# reformats that line, leaving HealthModule unregistered with no build or -# lint failure — only a 404 on /health/live, discovered by the HEALTHCHECK -# that quietly starts failing on every image. +# main.ts's un-awaited bootstrap() trips --max-warnings 0. HealthModule is +# wired here rather than shipped in app.module.ts because that file is the +# generator's own output; the grep pair after the sed calls turns a generator +# reformat that breaks the sed into a build failure instead of a silent 404 +# on /health/live. ADAPTER_POST_GENERATE='sed -i "s/^bootstrap();$/void bootstrap();/" src/main.ts && sed -i "1i import { HealthModule } from '"'"'./health/health.module'"'"';" src/app.module.ts && sed -i "s/imports: \[\]/imports: [HealthModule]/" src/app.module.ts && { grep -q "import { HealthModule } from '"'"'./health/health.module'"'"';" src/app.module.ts && grep -q "imports: \[HealthModule\]" src/app.module.ts || { echo "post-generate: HealthModule wiring missing from src/app.module.ts; the nest generator likely changed its output format — update the sed patterns in ADAPTER_POST_GENERATE to match" >&2; exit 1; }; } && pnpm exec prettier --write .' -# The generator produces `/` returning Hello World and nothing else. Both of -# these are routes this adapter ships itself (src/health/), because the -# Dockerfile's HEALTHCHECK has been probing a /health that never existed. +# the generator produces only `/`; both health routes are this adapter's own ADAPTER_LIVENESS_PATH="/health/live" ADAPTER_READINESS_PATH="/health/ready" diff --git a/adapters/nextjs/.dockerignore b/adapters/nextjs/.dockerignore index 1ed51ea..06bd904 100644 --- a/adapters/nextjs/.dockerignore +++ b/adapters/nextjs/.dockerignore @@ -1,11 +1,9 @@ -# node_modules is copied from a pinned build stage; a host copy landing after -# it would overwrite the pinned one with whatever the developer has locally. +# copied from a pinned build stage; a host copy would overwrite it node_modules .next dist coverage -# never belongs in an image .env .env.* !.env.example diff --git a/adapters/nextjs/adapter.env b/adapters/nextjs/adapter.env index a9fbf7c..8458582 100644 --- a/adapters/nextjs/adapter.env +++ b/adapters/nextjs/adapter.env @@ -10,8 +10,6 @@ ADAPTER_GENERATOR='pnpm create next-app@16 "$APP_DIR" --ts --app --eslint --tail # create-next-app ships neither prettier nor vitest, and its own output is not # formatted — the contract's format and test tasks need both. ADAPTER_POST_GENERATE='pnpm add -D prettier vitest && pnpm exec prettier --write .' -# A route handler answers without rendering the home page, so the probe does -# not depend on whatever the client later puts on `/`. The route lives at -# src/app/api/health/live/route.ts (copied in by apply_adapter's directory -# loop, not this adapter's flat file list). +# a route handler answers without rendering the home page, so the probe does +# not depend on whatever the client later puts on `/` ADAPTER_LIVENESS_PATH="/api/health/live" diff --git a/common/.dockerignore b/common/.dockerignore index 409be4f..688adc7 100644 --- a/common/.dockerignore +++ b/common/.dockerignore @@ -1,13 +1,11 @@ # only read when the build context is the project root — an all-typescript -# project's apps/*/Dockerfile.workspace (see docs/PROVENANCE.md). node_modules -# is copied from a pinned build stage; a host copy landing after it would -# overwrite the pinned one with whatever the developer has locally. +# project's apps/*/Dockerfile.workspace; node_modules is copied from a pinned +# build stage, so a host copy would overwrite it **/node_modules **/.next **/dist **/coverage -# never belongs in an image **/.env **/.env.* !**/.env.example diff --git a/common/.github/pull_request_template.md b/common/.github/pull_request_template.md new file mode 100644 index 0000000..91c680a --- /dev/null +++ b/common/.github/pull_request_template.md @@ -0,0 +1,14 @@ +## What this changes + + + +## How it was verified + + + +## Checklist + +- [ ] `mise run checklist` passes +- [ ] New behaviour has a test that fails without the change +- [ ] Docs that describe changed behaviour were updated in the same commit +- [ ] No unrelated changes diff --git a/common/.prettierignore b/common/.prettierignore index ad156aa..8f4781a 100644 --- a/common/.prettierignore +++ b/common/.prettierignore @@ -1,9 +1,5 @@ -# 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. +# prettier rewrites pnpm-lock.yaml and fights Release Please for CHANGELOG.md; +# neither is written by hand pnpm-lock.yaml **/pnpm-lock.yaml package-lock.json diff --git a/common/.vscode/extensions.json b/common/.vscode/extensions.json new file mode 100644 index 0000000..6815bc8 --- /dev/null +++ b/common/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "editorconfig.editorconfig", + "esbenp.prettier-vscode", + "timonwong.shellcheck" + ] +} diff --git a/common/example.env b/common/example.env index d413a29..7ea91e5 100644 --- a/common/example.env +++ b/common/example.env @@ -1,5 +1,3 @@ -# copy to .env and edit. install.sh does this for you and generates a random -# value for every password below in place of the literal `changeme`. the -# per-application port variables, and the database and cache variables, are -# appended by scaffold from whatever the project was generated with. +# copy to .env; install.sh does this and replaces every `changeme`. Scaffold +# appends the per-app port, database and cache variables at generation time. IMAGE_TAG=latest diff --git a/common/install.sh b/common/install.sh index b106d99..b4b2d3c 100755 --- a/common/install.sh +++ b/common/install.sh @@ -11,28 +11,22 @@ # curl -fsSL https://github.com/you/@PROJECT_NAME@/releases/latest/download/install.sh | bash # ═══════════════════════════════════════════════════════════════════════════ # -# Adapted from immich's install.sh -# (https://github.com/immich-app/immich/blob/main/install.sh); this project -# never overwrites an existing .env (see download_release_assets). +# Adapted from immich's install.sh; unlike immich, never overwrites an existing +# .env (see download_release_assets). set -o nounset set -o pipefail -# Substituted at generation time from the GitHub owner scaffold resolved and -# this project's own name, the same pair compose.yaml's image and the build -# workflows carry. It assumes the repository is named after the project -# directory; rename it and this line needs the new name too. +# Substituted at generation time; assumes the repo is named after the project +# directory. RepoUrl='https://github.com/you/@PROJECT_NAME@/releases/latest/download' TargetDir='./app' -# The owner/repo pair, taken from RepoUrl so a project still edits one line. RepoSlug="${RepoUrl#https://github.com/}" RepoSlug="${RepoSlug%/releases/latest/download}" -# The literal every password in the assembled example.env carries, and the -# contract example.env's own header states. Matched on the value rather than a -# *_PASSWORD name pattern: a service naming its variable differently (e.g. -# RABBITMQ_DEFAULT_PASS) still needs a real value generated for it. +# Matched on the placeholder value, not a *_PASSWORD name pattern, so a +# differently-named variable (e.g. RABBITMQ_DEFAULT_PASS) still gets a real value. PasswordPlaceholder='changeme' PasswordBytes=32 PasswordLength=24 @@ -41,12 +35,12 @@ PasswordLength=24 # release_asset_id — reads a release's JSON on stdin. # -# jq, not grep: an asset's own id precedes its name while the uploader's follows -# it, so "find the name, take the next id" returns the uploader's for every -# asset — and that request succeeds, fetching a different valid object. Only the -# token path needs jq, so it stays off the public path's dependencies. +# jq, not grep: an asset's own id precedes its name while the uploader's +# follows it, so "find the name, take the next id" returns the uploader's id — +# and that request succeeds, fetching a different valid object. release_asset_id() { - local name="$1" id + local -r name="$1" + local id id="$(jq -r --arg name "$name" \ 'first(.assets[] | select(.name == $name) | .id) // empty')" || return 1 if [ -z "$id" ]; then @@ -59,15 +53,13 @@ release_asset_id() { # fetch_release_asset # # Two endpoints: a private release's browser URL returns 404 both anonymously -# and with a Bearer token, while the API asset endpoint returns 200. A token -# alone does not fix the public URL — the URL is what has to change. +# and with a Bearer token, while the API asset endpoint returns 200. fetch_release_asset() { - local name="$1" dest="$2" id + local -r name="$1" dest="$2" + local id if [ -z "${GITHUB_TOKEN:-}" ]; then curl -fsSL "${RepoUrl}/${name}" -o "$dest" && return 0 - # A private release answers 404 to an anonymous request, which reads as "no - # such release" rather than "you are not signed in". echo "could not download ${name}; if this project is private, set GITHUB_TOKEN to a token with repo and read:packages" >&2 return 1 fi @@ -82,15 +74,11 @@ fetch_release_asset() { -H "Authorization: Bearer ${GITHUB_TOKEN}" \ -H 'Accept: application/octet-stream' \ "https://api.github.com/repos/${RepoSlug}/releases/assets/${id}" -o "$dest" && return 0 - # A token given but rejected by this endpoint is the token's problem, not its - # absence — this message must not repeat the no-token hint above. echo "could not download ${name} with the token given; it needs repo and read:packages" >&2 return 1 } -# jq is needed only to read a release's JSON, which only the token path does. -# Checked separately from main's curl/docker checks so a public install never -# learns about a dependency it does not use. +# Checked separately from main's other checks, so a public install never needs jq. require_private_tools() { [ -n "${GITHUB_TOKEN:-}" ] || return 0 command -v jq >/dev/null || { @@ -108,9 +96,8 @@ create_directory() { cd "$TargetDir" || return 1 } -# compose.yaml is always overwritten so it never drifts from the image it names. -# .env never is: it holds this installation's real password and the operator's -# edits. A kept .env is still checked for a password left at the placeholder. +# compose.yaml is always overwritten; a kept .env is only checked for a +# password left at the placeholder. # # Two cleanup mechanisms, both needed so no temp file is left holding a # plaintext password: the explicit `rm -f` before each `return 1`, since an EXIT @@ -148,8 +135,6 @@ download_release_assets() { rm -f "$tmp_env" return 1 fi - # Checked, like every other step here: an unchecked mv returns 0 through the - # trap below, reporting success and leaving the password file behind. if ! mv "$tmp_env" ./.env; then rm -f "$tmp_env" trap - EXIT INT TERM HUP @@ -161,17 +146,14 @@ download_release_assets() { # ─── configuring it ──────────────────────────────────────────────────────── -# Fails hard if a substitution misses: a password left at the placeholder is a -# credential defaulting to a known value. -# # Known, not fixed: each password is briefly visible in sed's argv to other -# local users. Pre-existing in the immich script this came from. +# local users. generate_service_passwords() { - local file="$1" name password + local -r file="$1" + local name password while IFS= read -r name; do # APP_KEY is not a password: laravel decrypts with it and rejects anything - # that is not `base64:` plus exactly 32 bytes. Inside this loop so - # example.env keeps one placeholder and the existing-.env guard covers it. + # that is not `base64:` plus exactly 32 bytes. if [ "$name" = APP_KEY ]; then password="base64:$(head -c "$PasswordBytes" /dev/urandom | base64)" else @@ -188,9 +170,8 @@ generate_service_passwords() { done < <(sed -n "s/^\([A-Za-z_][A-Za-z0-9_]*\)=${PasswordPlaceholder}\$/\1/p" "$file") } -# scaffold fills the image in, so this only catches a copy hand-edited back to a -# placeholder: docker rejects that itself, but with "invalid reference format" -# rather than anything actionable. +# Catches a copy hand-edited back to the placeholder; docker itself would only +# report an unhelpful "invalid reference format". require_configured_image() { if grep -i 'image:.*CHANGEME' compose.yaml >/dev/null; then echo "compose.yaml's image line still has a CHANGEME placeholder; edit it to this project's real registry path, then re-run this script" @@ -201,13 +182,9 @@ require_configured_image() { # ─── running it ──────────────────────────────────────────────────────────── start_stack() { - # A package's ghcr visibility is separate from its repository's, and a private - # package refuses an anonymous pull with `unauthorized`. The username is not - # checked for a token login; RepoSlug's owner just names something the - # operator recognises. - # - # --password-stdin, not an argument: argv is visible to every other user on - # the host through the process list. + # ghcr package visibility is separate from repository visibility; a private + # package refuses an anonymous pull with `unauthorized`. + # --password-stdin, not an argument: argv is visible to every other user on the host. if [ -n "${GITHUB_TOKEN:-}" ]; then printf '%s' "${GITHUB_TOKEN}" \ | docker login ghcr.io -u "${RepoSlug%%/*}" --password-stdin >/dev/null || { @@ -218,36 +195,29 @@ start_stack() { docker compose up --remove-orphans -d || return 1 } -# ADR-0014 seam 5 forbids migrations from an *entrypoint* — a container that -# migrates every time it starts cannot be scaled or rolled back. This is a human -# running one command on the target host. -# compose_has_service [--profile ] # Captured, never piped into `grep -q`: grep closes the pipe on its first match, # `docker compose` then dies of SIGPIPE, and `set -o pipefail` reports the whole # pipeline as failed. Measured at roughly one run in seven — a stack that # refused to migrate, at random, with a message about a service that was there. compose_has_service() { - local service="$1"; shift + local -r service="$1"; shift local services services="$(docker compose "$@" config --services)" || return 1 grep -qx "$service" <<<"$services" } +# ADR-0014 seam 5 forbids migrations from an entrypoint, so this is a human +# running one command on the target host. run_migrations() { - # `docker compose config --services` (no --profile) never lists a service - # gated behind a profile, so that guard alone always skipped the migration - # silently — measured: plain `config --services` prints only `app`, and - # `--profile migrate config --services` prints `migrate app`. + # `config --services` with no --profile never lists a service gated behind one. if compose_has_service migrate --profile migrate; then echo "running migrations..." docker compose --profile migrate run --rm migrate return fi - # A database with no migrate service beside it is not "nothing to migrate": - # every database driver ships a migrate command, so this only happens if the - # service, its profile or the command silently vanished. A project with no - # database is the only case that falls through. + # Every database driver ships a migrate command, so a database with none means + # the service, its profile or the command vanished — not "nothing to migrate". if compose_has_service database; then echo "a database service exists but no migrate service was found — refusing to start with unapplied schema" >&2 return 1 @@ -265,8 +235,8 @@ main() { start_stack || { echo 'could not start the stack; check the output above'; return 1; } run_migrations || { echo 'could not run migrations; check the output above'; return 1; } - # One line per application (ADR-0022), read out of .env so it reports the - # ports actually in effect, including any the operator changed. + # One line per application (ADR-0022), read out of .env so it reflects any + # port the operator changed. local name port while IFS='=' read -r name port; do [ -n "$port" ] || continue diff --git a/docs/superpowers/specs/2026-09-13-immich-parity-design.md b/docs/superpowers/specs/2026-09-13-immich-parity-design.md new file mode 100644 index 0000000..7ff2119 --- /dev/null +++ b/docs/superpowers/specs/2026-09-13-immich-parity-design.md @@ -0,0 +1,162 @@ +# Immich Parity Design + +**Goal:** close the remaining gaps between this toolbox and the project it was +derived from, in the two places a survey found them — files immich has that we +never added, and a presentation style we adopted in prose but not in mechanism. + +**Scope:** every tracked file in this repository was measured. Four work items +fall out. Nothing here changes behaviour except one test, which is strengthened +so it can fail for the reason it was written. + +## What the survey found + +Comment density, measured over every tracked file with at least five lines: + +| Area | scaffold | immich equivalent | +| --- | --- | --- | +| `tests/*.bats` + helpers (4,345 lines) | 19% | 0.9% (`e2e/src/*.ts`) | +| `.github/workflows/*` | 17.5% | 3.8% | +| `adapters/*/adapter.env` | 53–76% | — | +| `common/.dockerignore` | 79% | 0% | +| `common/example.env` | 80% | — | +| `common/.prettierignore` | 73% | — | +| `common/install.sh` | 36% | 3% (`install.sh`) | +| Steps carrying `name:` | 0 of 49 | 297 | + +The gap is not that immich explains less. It is that immich explains through +mechanisms a reader already has to look at: + +- **`name:` on a workflow step.** The name appears in the GitHub log while the + job runs. A comment above the step appears only to someone reading the file. + Same words, better placement, and it cannot go stale unnoticed because it is + on screen every run. +- **Function names that narrate.** immich's `install.sh` is 107 lines with three + comments, because `main()` reads as prose: `create_immich_directory`, + `download_docker_compose_file`, `generate_random_password`. Ours already has + this shape; what it still carries is a paragraph above each function. +- **`local -r`.** immich uses it for locals that never change (`local -r + Tgt='./immich-app'`). This is not the file-level `readonly` this repository + correctly rejected — that breaks re-sourcing into child processes, which this + codebase does by design. Function scope has no such problem. + +## Decisions + +**Adopt the mechanisms, keep the facts.** The comments that remain after the two +previous passes are largely third-party landmines: `mise exec` trusting and +executing a parent config, pnpm turning on frozen lockfiles whenever `CI` is set, +yq collapsing a document without `-P`, prisma 2.x changing `bsonSerialize()`, +`gh repo create --push` setting the default branch. immich sits at 3–4% because +it has no such layer — it calls its own tools. Those facts stay. What goes is +the narration around them. + +**Community assets go to both the toolbox and `common/`, rewritten for each +audience.** This repository ships `SECURITY.md` and `CODEOWNERS` to every +generated project and carries neither itself. It enforces a pull request body on +its own pull requests, with a CI job, and ships no template to clients. + +**No issue or discussion templates.** immich's are a triage funnel for thousands +of strangers, with a `config.yml` of Discord links. A client project is one to +three developers who sit together. Copying them produces ceremony, not +discipline. + +## Work items + +### A — Files immich has and we do not + +Toolbox root: + +- `SECURITY.md` — the text `common/SECURITY.md` already ships, unchanged. +- `CODEOWNERS` — `* @ttncode`. Hardcoded is correct here; this repository has + one owner and no substitution step. +- `.vscode/extensions.json` — `timonwong.shellcheck`, + `foxundermoon.shell-format`, `editorconfig.editorconfig`. Exactly the tools + `mise.toml` already pins and `.editorconfig` already configures. +- `.vscode/settings.json` — minimal. `files.associations` so an editor + recognises `scaffold`, an extensionless bash file, as shell. + +Shipped into every generated project: + +- `common/.github/pull_request_template.md` — the three headings this repository + enforces on itself, with a checklist written for a client project's own + commands. +- `common/.vscode/extensions.json` — the language-agnostic set. Per-adapter + extensions would need a fragment-merge mechanism like `lefthook.fragment.yml`; + that is not built, and nothing yet needs it. + +Any file added under `common/` that carries the owner or the project name must +join `PROJECT_OWNER_FILES` or `PROJECT_NAME_FILES` in `lib/project.sh`, or it +ships with the placeholder intact. + +One test changes. `tests/new-project.bats` asserts that no `@you` or `you/` +survives generation. That proves a placeholder is gone, not that the owner is +right: a hardcoded or mistyped account passes it. It becomes an assertion that +`CODEOWNERS` names the account the run resolved. + +### B — A name on every workflow step + +49 steps across the four workflows in `.github/workflows/`; none has a `name:`. +The five files in `common/.github/workflows/` are pure `uses:` call sites with no +steps, so they are untouched. + +Name every step, then delete the comments the name now carries. What stays is +GitHub's own behaviour: SARIF upload refused on a private repository without +Advanced Security, CodeQL needing `actions: read` to read its own run, the job +name `pull-request-body` being load-bearing for branch protection. + +Target: 17.5% to 6–8%. + +### C — Scripts in immich's shape + +- `local -r` for every local that is assigned once. +- `common/install.sh` from 36% to about 15%: each function's comment block down + to the sentence carrying the fact. The landmines stay — jq rather than grep for + a release asset's id, two endpoints because a private release's browser URL + answers 404, the trap baking its path with `printf %q` and naming its signals, + `sed` delimited on `|` because a base64 value contains `/`, `BASH_SOURCE[0]:-$0` + because a curl-piped script has no `BASH_SOURCE`. +- The same pass over `lib/*.sh` and `services/shared/*.sh`, which the previous + two rounds left at 37–52%. + +### D — Data files and tests + +Not in the original three-part split; the survey found it. + +- `tests/*.bats` and `tests/helpers/` — 833 comment lines in 4,345. A bats test + name is already a sentence; a comment above it that restates the name is the + dominant pattern here. Keep the ones recording why a test exists at all — + those are regression notes, and several name a defect that shipped. +- `adapters/*/adapter.env` — 53–76%. The laravel-inertia file is the extreme: a + seven-line block on `ADAPTER_GENERATOR` and a ten-line block on + `ADAPTER_POST_GENERATE`. Both record real traps (`rm -rf .github` because the + starter kit's inert dependabot config fails zizmor; the sed that wires + `routes/health.php` into `bootstrap/app.php` because Laravel auto-loads + neither). Compress, do not delete. +- `common/.dockerignore`, `common/.prettierignore`, `common/example.env`, + `adapters/*/.dockerignore`, `adapters/*/.env.example` — 42–80%. immich's + `.dockerignore` carries no comment at all and is perfectly legible. + +## Not doing + +| | Why | +| --- | --- | +| `ISSUE_TEMPLATE`, `DISCUSSION_TEMPLATE` | A triage funnel for a public product; a client project has three developers. | +| `labeler.yml`, `pr-labeler.yml` | One maintainer. | +| `.github/release.yml` changelog categories | Generated projects use Release Please, which writes its own changelog from conventional commits. | +| `.devcontainer`, `.pnpmfile.cjs`, `.prettierrc`, `.nvmrc` | No JS/TS in the toolbox; mise already pins node. | +| Root `.dockerignore` | The toolbox builds no image. | +| Path-filtered CI | Worth doing, deferred by choice. A `paths:` trigger makes a required check never report, which blocks merges forever — the failure this repository already hit with `pull-request-body`. If taken up, gate with `if:` at the job, which still reports. | +| `docker rmi` in `deploy-check.sh` | immich removes no image it builds, anywhere. Reclamation is documented as the operator's `docker image prune`. | + +## Verification + +Each work item is a pull request, verified the same way: + +- `mise run lint` +- `mise run test-runner` — both lanes under the runner's environment +- `mise exec -- zizmor --min-severity medium .github/workflows/` +- every YAML and TOML touched re-parsed with `yq` +- the pull request's own CI, which runs `deploy` and `smoke` against three + adapters on a real runner + +A adds one more: generate a project with an explicit owner and confirm +`CODEOWNERS` names it. diff --git a/lib/adapter.sh b/lib/adapter.sh index 9196670..2126169 100644 --- a/lib/adapter.sh +++ b/lib/adapter.sh @@ -17,7 +17,7 @@ ADAPTER_OPTIONAL_VARS=( ) load_adapter() { - local name="$1" + local -r name="$1" # `source` below executes whatever it reads, so the name must not leave # adapters/ — `--api ../../../tmp/evil` runs an arbitrary file. Checked before @@ -26,7 +26,7 @@ load_adapter() { ''|*[!a-z0-9-]*|-*) die "not a usable adapter name: ${name} (run: scaffold list)" ;; esac - local dir="${SCAFFOLD_ROOT}/adapters/${name}" + local -r dir="${SCAFFOLD_ROOT}/adapters/${name}" [ -d "$dir" ] || die "unknown adapter: ${name} (run: scaffold list)" ADAPTER_DIR="$dir" @@ -60,7 +60,8 @@ role_path() { } merge_lefthook_fragment() { - local fragment="$1" project="$2" rel="$3" rendered + local -r fragment="$1" project="$2" rel="$3" + local rendered [ -f "$fragment" ] || return 0 @@ -93,7 +94,7 @@ merge_lefthook_fragment() { # installed and dies steps later on a COPY of a node_modules that was never # created. Skipped for Laravel: composer has no --filter to miss. assert_workspace_filter_name() { - local dest="$1" + local -r dest="$1" [ -f "${ADAPTER_DIR}/Dockerfile.workspace" ] || return 0 @@ -108,8 +109,8 @@ assert_workspace_filter_name() { # name: `scaffold add` can place an adapter at any path, so the filter cannot be # baked to the role at adapter-authoring time. substitute_workspace_filter() { - local dest="$1" - local file="${dest}/Dockerfile.workspace" + local -r dest="$1" + local -r file="${dest}/Dockerfile.workspace" [ -f "$file" ] || return 0 @@ -121,7 +122,7 @@ substitute_workspace_filter() { # .env.example is not skipped; directories merge rather than replace, since # `src/` exists after the generator ran and `cp -R src dest/src` nests it. copy_adapter_files() { - local dest="$1" + local -r dest="$1" local file base dir had_dotglob=0 shopt -q dotglob && had_dotglob=1 @@ -144,11 +145,11 @@ copy_adapter_files() { } apply_adapter() { - local name="$1" project="$2" rel="$3" + local -r name="$1" project="$2" rel="$3" load_adapter "$name" - local dest="${project}/${rel}" + local -r dest="${project}/${rel}" local parent; parent="$(dirname "$dest")" mkdir -p "$parent" diff --git a/lib/contract.sh b/lib/contract.sh index 579dccc..62b840e 100644 --- a/lib/contract.sh +++ b/lib/contract.sh @@ -10,15 +10,14 @@ CONTRACT_TASKS=(install format format-fix lint check test build ci-unit checklis READ_ONLY_TASKS=(format lint check) -# Catches a read-only task copied from its own -fix sibling. Cannot catch a -# tool that writes by default with no flag saying so. +# Catches a read-only task copied from its -fix sibling, not a tool that writes +# by default with no flag saying so. WRITING_FLAGS=(--write --fix -w --in-place --overwrite) REQUIRED_ADAPTER_FILES=(adapter.env mise.toml Dockerfile .env.example) -# Both are read mid-generation — ADAPTER_GENERATOR by apply_adapter's eval, -# ADAPTER_FAMILY by the drivers/ lookup — so missing, they fail there with -# `unbound variable` instead of at `scaffold lint`. +# ADAPTER_GENERATOR and ADAPTER_FAMILY are read mid-generation, not at +# `scaffold lint`; missing, they fail there with `unbound variable`. REQUIRED_ADAPTER_VARS=(ADAPTER_NAME ADAPTER_ROLE ADAPTER_FAMILY ADAPTER_GENERATOR ADAPTER_LIVENESS_PATH) REQUIRED_SERVICE_FILES=( @@ -30,8 +29,7 @@ REQUIRED_SERVICE_FILES=( env.fragment ) -# SERVICE_IMAGE is the one place a service's digest is written — the compose -# fragments carry no image line, so a bump here reaches all three lanes at once. +# SERVICE_IMAGE is the one place a service's digest is written; the compose fragments carry no image line. REQUIRED_SERVICE_VARS=(SERVICE_NAME SERVICE_KIND SERVICE_IMAGE) # Holds the parameterised driver bodies every service sources, not a service. @@ -40,11 +38,9 @@ SHARED_DRIVERS_DIR=shared # A cache implements compose_migrate too: it has no schema and prints nothing. REQUIRED_DRIVER_FUNCTIONS=(service_driver_apply service_driver_dockerfile service_driver_compose_env service_driver_compose_migrate) -# The web tier opens no connection, so it takes no driver. Stated once about the -# role rather than as a "not applicable" entry in every service. +# The web tier opens no connection, so it takes no driver. DRIVEN_ROLES=(api app) -# What cmd_new picks when a project has a backend and --db was not given -# (ADR-0020). The wizard's default ordering reads this too, so a plain Enter -# cannot drift from what an omitted flag would pick. +# What cmd_new picks when --db is omitted (ADR-0020); the wizard's default +# ordering reads this too, so a plain Enter cannot drift from it. DEFAULT_DATABASE_SERVICE=mysql diff --git a/lib/lint.sh b/lib/lint.sh index 2973757..d37047f 100644 --- a/lib/lint.sh +++ b/lib/lint.sh @@ -57,7 +57,7 @@ driver_families() { # any, so a caller can run them all and still fail once at the end. lint_required_files() { - local name="$1" dir="$2"; shift 2 + local -r name="$1" dir="$2"; shift 2 local file status=0 for file in "$@"; do @@ -70,7 +70,7 @@ lint_required_files() { } lint_adapter_env() { - local name="$1" file="$2" + local -r name="$1" file="$2" local var role value status=0 for var in "${REQUIRED_ADAPTER_VARS[@]}"; do @@ -113,7 +113,7 @@ lint_adapter_env() { } lint_adapter_tasks() { - local name="$1" file="$2" + local -r name="$1" file="$2" local task body flag status=0 for task in "${CONTRACT_TASKS[@]}"; do @@ -141,7 +141,7 @@ lint_adapter_tasks() { } lint_adapters() { - local dir="$1" + local -r dir="$1" local adapter name status=0 for adapter in "$dir"/*/; do @@ -157,7 +157,7 @@ lint_adapters() { } lint_service_env() { - local name="$1" file="$2" + local -r name="$1" file="$2" local var status=0 for var in "${REQUIRED_SERVICE_VARS[@]}"; do @@ -181,7 +181,7 @@ lint_service_env() { # it at sourcing time and finds it unbound dies under the inherited `set -u`, # which is not the same problem as a missing function. lint_driver_functions() { - local name="$1" family="$2" driver="$3" service_dir="$4" + local -r name="$1" family="$2" driver="$3" service_dir="$4" local fn fault status=0 for fn in "${REQUIRED_DRIVER_FUNCTIONS[@]}"; do @@ -205,7 +205,7 @@ lint_driver_functions() { } lint_service_drivers() { - local name="$1" service="$2"; shift 2 + local -r name="$1" service="$2"; shift 2 local family driver status=0 for family in "$@"; do @@ -225,7 +225,7 @@ lint_service_drivers() { # Fails when any service is incomplete, or when a family that takes a driver has # no driver in some service. lint_services() { - local dir="$1" adapters="$2" + local -r dir="$1" adapters="$2" local service name status=0 local -a families=() diff --git a/lib/log.sh b/lib/log.sh index 7e5ab31..86d4d2b 100644 --- a/lib/log.sh +++ b/lib/log.sh @@ -17,7 +17,7 @@ step() { printf '→ %s\n' "$*" >&2; } # Captures output and prints it only on failure; SCAFFOLD_VERBOSE=1 passes it # straight through, for a run that hangs rather than fails. run_quietly() { - local what="$1"; shift + local -r what="$1"; shift local log status=0 if [ "${SCAFFOLD_VERBOSE:-0}" = 1 ]; then diff --git a/lib/manifest.sh b/lib/manifest.sh index 5e63f49..69a88de 100644 --- a/lib/manifest.sh +++ b/lib/manifest.sh @@ -14,8 +14,8 @@ CI_WORKFLOW=".github/workflows/ci.yml" BUILD_WORKFLOWS=(".github/workflows/build.yml" ".github/workflows/release.yml") register_config_root() { - local project="$1" root="$2" - local file="${project}/${MISE_CONFIG_FILE}" + local -r project="$1" root="$2" + local -r file="${project}/${MISE_CONFIG_FILE}" # Anchored on the exact formatting mise.root.toml ships, and verified: an # inline `config_roots = ["docs"]` matches neither awk, and a silent no-op @@ -53,7 +53,8 @@ config_roots() { } sync_ci_roots() { - local project="$1" json + local -r project="$1" + local json json="$(config_roots "$project" | jq -R . | jq -sc .)" sed -i.bak "s|^ roots: .*| roots: '${json}'|" \ "${project}/${CI_WORKFLOW}" @@ -64,7 +65,7 @@ sync_ci_roots() { # build workflows pass on (ADR-0022). Called after the workspace decision is # settled, since the build context depends on it. register_image_target() { - local project="$1" rel="$2" + local -r project="$1" rel="$2" local name context dockerfile image file current updated name="$(app_service_key "$rel")" diff --git a/lib/pnpm.sh b/lib/pnpm.sh index 6d564c8..f8fc9ef 100644 --- a/lib/pnpm.sh +++ b/lib/pnpm.sh @@ -57,8 +57,9 @@ restore_pnpm_workspace() { # whether the command was `new` or `add`, decides which Dockerfile variant an # app needs and what its build context has to be. app_is_workspace_member() { - local project="$1" rel="$2" - local workspace_file="${project}/${WORKSPACE_FILE}" glob + local -r project="$1" rel="$2" + local -r workspace_file="${project}/${WORKSPACE_FILE}" + local glob [ -f "$workspace_file" ] || return 1 @@ -75,7 +76,8 @@ app_is_workspace_member() { # pnpm reports its failures on stdout, so silencing the install leaves a `die` # that names the step and proves nothing. Shown only on failure. pnpm_install() { - local dir="$1" what="$2" log status=0 + local -r dir="$1" what="$2" + local log status=0 step "$what" log="$(mktemp)" @@ -102,7 +104,7 @@ pnpm_install() { # create-next-app writes its own nested pair, and pnpm's upward search finds # those first — so the app never resolves as part of the outer workspace. sync_workspace_lockfile() { - local project="$1" + local -r project="$1" find "$project" -mindepth 3 -maxdepth 3 \ \( -name "$LOCKFILE" -o -name "$WORKSPACE_FILE" \) -delete @@ -121,10 +123,10 @@ sync_workspace_lockfile() { # frozen install, not just the first, so relaxing it for one call would not # hold. The policy stays live for everything the project adds later. record_release_age_exceptions() { - local project="$1" - local settings="${2:-$1}" + local -r project="$1" + local -r settings="${2:-$1}" step "checking $(basename "$project")'s lockfile against the supply-chain policy" - local workspace_file="${settings}/${WORKSPACE_FILE}" + local -r workspace_file="${settings}/${WORKSPACE_FILE}" # Keyed on the lockfile pnpm will actually verify — which for an app outside # a workspace is the root's, found by walking up. @@ -182,7 +184,7 @@ record_release_age_exceptions() { # Only called when every application is typescript; sharing types across a # language boundary is a different problem, solved by openapi. enable_typescript_workspace() { - local project="$1" + local -r project="$1" mkdir -p "${project}/packages" mv "${project}/packages-types" "${project}/packages/types" @@ -195,8 +197,8 @@ enable_typescript_workspace() { # carrying ADR-0017's allowBuilds. Merged, not copied: common wins on a key both # name, the app's own generator keeps any key only it names. sync_standalone_build_policy() { - local app="$1" project="$2" - local file="${app}/${WORKSPACE_FILE}" + local -r app="$1" project="$2" + local -r file="${app}/${WORKSPACE_FILE}" [ -f "$file" ] || printf '{}\n' > "$file" @@ -209,8 +211,8 @@ sync_standalone_build_policy() { # Dockerfile and Dockerfile.workspace; exactly one may survive, whichever # app_is_workspace_member matches. finalize_app_dockerfile() { - local project="$1" rel="$2" - local dir="${project}/${rel}" + local -r project="$1" rel="$2" + local -r dir="${project}/${rel}" [ -f "${dir}/Dockerfile.workspace" ] || return 0 @@ -225,7 +227,7 @@ finalize_app_dockerfile() { # Every application is TypeScript, so they share one lockfile and one # node_modules at the root, and a packages/types can exist between them. join_typescript_workspace() { - local project="$1"; shift + local -r project="$1"; shift enable_typescript_workspace "$project" @@ -248,7 +250,7 @@ join_typescript_workspace() { # Not every application is TypeScript — or there are none — so each owns its # manifests and its own lockfile, and there is no shared workspace to join. keep_apps_standalone() { - local project="$1"; shift + local -r project="$1"; shift rm -rf "${project}/packages-types" diff --git a/lib/project.sh b/lib/project.sh index 26245e0..9b33708 100644 --- a/lib/project.sh +++ b/lib/project.sh @@ -5,31 +5,28 @@ # ═══════════════════════════════════════════════════════════════════════════ # shellcheck shell=bash -# Where a generated project records its own origin. Its own file rather than a -# `[vars]` entry: the apps table is a mapping, and mise's vars are flat strings. +# Its own file rather than a `[vars]` entry: the apps table is a mapping, and +# mise's vars are flat strings. SCAFFOLD_MANIFEST=".scaffold.toml" # Shared by init_project's die() and the wizard's prompt, so a rejected name # gets the same sentence either way. PROJECT_NAME_RULE="a project name must start with a lowercase letter or digit, and may contain only lowercase letters, digits, '.', '_' and '-'" -# init_project writes this and nothing else has a reason to; mise.toml alone is -# not proof, since any repository can carry one. +# init_project writes this; mise.toml alone is not proof, since any repository +# can carry one. PROJECT_MARKER="monorepo_root = true" -# The first commit is boilerplate, not authored by a person, so it must not -# depend on an ambient git config a CI runner does not have. +# Not an ambient git config, which a CI runner does not have. PROJECT_COMMIT_NAME="scaffold" PROJECT_COMMIT_EMAIL="scaffold@scaffold.invalid" -# Files carrying the `you/` placeholder, alongside every workflow. mise.root.toml -# carries the registry path ([vars] image) and must be substituted before it -# becomes mise.toml. +# Files carrying the `you/` placeholder, alongside every workflow; mise.root.toml +# carries the registry path and must be substituted before it becomes mise.toml. PROJECT_OWNER_FILES=(compose.yaml install.sh README.md mise.root.toml) -# Files carrying @PROJECT_NAME@. The image build.yml pushes to and the image -# compose.yaml pulls have to be one string. migrate inherits it later, from -# assemble_compose copying the app image across. +# Files carrying @PROJECT_NAME@; the image build.yml pushes to and the image +# compose.yaml pulls have to be one string. PROJECT_NAME_FILES=( .github/workflows/build.yml .github/workflows/release.yml docs/.vitepress/config.ts docs/index.md compose.yaml install.sh README.md @@ -39,12 +36,8 @@ PROJECT_NAME_FILES=( # heading, which want the capital project_name_is_usable forbids. PROJECT_TITLE_FILES=(docs/.vitepress/config.ts docs/index.md README.md) -# The account owning the generated workflows' `uses:` and image refs. Dies -# rather than shipping `you/`, which fails only on the first push. -# # `gh api user`, not `gh auth status`: the former reports who the token belongs -# to, the latter what login recorded, which goes stale after a rename. Seen -# disagreeing here. +# to, the latter what login recorded — seen disagreeing after a rename. resolve_github_owner() { local owner="${SCAFFOLD_GITHUB_OWNER:-}" source="" @@ -60,9 +53,8 @@ resolve_github_owner() { [ -n "$owner" ] || die "no GitHub account to substitute for 'you/' in the generated workflows — set SCAFFOLD_GITHUB_OWNER, sign in with 'gh auth login', or 'git config --global github.user '" - # This is interpolated into `sed s|you/|...|`, and GNU sed's s///e flag runs - # the pattern space as a shell command — an owner containing `|` is remote - # code execution. GitHub's own rule is alphanumerics and single hyphens. + # Interpolated into `sed s|you/|...|`; GNU sed's s///e flag runs the pattern + # space as a shell command, so an owner containing `|` is remote code execution. case "$owner" in *[!A-Za-z0-9-]*|-*|*-) die "not a usable GitHub account name: ${owner}" ;; @@ -71,13 +63,11 @@ resolve_github_owner() { printf '%s' "$owner" } -# project_name_is_usable -# The name goes into `sed s|@PROJECT_NAME@|...|`, where a `|` closes the -# expression early and a `&` expands to the whole match — an unchecked name can -# rewrite the file it is written into. The same characters are illegal in an OCI -# image name, so one rule covers both. +# The name goes into `sed s|@PROJECT_NAME@|...|`, where `|` closes the +# expression early and `&` expands to the whole match; the same characters are +# illegal in an OCI image name, so one rule covers both. project_name_is_usable() { - local name="$1" + local -r name="$1" case "$name" in [a-z0-9]*) ;; @@ -88,11 +78,9 @@ project_name_is_usable() { esac } -# scaffold_version — which toolbox produced a given project, in one string. -# `git describe`, not a VERSION file: every install of this toolbox is a clone, -# and a file goes stale the first time someone forgets to bump it. `--dirty` is -# the point as much as the tag — a project generated from uncommitted edits -# cannot be reproduced from any commit, and the string has to say so. +# `git describe`, not a VERSION file: a file goes stale the first time someone +# forgets to bump it. `--dirty` matters as much as the tag — a project generated +# from uncommitted edits cannot be reproduced from any commit. scaffold_version() { local version version="$(git -C "$SCAFFOLD_ROOT" describe --tags --always --dirty 2>/dev/null)" \ @@ -104,11 +92,8 @@ is_scaffold_project() { [ -f "${1}/mise.toml" ] && grep -q "^${PROJECT_MARKER}\$" "${1}/mise.toml" } -# init_scaffold_manifest -# Without this a generated project has no record of what produced it, and -# `scaffold update` has no "since when" to diff against. init_scaffold_manifest() { - local project="$1" + local -r project="$1" # A heredoc, not printf: the prose is full of backticks, which shellcheck # reads inside single quotes as an unescaped command substitution. @@ -128,8 +113,8 @@ EOF } record_scaffold_app() { - local project="$1" rel="$2" adapter="$3" - local file="${project}/${SCAFFOLD_MANIFEST}" + local -r project="$1" rel="$2" adapter="$3" + local -r file="${project}/${SCAFFOLD_MANIFEST}" [ -f "$file" ] \ || die "no ${SCAFFOLD_MANIFEST} in ${project} — this project predates it; see 'scaffold update'" @@ -141,7 +126,7 @@ record_scaffold_app() { } substitute_in_files() { - local expression="$1"; shift + local -r expression="$1"; shift local file for file in "$@"; do @@ -151,7 +136,7 @@ substitute_in_files() { } init_project() { - local dir="$1" name="$2" + local -r dir="$1" name="$2" project_name_is_usable "$name" || die "${PROJECT_NAME_RULE}: ${name}" @@ -161,16 +146,15 @@ init_project() { owner="$(resolve_github_owner)" mkdir -p "$dir" - # From here on this run owns $dir; a later step failing must remove it, not - # leave debris behind the overwrite guard above. $dir is baked into the trap - # command so it survives this function's locals going away; $? stays deferred. + # $dir is baked into the trap command with printf %q so it survives this + # function's locals going away; a later step failing must clean up $dir. # shellcheck disable=SC2064 # $dir expanding now is intentional; $? is escaped and still deferred trap "cmd_new_cleanup $(printf '%q' "$dir") \"\$?\"" EXIT git -C "$dir" init --initial-branch=main --quiet cp -R "${SCAFFOLD_ROOT}/common/." "${dir}/" - # cp -R preserves the committed executable bit, but that depends on the - # source checkout's own mode surviving clone/checkout (e.g. core.fileMode). + # cp -R's preserved executable bit depends on the source checkout's own mode + # surviving clone/checkout (e.g. core.fileMode). chmod +x "${dir}/install.sh" local -a owner_files=("${dir}/.github/workflows/"*.yml) @@ -178,9 +162,7 @@ init_project() { substitute_in_files "s|you/|${owner}/|g" "${owner_files[@]}" # CODEOWNERS carries the placeholder as `@you`, which the pattern above does - # not match. SECURITY.md points vulnerability reports at whoever CODEOWNERS - # names, and GitHub treats an unresolvable owner as a syntax error — an - # untouched file here makes the security contact unreachable. + # not match; GitHub treats an unresolvable owner in it as a syntax error. substitute_in_files "s|@you\b|@${owner}|g" "${dir}/CODEOWNERS" sed "s|@PROJECT_NAME@|${name}|g" "${dir}/mise.root.toml" > "${dir}/mise.toml" @@ -193,34 +175,28 @@ init_project() { mise trust -y --quiet -C "$dir" } -# lock_toolchains # `mise install` writes a lockfile naming versions but no download URLs when # the tools were already in the local cache, and CI's `mise install --locked` # rejects exactly that file. `mise lock` fills in the URLs and checksums. lock_toolchains() { - # a mise.toml above the new project is neither trusted nor necessarily - # parseable, and mise reads it before ours. That breaks locking but not the - # project, so say so and leave the environment to whoever owns it. + # A mise.toml above the new project, read before ours, can make this fail + # without breaking the project — so warn and leave it to whoever owns it. mise lock --quiet -C "$1" >/dev/null \ || warn "could not lock the toolchain — run 'mise lock' before committing mise.lock, or CI's 'mise install --locked' will reject it" } finalize_project() { - local project="$1" + local -r project="$1" sync_ci_roots "$project" lock_toolchains "$project" git -C "$project" add -A - # `feat:`, not `chore:`. Release Please hides chore from the changelog and - # cuts nothing for it, so a new project's first push ran the release - # workflow, found no releasable commit and finished green with no release — - # leaving install.sh with nothing to download. This commit really is the - # project's first feature, and the release it cuts from 0.0.0 is v1.0.0. + # `feat:`, not `chore:`: Release Please hides chore from the changelog and + # cuts nothing for it, leaving install.sh with no release to download. # # GIT_AUTHOR_*/GIT_COMMITTER_* rather than `-c user.name=`: these env vars - # outrank `-c` config in git's own precedence, so a caller that exports one - # would otherwise still leak through. + # outrank `-c` config, so a caller that exports one would otherwise leak through. GIT_AUTHOR_NAME="$PROJECT_COMMIT_NAME" GIT_AUTHOR_EMAIL="$PROJECT_COMMIT_EMAIL" \ GIT_COMMITTER_NAME="$PROJECT_COMMIT_NAME" GIT_COMMITTER_EMAIL="$PROJECT_COMMIT_EMAIL" \ git -C "$project" commit --quiet -m "feat: scaffold project" diff --git a/lib/publish.sh b/lib/publish.sh index 078003a..df7156d 100644 --- a/lib/publish.sh +++ b/lib/publish.sh @@ -35,7 +35,7 @@ gh_repo_exists() { } create_repo() { - local project="$1" slug="$2" visibility="$3" + local -r project="$1" slug="$2" visibility="$3" # One `gh` call doing three things — create, add the remote, push — so a # failure in the second or third leaves the first behind. Everything here is @@ -72,7 +72,8 @@ main_is_protected() { # changes whenever an application is added. Requiring a pull request and # refusing force-pushes is the part that generalises. protect_main() { - local slug="$1" response status=0 + local -r slug="$1" + local response status=0 response="$(gh api -X POST "repos/${slug}/rulesets" --input - 2>&1 <<'EOF' { @@ -138,7 +139,7 @@ EOF # back to GITHUB_TOKEN. What the fallback costs is a release pull request whose # checks sit at "Action required" and then expire red. set_release_secrets() { - local slug="$1" + local -r slug="$1" [ -n "${RELEASE_APP_ID:-}" ] && [ -n "${RELEASE_APP_PRIVATE_KEY:-}" ] || return 1 diff --git a/lib/service.sh b/lib/service.sh index 2261581..9ab525b 100644 --- a/lib/service.sh +++ b/lib/service.sh @@ -23,13 +23,13 @@ SERVICE_SETUP_ANCHOR="# @SERVICE_SETUP@" # Same guard as load_adapter, for the same reason: `source` below executes # whatever it reads, so the name must not be able to leave services/. load_service() { - local name="$1" + local -r name="$1" case "$name" in ''|*[!a-z0-9-]*|-*) die "not a usable service name: ${name} (run: scaffold list)" ;; esac - local dir="${SCAFFOLD_ROOT}/services/${name}" + local -r dir="${SCAFFOLD_ROOT}/services/${name}" [ -d "$dir" ] || die "unknown service: ${name} (run: scaffold list)" # shellcheck disable=SC2034 # read by the caller @@ -54,8 +54,8 @@ service_compose_key() { } record_services() { - local project="$1" database="$2" cache="$3" - local file="${project}/mise.toml" + local -r project="$1" database="$2" cache="$3" + local -r file="${project}/mise.toml" sed -i.bak -e "s|@DATABASE@|${database}|" -e "s|@CACHE@|${cache}|" "$file" rm -f "${file}.bak" @@ -69,7 +69,8 @@ record_services() { # Prints nothing for `none`, so a caller can test the value rather than compare # it to a word. project_service() { - local project="$1" key="$2" value + local -r project="$1" key="$2" + local value value="$(yq -p toml -oy -r ".vars.${key} // \"\"" "${project}/mise.toml" 2>/dev/null || true)" [ "$value" = "none" ] || [ "$value" = "null" ] && return 0 @@ -100,7 +101,8 @@ app_port_variable() { # the first port because yq's `max` over an empty sequence prints nothing at # all, which `// default` does not catch. next_app_port() { - local project="$1" highest + local -r project="$1" + local highest highest="$(SEED="$((FIRST_APP_PORT - 1))" yq -r '[(env(SEED) | tonumber), (.services[].ports[]? | capture("\{[A-Za-z0-9_]+:-(?P[0-9]+)\}").port | tonumber)] | max' \ "${project}/${COMPOSE_FILE}")" @@ -112,7 +114,8 @@ next_app_port() { # application did. The build.yml fallback is what lets `scaffold update` work on # a project generated before [vars] image existed. project_image_base() { - local project="$1" value + local -r project="$1" + local value value="$(yq -p toml -oy -r '.vars.image // ""' "${project}/mise.toml" 2>/dev/null || true)" if [ -z "$value" ] || [ "$value" = null ]; then @@ -140,7 +143,7 @@ compose_lane_file() { # Removes the fragment on both paths: under `set -e` a yq failure leaves # immediately and the temporary file would survive the run. merge_compose_fragment() { - local file="$1" fragment="$2" what="$3" + local -r file="$1" fragment="$2" what="$3" if ! yq eval-all --inplace 'select(fileIndex==0) * select(fileIndex==1)' \ "$file" "$fragment"; then @@ -155,7 +158,7 @@ merge_compose_fragment() { # merged in per lane. The image is injected here rather than written in a # fragment so a service's digest lives only in its service.env. assemble_compose() { - local project="$1"; shift + local -r project="$1"; shift local service lane file key merged for service in "$@"; do @@ -194,7 +197,7 @@ assemble_compose() { # service's driver, into the app's own .env.example: DB_CONNECTION is Laravel's # phrasing and DATABASE_URL is Prisma's for the same server. assemble_example_env() { - local project="$1"; shift + local -r project="$1"; shift local service for service in "$@"; do @@ -210,8 +213,8 @@ assemble_example_env() { # same base the build workflows get, because this is the path they push to: the # two cannot be written independently without drifting apart. add_app_service() { - local project="$1" rel="$2" role="$3" - local file="${project}/${COMPOSE_FILE}" + local -r project="$1" rel="$2" role="$3" + local -r file="${project}/${COMPOSE_FILE}" local key port_var port image fragment kind recorded [ -f "$file" ] || die "no ${COMPOSE_FILE} in ${project}" @@ -268,7 +271,7 @@ add_app_service() { # against an .env.example the adapter shipped, so appending blindly would leave # two values for one key and let the loser win depending on the reader. write_env_lines() { - local file="$1"; shift + local -r file="$1"; shift local line key rendered [ -f "$file" ] || : > "$file" @@ -304,7 +307,8 @@ write_env_lines() { # one overwriting the other. Both Dockerfile variants get the anchor resolved: # cmd_new decides which survives only after this runs. apply_service_dockerfile() { - local app="$1" block="$2" + local -r app="$1" + local block="$2" local file found=0 for file in "${app}/Dockerfile" "${app}/Dockerfile.workspace"; do @@ -333,8 +337,9 @@ apply_service_dockerfile() { # block-style `KEY: value` line per driver — and -P rewrites nodes the merge # never touched. apply_service_compose_env() { - local project="$1" service="$2" block="$3" - local file="${project}/${COMPOSE_FILE}" fragment + local -r project="$1" service="$2" block="$3" + local -r file="${project}/${COMPOSE_FILE}" + local fragment [ -n "$block" ] || return 0 [ -f "$file" ] || die "no ${COMPOSE_FILE} in ${project}" @@ -354,8 +359,9 @@ apply_service_compose_env() { # For a driver needing a whole sibling service (the migrate runner below) rather # than another line under one application's environment. apply_service_compose_service() { - local project="$1" block="$2" - local file="${project}/${COMPOSE_FILE}" fragment + local -r project="$1" block="$2" + local -r file="${project}/${COMPOSE_FILE}" + local fragment [ -n "$block" ] || return 0 [ -f "$file" ] || die "no ${COMPOSE_FILE} in ${project}" @@ -371,8 +377,9 @@ apply_service_compose_service() { # explicitly, once, after the stack is up. An empty command (no database, or a # cache-only driver) merges nothing. apply_service_compose_migrate() { - local project="$1" service="$2" env_block="$3" command="$4" - local file="${project}/${COMPOSE_FILE}" image block + local -r project="$1" service="$2" env_block="$3" command="$4" + local -r file="${project}/${COMPOSE_FILE}" + local image block [ -n "$command" ] || return 0 [ -f "$file" ] || die "no ${COMPOSE_FILE} in ${project}" @@ -411,7 +418,7 @@ apply_service_compose_migrate() { # the project's mise.toml does not pin, and `mise exec` resolves PATH from # scratch. composer stays ambient either way (ADR-0016). run_driver_apply() { - local app="$1" project="$2" family="$3" service="$4" driver="$5" + local -r app="$1" project="$2" family="$3" service="$4" driver="$5" local pnpm_bin node_bin pnpm_bin="$(dirname "$(mise which pnpm -C "$app")")" @@ -420,7 +427,7 @@ run_driver_apply() { # Held in a variable so it reaches `bash -c` through `env` intact. Its # `$1`/`$2` and ${SCAFFOLD_ROOT} are the child's to expand. # shellcheck disable=SC2016 - local driver_script=' + local -r driver_script=' cd "$1" . "${SCAFFOLD_ROOT}/lib/log.sh" . "${SCAFFOLD_ROOT}/lib/service.sh" @@ -446,7 +453,7 @@ driver_output() { # resolve_driver — the driver file, by name, or die. resolve_driver() { load_service "$2" - local driver="${SERVICE_DIR}/drivers/${1}.sh" + local -r driver="${SERVICE_DIR}/drivers/${1}.sh" [ -f "$driver" ] || die "${2} has no driver for ${1} — run 'scaffold lint'" printf '%s' "$driver" } @@ -458,7 +465,7 @@ resolve_driver() { # project-root is an argument, not `app`'s ancestor counted in `..`: cmd_new's # apps/ and cmd_add's caller-chosen directory nest at different depths. apply_service_drivers() { - local app="$1" project="$2" family="$3"; shift 3 + local -r app="$1" project="$2" family="$3"; shift 3 local service driver rendered local block="" env_block="" migrate_block="" diff --git a/lib/tui.sh b/lib/tui.sh index e6232fa..a4dbd90 100644 --- a/lib/tui.sh +++ b/lib/tui.sh @@ -118,13 +118,14 @@ tui_header() { # banner.sh's _banner_edge, cut down to a label centred in a horizontal rule, # drawn once so it carries none of that file's rebuild-on-resize bookkeeping. _tui_header_edge() { - local left="$1" right="$2" label="$3" width="$4" - local inner=$(( width - 2 )) + local -r left="$1" right="$2" width="$4" + local label="$3" + local -r inner=$(( width - 2 )) _tui_fit " ${label} " "$inner" label="$REPLY" - local side=$(( (inner - ${#label}) / 2 )) - local extra=$(( inner - ${#label} - side * 2 )) + local -r side=$(( (inner - ${#label}) / 2 )) + local -r extra=$(( inner - ${#label} - side * 2 )) local l r printf -v l '%*s' "$side" ''; l="${l// /─}" printf -v r '%*s' "$(( side + extra ))" ''; r="${r// /─}" @@ -135,8 +136,9 @@ _tui_header_edge() { # _tui_header_row [dim|bold] — banner.sh's _banner_row, minus # the styles it never uses here. _tui_header_row() { - local style="$1" text="$2" width="$3" - local inner=$(( width - 2 )) + local -r style="$1" width="$3" + local text="$2" + local -r inner=$(( width - 2 )) _tui_fit "$text" "$inner" text="$REPLY" @@ -238,7 +240,7 @@ _tui_read_line() { # value in TUI_CHOICE; returns 1 on Esc rather than dying, so the caller decides # what cancelling the wizard means. tui_select() { - local prompt="$1"; shift + local -r prompt="$1"; shift local -a options=("$@") local cursor=0 key i value @@ -305,7 +307,7 @@ tui_select() { _TUI_RENDER_HEIGHT=0 _tui_render() { - local prompt="$1" cursor="$2"; shift 2 + local -r prompt="$1" cursor="$2"; shift 2 local -a options=("$@") local cols limit cols="$(tput cols 2>/dev/null || echo "$DEFAULT_TERM_COLS")" @@ -350,7 +352,7 @@ _tui_render() { # per row per keypress without forking a subshell while a held key is still # sending bytes at the (echo-disabled) tty. _tui_fit() { - local text="$1" limit="$2" + local -r text="$1" limit="$2" if (( ${#text} <= limit )); then REPLY="$text" elif (( limit <= 1 )); then diff --git a/lib/update.sh b/lib/update.sh index 05357eb..8753ff9 100644 --- a/lib/update.sh +++ b/lib/update.sh @@ -28,7 +28,8 @@ COMMON_PATCH_EXCLUDES=(':(exclude)common/mise.root.toml') # manifest_version — the toolbox commit a project was generated from. manifest_version() { - local file="${1}/${SCAFFOLD_MANIFEST}" version + local -r file="${1}/${SCAFFOLD_MANIFEST}" + local version [ -f "$file" ] || return 1 version="$(yq -p toml -oy -r '.version // ""' "$file" 2>/dev/null || true)" @@ -70,7 +71,7 @@ project_image_name() { # with `|` delimiting the expression sed reads that alternation as the end of # the pattern. rewrite_patch_paths() { - local from="$1" to="$2" + local -r from="$1" to="$2" sed -E \ -e "s#^diff --git a/${from}#diff --git a/${to}#" \ -e "s#^(diff --git a/[^ ]+) b/${from}#\1 b/${to}#" \ @@ -85,7 +86,7 @@ rewrite_patch_paths() { # about to become the project's content, and the context lines because # otherwise no hunk matches anything. substitute_placeholders() { - local project="$1" rel="${2:-}" + local -r project="$1" rel="${2:-}" local owner name filter owner="$(project_image_owner "$project")" name="$(project_image_name "$project")" @@ -106,7 +107,7 @@ substitute_placeholders() { # ─── building the patch ──────────────────────────────────────────────────── common_patch() { - local project="$1" + local -r project="$1" git -C "$SCAFFOLD_ROOT" diff "${SCAFFOLD_UPDATE_FROM}..HEAD" -- \ common/ "${COMMON_PATCH_EXCLUDES[@]}" \ @@ -124,8 +125,8 @@ common_patch() { # mapped onto `Dockerfile` and the other is dropped, rather than emitting a # patch against a path that is not there. adapter_patch() { - local project="$1" rel="$2" adapter="$3" - local dir="adapters/${adapter}" + local -r project="$1" rel="$2" adapter="$3" + local -r dir="adapters/${adapter}" local kept dropped internal local -a excludes=() @@ -156,7 +157,8 @@ adapter_patch() { # update_patch — everything the project has not received, as one # patch against its own paths. update_patch() { - local project="$1" rel adapter + local -r project="$1" + local rel adapter common_patch "$project" while IFS=$'\t' read -r rel adapter; do @@ -178,7 +180,8 @@ update_patch() { # Only when the value came back empty, so this never rewrites targets that were # already right. resync_derived_files() { - local project="$1" rel + local -r project="$1" + local rel sync_ci_roots "$project" diff --git a/lib/wizard.sh b/lib/wizard.sh index 72a0ff7..06c668d 100644 --- a/lib/wizard.sh +++ b/lib/wizard.sh @@ -47,7 +47,7 @@ wizard_shapes() { # The order the answers constrain each other in. `web` asks nothing about a # database because `scaffold new` refuses --db without an api or app adapter. wizard_questions() { - local shape="$1" + local -r shape="$1" grep -qx "$shape" <<<"$(wizard_shapes | cut -f1)" || die "unknown project shape: ${shape}" @@ -93,7 +93,7 @@ wizard_prompt_width() { # is cmd_list's tab-separated output; every option comes from there # rather than a second copy of what the adapters and services already declare. wizard_options() { - local listing="$1" kind="$2" + local -r listing="$1" kind="$2" case "$kind" in web|api|app) @@ -126,7 +126,8 @@ wizard_options() { # cmd_new's own unset-flag default moved first, so a plain Enter picks what the # flags would have picked unset. wizard_order_options() { - local kind="$1" listing="$2" default="" line + local -r kind="$1" listing="$2" + local default="" line case "$kind" in database) default="$DEFAULT_DATABASE_SERVICE" ;; @@ -179,7 +180,7 @@ wizard_new_args() { # What the answers would have been typed as. Printed before the run so the # second project is scripted rather than clicked. wizard_command() { - local name="$1"; shift + local -r name="$1"; shift local -a args; mapfile -t args < <(wizard_new_args "$@") local out="scaffold new ${name}" [ "${#args[@]}" -eq 0 ] || out+=" ${args[*]}" diff --git a/services/mongodb/drivers/laravel.sh b/services/mongodb/drivers/laravel.sh index c7bdc56..6fa72cc 100644 --- a/services/mongodb/drivers/laravel.sh +++ b/services/mongodb/drivers/laravel.sh @@ -8,11 +8,10 @@ # a DSN and a config/database.php connection instead of the decomposed # DB_HOST/DB_PORT/DB_USERNAME/DB_PASSWORD every relational driver shares. service_driver_apply() { - # Recorded in composer.lock BEFORE `composer require`, never after: it is what - # lets both this call and the Docker vendor stage's from-scratch `composer - # install` resolve laravel-mongodb with no mongodb extension on the host. - # - # 1.21.0 must stay the version service_driver_dockerfile builds below. + # Recorded in composer.lock before `composer require`: it is what lets both + # this call and the Docker vendor stage's from-scratch `composer install` + # resolve laravel-mongodb with no mongodb extension on the host. 1.21.0 must + # stay the version service_driver_dockerfile builds below. composer config platform.ext-mongodb 1.21.0 --no-interaction || return 1 composer require mongodb/laravel-mongodb --no-interaction || return 1 @@ -25,24 +24,21 @@ service_driver_apply() { register_mongodb_connection config/database.php # APP_KEY is per-family, not per-service, so no env.fragment can carry it into - # the project's example.env. Without a value here, compose.yaml's - # `APP_KEY: ${APP_KEY}` interpolates to empty and laravel refuses to boot. + # example.env; unset, compose.yaml's `APP_KEY: ${APP_KEY}` interpolates to + # empty and laravel refuses to boot. write_env_lines "${SCAFFOLD_PROJECT_ROOT}/example.env" "APP_KEY=changeme" || return 1 # mongodb has no SQL to run a `select 1` against; ping is what - # laravel-mongodb exposes. - # - # The throw is replaced in place rather than left below the probe: pint - # rejects dead code after a path that always returns. The class arrives as a - # short name with its own `use`, because pint's fully_qualified_strict_types - # rejects an inline FQCN once the file has imports — and a --db none project - # runs neither substitution, so it keeps both the throw and the FQCN. + # laravel-mongodb exposes. The class arrives as a short name with its own + # `use`: pint's fully_qualified_strict_types rejects an inline FQCN once the + # file has imports. sed -i.bak 's|use Illuminate\\Support\\Facades\\Route;|use Illuminate\\Support\\Facades\\DB;\nuse Illuminate\\Support\\Facades\\Route;|' \ routes/health.php || return 1 sed -i.bak 's|// @DB_PROBE@|DB::connection(\x27mongodb\x27)->getMongoDB()->command([\x27ping\x27 => 1]);|' \ routes/health.php || return 1 - # Matched with its leading indentation so the replacement's `\n` opens a bare - # blank line, which is what pint's blank_line_before_statement wants here. + # Replaced in place, not left below the probe: pint rejects dead code after an + # always-returning path. Matched with its leading indentation so the + # replacement's `\n` opens a bare blank line, which blank_line_before_statement wants. sed -i.bak "s| throw new RuntimeException('no database is configured for this project');|\\n return response()->json(['status' => 'ok']);|" \ routes/health.php || return 1 rm -f routes/health.php.bak @@ -53,14 +49,13 @@ service_driver_apply() { } service_driver_dockerfile() { - # pecl, not apk: the extension is not in alpine's repositories, which is why - # this block installs build dependencies and nothing else does. + # pecl, not apk: the extension is not in alpine's repositories. # - # Pinned to 1.21.0, matching platform.ext-mongodb above. mongodb/mongodb's + # Pinned to 1.21.0, matching platform.ext-mongodb above: mongodb/mongodb's # BSONArray/BSONDocument declare bsonSerialize() against the 1.x signature and - # the 2.x extension changed it, so any code path loading those classes is a - # PHP fatal error, not an exception this project's try/catch can see — a 500 - # on /health/ready before this pin. + # the 2.x extension changed it, so loading those classes is a PHP fatal error, + # not an exception this project's try/catch can see — a 500 on /health/ready + # before this pin. printf '%s\n' \ 'RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS openssl-dev \' \ ' && pecl install mongodb-1.21.0 \' \ @@ -68,9 +63,9 @@ service_driver_dockerfile() { ' && apk del .build-deps' } -# DB_CONNECTION first and always: config/database.php defaults to sqlite, so its -# absence is a silent wrong answer. DB_URI is assembled here because -# laravel-mongodb reads one DSN string, not decomposed credentials. +# config/database.php defaults to sqlite, so DB_CONNECTION's absence is a +# silent wrong answer. DB_URI is assembled here: laravel-mongodb reads one DSN +# string, not decomposed credentials. service_driver_compose_env() { printf 'DB_CONNECTION: mongodb\n' printf 'DB_URI: ${DB_URI:-mongodb://${DB_USERNAME:-app}:${DB_PASSWORD}@database:27017/${DB_DATABASE:-app}?authSource=admin}\n' @@ -83,15 +78,13 @@ service_driver_compose_migrate() { printf 'command: ["php", "artisan", "migrate", "--force"]\n' } -# register_mongodb_connection # laravel-mongodb needs a 'mongodb' entry in the connections array; the Laravel # skeleton ships none. Insert-then-verify, like register_config_root: an anchor -# that stops matching after a skeleton upgrade must fail loudly here, not ship -# an app whose DB_CONNECTION names a connection that does not exist. +# that stops matching after a skeleton upgrade must fail loudly here. register_mongodb_connection() { - local file="$1" - local anchor=" 'connections' => [" - local block=" 'mongodb' => [ + local -r file="$1" + local -r anchor=" 'connections' => [" + local -r block=" 'mongodb' => [ 'driver' => 'mongodb', 'dsn' => env('DB_URI', 'mongodb://localhost:27017'), 'database' => env('DB_DATABASE', 'app'), diff --git a/services/shared/laravel.sh b/services/shared/laravel.sh index 6c92934..ce928f7 100644 --- a/services/shared/laravel.sh +++ b/services/shared/laravel.sh @@ -4,9 +4,9 @@ # Description : The shared Laravel SQL driver body. # Author : ttncode # ═══════════════════════════════════════════════════════════════════════════ -# A service's drivers/laravel.sh sets the parameters below and sources this. -# mysql and postgres only — mongodb is self-contained: a DSN and a -# config/database.php edit differ in kind from these decomposed credentials. +# A service's drivers/laravel.sh sets these and sources this. mysql and +# postgres only — mongodb is self-contained: a DSN and a config/database.php +# edit differ in kind from these decomposed credentials. # # LARAVEL_CONNECTION the DB_CONNECTION value # LARAVEL_PORT the default port for .env.example @@ -21,8 +21,7 @@ service_driver_apply() { fi # localhost, not the compose service name: .env.example describes host-side - # `mise run dev`, which reaches the database through compose.dev.yaml's - # published port, not the compose network. + # `mise run dev`, reached through compose.dev.yaml's published port. write_env_lines .env.example \ "DB_CONNECTION=${LARAVEL_CONNECTION}" \ "DB_HOST=localhost" \ @@ -33,24 +32,22 @@ service_driver_apply() { || return 1 # APP_KEY is per-family, not per-service, so no env.fragment can carry it into - # the project's example.env. Without a value here, compose.yaml's - # `APP_KEY: ${APP_KEY}` interpolates to empty and laravel refuses to boot. + # example.env; unset, compose.yaml's `APP_KEY: ${APP_KEY}` interpolates to + # empty and laravel refuses to boot. write_env_lines "${SCAFFOLD_PROJECT_ROOT}/example.env" "APP_KEY=changeme" || return 1 # Spliced here rather than shipped in the route, so the file carries exactly # one probe, for the connection this project actually has. # - # The throw is replaced in place rather than left below the probe: pint - # rejects dead code after a path that always returns. The class arrives as a - # short name with its own `use`, because pint's fully_qualified_strict_types - # rejects an inline FQCN once the file has imports — and a --db none project - # runs neither substitution, so it keeps both the throw and the FQCN. + # The class arrives as a short name with its own `use`: pint's + # fully_qualified_strict_types rejects an inline FQCN once the file has imports. sed -i.bak 's|use Illuminate\\Support\\Facades\\Route;|use Illuminate\\Support\\Facades\\DB;\nuse Illuminate\\Support\\Facades\\Route;|' \ routes/health.php || return 1 sed -i.bak 's|// @DB_PROBE@|DB::connection()->select(\x27select 1\x27);|' \ routes/health.php || return 1 - # Matched with its leading indentation so the replacement's `\n` opens a bare - # blank line, which is what pint's blank_line_before_statement wants here. + # Replaced in place, not left below the probe: pint rejects dead code after an + # always-returning path. Matched with its leading indentation so the + # replacement's `\n` opens a bare blank line, which blank_line_before_statement wants. sed -i.bak "s| throw new RuntimeException('no database is configured for this project');|\\n return response()->json(['status' => 'ok']);|" \ routes/health.php || return 1 rm -f routes/health.php.bak @@ -64,10 +61,9 @@ service_driver_dockerfile() { [ -z "$LARAVEL_SETUP" ] || printf '%s\n' "$LARAVEL_SETUP" } -# DB_CONNECTION first and always: config/database.php defaults to sqlite, so its -# absence is a silent wrong answer, not an error. The credentials already reach -# the container through compose.yaml's env_file, so only what laravel cannot -# otherwise know — the connection name, the host, the key — is added here. +# config/database.php defaults to sqlite, so DB_CONNECTION's absence is a +# silent wrong answer, not an error. Credentials reach the container through +# compose.yaml's env_file already; only what laravel cannot otherwise know goes here. service_driver_compose_env() { printf 'DB_CONNECTION: %s\n' "$LARAVEL_CONNECTION" printf '%s\n' "$LARAVEL_COMPOSE_ENV" diff --git a/services/shared/nest.sh b/services/shared/nest.sh index 9c062b1..cf7f7bd 100644 --- a/services/shared/nest.sh +++ b/services/shared/nest.sh @@ -15,13 +15,13 @@ # credentials left as compose interpolations service_driver_apply() { - # Before the installs, not after: all three packages place the query engine - # binary through an install-time script with no pure-js fallback, and - # undecided the first `pnpm add` below is refused with + # Before the installs, not after: prisma, its engines and its client all place + # the query engine binary through an install-time script with no pure-js + # fallback, and undecided the first `pnpm add` below is refused with # ERR_PNPM_IGNORED_BUILDS wherever CI=true leaves pnpm no prompt. # - # SCAFFOLD_PROJECT_ROOT, exported by apply_service_drivers: cmd_add's app - # directory is caller-chosen, so a fixed `../..` reaches outside the project. + # SCAFFOLD_PROJECT_ROOT, not a fixed `../..`: cmd_add's app directory is + # caller-chosen. if ! yq --inplace \ '.allowBuilds.prisma = true | .allowBuilds."@prisma/engines" = true @@ -32,16 +32,15 @@ service_driver_apply() { # major-pinned, not @latest: 7 dropped the datasource `url` this driver writes # below for a prisma.config.ts adapter, and latest resolves to an 8.x release - # candidate. 6 is the newest stable major that still reads `url`. + # candidate. pnpm add @prisma/client@6 || return 1 # A regular dependency, not -D: `pnpm prune --prod` drops devDependencies, and - # the published image is what runs `migrate deploy`. The engines cost image - # size (ADR-0021). + # the published image is what runs `migrate deploy`. pnpm add prisma@6 || return 1 mkdir -p prisma || return 1 - # datasource and generator only. models describe the client's domain, which - # this toolbox does not know — see the spec's non-goals. + # datasource and generator only; models describe the client's domain, which + # this toolbox does not know. cat > prisma/schema.prisma </node_modules/.bin for the workspace shape, node_modules/.bin -# at the container root for the standalone one. The command tries both rather -# than guessing, and `cd`s into whichever matched: WORKDIR stays the container -# root either way, and prisma resolves `./prisma/schema.prisma` from its own -# working directory, which is nested under the app directory in the workspace -# shape (measured: `Could not find Prisma Schema` before this `cd`). +# not in the built image. prisma's own bin survives `pnpm prune --prod`, but at +# one of two locations depending on which Dockerfile shape wins, a decision made +# after this driver runs — so the command tries both and `cd`s into whichever +# matched, since prisma resolves `./prisma/schema.prisma` from its own working +# directory (measured: `Could not find Prisma Schema` before this `cd`). service_driver_compose_migrate() { local args case "$PRISMA_PROVIDER" in mongodb) args='db push --skip-generate' ;; *) args='migrate deploy' ;; esac - # `$${d}`/`$$d`, not `${d}`/`$d`: compose interpolates `$var` in compose.yaml - # before the command reaches the container, and a single `$` resolves to an - # unset variable that blanks the loop out entirely. `$$` is compose's escape - # for a literal `$`. + # `$${d}`/`$$d`, not `${d}`/`$d`: compose interpolates `$var` before the + # command reaches the container; `$$` is compose's escape for a literal `$`. printf 'command: ["sh", "-c", "for d in apps/*/ ./; do [ -x $${d}node_modules/.bin/prisma ] && cd $$d && exec node_modules/.bin/prisma %s; done; echo prisma binary not found >&2; exit 1"]\n' "$args" } diff --git a/tests/new-project.bats b/tests/new-project.bats index 15a2bc2..ac57077 100644 --- a/tests/new-project.bats +++ b/tests/new-project.bats @@ -246,6 +246,13 @@ collect_roots() { # error, so the security contact was a name that cannot receive anything. run grep -rn '@you\b\|you/' "$PROJECT" --include='*.yml' --include='*.md' --include='CODEOWNERS' [ -z "$output" ] || { echo "placeholder left in:"; echo "$output"; false; } + + # Absence of the placeholder is not presence of the owner: a hardcoded or + # mistyped account passes the grep above. tests/helpers/setup.bash exports + # SCAFFOLD_GITHUB_OWNER=test-owner. + run cat "${PROJECT}/CODEOWNERS" + [ "$output" = "* @test-owner" ] \ + || { echo "CODEOWNERS says '${output}', not the account this run resolved"; false; } } @test "no @PROJECT_ placeholder survives into the generated project" {