feather-reader 0.4.7

A minimalist, atproto-native RSS/Atom reader in Rust — your feed subscriptions live in your own PDS.
Documentation
name: release-image

# Build the FeatherReader container image on a version tag, run the upgrade-boot
# gate on it, and only then push that exact image and sign SLSA build provenance
# (and, optionally, a CycloneDX SBOM) for its DIGEST. The last job dispatches
# release-crate.yml against the tag, so a failed gate publishes nothing anywhere.
# This does NOT deploy to Fly — that stays with the deploy runbook.
#
# Trigger: pushing a semver tag, e.g.  git tag v0.4.4 && git push origin v0.4.4
#
# Deploy contract: the attestations are bound to the image DIGEST. The runbook
# resolves the tag to that digest, runs `gh attestation verify` (fail-closed),
# then `fly deploy -i ghcr.io/...@<digest>`. Deploying a mutable tag defeats the
# guarantee. `:latest` is NOT only the newest release: metadata-action has no
# memory of earlier tags, and latest=auto emits :latest for ANY non-prerelease
# semver tag, so a hotfix on an older line pushed after a newer release moves
# :latest backward. Deploy by digest regardless.

on:
  push:
    tags:
      - "v*.*.*"
  workflow_dispatch:
    inputs:
      enable_sbom:
        description: "PART C: build with cargo-auditable + emit & attest a CycloneDX SBOM"
        type: boolean
        default: false

# Top-level default is intentionally narrowed to least privilege — checkout only.
# Any FUTURE job that forgets its own `permissions:` block inherits only this, so
# it can never accidentally push or sign. The publish + attestation scopes
# (packages:write / id-token:write / attestations:write) are granted explicitly at
# JOB level below, exactly where they are needed and nowhere else.
permissions:
  contents: read

env:
  IMAGE: ghcr.io/justin-stanley/feather-reader
  # PART C master toggle. Flip to '1' (or dispatch with enable_sbom=true) to build
  # the Rust binary with cargo-auditable AND generate + attest a CycloneDX SBOM.
  # Default '0' keeps the image a plain --release build with provenance only.
  ENABLE_SBOM: "0"

jobs:
  # Always-run visibility guard. The build job is gated to public repos, and a
  # skipped job leaves the run looking green — so on a private repo a pushed tag
  # would silently build/push NOTHING. This job runs unconditionally and emits a
  # loud warning annotation so the no-op is obvious in the run summary.
  gate:
    name: release gate
    runs-on: ubuntu-latest
    steps:
      - name: Report release gate
        run: |
          if [ "${{ github.event.repository.private }}" = "true" ]; then
            echo "::warning title=release-image skipped::Repo is PRIVATE — no image was built or pushed. Flip the repo public to enable tag releases."
          else
            echo "Repo is public — the build/push/attest job runs."
          fi

  build-push-attest:
    name: build, push, attest
    runs-on: ubuntu-latest
    if: ${{ github.event.repository.private == false }}
    # Least privilege — exactly the scopes the publish + attestation need:
    #   contents:read       checkout only (no tag/release writes)
    #   packages:write      push to GHCR
    #   id-token:write      OIDC token for keyless (Sigstore) attestation signing
    #   attestations:write  record the provenance/SBOM attestations on the image
    permissions:
      contents: read
      packages: write
      id-token: write
      attestations: write
    steps:
      # All actions are SHA-pinned (comment = the human-readable tag). Re-resolve
      # with `gh api repos/<owner>/<repo>/commits/<tag> --jq .sha` when bumping.
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

      - name: Resolve SBOM toggle
        id: toggle
        env:
          DISPATCH_SBOM: ${{ github.event.inputs.enable_sbom }}
        run: |
          if [ "${DISPATCH_SBOM}" = "true" ] || [ "${ENABLE_SBOM}" = "1" ]; then
            echo "enabled=1" >> "$GITHUB_OUTPUT"
          else
            echo "enabled=0" >> "$GITHUB_OUTPUT"
          fi

      - name: Set up Buildx
        uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1

      - name: Log in to GHCR
        uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Derive image tags + labels
        id: meta
        uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
        with:
          images: ${{ env.IMAGE }}
          # latest=auto emits :latest for EVERY non-prerelease semver tag:
          # metadata-action has no memory of earlier tags, so a hotfix on an older
          # line pushed after a newer release moves :latest backward. (Deploys
          # resolve a DIGEST — :latest is a convenience, never a deploy target.)
          flavor: |
            latest=auto
          tags: |
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=sha

      # --- Upgrade-boot gate: nothing is pushed until this passes -------------
      # Builds the image locally and runs it against a database the last good
      # release created (scripts/upgrade-boot.sh). 0.3.9 was pushed, published
      # and deployed with a schema bug that failed on every existing database;
      # this is the check that would have stopped it.
      #
      # **Built ONCE.** The step after the gate pushes this exact image, rather
      # than building again: a second build only matches the tested one if every
      # layer comes from the cache, and a cache eviction or a failed cache export
      # would publish — and attest — layers nothing tested.
      - name: Build the candidate for the upgrade-boot gate
        uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
        with:
          context: .
          load: true
          push: false
          tags: feather-reader:candidate
          labels: ${{ steps.meta.outputs.labels }}
          # PART C: pass the cargo-auditable build-arg through to the Dockerfile.
          build-args: |
            ENABLE_SBOM=${{ steps.toggle.outputs.enabled }}
          provenance: false
          sbom: false
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Upgrade-boot gate
        run: |
          prev="ghcr.io/justin-stanley/feather-reader:$(tr -d '[:space:]' < deploy/upgrade-from)"
          ./scripts/upgrade-boot.sh "$prev" feather-reader:candidate

      - name: Push the image the gate tested
        id: build
        env:
          TAGS: ${{ steps.meta.outputs.tags }}
        run: |
          set -euo pipefail
          while IFS= read -r tag; do
            [ -n "$tag" ] || continue
            docker tag feather-reader:candidate "$tag"
            docker push "$tag"
          done <<< "$TAGS"
          # Every tag is the same local image, so the registry digest is shared;
          # read it back rather than trusting push output formatting.
          digest="$(docker inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' feather-reader:candidate \
            | grep "^${IMAGE}@" | head -n1 | cut -d@ -f2)"
          case "$digest" in
            sha256:*) echo "digest=$digest" >> "$GITHUB_OUTPUT" ;;
            *) echo "::error::could not read the pushed digest back"; exit 1 ;;
          esac

      # --- SLSA build provenance for the pushed image DIGEST -----------------
      # Signs a provenance attestation bound to the exact digest, stored in the
      # GHCR registry alongside the image. Keyless (Sigstore) via the OIDC token.
      - name: Attest build provenance
        uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
        with:
          subject-name: ${{ env.IMAGE }}
          subject-digest: ${{ steps.build.outputs.digest }}
          push-to-registry: true

      # ======================================================================
      # PART C (TOGGLABLE) — CycloneDX SBOM + SBOM attestation.
      # Runs only when the SBOM toggle is on. The image was already built with
      # `cargo auditable` (ENABLE_SBOM build-arg) so the Rust dep graph is
      # embedded in the binary (readable later with `cargo audit bin`).
      # ======================================================================
      - name: Generate CycloneDX SBOM (PART C)
        if: steps.toggle.outputs.enabled == '1'
        id: sbom
        uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3
        with:
          image: ${{ env.IMAGE }}@${{ steps.build.outputs.digest }}
          format: cyclonedx-json
          output-file: sbom.cdx.json
          artifact-name: feather-reader-sbom.cdx.json

      - name: Attest SBOM against the image digest (PART C)
        if: steps.toggle.outputs.enabled == '1'
        uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
        with:
          subject-name: ${{ env.IMAGE }}
          subject-digest: ${{ steps.build.outputs.digest }}
          sbom-path: sbom.cdx.json
          push-to-registry: true

      # --- Verification note (no deploy) ------------------------------------
      # Deployment is intentionally NOT automated here — it stays in the deploy
      # runbook and MUST deploy the DIGEST after verifying its attestation:
      #
      #   DIGEST="${{ steps.build.outputs.digest }}"
      #   gh attestation verify \
      #     oci://ghcr.io/justin-stanley/feather-reader@${DIGEST} \
      #     --repo justin-stanley/feather-reader        # fail-closed gate
      #   fly deploy -i ghcr.io/justin-stanley/feather-reader@${DIGEST}
      #
      # (Add `--predicate-type https://cyclonedx.org/bom` to check the SBOM
      # attestation specifically when PART C is enabled.)
      - name: Verification note
        run: |
          echo "Image (deploy THIS, by digest): ${IMAGE}@${{ steps.build.outputs.digest }}"
          echo "Verify: gh attestation verify oci://${IMAGE}@${{ steps.build.outputs.digest }} --repo ${{ github.repository }}"
          echo "Then:   fly deploy -i ${IMAGE}@${{ steps.build.outputs.digest }}"

  # Publish the crate only once this image passed the gate and was pushed. A
  # dispatch, not a `workflow_run` trigger in release-crate.yml: crates.io
  # Trusted Publishing refuses `workflow_run`. GITHUB_TOKEN may dispatch a
  # workflow (the one event it is allowed to start), with `actions: write`.
  dispatch-crate:
    name: dispatch the crate release
    needs: build-push-attest
    if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
    runs-on: ubuntu-latest
    permissions:
      actions: write
    steps:
      - name: Dispatch release-crate.yml against this tag
        env:
          GH_TOKEN: ${{ github.token }}
        run: gh workflow run release-crate.yml --repo "$GITHUB_REPOSITORY" --ref "$GITHUB_REF_NAME"