mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-29 05:09:10 +00:00
638bd808f8
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.
375 lines
18 KiB
YAML
375 lines
18 KiB
YAML
name: Build and Publish Docker Image
|
|
|
|
on:
|
|
push:
|
|
tags:
|
|
- 'v*'
|
|
workflow_dispatch:
|
|
|
|
concurrency:
|
|
group: docker-publish
|
|
cancel-in-progress: true
|
|
|
|
jobs:
|
|
push_to_registry:
|
|
name: Push Docker image to Docker Hub and GHCR
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 30
|
|
# DOCKERHUB_USERNAME and DOCKERHUB_TOKEN live in the `production` environment,
|
|
# not repo-wide secrets. Any future workflow that tries to push to Docker Hub
|
|
# without declaring this environment will fail to resolve the credentials,
|
|
# which is exactly the blast-radius reduction we want. Adding a required
|
|
# reviewer to the environment in repo settings also turns every release into
|
|
# a manual-approval gate without any workflow change.
|
|
environment: production
|
|
permissions:
|
|
contents: write
|
|
# Required for cosign keyless signing via GitHub OIDC and for uploading
|
|
# SBOM/VEX files to GitHub Releases via softprops/action-gh-release.
|
|
id-token: write
|
|
# Required to push the multi-arch image to ghcr.io/studio-saelix/sencho
|
|
# using the auto-provisioned GITHUB_TOKEN. Docker Hub credentials still
|
|
# come from the production environment above.
|
|
packages: write
|
|
steps:
|
|
- name: Check out the repo
|
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
|
|
- name: Set up QEMU
|
|
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4
|
|
with:
|
|
platforms: arm64
|
|
|
|
- name: Set up Docker Buildx
|
|
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
|
|
|
# Computed once per job run. Feeds Dockerfile's APK_CACHE_BUST arg so
|
|
# the `apk upgrade` layer rebuilds at least once per calendar day, even
|
|
# when every other input to the layer is cached. See Dockerfile:115-122
|
|
# for the rationale. Both the pre-publish scan build and the multi-arch
|
|
# push build below consume this so Trivy scans a fresh layer.
|
|
- name: Compute daily apk cache bust value
|
|
id: apk-bust
|
|
run: echo "date=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Log in to Docker Hub
|
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
|
with:
|
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
|
|
- name: Log in to GitHub Container Registry
|
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
|
with:
|
|
registry: ghcr.io
|
|
# repository_owner resolves to a fixed value (studio-saelix) on every
|
|
# event type. github.actor varies (a maintainer on workflow_dispatch,
|
|
# github-actions[bot] on the release tag push) and is just a label;
|
|
# GITHUB_TOKEN is what actually authenticates.
|
|
username: ${{ github.repository_owner }}
|
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Install cosign
|
|
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
|
|
|
- name: Extract metadata (tags, labels) for Docker
|
|
id: meta
|
|
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
|
with:
|
|
# Publish the same manifest under both registries so users on either
|
|
# platform can pull. Docker Hub remains primary for discoverability;
|
|
# GHCR mirrors at ghcr.io/studio-saelix/sencho with identical tags.
|
|
images: |
|
|
saelix/sencho
|
|
ghcr.io/studio-saelix/sencho
|
|
# On a v-tag push we publish:
|
|
# latest always points at the newest release
|
|
# X.Y.Z the immutable semver tag
|
|
# X.Y moving minor tag for users who want latest patch
|
|
# The pre-1.0 constraint hides the {{major}}-only tag until we ship 1.0,
|
|
# since on 0.x every minor is potentially breaking.
|
|
tags: |
|
|
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
|
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
|
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
|
|
|
# Build an amd64-only variant into the local daemon first so Trivy can
|
|
# scan the exact release artifact before it is tagged and pushed. This
|
|
# keeps vulnerable releases out of the `latest` and semver tags that
|
|
# users actually pull. Because the push-build step that follows runs in
|
|
# the same job against the same buildkit daemon, it reuses this build's
|
|
# layers from the daemon's in-memory cache (observed wall-time: the
|
|
# push-build typically finishes faster than the scan-build despite
|
|
# producing multi-arch output). The `cache-from` pull is just a
|
|
# cold-start fallback for the first-ever run or when the buildkit daemon
|
|
# is fresh; we intentionally do NOT write `cache-to` here because the
|
|
# push-build below writes a strictly better (multi-arch, mode=max)
|
|
# cache entry moments later. The tag lives in the `localhost/` namespace
|
|
# so a future `push: false` -> `push: true` mistake cannot publish it.
|
|
# Scanning only amd64 is a defensible proxy for the multi-arch release:
|
|
# distro package CVEs are arch-agnostic at the manifest level, and
|
|
# arch-specific container-relevant CVEs are extraordinarily rare.
|
|
- name: Build release image for pre-publish scan (amd64, loaded)
|
|
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
|
|
with:
|
|
context: .
|
|
push: false
|
|
load: true
|
|
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.
|
|
# CVEs suppressed via the OpenVEX document (trivy.yaml -> security/vex/
|
|
# sencho.openvex.json) are the single source of truth across PR CI and
|
|
# release CI.
|
|
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
|
with:
|
|
image-ref: localhost/sencho:release-scan
|
|
exit-code: '1'
|
|
severity: 'CRITICAL,HIGH'
|
|
format: 'table'
|
|
trivy-version: 'latest'
|
|
trivy-config: trivy.yaml
|
|
|
|
# Start the scanned image headless on the runner and poll /api/health
|
|
# until it returns 200. Catches entrypoint regressions, native module
|
|
# ABI breakage, and first-boot crashes on the exact artifact that the
|
|
# multi-arch push below will republish moments later. Intentionally
|
|
# placed BEFORE the publish step so a smoke failure does not move
|
|
# `latest` or the semver tags on Docker Hub. The health endpoint is
|
|
# public and returns before any DB or Docker-socket work, so the
|
|
# container only needs a free port, no env vars, and no volume mounts.
|
|
- name: Smoke test release image (pre-publish)
|
|
run: |
|
|
set -euo pipefail
|
|
# Verify source-built Docker CLI and Compose binaries are present and functional.
|
|
# Both checks run in one container to avoid double start-up overhead.
|
|
docker run --rm --entrypoint sh localhost/sencho:release-scan -c \
|
|
'docker --version && docker compose version'
|
|
|
|
# Not using --rm so that a crashed container sticks around long
|
|
# enough for `docker logs` in the trap to surface the stack trace.
|
|
# The trap force-removes it explicitly whether it is still running
|
|
# or already exited.
|
|
docker run -d --name sencho-smoke -p 1852:1852 localhost/sencho:release-scan
|
|
trap 'docker logs sencho-smoke 2>&1 || true; docker rm -f sencho-smoke >/dev/null 2>&1 || true' EXIT
|
|
for i in $(seq 1 30); do
|
|
if curl -fsS http://localhost:1852/api/health >/dev/null 2>&1; then
|
|
echo "Container healthy after ${i}s"
|
|
curl -s http://localhost:1852/api/health
|
|
exit 0
|
|
fi
|
|
sleep 1
|
|
done
|
|
echo "FAILED: /api/health did not become ready within 30s"
|
|
exit 1
|
|
|
|
- name: Build and push Docker image
|
|
id: build
|
|
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
|
|
with:
|
|
context: .
|
|
push: true
|
|
platforms: linux/amd64,linux/arm64
|
|
tags: ${{ steps.meta.outputs.tags }}
|
|
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 <img>
|
|
sbom: true
|
|
provenance: mode=max
|
|
|
|
- name: Sign published image with cosign (keyless)
|
|
# Signs every tag produced by metadata-action using the ambient GitHub
|
|
# OIDC token. No private keys, no secrets. Users verify with:
|
|
# cosign verify saelix/sencho:<tag> \
|
|
# --certificate-identity-regexp "https://github.com/studio-saelix/sencho/.*" \
|
|
# --certificate-oidc-issuer https://token.actions.githubusercontent.com
|
|
# Each ref is pinned to the immutable digest so signatures are bound to
|
|
# the exact manifest, not the mutable tag pointer.
|
|
env:
|
|
TAGS: ${{ steps.meta.outputs.tags }}
|
|
DIGEST: ${{ steps.build.outputs.digest }}
|
|
run: |
|
|
# Build a single cosign invocation with every tag pinned to the digest.
|
|
# All tags resolve to the same manifest, so one call signs them all and
|
|
# saves N-1 Rekor/Fulcio round trips. On workflow_dispatch $TAGS is empty
|
|
# and the refs array stays empty, so we no-op cleanly.
|
|
refs=()
|
|
while IFS= read -r tag; do
|
|
[ -n "$tag" ] || continue
|
|
refs+=("${tag}@${DIGEST}")
|
|
done <<< "$TAGS"
|
|
if [ ${#refs[@]} -gt 0 ]; then
|
|
cosign sign --yes "${refs[@]}"
|
|
else
|
|
echo "No tags to sign (likely a workflow_dispatch run on a non-tag ref)."
|
|
fi
|
|
|
|
- name: Generate CycloneDX SBOM
|
|
# syft scans the published manifest by digest for richer package
|
|
# extraction than the BuildKit-native SBOM. Also installs syft into
|
|
# PATH so the SPDX step below can reuse the OCI layer cache.
|
|
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.20.2
|
|
with:
|
|
image: saelix/sencho@${{ steps.build.outputs.digest }}
|
|
format: cyclonedx-json
|
|
output-file: sbom.cdx.json
|
|
upload-artifact: false
|
|
|
|
- name: Generate SPDX SBOM
|
|
# Reuses the syft binary and OCI layer cache from the prior step.
|
|
run: |
|
|
syft saelix/sencho@${{ steps.build.outputs.digest }} \
|
|
-o spdx-json=sbom.spdx.json
|
|
|
|
- name: Attest SBOMs and VEX with cosign (keyless)
|
|
# Attaches CycloneDX SBOM, SPDX SBOM, and OpenVEX document as signed
|
|
# OCI referrer attestations on the published digest, separately to each
|
|
# registry path. Attestations live next to the image manifest in the
|
|
# registry, so a verifier pulling from GHCR cannot resolve attestations
|
|
# written only to Docker Hub. Verification commands are documented in
|
|
# docs/operations/verifying-images.mdx.
|
|
env:
|
|
DIGEST: ${{ steps.build.outputs.digest }}
|
|
run: |
|
|
for IMAGE_REF in \
|
|
"saelix/sencho@${DIGEST}" \
|
|
"ghcr.io/studio-saelix/sencho@${DIGEST}"
|
|
do
|
|
cosign attest --yes --predicate sbom.cdx.json --type cyclonedx "${IMAGE_REF}"
|
|
cosign attest --yes --predicate sbom.spdx.json --type spdxjson "${IMAGE_REF}"
|
|
cosign attest --yes --predicate security/vex/sencho.openvex.json --type openvex "${IMAGE_REF}"
|
|
done
|
|
|
|
- name: Upload SBOM and VEX to GitHub Release
|
|
# Fallback for consumers who do not use cosign; files are also
|
|
# available as signed OCI attestations via the attest step above.
|
|
if: startsWith(github.ref, 'refs/tags/v')
|
|
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0.3.3.0.0
|
|
with:
|
|
files: |
|
|
sbom.cdx.json
|
|
sbom.spdx.json
|
|
security/vex/sencho.openvex.json
|
|
|
|
push_mesh_sidecar:
|
|
name: Push Sencho Mesh sidecar image to Docker Hub and GHCR
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 20
|
|
environment: production
|
|
permissions:
|
|
contents: read
|
|
id-token: write
|
|
# Required to push the sidecar image to ghcr.io/studio-saelix/sencho-mesh.
|
|
packages: write
|
|
steps:
|
|
- name: Check out the repo
|
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
|
|
- name: Set up QEMU
|
|
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4
|
|
with:
|
|
platforms: arm64
|
|
|
|
- name: Set up Docker Buildx
|
|
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
|
|
|
- name: Log in to Docker Hub
|
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
|
with:
|
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
|
|
- name: Log in to GitHub Container Registry
|
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
|
with:
|
|
registry: ghcr.io
|
|
# repository_owner resolves to a fixed value (studio-saelix) on every
|
|
# event type. github.actor varies (a maintainer on workflow_dispatch,
|
|
# github-actions[bot] on the release tag push) and is just a label;
|
|
# GITHUB_TOKEN is what actually authenticates.
|
|
username: ${{ github.repository_owner }}
|
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Install cosign
|
|
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
|
|
|
- name: Extract metadata for Mesh sidecar image
|
|
id: mesh_meta
|
|
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
|
with:
|
|
# Sencho Mesh sidecar image. Versioned in lockstep with the main
|
|
# sencho image so each Sencho release has a matched mesh sidecar.
|
|
# Same registry pair as the main image; tags are identical across
|
|
# both so users on either registry pull the same content.
|
|
images: |
|
|
saelix/sencho-mesh
|
|
ghcr.io/studio-saelix/sencho-mesh
|
|
tags: |
|
|
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
|
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
|
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
|
|
|
- name: Build and push Mesh sidecar image
|
|
id: mesh_build
|
|
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
|
|
with:
|
|
context: ./mesh-sidecar
|
|
file: ./mesh-sidecar/Dockerfile
|
|
push: true
|
|
platforms: linux/amd64,linux/arm64
|
|
tags: ${{ steps.mesh_meta.outputs.tags }}
|
|
labels: ${{ steps.mesh_meta.outputs.labels }}
|
|
cache-from: type=registry,ref=saelix/sencho-mesh:buildcache
|
|
cache-to: type=registry,ref=saelix/sencho-mesh:buildcache,mode=max
|
|
sbom: true
|
|
provenance: mode=max
|
|
|
|
- name: Sign Mesh sidecar with cosign (keyless)
|
|
env:
|
|
TAGS: ${{ steps.mesh_meta.outputs.tags }}
|
|
DIGEST: ${{ steps.mesh_build.outputs.digest }}
|
|
run: |
|
|
refs=()
|
|
while IFS= read -r tag; do
|
|
[ -n "$tag" ] || continue
|
|
refs+=("${tag}@${DIGEST}")
|
|
done <<< "$TAGS"
|
|
if [ ${#refs[@]} -gt 0 ]; then
|
|
cosign sign --yes "${refs[@]}"
|
|
else
|
|
echo "No tags to sign (likely a workflow_dispatch run on a non-tag ref)."
|
|
fi
|