Skip to content

ci: build images once, push to GHCR, reuse in smoke/validate/scan#1695

Merged
larryro merged 3 commits into
mainfrom
ci/build-once-push-ghcr
May 10, 2026
Merged

ci: build images once, push to GHCR, reuse in smoke/validate/scan#1695
larryro merged 3 commits into
mainfrom
ci/build-once-push-ghcr

Conversation

@larryro

@larryro larryro commented May 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

build, smoke-test, image-validate, and (on push) vulnerability-scan each rebuilt the same six service images independently — duplicate compute per run for identical artifacts with no shared cache.

build is now the single producer: full 6-service matrix (db, convex, crawler, rag, platform, proxy), per-service GHA layer cache, pushes to ghcr.io/tale-project/tale/tale-{svc}:{tag} where tag is pr-{n}-sha-{shortsha} on PRs and sha-{shortsha} on main/merge_group. Downstream jobs needs: build, log into GHCR, pull + re-tag locally as :latest, then run the existing test scripts with SKIP_BUILD=true PULL_POLICY=never (env vars already supported — no script changes). vulnerability-scan points trivy directly at the GHCR ref.

A new cleanup-pr-images.yml workflow deletes every pr-{n}-sha-* tag for the closed PR via the GHCR API on pull_request: closed.

Tradeoffs (be aware before merging)

  • Wallclock: ~5 min slower per PR run. OLD: build + smoke ran in parallel (~8 min total). NEW: build → push → pull → smoke is serial (~13–14 min total). Compute-minutes are roughly flat in the all-services-changed case; less of a win than originally framed.
  • Coverage gap (image CVE scans). vulnerability-scan runs only on event_name == 'push', so PRs get no image-level CVE scan today — only security.yml's filesystem scan, which catches lockfile CVEs and Dockerfile misconfigs but not base-image OS-package CVEs. Web/docs Dockerfiles are also not image-scanned anywhere on CI today. A follow-up PR should either (a) add web/docs to the vulnerability-scan matrix on push-to-main, or (b) extend security.yml with a weekly cron that builds + image-scans both static-site Dockerfiles.
  • Architectural wins: single producer of artifacts; no four-way rebuild duplication; GHCR images reusable by downstream/QA; reproducible test inputs (smoke and image-validate exercise byte-identical artifacts).

Hardening from review feedback

This PR also includes the items surfaced by the multi-agent review:

  • Fork PR fallback. Forks get a read-only GITHUB_TOKEN and cannot push to GHCR. Build switches to push: false + load: true on forks; new smoke-test-fork / image-validate-fork jobs run the local-build pipeline (the pre-refactor shape) so external contributor PRs keep CI parity.
  • Cleanup workflow surfaces real failures. The list call gets a 3-attempt retry; per-DELETE results are counted (deleted / 404-already-gone / failures) and reported to the step summary; non-404 failures fail the job instead of being silently swallowed.
  • Failure-summary log redirect fixed. >> was only chaining to the echo; docker compose logs and ps -a were writing to stdout instead of $GITHUB_STEP_SUMMARY. Now grouped in { … } >> $GITHUB_STEP_SUMMARY with --tail=300 and collapsible <details> blocks.
  • Storybook trigger gap closed. New storybook paths-filter covers services/platform/**, packages/ui/**, packages/webui/** (where the bulk of stories actually live).
  • TURBO_TOKEN scoped. Moved from workflow-level env: (visible to every job/action) to the storybook job only.
  • SARIF upload guarded with hashFiles(...) so a Trivy crash doesn't surface as a confusing red cross on the upload step.
  • Action SHA pin comments tightened (docker/login-action # v4# v4.1.0; codeql-action # v4# v4.35.3); aquasecurity/trivy-action bumped to v0.36.0.
  • Storybook timeout 15 → 5 min; web-test / docs-test 15 → 8 min (per measured p95).
  • Service-list cross-references added at all 4 duplication sites (build matrix + 2 pull loops + cleanup matrix).
  • ci_tests filter narrowed from blanket services/** to the 6 compose-stack services explicitly.
  • provenance: false rationale documented inline.

Test plan

  • build matrix produces 6 successful pushes to ghcr.io/tale-project/tale/tale-{svc}:pr-1695-sha-<sha> (visible under the org Packages tab).
  • smoke-test logs show Pulling … lines for all 6 services, Skipping build (SKIP_BUILD=true), and all healthchecks pass (target: 18/18).
  • image-validate logs show SKIP_BUILD=true — using pre-built images and all assertions pass (target: 30/30).
  • Storybook job triggers on this PR (packages/ui/** or services/platform/** paths) and builds successfully.
  • smoke-test (fork PR) and Validate images (fork PR) are skipped (this PR is same-repo).
  • After this PR merges/closes, Cleanup PR images workflow fires; per-service step summaries show Deleted: N / Already gone: M / Failures: 0; the pr-1695-sha-* tags disappear from GHCR.
  • On a subsequent push to the same PR, GHA cache hits and unchanged services finish faster (compare to first push timing).
  • On main after merge: vulnerability-scan pulls from GHCR and uploads SARIF without rebuilding.

Follow-ups (not in this PR)

  • GHCR untagged-version sweeper (cancelled-push orphans accumulate; GHCR has no auto-GC).
  • Add image-CVE scan for tale-web and tale-docs (filesystem scan today doesn't cover base-image OS packages).

Summary by CodeRabbit

  • Chores
    • Improved CI/CD pipeline with consistent service builds using a centralized container registry
    • Added automated cleanup of temporary artifacts from pull requests

Review Change Stack

Previously `build`, `smoke-test`, `image-validate`, and `vulnerability-scan`
each rebuilt the same six service images from scratch in parallel — ~4×
the compute per push for identical artifacts. None of them shared a
build cache.

Now `build` is the single producer: it always builds all six compose-stack
services (db, convex, crawler, rag, platform, proxy), uses GHA layer cache
per service, and pushes to ghcr.io/tale-project/tale/tale-{svc} tagged
`pr-{n}-sha-{shortsha}` (PRs) or `sha-{shortsha}` (main / merge_group).

Downstream jobs depend on `build`, log into GHCR, pull and re-tag locally
as `:latest`, then run the existing test scripts with `SKIP_BUILD=true`
and `PULL_POLICY=never` — no script changes required, both env vars were
already supported. `vulnerability-scan` skips the rebuild and points trivy
straight at the GHCR ref. Web/docs are excluded from the build matrix
(they have their own compose stacks and dedicated test jobs); their
vulnerability coverage continues via security.yml's filesystem scan.

A new cleanup-pr-images workflow runs on `pull_request: closed` and
deletes every GHCR container version tagged `pr-{n}-sha-*` for the closed
PR so per-PR tags don't accumulate.
@coderabbitai

coderabbitai Bot commented May 10, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This PR refactors the container CI pipeline to centralize image building and distribution via GHCR. The build workflow now builds all six compose-stack services to GHCR under a shared per-run tag (pr-{number}-sha-{shortsha} or sha-{shortsha}), enabling downstream jobs to pull and reuse those images instead of building locally. The smoke-test, image-validate, and vulnerability-scan jobs are updated to authenticate to GHCR, pull their respective service images, retag them as :latest for local Docker Compose resolution, and execute tests/scans with build skipped. A new cleanup-pr-images workflow deletes PR-tagged image versions from GHCR when pull requests close, preventing registry clutter.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately reflects the main change: consolidating image building to a single job and reusing built images across downstream jobs via GHCR.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description provides a comprehensive summary, detailed tradeoffs, implementation details with hardening improvements, and a thorough test plan—all required elements.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ci/build-once-push-ghcr

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/build.yml:
- Around line 242-251: Hardcoded registry in the docker tag step causes fork
incompatibility; update the tag to use the existing REGISTRY_PATH variable (TAG
and REGISTRY_PATH are set above and the for svc loop does the pulls) so the
docker tag command tags the pulled IMAGE as
"${REGISTRY_PATH}/tale-${svc}:latest" instead of the hardcoded
ghcr.io/tale-project/tale/tale-${svc}:latest.
- Around line 356-365: The docker tag target is hardcoded to ghcr.io; update the
tag to use the REGISTRY_PATH variable instead. In the loop that builds IMAGE
(variables: TAG, REGISTRY_PATH, IMAGE, svc), replace the hardcoded target
"ghcr.io/tale-project/tale/tale-${svc}:latest" with a tag that uses
REGISTRY_PATH (e.g. "${REGISTRY_PATH}/tale-${svc}:latest") so docker tag uses
the same registry path as docker pull.

In @.github/workflows/cleanup-pr-images.yml:
- Line 63: The file .github/workflows/cleanup-pr-images.yml is missing a
trailing newline after the final line containing "done"; open the workflow file
and add a single newline character after that final line so the file ends with a
newline (ensure the last token "done" remains unchanged).
- Around line 46-50: The current pipeline appends "|| true" after the gh api
call that populates VERSION_IDS which hides all failures; update the logic so
the gh api call's exit code is checked instead of always ignoring errors: run
the gh api to fetch VERSION_IDS, capture its exit status and the HTTP response,
and if the call fails only suppress the error when the HTTP status is 404 (no
versions found); for any other non-zero exit/status (auth, rate limit,
permission, network), fail the job and surface the error via echo/exit or
processLogger equivalent. Ensure you reference VERSION_IDS and the gh api
invocation when making the change so the behavior is limited to this lookup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: b03242be-75b3-40c8-8592-baa0819adc65

📥 Commits

Reviewing files that changed from the base of the PR and between 4a38699 and 6012592.

📒 Files selected for processing (2)
  • .github/workflows/build.yml
  • .github/workflows/cleanup-pr-images.yml

Comment on lines +242 to +251
- name: Pull images from GHCR
run: |
TAG="${{ needs.changes.outputs.image_tag }}"
REGISTRY_PATH="${{ env.REGISTRY }}/${{ github.repository }}"
for svc in db convex crawler rag platform proxy; do
IMAGE="${REGISTRY_PATH}/tale-${svc}:${TAG}"
echo "Pulling ${IMAGE}..."
docker pull "${IMAGE}"
docker tag "${IMAGE}" "ghcr.io/tale-project/tale/tale-${svc}:latest"
done

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Hardcoded registry path in retag command breaks fork compatibility.

Line 250 hardcodes ghcr.io/tale-project/tale/tale-${svc}:latest while lines 244-249 correctly use REGISTRY_PATH. If this workflow runs on a fork, images will be pulled from the fork's GHCR but retagged with the upstream path, which may cause confusion or failures.

🐛 Proposed fix to use consistent registry path
       - name: Pull images from GHCR
         run: |
           TAG="${{ needs.changes.outputs.image_tag }}"
           REGISTRY_PATH="${{ env.REGISTRY }}/${{ github.repository }}"
           for svc in db convex crawler rag platform proxy; do
             IMAGE="${REGISTRY_PATH}/tale-${svc}:${TAG}"
             echo "Pulling ${IMAGE}..."
             docker pull "${IMAGE}"
-            docker tag "${IMAGE}" "ghcr.io/tale-project/tale/tale-${svc}:latest"
+            docker tag "${IMAGE}" "${REGISTRY_PATH}/tale-${svc}:latest"
           done
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/build.yml around lines 242 - 251, Hardcoded registry in
the docker tag step causes fork incompatibility; update the tag to use the
existing REGISTRY_PATH variable (TAG and REGISTRY_PATH are set above and the for
svc loop does the pulls) so the docker tag command tags the pulled IMAGE as
"${REGISTRY_PATH}/tale-${svc}:latest" instead of the hardcoded
ghcr.io/tale-project/tale/tale-${svc}:latest.

Comment on lines +356 to +365
- name: Pull images from GHCR
run: |
TAG="${{ needs.changes.outputs.image_tag }}"
REGISTRY_PATH="${{ env.REGISTRY }}/${{ github.repository }}"
for svc in db convex crawler rag platform proxy; do
IMAGE="${REGISTRY_PATH}/tale-${svc}:${TAG}"
echo "Pulling ${IMAGE}..."
docker pull "${IMAGE}"
docker tag "${IMAGE}" "ghcr.io/tale-project/tale/tale-${svc}:latest"
done

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Same hardcoded registry path issue as in smoke-test.

Line 364 hardcodes ghcr.io/tale-project/tale/tale-${svc}:latest instead of using the REGISTRY_PATH variable.

🐛 Proposed fix
       - name: Pull images from GHCR
         run: |
           TAG="${{ needs.changes.outputs.image_tag }}"
           REGISTRY_PATH="${{ env.REGISTRY }}/${{ github.repository }}"
           for svc in db convex crawler rag platform proxy; do
             IMAGE="${REGISTRY_PATH}/tale-${svc}:${TAG}"
             echo "Pulling ${IMAGE}..."
             docker pull "${IMAGE}"
-            docker tag "${IMAGE}" "ghcr.io/tale-project/tale/tale-${svc}:latest"
+            docker tag "${IMAGE}" "${REGISTRY_PATH}/tale-${svc}:latest"
           done
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: Pull images from GHCR
run: |
TAG="${{ needs.changes.outputs.image_tag }}"
REGISTRY_PATH="${{ env.REGISTRY }}/${{ github.repository }}"
for svc in db convex crawler rag platform proxy; do
IMAGE="${REGISTRY_PATH}/tale-${svc}:${TAG}"
echo "Pulling ${IMAGE}..."
docker pull "${IMAGE}"
docker tag "${IMAGE}" "ghcr.io/tale-project/tale/tale-${svc}:latest"
done
- name: Pull images from GHCR
run: |
TAG="${{ needs.changes.outputs.image_tag }}"
REGISTRY_PATH="${{ env.REGISTRY }}/${{ github.repository }}"
for svc in db convex crawler rag platform proxy; do
IMAGE="${REGISTRY_PATH}/tale-${svc}:${TAG}"
echo "Pulling ${IMAGE}..."
docker pull "${IMAGE}"
docker tag "${IMAGE}" "${REGISTRY_PATH}/tale-${svc}:latest"
done
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/build.yml around lines 356 - 365, The docker tag target is
hardcoded to ghcr.io; update the tag to use the REGISTRY_PATH variable instead.
In the loop that builds IMAGE (variables: TAG, REGISTRY_PATH, IMAGE, svc),
replace the hardcoded target "ghcr.io/tale-project/tale/tale-${svc}:latest" with
a tag that uses REGISTRY_PATH (e.g. "${REGISTRY_PATH}/tale-${svc}:latest") so
docker tag uses the same registry path as docker pull.

Comment thread .github/workflows/cleanup-pr-images.yml Outdated
Comment on lines +46 to +50
VERSION_IDS=$(gh api -H "Accept: application/vnd.github+json" \
"/orgs/${OWNER}/packages/container/${PACKAGE}/versions" \
--paginate \
--jq "[.[] | select(any(.metadata.container.tags[]?; startswith(\"${PREFIX}\"))) | .id] | .[]" \
|| true)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

|| true silently swallows API failures, including auth or permission issues.

If the gh api call fails due to authentication problems, rate limiting, or incorrect permissions, the workflow exits cleanly with "No matching versions found" rather than surfacing the error. Consider capturing the exit code and only ignoring "not found" (404) responses.

♻️ Proposed fix to surface real failures
-          VERSION_IDS=$(gh api -H "Accept: application/vnd.github+json" \
-            "/orgs/${OWNER}/packages/container/${PACKAGE}/versions" \
-            --paginate \
-            --jq "[.[] | select(any(.metadata.container.tags[]?; startswith(\"${PREFIX}\"))) | .id] | .[]" \
-            || true)
+          if ! VERSION_IDS=$(gh api -H "Accept: application/vnd.github+json" \
+            "/orgs/${OWNER}/packages/container/${PACKAGE}/versions" \
+            --paginate \
+            --jq "[.[] | select(any(.metadata.container.tags[]?; startswith(\"${PREFIX}\"))) | .id] | .[]" 2>&1); then
+            # 404 is expected when package has no versions yet; other errors should surface.
+            if echo "${VERSION_IDS}" | grep -q "404"; then
+              echo "No package versions exist for tale-${SVC} (404)."
+              exit 0
+            fi
+            echo "::error::Failed to list versions: ${VERSION_IDS}"
+            exit 1
+          fi
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/cleanup-pr-images.yml around lines 46 - 50, The current
pipeline appends "|| true" after the gh api call that populates VERSION_IDS
which hides all failures; update the logic so the gh api call's exit code is
checked instead of always ignoring errors: run the gh api to fetch VERSION_IDS,
capture its exit status and the HTTP response, and if the call fails only
suppress the error when the HTTP status is 404 (no versions found); for any
other non-zero exit/status (auth, rate limit, permission, network), fail the job
and surface the error via echo/exit or processLogger equivalent. Ensure you
reference VERSION_IDS and the gh api invocation when making the change so the
behavior is limited to this lookup.

Comment thread .github/workflows/cleanup-pr-images.yml Outdated
gh api -X DELETE \
"/orgs/${OWNER}/packages/container/${PACKAGE}/versions/${version_id}" \
|| echo "Failed to delete version ${version_id} (may already be gone)"
done

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Missing trailing newline at end of file.

POSIX convention expects files to end with a newline. Add a blank line after line 63.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/cleanup-pr-images.yml at line 63, The file
.github/workflows/cleanup-pr-images.yml is missing a trailing newline after the
final line containing "done"; open the workflow file and add a single newline
character after that final line so the file ends with a newline (ensure the last
token "done" remains unchanged).

larryro added 2 commits May 10, 2026 15:54
Critical fixes
- Fork-PR fallback: forks get a read-only GITHUB_TOKEN and cannot push to
  GHCR; build now switches to push:false + load:true on forks, and new
  smoke-test-fork / image-validate-fork jobs run the local-build pipeline
  so external contributor PRs keep CI parity.
- Cleanup workflow surfaces real failures: list call gets a 3-attempt
  retry; per-DELETE results are counted (deleted / 404-already-gone /
  failures) and reported to the step summary; non-404 failures fail the
  job instead of being silently swallowed.
- Failure-summary log redirect: the >> only chained to the echo, so
  `docker compose logs` and `ps -a` were writing to stdout instead of
  $GITHUB_STEP_SUMMARY. Now grouped in { ... } >> $GITHUB_STEP_SUMMARY
  with --tail=300 and collapsible <details> blocks.

Hygiene
- Storybook trigger gate broadened: new `storybook` paths-filter covers
  services/platform/**, packages/ui/**, packages/webui/** (where the
  bulk of stories actually live).
- TURBO_TOKEN scoped to the storybook job (was workflow-level env,
  process-readable by every action in every job).
- SARIF upload guarded with hashFiles(...) so a Trivy crash doesn't
  surface as a confusing red cross on the upload step; missing-SARIF
  fallback writes a step-summary note.
- Action SHA pin comments tightened: docker/login-action # v4 -> # v4.1.0
  (4× sites); github/codeql-action # v4 -> # v4.35.3; bumped
  aquasecurity/trivy-action 0.35.0 -> v0.36.0.
- Storybook timeout 15->5 min; web-test/docs-test 15->8 min (per
  measured p95).
- Service-list cross-references at all 4 duplication sites.
- ci_tests filter narrowed from blanket services/** to the 6 compose-stack
  services explicitly.
- Failure-summary step on build-job matrix legs (was empty on failure).
- provenance:false rationale documented inline; multi-arch caveat added
  to cleanup workflow's jq filter.
- fetch-depth note for future Dockerfile authors.
Top-level paths missed several Docker-affecting files (docs/, patches/,
.dockerignore, bunfig.toml, tsconfig.base.json, turbo.json, .env.test);
docs-only PRs in particular never reached the existing docs-test job.

Add VERSION=${image_tag} to mirror release.yml — service Dockerfiles bake
this into TALE_VERSION, used by Sentry release tagging and convex seed
marker namespacing. Without it PR images reported SENTRY_RELEASE=unknown.
@larryro larryro merged commit e582e23 into main May 10, 2026
19 checks passed
@larryro larryro deleted the ci/build-once-push-ghcr branch May 10, 2026 09:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant