Browse Source

Merge remote-tracking branch 'origin/main'

achiappa 11 hours ago
parent
commit
2a07133c13
5 changed files with 466 additions and 20 deletions
  1. 176 10
      CHANGELOG.md
  2. 215 1
      SPEC.md
  3. 63 7
      bin/appctl
  4. 11 1
      bin/appctl-completion.bash
  5. 1 1
      src/Meta.php

+ 176 - 10
CHANGELOG.md

@@ -6,23 +6,188 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ## [Unreleased]
 
+## [0.26.0] — 2026-05-09
+
+Tooling- and dev-stack-heavy release on top of `v0.25.0`. Most of the
+work lives outside the running app: the Dockerfile splits into four
+named build targets (`css-builder` / `css-watcher` / `runtime` /
+`tests`), a `docker-compose.dev.yml` overlay adds a Tailwind `--watch`
+sidecar with edit-without-rebuild bind mounts, and a hand-written
+`bin/appctl` Bash wrapper replaces the Makefile so the project no
+longer needs `make`. A new `/check` Claude Code skill runs lint +
+PHPUnit inside the `tests` stage via a Haiku-powered subagent, keeping
+verbose phpunit output out of the main session. Inside the app, three
+REVIEW_02 simplifications fired: the JS-side capacity arithmetic is
+gone (server response is now authoritative), the task-row markup
+collapses into a single `_task_row.twig` partial cloned from a hidden
+`<template>` instead of being rebuilt by hand, and the form-endpoint
+admin/CSRF triple-line gets folded into a single
+`SessionGuard::requireAdminForm` helper. Smaller items: the present
+view gains a sprint-switcher dropdown and the brand logo, htmx no
+longer fights strict CSP by injecting an inline indicator stylesheet,
+the new-sprint settings page only pre-checks weekdays whose calendar
+date falls inside the chosen `[start, end]` range, the published host
+port becomes configurable via `HTTP_PORT`, and two dev-stack
+regressions (a css-watcher restart loop and a host-bind-mount
+read-only-database 500) get fixed.
+
+### Added
+
+- **`/check` Claude Code skill + `container-tester` (Haiku) subagent.**
+  New project-level slash command at `.claude/skills/check/SKILL.md`
+  delegates to a Haiku subagent (`.claude/agents/container-tester.md`,
+  Bash + Read only) that runs lint + PHPUnit inside the new `tests`
+  Dockerfile target and surfaces only failures + a one-line summary.
+  Verbose phpunit / docker build output stays inside the subagent and
+  never reaches the main session. Subcommands: `/check`,
+  `/check lint`, `/check test`, `/check <free text>`. `.gitignore`
+  flips from a blanket `/.claude/` ignore to `/.claude/* + !agents/ +
+  !skills/` so the two committed files ship with the repo while
+  session scratch / worktrees stay ignored.
+- **`HTTP_PORT` env var for the published host port.** `docker-compose.yml`
+  switches from a hard-coded `8088:80` to `${HTTP_PORT:-8080}:80` so
+  operators set the host port in `.env` alongside `APP_BASE_URL`
+  instead of editing the compose file. Default moves from `8088` to
+  `8080` so the compose default and the `APP_BASE_URL` default
+  finally agree. Docs: SPEC §8 env block, README quick-setup, and a
+  new admin-manual §3.3 "Host port".
+- **Present view: sprint switcher dropdown next to Close.** Header
+  `<select>` (visible to every signed-in user, hidden when only one
+  sprint exists) lists all sprints newest-start-first; on change the
+  page navigates to the chosen sprint's `/sprints/{id}/present`. The
+  change handler lives next to the existing `data-close-present`
+  block in `sprint-planner.js` so strict CSP stays intact.
+- **Present view: brand logo in the header.** Mirrors the cycle SVG
+  used in the main page header so the projection / beamer view
+  carries the same brand mark.
+- **`doc/REVIEW_02.md` — simplification + maintainability audit.**
+  Sister document to `REVIEW_01.md` (security). Catalogs over-complicated
+  patterns that can be rewritten more simply with no functional change;
+  same `R02-Nxx / Severity / Status` format so the `/loop` workflow
+  plugs in directly. The bold preamble requires re-reading SPEC.md
+  before raising any finding (the spec is the contract these refactors
+  must preserve, and §9's build-phase log explains why "complicated"
+  patterns landed that way). Three HIGH findings (R02-N01..N03) all
+  landed in this release.
+
 ### Changed
 
-- **Tooling: `make` → `appctl`.** The `Makefile` is gone; a hand-written
-  bash wrapper at `bin/appctl` (with a top-level `./appctl` symlink)
-  takes over. Subcommand surface follows the verb-then-action pattern:
+- **Build: split `Dockerfile` into 4 targets + dev compose overlay.**
+  The single Dockerfile now exposes `css-builder` (one-shot Tailwind
+  for prod, existing behaviour), `css-watcher` (node sidecar running
+  `tailwindcss --watch`, dev only), `runtime` (PHP/Apache prod image,
+  renamed but otherwise unchanged), and `tests` (`FROM runtime` +
+  `composer install` w/ dev deps for phpunit). `docker-compose.yml`
+  stays prod-shaped (now with explicit `target: runtime`).
+  `docker-compose.dev.yml` is an explicit overlay (no auto-load) that
+  flips `APP_ENV=development` (Twig `auto_reload`), bind-mounts the
+  source over the runtime image, and runs the css-watcher sidecar as
+  `${HOST_UID}:${HOST_GID}` so files written into bind-mounted host
+  paths land with normal ownership instead of root. `bin/dev-css-watcher.sh`
+  seeds vendor JS bundles (alpine-csp, htmx, sortable) into the
+  host-mounted `public/assets/js/vendor` on first start, then execs
+  `tailwindcss --watch` directly (skipping `npx`, which would want to
+  write `$HOME` under a non-root user). `tests` runs under
+  `profiles: [test]` for one-shot `--rm` invocations.
+- **Tooling: `make` → `appctl`.** A hand-written Bash wrapper at
+  `bin/appctl` (with a top-level `./appctl` symlink) replaces the
+  short-lived `Makefile` so the project no longer needs `make`.
+  Subcommand surface follows the verb-then-action pattern:
   `./appctl dev start|stop|build|shell|logs`,
   `./appctl prod start|stop|build`, and the unchanged check trio
-  `./appctl lint|test|check`. `HOST_UID` / `HOST_GID` are still exported
-  for the css-watcher's bind-mount ownership; the long
+  `./appctl lint|test|check`. `HOST_UID` / `HOST_GID` are still
+  exported for the css-watcher's bind-mount ownership; the long
   `docker compose -f docker-compose.yml -f docker-compose.dev.yml …`
-  invocation is still the one being wrapped. Bash completion ships at
+  invocation is what's being wrapped. Bash completion ships at
   `bin/appctl-completion.bash`; the first interactive `./appctl`
   invocation offers to add a `source` line to `~/.bashrc`, with a
   marker file under `~/.config/appctl/` so the prompt only appears
-  once. Updated SPEC §3 / §11, README, `doc/admin-manual.md`, the
-  `/check` Claude Code skill, and the `container-tester` agent so
-  `make check` no longer appears anywhere.
+  once (`APPCTL_NO_COMPLETION_PROMPT=1` silences it for CI). SPEC §3
+  / §11, README, `doc/admin-manual.md`, the `/check` skill, and the
+  `container-tester` agent all updated so `make check` no longer
+  appears anywhere.
+- **Sprint create: pre-check only weekdays inside the date range.**
+  `materializeWeeks` now takes `endDate` and seeds each week's
+  `active_days_mask` with the Mo–Fr bits whose calendar dates fall
+  inside `[start, end]` (`max_working_days = popcount(mask)`). The
+  settings page still renders all five checkboxes per week; only the
+  in-range ones are pre-checked, and editing/saving works as before.
+  The Weeks-table Start column also gains `dd.mm.YYYY` formatting,
+  matching the audit page's date convention.
+- **CSP: stop htmx from injecting its inline indicator `<style>`.**
+  Strict `style-src 'self'` was blocking htmx's built-in indicator
+  stylesheet on every page load, flooding the audit log with
+  `csp_violation` rows. Disable the auto-injection via
+  `<meta name="htmx-config" content='{"includeIndicatorStyles": false}'>`
+  and ship the equivalent `.htmx-indicator` / `.htmx-request` rules
+  from `app.css` instead.
+- **Fix R02-N01: drop JS-side capacity arithmetic — server is
+  authoritative.** The capacity formula was reproduced in
+  `sprint-planner.js` (~30 lines of `roundHalf` / `after_reserves` /
+  `committed_p1` chained math) so on-screen totals updated during
+  typing without the 400 ms PATCH round-trip. The PHP
+  `CapacityCalculator` is the spec's contract — every PATCH response
+  already returns the freshly computed `per_worker` values that
+  `applyServerCapacity()` applies, so the JS copy was just a
+  typing-feel optimisation that doubled the maintenance cost of any
+  future change. Path B from the finding: keep an immediate visual
+  update for the Ressourcen sum (a plain DOM sum of `[data-day]`
+  inputs) but drop the reserve arithmetic. Available / Reserves now
+  move on the server response. Removed: `capacity()`,
+  `committedPrio1FromDom()`, `recomputeAllCapacity()`, and the
+  `data-reserve-fraction` attribute on `show.twig` /
+  `present.twig`. `POST /tasks/{id}/move` now also returns
+  `per_worker` for the source sprint so the move-out path can call
+  `applyServerCapacity()` instead of recomputing locally. SPEC §5 /
+  §9 / §13 refreshed; the "any edit must touch both" line in §5 goes
+  away.
+- **Fix R02-N02: clone `<template data-task-row-template>` instead
+  of mirroring `_task_list` in JS.** Extracted the task-row markup
+  into `views/sprints/_task_row.twig` and call it twice from
+  `_task_list.twig` — once inside the for-loop, once inside a hidden
+  `<template data-task-row-template>` at the bottom (admin-only).
+  `sprint-planner.js`'s `buildTaskRow` now clones the template and
+  populates the few fields that vary; ~150 lines of hand-rolled DOM
+  construction are gone, along with the `ownerChoices()` /
+  `sprintWorkerHeaders()` helpers that only existed to feed it.
+  Server-rendered rows and JS-built rows share a single Twig source,
+  so the historical drift incidents (hotfixes 7c298d3, 23ab365, and
+  the whitespace-nowrap re-mirror in f204611) can no longer happen.
+  TwigViewTest pins both directions: a marker on the template body
+  for the admin path, and the absence of `<template
+  data-task-row-template>` for read-only users.
+- **Fix R02-N03: extract `SessionGuard::requireAdminForm` + drop
+  `SprintController::gateJsonAdmin`.** Form-handling controllers used
+  to open with the same three-line block (`requireAdmin` + Response
+  early-return + `verifyCsrf`) at nine sites: `SprintController::create`
+  / `::delete`, `WorkerController::create` / `::update`,
+  `UserController::update` / `::tombstone`, `SettingsController::update`,
+  `ImportController::upload` / `::commit`. New
+  `SessionGuard::requireAdminForm(Request, UserRepository): User|Response`
+  mirrors the existing `requireAdminJson` but speaks the form dialect
+  (redirect to `/auth/login` when anonymous, 403 text on non-admin,
+  403 text on CSRF mismatch). All nine sites collapse to a single
+  call. `SprintController::gateJsonAdmin` was an identical local twin
+  of `requireAdminJson`; its eight call sites switch to the
+  `SessionGuard` helper and the private method goes away. Net: +37
+  -64 lines; tests unchanged at 340 / 340.
+
+### Fixed
+
+- **Dev stack: css-watcher restart loop + readonly-DB 500.** Two
+  unrelated `appctl dev start` regressions caused by the bind-mount
+  overlay clashing with the upstream images. The css-watcher
+  restarted forever because Tailwind's `--watch` listens on stdin and
+  exits on EOF; `compose up` closes stdin by default, so
+  `restart: unless-stopped` looped it. Fixed by adding
+  `stdin_open` + `tty` to keep chokidar alive. The host bind mount at
+  `/var/www/data` was masking the Dockerfile's `chown www-data`,
+  leaving `app.sqlite` (root:1000 644) and no `sessions/` dir, so the
+  first request 500'd with `attempt to write a readonly database`.
+  Self-heal in `bin/docker-entrypoint.sh` by `mkdir`-ing the data +
+  session dirs and `chown`-ing to `www-data` on every start (twice —
+  migrate runs as root and would otherwise leave new SQLite/WAL
+  files root-owned).
 
 ## [0.25.0] — 2026-05-07
 
@@ -417,6 +582,7 @@ R01-N09 (`SameSite=Lax` retained — `Strict` would block the OIDC
 callback), R01-N17 (concurrent-tab OIDC clobber is correct
 RFC behaviour), R01-N29, R01-N30, R01-N32, R01-N33, R01-N34.
 
-[Unreleased]: https://github.com/chiappa/sprint_planer_web/compare/v0.23.0...HEAD
+[Unreleased]: https://github.com/chiappa/sprint_planer_web/compare/v0.26.0...HEAD
+[0.26.0]: https://github.com/chiappa/sprint_planer_web/compare/v0.25.0...v0.26.0
 [0.23.0]: https://github.com/chiappa/sprint_planer_web/compare/v0.22.0...v0.23.0
 [0.22.0]: https://github.com/chiappa/sprint_planer_web/releases/tag/v0.22.0

+ 215 - 1
SPEC.md

@@ -1633,6 +1633,123 @@ OIDC kill-switch (`OIDC_ENABLED=false`):
       same way the sprints table on `/` does — also mirrored on
       JS-built rows in `sprint-planner.js::buildTaskRow`.
 
+- [x] **R01-N22 — migrations move to deploy time + refuse-to-serve
+      safety net** (`43b2fc9` / `114de03`). `bin/docker-entrypoint.sh`
+      now runs `php bin/migrate.php` before Apache binds the port; the
+      request path only checks for pending migrations and 503s on
+      mismatch (no auto-migration mid-request). Closes the "fatal
+      error during migration leaves the schema partially applied"
+      class of failure. Fix from `doc/REVIEW_01.md`.
+
+- [x] **R01-N23 — soft erasure via `users.tombstoned_at`**
+      (`22a3840` / `3853dda`). Privacy-request flow: a TOMBSTONE
+      action on `users` zeroes identifying columns and stamps
+      `tombstoned_at`; the row stays for FK integrity so audit
+      history doesn't break. Fix from `doc/REVIEW_01.md`.
+
+- [x] **R01-N24 — JSON body cap (1 MiB) + batch cap (5000 items)**
+      (`821122d` / `abe9595`). All JSON endpoints reject oversized
+      bodies and oversized arrays before any parse/loop work, so a
+      runaway or malicious client can't OOM PHP or hold a worker.
+      Fix from `doc/REVIEW_01.md`.
+
+- [x] **R01-N25 + R01-N26 + R01-N27 — `X-Permitted-Cross-Domain-Policies`,
+      one-shot delete-chip flash, backgrounded session GC**
+      (`f6ce13f` / `1f11117` / `32d03fc` / `b706a17`). Three LOW-severity
+      fixes batched: response header pinned to `none`; the post-delete
+      success chip is now a one-shot session flash (no replay on
+      refresh); the entrypoint runs a backgrounded session-file GC
+      loop so stale `sess_*` files don't pile up under low traffic.
+      Fixes from `doc/REVIEW_01.md`.
+
+- [x] **R01-N28 — drop dead `$changed[]` accumulator**
+      (`216c15d` / `8e5a8d1`). `SettingsController::update` built a
+      `$changed[]` list it never read; deleted along with surrounding
+      bookkeeping. Fix from `doc/REVIEW_01.md`.
+
+- [x] **R01-N29 / R01-N30 / R01-N32 / R01-N33 / R01-N34 —
+      accepted-by-design** (`8e5a8d1` / `1edc853`). Five findings
+      closed without code changes after audit-side concurrence: N29
+      (`Migrator::file_get_contents` — migrations are committed code
+      reviewed in PRs and now run at deploy time post-N22); N30
+      (import_upload colour-mapping — only emitter is admin-gated
+      `GET /sprints/import`); N32 (sprint delete confirmation —
+      server-side `confirm_name` check is authoritative,
+      `data-confirm-name` is JS UX); N33 (`data-csrf` exposure —
+      standard fetch/htmx pattern, token rotation + strict CSP keep
+      it safe); N34 (migrations/ on disk — committed code,
+      deploy-time apply per N22). Closes `doc/REVIEW_01.md` with no
+      open findings.
+
+- [x] **Release v0.22.0** (`7449fb2`). Apache 2.0 license,
+      `CHANGELOG.md`, and per-file SPDX headers. No behavioural
+      change.
+
+- [x] **OIDC kill-switch + prod-bootstrap guard + Runtime-panel
+      refresh — Release v0.23.0** (`60aeb55` / `56e6526` / `5f6febf`
+      / `3f014af`). New `OIDC_ENABLED=false` env var (default on)
+      hard-disables OIDC and routes all sign-ins through
+      `LOCAL_ADMIN_*` — meant for dev / testing / on-prem deploys.
+      The bootstrap path refuses to start in prod when ENTRA vars
+      look unconfigured (loud over silent). The admin-only Runtime
+      panel on `/` reads the OIDC enabled/disabled state and
+      surfaces app version + creator from `App\Meta::VERSION` /
+      `::CREATOR`.
+
+- [x] **2-pane task hamburger popup + non-admin actions —
+      Release v0.24.0** (`8c72e7a` / `31506c6` / `2a20f35` /
+      `970ebe4`). Per-task hamburger menu becomes a 2-pane popup
+      (action list left, sprint flyout right for move/copy).
+      Non-admin viewers get a reduced action set (info popover, no
+      destructive ops). Task title row gains inline reference icons
+      + a per-row info popover. The beamer view keeps the hamburger
+      trigger visible so the popup works there too.
+
+- [x] **Welcome/login logo polish + Status column drop —
+      Release v0.25.0** (`cdb1249` / `8b2bd48` / `90b10d5` /
+      `5f28590` / `d8e7f89` / `b8a6603` / `5cbecee`). Five SVG logo
+      concepts shipped under `doc/`; concept #3 (cycle, with radial
+      indigo glow) wired in as the page-header brand mark + SVG
+      favicon. Welcome / local-login flows centre the logo above the
+      card at 144 px. The Home sprint table drops its Status column
+      (derivable from start/end dates, no audit signal lost).
+      Audit-log "When" flips to viewer-local time
+      (`dd.mm.YYYY HH:mm:ss`); the beamer view centres vertical
+      worker-name headers in their cells (`964a2f5` / `d2e570e`).
+
+- [x] **Present view: brand logo + sprint switcher + htmx CSP fix**
+      (`f10a1dd` / `4ae4db6` / `1dba877`). Beamer view picks up the
+      header brand mark; a sprint-switcher dropdown sits next to
+      Close so the operator can hop to a neighbouring sprint without
+      visiting `/`. Side issue: htmx was injecting an inline
+      indicator `<style>` that would have required relaxing the
+      strict CSP — fixed by disabling the indicator-style injection
+      so `style-src 'self'` stays.
+
+- [x] **REVIEW_02 audit landing** (`937fbfd` / `dab0e66`). Initial
+      simplification + maintainability audit at `doc/REVIEW_02.md`,
+      paired with a workflow note requiring SPEC.md to be read
+      before any finding. R02-N01..N03 (above) are the resulting
+      fixes.
+
+- [x] **R02-N02 — Task-row markup unified between server and JS**
+      (`d5a09ff`). `sprint-planner.js::buildTaskRow` hand-rolled
+      ~150 lines of `<tr>` construction to mirror what
+      `views/sprints/_task_list.twig` rendered server-side, so any
+      task-row tweak had to be made in two places. Drift had bitten
+      three times (`7c298d3` owner-dropdown empty until refresh,
+      `23ab365` missing `data-col` stamps, `f204611` whitespace-nowrap
+      re-mirror). Fix: extract the row markup into
+      `views/sprints/_task_row.twig`, call it from `_task_list.twig`
+      in the for-loop, and once more inside a hidden
+      `<template data-task-row-template>` (admin-only) at the bottom.
+      `buildTaskRow` now clones the template and populates only the
+      varying fields; the unused `ownerChoices()` /
+      `sprintWorkerHeaders()` helpers are gone. `TwigViewTest` pins
+      both directions — marker on the admin template, absence of
+      `<template data-task-row-template>` for read-only viewers.
+      Second finding from `doc/REVIEW_02.md`.
+
 - [x] **R02-N01 — Capacity formula no longer duplicated in JS**
       (`14b1cfd`). The JS reproduced `roundHalf` / `after_reserves` /
       `committed_p1` / `available` in `sprint-planner.js` so on-screen
@@ -1654,9 +1771,46 @@ OIDC kill-switch (`OIDC_ENABLED=false`):
       `per_worker` for the source sprint (it didn't before, because the
       old code refreshed via `recomputeAllCapacity()`); the move handler
       applies it like every other mutation. JS file shrinks ~40 lines;
-      `CapacityCalculator.php` and its tests are untouched. First fix
+      `CapacityCalculator.php` and its tests are untouched.
+
+- [x] **R02-N03 — Form-handler boilerplate consolidated into
+      `SessionGuard::requireAdminForm`** (`57f4143`). Nine form
+      controllers (`SprintController::create / ::delete`,
+      `WorkerController::create / ::update`,
+      `UserController::update / ::tombstone`,
+      `SettingsController::update`,
+      `ImportController::upload / ::commit`) all opened with the same
+      three-line block: `SessionGuard::requireAdmin()` early-return,
+      then `verifyCsrf()` → 403 on mismatch. New form endpoints could
+      silently land missing either gate and the surrounding code
+      looked fine in review — the JSON path already had a twin
+      (`SessionGuard::requireAdminJson`,
+      `SprintController::gateJsonAdmin`); the form path just lacked
+      one. Adds `SessionGuard::requireAdminForm` mirroring
+      `requireAdminJson` but speaking the form dialect (redirect to
+      `/auth/login` on anonymous, 403 text on non-admin / CSRF
+      mismatch); replaces all nine inlined blocks with a single call;
+      migrates `gateJsonAdmin`'s eight call sites to
+      `requireAdminJson` and drops the now-unused private method.
+      Net `+37 / -64` lines, 340 tests still green. Third finding
       from `doc/REVIEW_02.md`.
 
+- [x] **`appctl prod upgrade` — pinned redeploy by release tag**
+      (`f87660e`). New subcommand on the `bin/appctl` wrapper that
+      stops the prod stack, fetches, checks out a target ref, rebuilds
+      images, and starts the stack again. The target defaults to the
+      `latest` lightweight git tag (manually pointed at the current
+      release; bump with `git tag -f latest vX.Y.Z`). The keyword
+      `test` is virtual and resolves to `origin/main` HEAD after
+      fetch, so bleeding-edge redeploys don't need a real branch tag.
+      Dirty working trees print the diff and prompt before any
+      destructive step; an unknown ref errors out *before* the stack
+      is stopped. Bash completion offers `latest`, `test`, and every
+      `v*` tag from `git tag` at the third word. `latest` is local-
+      only on the repo where this was introduced (`bcdc63a` =
+      `v0.26.0`) — it is not pushed to `origin` and won't appear in
+      fresh clones until someone runs `git push origin latest`.
+
 ### Upcoming
 
 Nothing scheduled.
@@ -1686,6 +1840,7 @@ invocations. Pick the one that matches what you're doing:
 | Goal | Command | What runs |
 |---|---|---|
 | Run prod build (or operate it) | `./appctl prod start` | `docker-compose.yml` only — the `runtime` image with baked CSS |
+| Pin a prod box to a release | `./appctl prod upgrade [VER]` | Stop → `git fetch --tags origin` → `git checkout --detach <ref>` → rebuild → start. `VER` defaults to the `latest` tag (manually pointed at the current release; bump locally with `git tag -f latest vX.Y.Z`). `test` is a virtual keyword that resolves to `origin/main` HEAD after fetch. Warns + prompts before proceeding on a dirty working tree |
 | Iterate on code locally | `./appctl dev start` | `docker-compose.yml` + `docker-compose.dev.yml` — adds source bind mounts, `APP_ENV=development`, and a `css-watcher` sidecar that runs `tailwindcss --watch` against the host |
 | Lint + tests, one-shot | `./appctl check` | Builds the `tests` Dockerfile target on demand and runs `php -l` + PHPUnit in a `--rm` container; doesn't require the dev stack to be running |
 
@@ -1847,7 +2002,66 @@ before acting — nothing here is load-bearing once it grows stale.
 ## 13. Git history (as of this writing)
 
 ```
+c913b7d Docs: SPEC §9 + §13 — note appctl prod upgrade tooling
+86baa95 Docs: SPEC §11 — add `appctl prod upgrade` row to the command table
+f87660e Tooling: appctl prod upgrade — pin a prod box to a release tag
+c5fafcf Release v0.26.0: tooling/dev-stack split + R02-N01..N03 simplifications
+50d34de Tooling: replace Makefile with bin/appctl bash wrapper + completion
+4354266 Build: make published host port configurable via HTTP_PORT (default 8080)
+cccc7de Sprint create: pre-check only weekdays inside the date range
+d1c3a0c Fix: dev stack — css-watcher restart loop + readonly-DB 500
+14b0068 Docs: dev/prod compose split + /check skill (SPEC §2 / §3 / §11, README, admin-manual)
+c1557b6 Tooling: /check Claude Code skill + container-tester (Haiku) subagent
+a004c1a Build: split Dockerfile into 4 targets + dev compose overlay + Makefile
+6a68633 Docs: mark R02-N03 fixed-in-57f4143
+57f4143 Fix R02-N03: extract SessionGuard::requireAdminForm + drop SprintController::gateJsonAdmin
+22d5e2a Docs: mark R02-N01 fixed, refresh SPEC §5 / §9 / §13
 14b1cfd Fix R02-N01: drop JS-side capacity arithmetic, server is authoritative
+dab0e66 doc/REVIEW_02.md: require reading SPEC.md before any finding
+a0ccd12 Docs: mark R02-N02 fixed-in-d5a09ff
+d5a09ff Fix R02-N02: clone <template data-task-row-template> instead of mirroring _task_list in JS
+937fbfd doc/REVIEW_02.md: simplification + maintainability audit
+1dba877 Present view: sprint switcher dropdown next to Close
+4ae4db6 CSP: stop htmx from injecting its inline indicator <style>
+f10a1dd Present view: add brand logo to the header
+5cbecee Release v0.25.0: welcome/login logo polish + drop Status column
+b8a6603 Home: drop the Status column from the sprint table
+d8e7f89 Welcome/login: move logo inside the card and triple its size (48 → 144)
+5f28590 Welcome: match local-login layout with centered logo above the box
+90b10d5 Brand: cycle logo in the page header + SVG favicon
+8b2bd48 Logo 3 (cycle): add a radial indigo glow inside the loop
+cdb1249 Doc: add 5 SVG logo concepts and a light/dark preview page
+d2e570e Beamer: horizontally center vertical worker-name headers in their cells
+964a2f5 Audit log: render When in viewer's local time, dd.mm.YYYY HH:mm:ss
+970ebe4 Release v0.24.0: 2-pane task hamburger popup; non-admin task actions
+2a20f35 Beamer: keep the task hamburger trigger visible
+31506c6 Hamburger popup: 2-pane layout, sprint flyout, non-admin actions
+8c72e7a Task title row: inline reference icons + per-row info popover
+3f014af Release v0.23.0: OIDC kill-switch + prod-bootstrap guard + Runtime-panel refresh
+5f6febf Runtime panel on / shows app version + creator; OIDC reads enabled/disabled
+56e6526 Docs: CHANGELOG entry for OIDC_ENABLED kill-switch + prod-bootstrap guard
+60aeb55 Add OIDC_ENABLED kill-switch for dev / testing on local-admin only
+7449fb2 Release v0.22.0: Apache 2.0 license + CHANGELOG + per-file headers
+1edc853 Docs: mark R01-N32/N33/N34 accepted-by-design
+8e5a8d1 Docs: mark R01-N28 fixed-in-216c15d; R01-N29/N30 accepted-by-design
+216c15d Fix R01-N28: drop dead $changed[] in SettingsController::update
+b706a17 Docs: mark R01-N25/N26/N27 fixed in f6ce13f/1f11117/32d03fc
+32d03fc Fix R01-N27: backgrounded session-file GC loop in entrypoint
+1f11117 Fix R01-N26: one-shot session flash for post-delete chip
+f6ce13f Fix R01-N25: X-Permitted-Cross-Domain-Policies: none
+abe9595 Docs: mark R01-N24 fixed in 821122d
+821122d Fix R01-N24: 1 MiB body cap + 5000-item batch cap on JSON endpoints
+3853dda Docs: mark R01-N23 fixed in 22a3840
+22a3840 Fix R01-N23: users.tombstoned_at — soft erasure for privacy requests
+114de03 Docs: mark R01-N22 fixed in 43b2fc9
+43b2fc9 Fix R01-N22: move migrations to deploy time, refuse-to-serve safety net
+f3ba328 Docs: mark R01-N21 fixed in 00bcf73
+00bcf73 Fix R01-N21: pin Twig auto-escape with regression tests
+2ea4b0b Docs: mark R01-N20 fixed in f1aa924
+f1aa924 Fix R01-N20: Response::redirect rejects non-path locations
+99f9850 Docs: mark R01-N19 fixed in f59f368
+f59f368 Fix R01-N19: CSP report-uri + audit endpoint
+cebaf0b Docs: mark R01-N16 / R01-N17 / R01-N18 closed, refresh SPEC §3 / §9 / §11 / §13
 ef9b9b8 Fix R01-N16, doc R01-N17: composer-audit helper + admin-manual cadence note
 a0b717e Fix R01-N18: trust OIDC email only when issuer hasn't marked it unverified
 50f9bd2 Docs: mark R01-N09 / R01-N13 / R01-N14 fixed, refresh SPEC §3 / §9 / §11 / §13

+ 63 - 7
bin/appctl

@@ -41,9 +41,13 @@ DEV
   appctl dev logs        tail logs from the dev stack
 
 PROD
-  appctl prod start      start prod stack detached
-  appctl prod stop       stop and remove prod containers
-  appctl prod build      rebuild prod images
+  appctl prod start          start prod stack detached
+  appctl prod stop           stop and remove prod containers
+  appctl prod build          rebuild prod images
+  appctl prod upgrade [VER]  stop, fetch, checkout VER, rebuild, start
+                             VER defaults to the `latest` git tag;
+                             `test` is a virtual keyword that resolves
+                             to origin/main HEAD after fetch.
 
 CHECKS (one-shot containers, no running stack required)
   appctl lint            php -l on src/ + tests/
@@ -127,15 +131,67 @@ cmd_dev() {
 
 cmd_prod() {
     local sub="${1:-}"
+    shift || true
     case "$sub" in
-        start)  "${COMPOSE_PROD[@]}" up -d ;;
-        stop)   "${COMPOSE_PROD[@]}" down ;;
-        build)  "${COMPOSE_PROD[@]}" build ;;
+        start)    "${COMPOSE_PROD[@]}" up -d ;;
+        stop)     "${COMPOSE_PROD[@]}" down ;;
+        build)    "${COMPOSE_PROD[@]}" build ;;
+        upgrade)  cmd_prod_upgrade "$@" ;;
         ""|help|-h|--help) usage ;;
-        *) die "unknown prod subcommand: $sub (try: start|stop|build)" ;;
+        *) die "unknown prod subcommand: $sub (try: start|stop|build|upgrade)" ;;
     esac
 }
 
+# Pinned redeploy. Resolves VER (default `latest` tag; `test` ⇒ origin/main
+# HEAD after fetch) to a commit, then runs: stop → fetch → checkout →
+# build → start. Dirty trees prompt before any destructive step.
+cmd_prod_upgrade() {
+    local version="${1:-latest}"
+
+    if [[ -n "$(git status --porcelain)" ]]; then
+        printf 'appctl: working tree has uncommitted changes:\n' >&2
+        git status --short >&2
+        printf '\nThe checkout step will fail or clobber these. Continue anyway? [y/N] ' >&2
+        local reply
+        read -r reply || reply=""
+        case "$reply" in
+            y|Y|yes|YES) ;;
+            *) die "aborted by user (working tree dirty)" ;;
+        esac
+    fi
+
+    printf 'appctl: git fetch --tags origin...\n'
+    git fetch --tags origin
+
+    local target_ref
+    if [[ "$version" == "test" ]]; then
+        target_ref="origin/main"
+    else
+        target_ref="$version"
+    fi
+
+    if ! git rev-parse --verify --quiet "${target_ref}^{commit}" >/dev/null; then
+        die "unknown version: '$version' (expected a release tag, 'latest', or 'test')"
+    fi
+    local target_sha
+    target_sha="$(git rev-parse --short "${target_ref}^{commit}")"
+    printf 'appctl: upgrading to %s (%s)\n' "$version" "$target_sha"
+
+    printf 'appctl: stopping prod stack...\n'
+    "${COMPOSE_PROD[@]}" down
+
+    printf 'appctl: git checkout --detach %s...\n' "$target_ref"
+    git checkout --detach "$target_ref"
+
+    printf 'appctl: building prod images...\n'
+    "${COMPOSE_PROD[@]}" build
+
+    printf 'appctl: starting prod stack...\n'
+    "${COMPOSE_PROD[@]}" up -d
+
+    printf 'appctl: upgrade to %s (%s) complete.\n' "$version" "$target_sha"
+}
+
 cmd_lint() {
     "${COMPOSE_DEV[@]}" --profile test run --rm tests \
         sh -c 'find src tests -name "*.php" -print0 | xargs -0 -n1 -P 4 php -l > /dev/null && echo "lint: OK"'

+ 11 - 1
bin/appctl-completion.bash

@@ -26,12 +26,22 @@ _appctl_complete() {
                 ;;
             prod)
                 # shellcheck disable=SC2207
-                COMPREPLY=( $(compgen -W "start stop build" -- "${cur}") )
+                COMPREPLY=( $(compgen -W "start stop build upgrade" -- "${cur}") )
                 return 0
                 ;;
         esac
     fi
 
+    if (( cword == 3 )) \
+        && [[ "${COMP_WORDS[1]}" == "prod" ]] \
+        && [[ "${COMP_WORDS[2]}" == "upgrade" ]]; then
+        local versions
+        versions="latest test $(git tag --list 'v*' 2>/dev/null)"
+        # shellcheck disable=SC2207
+        COMPREPLY=( $(compgen -W "${versions}" -- "${cur}") )
+        return 0
+    fi
+
     return 0
 }
 

+ 1 - 1
src/Meta.php

@@ -20,6 +20,6 @@ namespace App;
  */
 final class Meta
 {
-    public const VERSION = '0.25.0';
+    public const VERSION = '0.26.0';
     public const CREATOR = 'Alessandro Chiapparini';
 }