From 638bd808f82ee2f4bdd6ed356f11bf08fd05a425 Mon Sep 17 00:00:00 2001 From: Anso Date: Sat, 2 May 2026 13:24:33 -0400 Subject: [PATCH] ci(docker): install @studio-saelix/sencho-pro in production builds (#881) Phase 2b of the open-core hybrid extraction. After Phase 2a (PR #880) wired the public-side loader to dynamic-import the private package, this PR makes production Docker builds actually install the package so the runtime path uses it. Single image; saelix/sencho remains the only published image (the original ADR's dual-image plan was rejected because customers buying paid tiers would otherwise need GitHub auth to pull a second image, breaking the purchase flow). Dockerfile (prod-deps stage): a new RUN block after npm ci installs @studio-saelix/sencho-pro using a BuildKit secret-mounted github_token for npm.pkg.github.com auth. The .npmrc carrying the token is written and removed inside the same RUN, plus /root/.npm is wiped to scrub any verbose-log artifacts that npm might otherwise stash. The token never enters an image layer (BuildKit excludes secret content from both layer filesystems and cache keys; docker history shows the literal $(cat /run/secrets/...) command, not the substituted value). PRO_PACKAGE_VERSION is a build arg pinned by CI to a literal SemVer (0.1.0 today) so the scan build and the publish build resolve to the same package version. Default of `latest` keeps local builds convenient. When the pro package ships a new version, bump the value in docker-publish.yml in the same PR that ships the matching public Sencho release; release-please does not coordinate the two cadences. Empty-secret branch (no github_token provided, e.g. local dev or fork PRs) skips the install and prints a notice. The resulting image runs through the loader's in-tree LicenseService fallback, so PR validation builds and contributor builds work without any GitHub auth setup. docker-publish.yml: both build-push-action invocations (the pre-publish scan and the multi-arch publish) pass the github_token secret and PRO_PACKAGE_VERSION build arg. The auto-provisioned GITHUB_TOKEN's packages:read scope is sufficient because the public Sencho repo and the private package live in the same Studio-Saelix GitHub org. Moving the package to a different org would silently break this contract; the Dockerfile comment block records the invariant. ci.yml is intentionally not changed. The PR-time Docker validation job builds without the secret and exercises the loader's in-tree fallback path, which is correct for fork PRs (no token access) and useful for catching fallback-path regressions. Test plan: tsc clean (no TS changes). The dockerfile install path is exercised by the next release's pre-publish scan + smoke test, both of which boot the actual image and call /api/health. Failed dynamic import or constructor throw would block bootstrap before the listener binds, so the existing smoke test covers the runtime contract. --- .github/workflows/docker-publish.yml | 24 +++++++++++++++++ Dockerfile | 39 ++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index bc5eb8c8..4a659019 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -118,8 +118,22 @@ jobs: platforms: linux/amd64 tags: localhost/sencho:release-scan cache-from: type=registry,ref=saelix/sencho:buildcache + # PRO_PACKAGE_VERSION pins @studio-saelix/sencho-pro to a + # specific SemVer so the scan build and the publish build + # below resolve identically. Bumping the pro package: bump + # this string in the same PR that ships the matching public + # Sencho release; release-please does not coordinate the two. build-args: | APK_CACHE_BUST=${{ steps.apk-bust.outputs.date }} + PRO_PACKAGE_VERSION=0.1.0 + # GITHUB_TOKEN carries packages:read scope by default, which + # is enough to install @studio-saelix/sencho-pro from + # GitHub Packages during the prod-deps stage. Passing it via + # BuildKit secret keeps it out of image layers; the + # Dockerfile reads /run/secrets/github_token only inside the + # one RUN that authenticates to npm.pkg.github.com. + secrets: | + github_token=${{ secrets.GITHUB_TOKEN }} - name: Re-scan release image for vulnerabilities (Trivy) # Gates the release on the same HIGH/CRITICAL policy as the PR scan. @@ -179,8 +193,18 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=registry,ref=saelix/sencho:buildcache cache-to: type=registry,ref=saelix/sencho:buildcache,mode=max + # PRO_PACKAGE_VERSION must match the value passed to the + # scan build above so Trivy and the smoke test exercise the + # same package version that ships. build-args: | APK_CACHE_BUST=${{ steps.apk-bust.outputs.date }} + PRO_PACKAGE_VERSION=0.1.0 + # Same secret as the pre-publish scan above so the published + # image carries @studio-saelix/sencho-pro identical to what + # Trivy and the smoke test exercised. BuildKit keeps the + # token out of the published layers. + secrets: | + github_token=${{ secrets.GITHUB_TOKEN }} # SBOM + provenance attestations are embedded as OCI referrers on the # published image. Inspect with: docker buildx imagetools inspect sbom: true diff --git a/Dockerfile b/Dockerfile index 27c686e1..99c56da3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -90,6 +90,45 @@ RUN if [ "$TARGETARCH" = "$BUILDARCH" ]; then \ npm ci --omit=dev; \ fi +# Add the private @studio-saelix/sencho-pro package on top of the +# production dependencies installed above. The package contains the +# Lemon Squeezy validation client; the public Sencho core's +# loadEntitlementProvider() dynamic-imports it at runtime when present +# and falls back to the in-tree LicenseService when it is not. +# +# Authentication uses a BuildKit secret rather than a build arg so the +# token never lands in an image layer. The github_token secret is +# expected to carry packages:read scope against the Studio-Saelix org; +# CI passes the auto-provisioned GITHUB_TOKEN, which has that scope +# automatically because the public Sencho repo and the private package +# are in the same GitHub org. Moving the package to a different org +# would silently break this contract; the auto-token would lose scope +# and every build would fall through to the empty-secret branch. +# Local builds without the secret skip the install entirely and the +# resulting image runs through the loader's in-tree fallback. +# +# The install is pure-JS (axios is the only runtime dep, no native +# modules) so cross-compilation env vars are not needed here. We use +# `npm install --no-save` so package.json and package-lock.json stay +# unchanged in the source tree; the package is added to node_modules +# only inside this build layer. +# +# PRO_PACKAGE_VERSION is pinned by CI at build time (a literal SemVer +# like `0.1.0`) so the scan build and the publish build resolve to the +# same package version. Defaulting to `latest` keeps local builds +# convenient; CI overrides this so production never tracks a moving +# tag. +ARG PRO_PACKAGE_VERSION=latest +RUN --mount=type=secret,id=github_token \ + if [ -s /run/secrets/github_token ]; then \ + printf '@studio-saelix:registry=https://npm.pkg.github.com\n//npm.pkg.github.com/:_authToken=%s\n' "$(cat /run/secrets/github_token)" > /root/.npmrc && \ + npm install --no-save --omit=dev "@studio-saelix/sencho-pro@${PRO_PACKAGE_VERSION}" && \ + rm -f /root/.npmrc && \ + rm -rf /root/.npm; \ + else \ + echo "[Sencho] No github_token secret provided; @studio-saelix/sencho-pro will not be installed. Loader will use the in-tree LicenseService fallback at runtime."; \ + fi + # Stage 4a: Build Docker CLI from source against Go 1.26.2 # # CLI v29.4.1 ships otel/sdk v1.43.0, resolving CVE-2026-39883 (BSD kenv) and