name: Build and Publish Docker Image # Release-only path: fires on v* tag pushes and publishes the public release # (Docker Hub + GHCR, latest/semver/moving-minor, SBOM, cosign, GitHub Release). # Integration images for pre-release testing are built on every push to main by # docker-dev.yml and published as ghcr.io/studio-saelix/sencho-dev:dev; nothing # here runs for those. Keep release artifacts out of the dev path and vice versa. 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up QEMU uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4 with: platforms: arm64 - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # 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@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Log in to GitHub Container Registry uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # 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@dc802804100637a589fabce1cb79ff13a1411302 # 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@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # 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@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # 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 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: \ # --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@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2.3.3.0.2 with: files: | sbom.cdx.json sbom.spdx.json security/vex/sencho.openvex.json