Files
familysync/.planning/phases/08-gitea-ci/08-REVIEW.md
T
Lucas Berger 5f065183b0
CI / fast-checks (pull_request) Successful in 49s
CI / api (pull_request) Successful in 1m3s
CI / harness (pull_request) Successful in 3m27s
docs(08-fix): code review + auto-fix report (5 fixed, IN-02 deferred to Phase 13)
2026-06-11 19:29:36 -04:00

217 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 08-gitea-ci
reviewed: 2026-06-11T00:00:00Z
depth: deep
files_reviewed: 3
files_reviewed_list:
- .gitea/workflows/ci.yml
- .gitea/workflows/publish.yml
- apps/api/src/db/migrations/0000_baseline.sql
findings:
critical: 0
warning: 5
info: 2
total: 7
status: fixes_applied
fix_summary:
fixed: [WR-02, WR-03, WR-04, WR-05, IN-01]
deferred: [IN-02] # ESLint gate is Phase 13's deliverable
fixed_at: 2026-06-11
---
# Phase 8: Code Review Report (Re-Review, Post-Split)
**Reviewed:** 2026-06-11
**Depth:** deep
**Files Reviewed:** 3
**Status:** issues_found
## Summary
Re-review of Phase 8 (Gitea CI) against the post-split state: `publish` is now its own
`publish.yml` triggered on `push: branches: [main]`, and `ci.yml` holds the three PR-gated
jobs (`fast-checks`, `api`, `harness`).
**Resolved since prior review:**
- **WR-01 (publish ran without test gating / orphaned pending status)** — RESOLVED. Publish
is split into `publish.yml`, triggered only on `push` to `main`. PR jobs gate the merge via
required status checks + branch protection (direct/force push blocked). No `needs:` is needed
because publish never shares a workflow invocation with the test jobs. Architecture confirmed
intentional; not re-raised.
**Still open (re-located to the split files):**
- WR-02 (harness HTML report built then discarded) — still open, now in `ci.yml`.
- WR-03 (unguarded `${GITHUB_SHA:0:7}` → malformed tag) — still open, now in `publish.yml`.
- WR-04 (non-atomic two-push, `:latest` before immutable tag) — still open, now in `publish.yml`.
- IN-01 (>3072-byte UNIQUE indexes, MariaDB-only) — still open in `0000_baseline.sql`.
**New findings from the deep pass:**
- WR-05 (secret interpolated into `run:` script body via `echo` instead of `env:`) — new.
- IN-02 (`lint` step is a documented no-op that masks lint failures) — new.
No Critical findings. The publish flow has no test `needs:` by design (gated by branch
protection), so it is not flagged. The dominant theme is robustness/observability gaps in the
publish + harness steps and MariaDB-specific schema portability.
## Narrative Findings (AI reviewer)
## Warnings
### WR-02: Harness HTML report is generated but never uploaded — FIXED (commit 44a9c30)
> Resolution: extended the existing `ChristopherHX/gitea-upload-artifact@v4` failure step's
> `path:` to a multi-line list uploading both `apps/pwa/test-results/` and
> `apps/pwa/playwright-report/`. Reporter unchanged; step still `if: failure()`.
**File:** `.gitea/workflows/ci.yml:293` (report generation) and `:301-307` (upload step)
**Issue:** The harness runs Playwright with `--reporter=list,html`. The `html` reporter writes
its output to `apps/pwa/playwright-report/` (Playwright's default `outputFolder`, not overridden
in `playwright.config.ts`). The failure-artifact upload step (`if: failure()`) only uploads
`path: apps/pwa/test-results/`. The HTML report — the most useful artifact for triaging a remote
CI failure — is built on every run and then discarded when the runner is torn down. `test-results/`
contains traces/screenshots/videos but not the navigable HTML report.
**Fix:** Either drop `html` from the reporter (saves build time if it is genuinely unwanted), or
upload it. Preferred — add the report to the existing upload, or a second upload step:
```yaml
- name: Upload Playwright HTML report
if: failure()
uses: https://github.com/ChristopherHX/gitea-upload-artifact@v4
with:
name: playwright-report-${{ github.run_id }}
path: apps/pwa/playwright-report/
retention-days: 14
```
(Or set `path: |` with both `apps/pwa/test-results/` and `apps/pwa/playwright-report/` on the
existing step.)
### WR-03: Unguarded `${GITHUB_SHA:0:7}` can emit a malformed image tag — FIXED (commit 6bcf867)
> Resolution: added `set -euo pipefail` + a `: "${GITHUB_SHA:?...}"` fail-closed guard
> before `SHORT_SHA=${GITHUB_SHA:0:7}`, so an empty SHA aborts the step instead of
> producing `:v1.1-`.
**File:** `.gitea/workflows/publish.yml:44`
**Issue:** `SHORT_SHA=${GITHUB_SHA:0:7}` has no guard for an empty/unset `GITHUB_SHA`. If the
runner does not populate `GITHUB_SHA` (Gitea Actions env parity is not guaranteed across runner
versions; the comment only asserts it via probe P-13, not a runtime check), `SHORT_SHA` becomes
empty and the immutable tag silently degrades to `git.bergerhouse.net/luckberg/familysync-api:v1.1-`
— a valid-but-wrong tag that overwrites the milestone pointer and destroys rollback traceability.
Because the default Actions shell runs with `pipefail`/`-e` but NOT `-u`, the empty expansion does
not error; it proceeds.
**Fix:** Fail closed when the SHA is missing:
```bash
set -euo pipefail
if [ -z "${GITHUB_SHA:-}" ]; then
echo "GITHUB_SHA is empty — cannot compute immutable tag" >&2
exit 1
fi
SHORT_SHA=${GITHUB_SHA:0:7}
```
### WR-04: Build-and-push is non-atomic and pushes `:latest` before the immutable tag — FIXED (commit 4001cd5)
> Resolution: reordered the pushes so the immutable `:<milestone>-<sha>` tag goes first,
> `:latest` second; added `set -euo pipefail` so the step stops on the first failed push.
**File:** `.gitea/workflows/publish.yml:64-72`
**Issue:** The step runs `docker build` then two sequential `docker push` calls. `:latest` is
pushed first (line 71), then `:<milestone>-<sha>` (line 72). If the second push fails (registry
hiccup, auth expiry, network), `:latest` already moved to the new image while the immutable,
rollback-traceable tag was never published — the exact tag operators would reach for to roll back
does not exist, but `:latest` already advanced. Although the Actions default shell injects `-e`
(so a failed first command aborts the step), ordering still means a partial-failure window leaves
`:latest` ahead of the immutable record. Push order should be immutable-first.
**Fix:** Push the immutable tag first, then `:latest`, and make the shell strict explicitly:
```bash
set -euo pipefail
docker build --target production \
-f apps/api/Dockerfile \
-t ${{ steps.tags.outputs.latest }} \
-t ${{ steps.tags.outputs.sha_tag }} \
.
docker push ${{ steps.tags.outputs.sha_tag }} # immutable first
docker push ${{ steps.tags.outputs.latest }} # move pointer only after immutable lands
```
### WR-05: Registry PAT is interpolated into the `run:` script body instead of passed via `env:` — FIXED (commit 58861d9)
> Resolution: bound `REGISTRY_PAT` through step-level `env:` and replaced the
> `echo "${{ secrets.REGISTRY_PAT }}" | ...` pipe with `printf '%s' "$REGISTRY_PAT" | ...`.
> Existing PAT-via-stdin and REGISTRY_PAT-naming comments preserved.
**File:** `.gitea/workflows/publish.yml:55-60`
**Issue:** `echo "${{ secrets.REGISTRY_PAT }}" | docker login ... --password-stdin` interpolates
the secret into the shell script text at template-expansion time. Two problems:
(1) Robustness — if the PAT ever contains a shell-significant character or a trailing newline,
`echo` may mangle or split it (`echo` is not safe for arbitrary strings; `printf %s` is). A
mangled-but-nonempty password produces a confusing `unauthorized` rather than a clear failure.
(2) Surface — template-substituting a secret into the script body is the documented anti-pattern
versus binding it through `env:` (the script then references `$REGISTRY_PAT`), which keeps the
secret out of the rendered command line / step definition and is the recommended pattern for
Actions-compatible runners. Gitea's log scrubber masks it either way, so this is a robustness/
hardening WARNING, not a leak.
**Fix:**
```yaml
- name: Docker login
env:
REGISTRY_PAT: ${{ secrets.REGISTRY_PAT }}
run: |
set -euo pipefail
printf '%s' "$REGISTRY_PAT" | docker login git.bergerhouse.net \
--username luckberg --password-stdin
```
## Info
### IN-01: Two UNIQUE constraints exceed the 3072-byte index limit (MariaDB-only) — FIXED, docs-only (commit bf09110)
> Resolution: added explanatory SQL comments above `uniq_calendar_user_url` and
> `uniq_push_endpoint` in the already-applied `0000_baseline.sql`, documenting the
> over-length-index dependency on MariaDB 11's long-unique HASH behavior. NO schema,
> column, or index altered (migration is live on main/production).
**File:** `apps/api/src/db/migrations/0000_baseline.sql:100` (`uniq_push_endpoint`) and `:48`
(`uniq_calendar_user_url`)
**Issue:** With the default `utf8mb4` charset (4 bytes/char):
- `uniq_push_endpoint UNIQUE(endpoint)` where `endpoint varchar(2048)` → 2048 × 4 = 8192 bytes.
- `uniq_calendar_user_url UNIQUE(user_id, url)` where `url varchar(1024)` → 4 + 1024 × 4 = 4100 bytes.
Both exceed InnoDB's 3072-byte index-key limit. They succeed on MariaDB 11.x (the CI service image
`mariadb:11`) because MariaDB silently builds over-length UNIQUE constraints as long-unique HASH
indexes. The same DDL fails hard on MySQL 8 and on MariaDB configured with
`innodb_large_prefix` semantics disabled or a stricter SQL mode. This is engine-pinned, not a bug
on the current target — the project hard-constrains to MariaDB (no PostgreSQL/MySQL) — so it is INFO.
**Fix:** No change required while MariaDB is the only target. If portability is ever wanted, either
(a) shorten the columns (e.g. `url varchar(768)`, `endpoint` hashed to a `char(64)` digest column
with the UNIQUE on the digest), or (b) add an explicit comment in `schema.ts` documenting the
MariaDB long-unique-HASH dependency so a future MySQL migration is not silently broken. Schema.ts
already carries a CR-02 note on the `endpoint(2048)` width; extend it to record the index-limit
caveat.
### IN-02: `Lint` step is a no-op that will mask real lint failures once ESLint is wired — DEFERRED to Phase 13
> Not fixed this phase. Wiring a real ESLint gate (`pnpm -r --if-present lint`) is Phase 13's
> deliverable. `ci.yml`'s lint step left untouched intentionally.
**File:** `.gitea/workflows/ci.yml:31-32` (`pnpm lint`)
**Issue:** Per the in-file comment, no package defines a `lint` script, so `pnpm lint` (root) prints
`ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT` but exits 0 — the step is green regardless. This is acceptable
for the current phase (lint wiring is explicitly out of scope), but it is a latent trap: when a
`lint` script is later added to one package, `pnpm lint` at the root still will not run it unless
the invocation is `pnpm -r lint`, and even then `--if-present` semantics differ. The step gives a
false sense that linting is enforced.
**Fix:** When lint is wired, switch to `pnpm -r --if-present lint` (runs lint only in packages that
define it, fails the job on real lint errors) and remove the no-op comment. No action this phase;
tracked so the green-but-empty step is not mistaken for working lint enforcement.
---
_Reviewed: 2026-06-11_
_Reviewer: Claude (gsd-code-reviewer)_
_Depth: deep_