zstd saving stdout auto-extraction + measurement finding: zstd ~0% on warm cache (seed #169-1)
Goal
- Recover Sprint 169 carryover seed #169-1: have
report-build-metrics.shemitcompression saving %to stdout (job log) as a greppable marker, in addition to$GITHUB_STEP_SUMMARY→ auto-collect the 8-service zstd % viagh run view --log | grep→ complete the incomplete static table from Sprint 169 ADR D3 (only ai-analysis had a raw %) - Extend the
quality-ci-scriptsunit test alongside the helper change (mandatory regression guard) - The measurement-automation stage of the "security gate (165) → visibility (166) → measurement (167) → full adoption (168) → verification/observability settling (169) → measurement automation (170)" cycle
Decisions
D0. Adopted approach — Option A (stdout echo)
- Option comparison:
- A (chosen): the helper echoes the saving metric as one line to stdout (job log) → extract via
gh run view --log | grep. Zero storage cost, log-observability bonus - B: upload the zstd tarball as an artifact →
gh run download+ stat for exact bytes. But storage cost + no Trivy consumer makes the artifact pointless
- A (chosen): the helper echoes the saving metric as one line to stdout (job log) → extract via
- Rationale: the Step Summary is not exposed via GitHub REST API/
gh run view --logso it cannot be extracted programmatically (the Sprint 169 D3 carryover reason). Option A is superior — zero storage + grep all 8 services at once from the log
D1. Marker format — plain greppable line (no ::notice::)
- Format:
ZSTD-METRIC service=<label> docker_bytes=<n> zstd_bytes=<n> saving_pct=-<pct> - Plain marker chosen over
::notice::annotation — grep robustness + avoids GitHub annotation limits/noise - Output location: stdout immediately after the
} >> "$GITHUB_STEP_SUMMARY"block. The group command{ }is not a subshell, soZSTD_BYTES/SAVE_PCTset in the zstd branch remain valid after the block → no recomputation (minimal change) - No marker when the zstd file is absent (N/A) — the current 8 services always produce zstd, so success-only is sufficient
D2. Unit test Case 7 (mandatory regression guard)
- Added Case 7 to
tests/ci/report-build-metrics-test.sh— mandatoryquality-ci-scriptsregression guard on helper changes (Sprint 169 seed #168-4 settling) - 3 verifications: (1) marker value accuracy (
docker=1000 zstd=250 saving=-75.0) (2) marker not mixed into the Summary file (stdout separation) (3) marker absent when no zstd - Inherits the existing GNU stat guard — macOS local SKIP (exit 0), full run in CI (ubuntu-latest) (18 → 22 assertions)
D3. Measurement result — zstd ~0% on warm cache (key finding)
Measurement method: PR #296's CI run #26140657480 (no separate workflow_dispatch needed). The helper file is included in every service's detect-changes filter ('scripts/ci/report-build-metrics.sh'), so a helper-change PR triggers all 8 build jobs → the PR run itself is the measurement run. The PR has push=${{ github.ref == 'refs/heads/main' }} = false → zero GHCR push/ArgoCD deploy side effects (the Sprint 169 D4 isolation principle naturally satisfied by the PR run).
gh run view 26140657480 --log | grep ZSTD-METRIC auto-collection result (head SHA 12b3535, 8 build artifacts):
| build artifact | docker tarball | oci+zstd tarball | compression saving |
|---|---|---|---|
| gateway | 94.8 MB (99,382,272 B) | 94.8 MB (99,380,224 B) | -0.0% |
| identity | 77.4 MB (81,171,456 B) | 77.4 MB (81,169,408 B) | -0.0% |
| submission | 77.8 MB (81,590,272 B) | 77.8 MB (81,588,224 B) | -0.0% |
| problem | 77.5 MB (81,282,560 B) | 77.5 MB (81,280,512 B) | -0.0% |
| github-worker | 64.0 MB (67,122,688 B) | 64.0 MB (67,120,640 B) | -0.0% |
| ai-analysis | 55.8 MB (58,486,784 B) | 55.8 MB (58,484,736 B) | -0.0% |
| frontend | 94.0 MB (98,544,640 B) | 94.0 MB (98,543,104 B) | -0.0% |
| blog | 30.8 MB (32,316,928 B) | 30.8 MB (32,315,392 B) | -0.0% |
Finding: across all 8 services the size difference between the docker tarball and the oci+zstd tarball is exactly 2048 bytes (container-format manifest/index overhead) → the layer blobs are byte-identical between the two exporters → zstd compression is not actually applied to the layers (saving ~0%).
Cause (cold vs warm cache):
- The build log shows
#7 importing cache manifest from gha:...→cache-from: type=ghawarm cache hit - Without
force-compression=true, buildx reuses the existing compressed layer blobs from cache/base images without recompressing → bothtype=dockerandtype=oci,compression=zstdembed the same blob → identical size - Sprint 168's ai-analysis -63.7% (docker 66.9 MB → zstd 24.3 MB) was a one-time measurement from the first-ever zstd build (cold/fresh layers) + the zstd value was manually typed by the user from the UI → not representative of steady state
Implications:
- The current zstd OCI export provides ~0% saving on warm-cache steady-state builds + Trivy consumes the docker tarball (the zstd tarball is not uploaded, no consumer) → the zstd export is effectively an unconsumed, zero-saving redundant export step
- This finding is the result of #169-1 auto-measurement working as intended — manual Step Summary inspection only confirmed "box shown across all services" (Sprint 169 D3), whereas auto-extraction's raw-byte comparison revealed the ~0% fact
- The follow-up decision is split into seed #170-1 (avoid scope creep, measurement-sprint principle)
D4. PR run = measurement run (natural evolution of Sprint 169 D4)
- Sprint 169 D4 ran a separate
workflow_dispatch + rebuild_all=trueon a feature branch for measurement - This sprint: the helper file is registered in every service's detect-changes filter, so a helper-change PR auto-triggers all 8 build jobs → the PR run becomes the measurement run without a separate dispatch → saves one CI run
- The PR run's build
push=false+ the deploy job's main gate → zero deploy side effects (same D4 isolation principle satisfied)
Implementation (single PR, 38 consecutive sprints of branch discipline)
Branch: feat/sprint-170-zstd-metric-stdout (new, based on main c8f6cb6)
Phase A — helper stdout marker (commit 12b3535)
Immediately after the Summary block in scripts/ci/report-build-metrics.sh:
# Sprint 170 seed #169-1: emit zstd saving to stdout (job log) as a greppable marker.
if [ -n "$ZSTD_TARBALL" ] && [ -f "$ZSTD_TARBALL" ]; then
echo "ZSTD-METRIC service=${LABEL} docker_bytes=${SIZE_BYTES} zstd_bytes=${ZSTD_BYTES} saving_pct=-${SAVE_PCT}"
fi
Phase B — unit test Case 7 (commit 12b3535)
tests/ci/report-build-metrics-test.sh Case 7 — capture stdout then verify marker value / Summary non-mixing / absence when no zstd + add verification case 7 to the header comment.
Phase C — automated measurement collection (external trigger, zero code change)
- PR #296 CI run #26140657480 (8 build jobs success) →
gh run view --log | grep ZSTD-METRICauto-collected the 8-service raw bytes → completed the D3 static table
Phase D — ADR recording (commit ffc0861)
docs/adr/sprints/sprint-170.md(KR) +docs/adr-en/sprints/sprint-170.md(EN 1:1 mapping)docs/adr/README.mdcount 109→110, range 62169→62170 (lines 18/52/54)
Critic cycle
R1 (codex review --base c8f6cb6, session 019e439a-203a-7dc0-b144-9f6eb1cf5b69):
- Result: P0/P1/P2/P3 0 findings, PASS ✅
- codex verdict: "The change adds a stdout marker for existing zstd metrics and a regression test covering the new behavior. I did not find a discrete issue in the diff that would break existing functionality or CI."
- codex directly checked: grepped helper/test/ci.yml to verify the marker addition + Case 7 regression-test coverage. The stdout marker output leaves existing build/Summary behavior unchanged
- Zero self-contradiction detected — sprint goal (auto-measurement + documenting the finding) ↔ implementation aligned. Single-pass PASS (Sprint 169 normal pattern reproduced, no codex hang)
Risk / regression guard
Prediction 1: zero side effect from marker output
- The marker is one stdout line in a build job step → no downstream parser (pure log). Build/deploy logic unchanged
quality-ci-scriptsjob success (run #26140657480) → 22 assertions PASS in real CI
Prediction 2: measurement accuracy
- The helper compares actual file sizes (
stat -c %s) → the measurement itself is accurate. ~0% is not a measurement error but the real result of buildx behavior (absence of force-compression) - The constant 2048-byte difference = OCI/docker container-format overhead (evidence of layer-blob identity)
Verification
Local
bash tests/ci/report-build-metrics-test.sh(with GNU stat shim) — 22/22 assertions PASS / macOS default SKIP (exit 0)bash -nsyntax check PASS (helper + test)node scripts/check-adr-en-coverage.mjs --strict119/119 (100.0%) PASSnode scripts/check-doc-refs.mjs303 files 0 broken refs PASS
CI
- PR #296 run #26140657480: success — 8 build jobs +
quality-ci-scriptsall success gh run view 26140657480 --log | grep ZSTD-METRIC→ 8-service markers auto-extracted (D3)- mergeStateStatus CLEAN target
New UAT (Sprint 170)
- User direct: visually confirm the 8-service markers appear in the log via
gh run view --log | grep ZSTD-METRICon the PR or main run
Result
Changed files (PR #296):
- modified 1:
scripts/ci/report-build-metrics.sh(stdout marker) - modified 1:
tests/ci/report-build-metrics-test.sh(Case 7) - new 2:
docs/adr/sprints/sprint-170.md(KR) +docs/adr-en/sprints/sprint-170.md(EN 1:1) - modified 1:
docs/adr/README.md(lines 18/52/54)
Commits (PR #296):
12b3535feat(ci): zstd saving stdout greppable marker + unit test Case 7 (seed #169-1)ffc0861docs(adr): Sprint 170 ADR (KR + EN) + README update<TBD-CRITIC-DOC>docs(adr): document Sprint 170 Critic R1 cycle (KR + EN)- Squash merge:
<TBD-MERGE-SHA>
New patterns
- stdout greppable marker = bypassing the Step Summary's programmatic-extraction gap — the GitHub Step Summary is not exposed via REST API/job log so it cannot be auto-collected. If the helper echoes the same metric to stdout as a plain marker, it becomes extractable via
gh run view --log | grep. Zero storage + log-observability bonus. A dual-output pattern: Summary (human render) + stdout marker (machine collection) - leveraging group-command variable persistence = minimal change without recomputation —
{ ... } >> fileis not a subshell, so variables set inside the block remain valid afterward. The marker needs no recomputation of zstd bytes/saving - registering the helper file in all service filters = turning the PR run into the measurement run — the shared helper (
scripts/ci/report-build-metrics.sh) is registered in every service's detect-changes filter, so a helper-change PR auto-triggers all 8 build jobs → no separate measurement dispatch needed. PR run = measurement run (saves one CI run, a natural evolution of Sprint 169 D4) - auto-measurement exposes the blind spot of manual inspection — Sprint 169 D3 could only manually confirm "the Step Summary box is shown"; raw-byte auto-extraction quantitatively exposed the ~0% saving across all 8 services. Proves the value of the manual-visual → auto-quantitative transition