Files
sencho/.github/workflows/docker-publish.yml
T
Anso efcc06d50b ci: harden CI and supply-chain pipeline (#1169)
* ci: harden CI and supply-chain pipeline

* Add frontend Vitest step to ci.yml so the 241 existing frontend tests run on
  every PR (mirrors the backend build/test/lint/audit order).
* Pin Node 26 as a single source of truth: new .node-version, node-version-file
  on all setup-node calls, engines.node ">=26.0.0" in all three package.json
  files. Matches the Dockerfile's node:26-alpine.
* SHA-pin remaining mutable actions in the start-app composite
  (actions/setup-node v6, actions/cache v4.3.0).
* Pin Dockerfile supply-chain inputs: golang:1.26.3-alpine by sha256 digest in
  both builder stages; replace mutable-tag git clone with commit-SHA fetch for
  docker/cli (v29.4.1) and docker/compose (v5.1.3). LDFLAGS version strings
  and otel patch preserved unchanged.
* Ref-scope docker-publish concurrency so two different release tags cannot
  cancel each other; same-ref reruns still cancel as before.
* Harden CLA workflow: drop actions:write from permissions; tighten the
  issue_comment trigger to PRs only (github.event.issue.pull_request != null)
  matching the two documented CLA phrases. No PR code is checked out.
* Drop trivy-version: latest from both Trivy scans so the SHA-pinned
  aquasecurity/trivy-action governs the bundled binary version. The
  HIGH/CRITICAL gate, severity filter, and trivy.yaml (OpenVEX) are unchanged.
* Restructure Dependabot: add applies-to: security-updates groups for npm
  (root/backend/frontend), docker, and github-actions; switch github-actions
  to directories so the local composite action is monitored alongside the
  top-level workflows.
* Add a daily scheduled SARIF security scan (security-scan.yml): two parallel
  jobs scanning saelix/sencho:latest and a fresh main HEAD build, uploading to
  GitHub code scanning. Least-privilege (contents: read, security-events:
  write). Visibility only; existing PR-blocking and release-blocking Trivy
  gates are not weakened.

Validation: backend tsc clean; frontend tsc clean; frontend npm test 27 files
/ 241 tests pass; npm audit --audit-level=high passes at root, backend, and
frontend; docker buildx build --check passes with no warnings (all pinned
digests resolve from the registry).

* ci(frontend): set explicit jsdom URL so localStorage initializes in CI

jsdom does not instantiate window.localStorage / sessionStorage when the
document has the opaque about:blank origin. Five frontend test files that
call localStorage.clear() in beforeEach started failing once the new
frontend Vitest step in this PR began running them on Linux CI runners.

Configuring environmentOptions.jsdom.url with a real same-origin URL is
the documented Vitest 4.x workaround and is a config-only change. All 27
test files (241 tests) pass locally with the fix applied.
2026-05-23 02:37:29 -04:00

264 lines
13 KiB
YAML

name: Build and Publish Docker Image
on:
push:
tags:
- 'v*'
workflow_dispatch:
concurrency:
# Ref-scoped so two different release tags (e.g. v0.86.4 and v0.86.5) cannot
# cancel each other. Reruns of the exact same ref still cancel the prior
# run for that ref, which is the cancel behavior we actually want.
group: docker-publish-${{ github.ref }}
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@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- 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-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