Files
familysync/.planning/milestones/v1.1-phases/08-gitea-ci/08-REVIEW.md
T
2026-06-18 22:21:38 -04:00

11 KiB
Raw Blame History

phase, reviewed, depth, files_reviewed, files_reviewed_list, findings, status, fix_summary
phase reviewed depth files_reviewed files_reviewed_list findings status fix_summary
08-gitea-ci 2026-06-11T00:00:00Z deep 3
.gitea/workflows/ci.yml
.gitea/workflows/publish.yml
apps/api/src/db/migrations/0000_baseline.sql
critical warning info total
0 5 2 7
fixes_applied
fixed deferred fixed_at
WR-02
WR-03
WR-04
WR-05
IN-01
IN-02
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:

      - 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:

          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:

          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:

      - 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