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 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 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: 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: images: 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 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/AnsoCode/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. Verification # commands are documented in docs/operations/verifying-images.mdx. env: DIGEST: ${{ steps.build.outputs.digest }} run: | IMAGE_REF="saelix/sencho@${DIGEST}" 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}" - 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