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: read # Required for cosign keyless signing via GitHub OIDC. id-token: write steps: - name: Check out the repo uses: actions/checkout@v6 - name: Set up QEMU uses: docker/setup-qemu-action@v4 with: platforms: arm64 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Log in to Docker Hub uses: docker/login-action@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@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@v7 with: context: . push: false load: true platforms: linux/amd64 tags: localhost/sencho:release-scan cache-from: type=registry,ref=saelix/sencho:buildcache - name: Re-scan release image for vulnerabilities (Trivy) # Gates the release on the same HIGH/CRITICAL policy as the PR scan. # Entries in .trivyignore at the repo root are honored so the acknowledged # CVE list is the single source of truth across PR CI and release CI. uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 with: image-ref: localhost/sencho:release-scan exit-code: '1' severity: 'CRITICAL,HIGH' format: 'table' - name: Build and push Docker image id: build uses: docker/build-push-action@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 # 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 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