No description
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Shawn McNaughton 49dd9660af Plan pinning and Renovate (TODO item 2)
Phase 1 replaces the docker/* actions with shell and pins what's left;
phase 2 sets up Renovate to keep the pins current.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:14:32 -04:00
.forgejo/workflows Describe the runner's Docker daemon accurately; update the TODO list 2026-10-12 23:15:16 -04:00
cosign.pub Add the image signing public key 2026-10-12 23:00:01 -04:00
README.md Describe the runner's Docker daemon accurately; update the TODO list 2026-10-12 23:15:16 -04:00
TODO.md Plan pinning and Renovate (TODO item 2) 2026-10-13 00:14:32 -04:00

hitoma-image-ci

The shared build pipeline for the hitoma-* image repos, as a Forgejo Actions reusable workflow. Each run:

  1. builds the image and loads it into the runner's Docker daemon, without pushing it
  2. runs the calling repo's test/verify-image.sh IMAGE, then test/smoke.sh IMAGE
  3. scans the image with Trivy and generates its SBOM
  4. pushes this build's tags (not on pull requests)
  5. signs the pushed image with cosign, and attaches the SBOM and the scan as signed attestations (not on pull requests)
  6. force-pushes a vulnerability badge, the full report and the SBOM to the calling repo's badges branch (main only)
  7. fails the run if there are fixable HIGH or CRITICAL findings

A failed build or test stops the run before anything is pushed. A failed scan doesn't block the push: the image is still published, and the red run shows a package needs patching.

This repo must stay public: Forgejo only runs reusable workflows from public repositories.

Using it

The calling repo keeps its triggers and resolves its own upstream version, then hands off:

jobs:
  upstream:
    runs-on: docker
    outputs:
      version: ${{ steps.upstream.outputs.version }}
    steps: [...]

  image:
    needs: upstream
    uses: root/hitoma-image-ci/.forgejo/workflows/image.yml@v1
    with:
      version: ${{ needs.upstream.outputs.version }}
      build-args: |
        FOO_VERSION=${{ needs.upstream.outputs.version }}
    secrets: inherit

with: can't read the env context, so pass any values from env through job outputs.

Input Default Purpose
version blank Adds a version tag and the org.opencontainers.image.version label. Leave it blank if there's no upstream version.
extra-tags blank More metadata-action tag rules, one per line, such as a series or major-version tag
build-args blank KEY=value lines. CREATED (the image creation time) is always added.

Tags on main: latest, the version, any extra tags, YYYYMMDD, and sha-<short>. Pull requests only get sha-<short>, and nothing is pushed.

The calling repo needs:

  • test/verify-image.sh and test/smoke.sh, each taking the image reference as its only argument. Both are required.
  • Repository secrets REGISTRY_USER and REGISTRY_TOKEN (a Forgejo access token with write:package scope).
  • A runner with the docker label whose job containers can reach a Docker daemon, such as a docker:dind sidecar through DOCKER_HOST. The smoke tests start their own containers on that daemon.

Signing

Every pushed image is signed with the key pair whose public half is cosign.pub. Two signed attestations go with it: the CycloneDX SBOM (cyclonedx) and the Trivy scan (vuln). Nothing is uploaded to Sigstore's public transparency log (Rekor), so verification needs --insecure-ignore-tlog. The signature is checked against the public key alone.

IMAGE=forgejo.hitoma.com/root/hitoma-caddy:latest

# Signature
cosign verify --key cosign.pub --insecure-ignore-tlog "$IMAGE"

# SBOM: the attestation's payload is base64; .predicate is the CycloneDX document
cosign verify-attestation --key cosign.pub --insecure-ignore-tlog --type cyclonedx "$IMAGE" \
  | jq -r .payload | base64 -d | jq .predicate

# Scan results from when the image was built
cosign verify-attestation --key cosign.pub --insecure-ignore-tlog --type vuln "$IMAGE" \
  | jq -r .payload | base64 -d | jq .predicate.scanner.result

The registry has no OCI referrers API, so cosign keeps all three in one tag next to the image: sha256-<image digest>.

The pipeline signs only when COSIGN_PRIVATE_KEY and COSIGN_PASSWORD are available. Set them once as user-level Actions secrets of root (Settings → Actions → Secrets), and every repo gets them through secrets: inherit. Without them, the run warns and pushes the image unsigned.

To replace the key, generate a new pair, update both secrets and cosign.pub, and move the v1 tag. Images signed with the old key keep their old signatures until they're rebuilt, which happens weekly.

Registry cleanup

Every build of main pushes a new sha-<short> tag and a date tag. One Forgejo package cleanup rule for the root owner removes them once they're old enough. Configure it under Settings → Packages → Cleanup rules:

Setting Value
Enabled yes
Type container
Apply pattern to full package name no
Keep the most recent 10
Keep versions matching buildcache
Remove versions older than 30 days
Remove versions matching sha-[0-9a-f]+|[0-9]{8}|sha256-[0-9a-f]{64}

What stays:

  • Always kept: latest (Forgejo never removes it), buildcache, and the upstream version tags (2026.8.3, v0.30.2, 15 and so on), which don't match the remove pattern.
  • Removed: sha-*, YYYYMMDD and signature (sha256-<digest>) tags more than 30 days old, unless they're among a package's 10 newest versions. Every live tag is rebuilt and re-signed weekly, so only signatures of images that have been replaced reach that age. If rebuilds stop for more than 30 days, the current image's signature can be removed too.
  • Digests: untagged sha256: versions are never removed by the rule. These are the image and attestation manifests behind the indexes the old buildx pipelines pushed. Forgejo's own cleanup job deletes them once no remaining tag references them. Unreferenced blobs go the same way, which is what frees the disk space.

A date tag points at that day's last build. An earlier build from the same day keeps only its sha-* tag.

Use the rule's preview to check what it would delete before enabling it.

Versions

Callers pin @v1. Changes that don't break callers move the v1 tag forward:

git tag -f v1 && git push -f origin v1

A change that needs callers to change (a new required input, a different test convention) gets v2, and callers move over one at a time.