Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# Editor configuration, see https://editorconfig.org
root = true

[*]
Expand Down
26 changes: 6 additions & 20 deletions .github/workflows/adapters.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ on:
schedule:
# nightly: tier a only (docs/decisions/0012)
- cron: "17 3 * * *"
# weekly: tier a, every tier b adapter, plus the compose/install check —
# this exact string is also scripts/adapter-matrix.sh's WEEKLY_CRON
# weekly: every tier plus compose; must equal scripts/adapter-matrix.sh's WEEKLY_CRON
- cron: "23 2 * * 1"
workflow_dispatch:

Expand Down Expand Up @@ -49,10 +48,8 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
# one full generation per test, 5-6 tests per adapter
timeout-minutes: 15
strategy:
# a broken adapter must not hide the state of the others
fail-fast: false
matrix:
adapter: ${{ fromJson(needs.discover.outputs.tier-a) }}
Expand Down Expand Up @@ -88,8 +85,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
# measured at ~5 minutes per test (docs/decisions/0012); five tests
# per adapter plus margin, well under an unbounded hang.
# ~5 minutes per test, five tests per adapter (docs/decisions/0012)
timeout-minutes: 40
strategy:
fail-fast: false
Expand Down Expand Up @@ -127,7 +123,6 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
# a generation plus an image build plus a container start, per adapter.
timeout-minutes: 30
strategy:
fail-fast: false
Expand Down Expand Up @@ -165,7 +160,6 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
# same per-adapter cost as deploy: generation, image build, container start
timeout-minutes: 30
strategy:
fail-fast: false
Expand Down Expand Up @@ -198,9 +192,7 @@ jobs:
run: ./scripts/deploy-check.sh "$ADAPTER"

deploy-multi-app:
# deploy runs one adapter at a time, so nothing else proves a project with
# two applications comes up on two images (ADR-0022). Weekly, like compose
# below: two generations plus two image builds.
# The only proof two applications come up on two images (ADR-0022).
if: ${{ github.event.schedule == '23 2 * * 1' || github.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
permissions:
Expand All @@ -219,8 +211,6 @@ jobs:
run: ./scripts/deploy-check.sh nextjs nestjs --db postgres

compose:
# expensive relative to the other checks here, so gated on the weekly
# schedule (not the nightly tier-a-only one) or a manual run
if: ${{ github.event.schedule == '23 2 * * 1' || github.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
permissions:
Expand All @@ -241,8 +231,7 @@ jobs:
run: bats tests/compose.bats

services:
# Eight combinations per adapter at minutes each, so the full grid is
# nightly; a pull request gets the default cell through tests/new-*.bats.
# The full grid is nightly; a pull request gets the default cell via tests/new-*.bats.
if: ${{ github.event.schedule == '17 3 * * *' || github.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
permissions:
Expand Down Expand Up @@ -280,9 +269,7 @@ jobs:
ADAPTER: ${{ matrix.adapter }}
DB: ${{ matrix.db }}
CACHE: ${{ matrix.cache }}
# resolve_github_owner falls back to `gh auth login` or git's
# github.user before giving up, and a runner has neither. Every other
# caller sets its own; this job calls `scaffold new` directly.
# A runner has neither `gh auth` nor git's github.user to fall back on.
SCAFFOLD_GITHUB_OWNER: ${{ github.repository_owner }}
run: |
work="$(mktemp -d)"
Expand All @@ -293,8 +280,7 @@ jobs:
mise run //apps/api:checklist

notify-on-schedule-failure:
# a weekly-only lane nobody watches is theatre; surface a red run as an
# issue instead of leaving it in a schedule tab no one opens.
# Nobody watches the schedule tab; a red scheduled run becomes an issue.
if: >-
${{ always() && github.event_name == 'schedule' &&
(needs.smoke.result == 'failure' || needs.smoke-tier-b.result == 'failure' ||
Expand Down
25 changes: 4 additions & 21 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
# ci-unit is lint plus test-unit, and test-unit is genuinely offline — no
# adapter generator anywhere in its setup(), asserted by
# tests/contract.bats rather than promised by this comment — so no
# pnpm/php provisioning is needed here. The whole job measured 79 seconds
# on a runner, which is what the timeout below leaves room around.
# No pnpm/php: tests/contract.bats asserts test-unit generates no adapter.
timeout-minutes: 5
steps:
- name: Checkout
Expand All @@ -35,20 +31,12 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
# add-app.bats, compose.bats, docs.bats and workflows.bats each
# generate a real nestjs/laravel-api project as a fixture — same cost
# class as tier a in adapters.yml (bounded, not tier b's ~25 minutes),
# moved here because ADR-0012's tiers are about per-adapter smoke
# tests, not these cross-cutting mechanics suites.
timeout-minutes: 25
steps:
- 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.
# A default checkout has one commit and no parent, so the test fails
# on `unknown revision` rather than on anything it is testing.
# workflows.bats diffs the commit that introduced an adapter against its parent.
fetch-depth: 0
persist-credentials: false
- name: Setup mise
Expand All @@ -74,12 +62,7 @@ jobs:
persist-credentials: false
- 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
# finding — a failure about the placeholder, not about the pipeline.
# 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.
# Not common/: its placeholder `uses: you/.github/...` makes ref-confusion
# error out. tests/workflows.bats checks those call sites structurally.
- name: Run zizmor
run: mise exec -- zizmor .github/workflows/
18 changes: 3 additions & 15 deletions .github/workflows/provenance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,7 @@ permissions: {}

jobs:
self-test:
# "does the checker work" belongs on every change; "has upstream moved"
# (the check job below) belongs on a schedule — different questions.
# workflow_dispatch is included here, rather than duplicating this job's
# bats step inside check, so a manual run exercises both jobs.
# Tests the checker on every change; `check` asks whether upstream moved.
if: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
permissions:
Expand All @@ -30,9 +27,6 @@ jobs:
persist-credentials: false
- 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.
- 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
Expand All @@ -52,21 +46,15 @@ jobs:
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.
# check-provenance.sh never fetches; blob:none still gives the full commit graph.
- 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"
# a bare custom if: is implicitly ANDed with success() — on a real
# check failure the job is already failing, success() is false, and
# 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.
# A bare if: is ANDed with success(), so it would never run after a failure.
- name: Open an issue for upstream drift
if: failure() && steps.check.outcome == 'failure'
env:
Expand Down
17 changes: 3 additions & 14 deletions .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
name: Pull request
# Its own workflow, not a job in ci.yml, because of `edited`: this check is
# fixed by editing the body, which ci.yml does not run on — so it stayed red for
# a fix already made. Adding `edited` to ci.yml instead would re-run the
# twenty-minute integration lane on every description tweak.
# Not in ci.yml: `edited` there would re-run the integration lane on every body edit.
on:
pull_request:
types: [opened, edited, synchronize, reopened]
Expand All @@ -14,16 +11,8 @@ concurrency:
permissions: {}

jobs:
# The checklist is only worth having if something reads it. This parses the
# headings out of the template itself rather than holding a second copy of
# them, so editing the template changes what is enforced — the convention and
# its enforcement cannot drift apart. Borrowed from immich's auto-close.yml.
#
# The job's name is load-bearing: `main`'s branch protection requires a check
# called `pull-request-body`, and a required check that never reports blocks
# every merge forever. Rename it here and the repository setting has to change
# first — nothing in this repository records that setting, which is ADR-0004's
# point about the guardrail that is not a file.
# Headings come from the template itself, so the two cannot drift. The job name
# is load-bearing: main's branch protection requires `pull-request-body`.
pull-request-body:
runs-on: ubuntu-latest
permissions:
Expand Down
7 changes: 1 addition & 6 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,9 @@
.DS_Store
*.swp

# A node_modules anywhere here is a stray pnpm run, and tmp/ is where a probe
# or a hand-run script lands. Both have been committed by a wide `git add -A`
# before — tmp/ carried a whole generated Nest project for a week.
node_modules/
tmp/

# Rooted, not bare: four files under common/ carry these names and ship into
# every generated project. Unanchored, the rule matched those too, so a new
# template beside them would be dropped by `git add -A` without a word.
# Rooted: unanchored, `git add -A` would silently drop common/'s same-named templates.
/package.json
/pnpm-lock.yaml
4 changes: 1 addition & 3 deletions adapters/flask/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
# syntax=docker/dockerfile:1

# python3.13-bookworm-slim is not published for this uv release — only
# python3.13-trixie-slim is; python:3.13-slim below already resolves to
# slim-trixie, so this keeps both stages on the same Debian release.
# trixie, matching python:3.13-slim below; uv publishes no bookworm-slim tag.
FROM ghcr.io/astral-sh/uv:0.12.13-python3.13-trixie-slim@sha256:c0ba49559fc5622531fd05a5747b52afb49ffa883574bbf8eb719ebd103efb84 AS deps
WORKDIR /app
COPY pyproject.toml uv.lock .python-version ./
Expand Down
9 changes: 3 additions & 6 deletions adapters/flask/adapter.env
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,10 @@ ADAPTER_ROLE="api"
ADAPTER_TIER="A"
ADAPTER_LANGUAGE="python"
ADAPTER_FAMILY="flask"
# --bare writes pyproject.toml and nothing else; the default template adds a
# .git directory inside the app, a src/<dir-name> package and a README the
# overlay would have to delete. uv comes through `mise x` because the generator
# runs in the project root, whose mise.toml pins node and pnpm only.
# --bare: the default template adds .git, a src/ package and a README. `mise x`:
# the project root's mise.toml pins only node and pnpm.
ADAPTER_GENERATOR='mise x uv@0.12.13 -- uv init --bare --vcs none --author-from none --no-workspace --python 3.13 "$APP_DIR"'
# The grep pair turns a resolution that reported success but wrote nothing into
# a build failure instead of an ImportError at container start.
# The grep pair fails a `uv add` that reports success but writes nothing.
ADAPTER_POST_GENERATE='uv add flask gunicorn && uv add --dev ruff mypy pytest && { grep -q "flask" pyproject.toml && grep -q "gunicorn" pyproject.toml; } || { echo "post-generate: flask or gunicorn missing from pyproject.toml after uv add" >&2; exit 1; }'
ADAPTER_LIVENESS_PATH="/health/live"
ADAPTER_READINESS_PATH="/health/ready"
5 changes: 2 additions & 3 deletions adapters/flask/app/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ def ready() -> Reply:
# @DB_PROBE@
raise RuntimeError("no database is configured for this project")
except Exception as error: # noqa: BLE001
# Logged, not returned: a driver's connection error names the host,
# port, user and database, and /health/ready is unauthenticated. An
# orchestrator reads the status code and nothing else.
# Logged, not returned: the error names host and user, and this route is
# unauthenticated.
current_app.logger.warning("readiness probe failed: %s", error)
return jsonify(status="unavailable"), 503
11 changes: 4 additions & 7 deletions adapters/flask/mise.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
# uv alone: uv resolves its own managed interpreter, so a `python` entry here
# would be installed and then ignored. .python-version is the pin uv reads.
# No python: uv installs its own from .python-version and would ignore mise's.
[tools]
uv = "0.12.13"

Expand All @@ -22,14 +21,12 @@ run = "uv run mypy --strict app tests"
run = "uv run pytest -q"

[tasks.build]
# The production sync drops ruff, which the pre-commit hook calls directly, so
# the second sync puts it back; `status=$?` keeps the production exit code.
# The second sync restores ruff for the pre-commit hook; `status=$?` keeps the
# production sync's exit code.
run = "uv sync --locked --no-dev; status=$?; uv sync --locked --quiet; exit $status"

[tasks.migrate]
# alembic.ini exists only for postgres/mysql, app/migrate.py only for mongodb;
# --db none or a cache-only project ships neither, so this must not assume one
# of the two is always there.
# alembic.ini: postgres/mysql; app/migrate.py: mongodb; neither: --db none.
run = "if [ -f alembic.ini ]; then uv run alembic upgrade head; elif [ -f app/migrate.py ]; then uv run python -m app.migrate; else echo 'nothing to migrate'; fi"

[tasks.ci-unit]
Expand Down
5 changes: 2 additions & 3 deletions adapters/laravel-api/.dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ node_modules
storage/logs
storage/framework/cache

# 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
# the manifests, not the directory: local ones name dev providers a --no-dev
# image lacks, and artisan will not boot without the directory
bootstrap/cache/*.php
2 changes: 1 addition & 1 deletion adapters/laravel-api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost:8000
# the database variables are written by the selected service's driver
# the readiness probe logs its failure reason; storage/logs is unreachable in a container
# storage/logs is unreadable in a container, and the readiness probe logs there
LOG_CHANNEL=stderr
24 changes: 8 additions & 16 deletions adapters/laravel-api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,21 @@ COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-interaction \
--prefer-dist --optimize-autoloader

# FrankenPHP, because php-fpm speaks FastCGI and this stack has no web server
# in front of it: nothing served HTTP at all, and compose published a dead
# port. Documented as a first-class server in laravel.com/docs/13.x/deployment,
# part of the PHP Foundation since May 2025, and what Laravel Cloud runs.
# Alpine specifically: services/mongodb/drivers/laravel.sh emits `apk add`.
# FrankenPHP: php-fpm speaks FastCGI and nothing fronts it.
# Alpine: the mongodb driver emits `apk add`.
FROM dunglas/frankenphp:1.12.7-php8.3-alpine@sha256:049b8d8356efceb93c91ed42866de890534310bcef4ad4dde902029e4a0d20c3 AS runtime
RUN docker-php-ext-install opcache
# @SERVICE_SETUP@
WORKDIR /var/www
COPY --from=vendor /app/vendor ./vendor
COPY . .
COPY docker/opcache.ini /usr/local/etc/php/conf.d/opcache.ini
# :8080 is how frankenphp listens on an unprivileged port and declines to
# provision TLS, which belongs to whatever proxy this lands behind.
# A bare port: unprivileged, and no TLS, which belongs to the proxy in front.
ENV SERVER_NAME=:8080
# laravel's default `stack` channel writes to storage/logs, which nothing
# reads in a container; the readiness probe's failure reason is logged.
# Nothing reads storage/logs in a container; the readiness probe logs failures.
ENV LOG_CHANNEL=stderr
# Caddy writes here and will not start if it may not. The app tree needs the
# same: COPY runs as root, the server runs as www-data, and the first request
# that compiles a blade view or writes a log fails without this.
# Caddy will not start without writable config and data dirs, and www-data
# must write storage/ and bootstrap/cache/ that COPY left owned by root.
ENV XDG_CONFIG_HOME=/config XDG_DATA_HOME=/data
RUN mkdir -p /config /data \
&& chown -R www-data:www-data /config /data \
Expand All @@ -35,8 +29,6 @@ USER www-data
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget -qO- http://localhost:8080/up || exit 1
# CMD replaces the base image's default args entirely rather than extending
# them, and those args are what point frankenphp at the Caddyfile that
# defines the :8080 site block; without them it starts only the admin API on
# 127.0.0.1:2019 and nothing ever listens on 8080.
# The base image's args, restated: CMD replaces them, and without the Caddyfile
# only the admin API starts and nothing listens on 8080.
CMD ["frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile", "--adapter", "caddyfile"]
Loading