mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
e5b1c7b22b
Removes backend/src/entitlements/ (registry, loadProvider, CommunityEntitlementProvider, types, headers, normalize) and the two abstraction-only tests. Relocates headers/normalize/types to services/license-*.ts. Swaps 22 consumer call sites from getEntitlementProvider() to LicenseService.getInstance(). Drops the Dockerfile install step plus PRO_PACKAGE_VERSION build-arg and github_token BuildKit secret in docker-publish.yml. Removes the now stale no-restricted-imports rule in backend/eslint.config.mjs. Net: 37 files changed, ~700 lines removed, no behavior change. Local dev no longer requires GitHub Packages auth to start the backend. Rationale and revisit conditions in docs/internal/adrs/2026-05-02-collapse-entitlement-provider.md.
351 lines
16 KiB
YAML
351 lines
16 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
|
|
build-args: |
|
|
APK_CACHE_BUST=${{ steps.apk-bust.outputs.date }}
|
|
|
|
- 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
|
|
build-args: |
|
|
APK_CACHE_BUST=${{ steps.apk-bust.outputs.date }}
|
|
# 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
|