From 36a40662e23f1cc89f1357924edfa3a3ec4d22aa Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Mon, 27 Jul 2026 09:20:06 +0200 Subject: [PATCH 1/6] Build hardened Postgres runtime image --- .github/workflows/ci.yml | 111 ++++++++++++++++++ .github/workflows/releaser.yml | 44 +++++++ Dockerfile | 40 +++++++ builder.go | 9 +- main.go | 15 +-- main_test.go | 10 ++ .../kustomize/base/stateful-set.yaml.tmpl | 4 +- 7 files changed, 214 insertions(+), 19 deletions(-) create mode 100644 Dockerfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1930fb4..dad7f8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,117 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + packages: write + jobs: ci: + needs: image uses: codefly-dev/core/.github/workflows/go-service-ci.yml@main + + image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + + - name: Resolve runtime image tag + id: image + run: echo "tag=runtime-$(sha256sum Dockerfile | cut -c1-16)" >> "$GITHUB_OUTPUT" + + - name: Build runtime image + run: docker build --tag service-postgres:test . + + - name: Smoke test runtime image + shell: bash + run: | + set -euo pipefail + + cleanup() { + docker rm -f service-postgres-root service-postgres-nonroot >/dev/null 2>&1 || true + } + trap cleanup EXIT + + smoke() { + local container_name="$1" + shift + docker run --detach --name "$container_name" "$@" \ + --env POSTGRES_PASSWORD=test service-postgres:test >/dev/null + + local ready=false + for _ in $(seq 1 60); do + local state + local ready_count + state=$(docker inspect --format '{{.State.Status}}' "$container_name") + ready_count=$(docker logs "$container_name" 2>&1 | + grep -c 'database system is ready to accept connections' || true) + if [[ "$ready_count" -ge 2 ]] && + docker exec "$container_name" pg_isready -U postgres >/dev/null 2>&1; then + ready=true + break + fi + if [[ "$state" != running ]]; then + break + fi + sleep 1 + done + + if [[ "$ready" != true ]]; then + docker logs "$container_name" + return 1 + fi + + docker exec "$container_name" psql \ + -v ON_ERROR_STOP=1 -U postgres -d postgres \ + -c "CREATE EXTENSION vector" \ + -c "SELECT round(('[1,2,3]'::vector <-> '[4,5,6]'::vector)::numeric, 6)" + docker rm -f "$container_name" >/dev/null + } + + smoke service-postgres-root + smoke service-postgres-nonroot \ + --user 70:70 \ + --tmpfs /var/lib/postgresql/data:uid=70,gid=70 + + - name: Scan runtime image + run: | + docker save --output /tmp/service-postgres-image.tar service-postgres:test + mkdir -p /tmp/trivy-cache + docker run --rm \ + --volume /tmp/trivy-cache:/root/.cache/trivy \ + --volume /tmp/service-postgres-image.tar:/image.tar:ro \ + aquasec/trivy@sha256:cffe3f5161a47a6823fbd23d985795b3ed72a4c806da4c4df16266c02accdd6f \ + image --quiet --format table --severity HIGH,CRITICAL \ + --exit-code 1 --input /image.tar + + - name: Log in to GitHub Container Registry + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 + + - if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f + + - name: Publish runtime image for agent tests + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ghcr.io/codefly-dev/service-postgres:${{ steps.image.outputs.tag }} + + - name: Make runtime image public + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api --method PATCH \ + /orgs/codefly-dev/packages/container/service-postgres \ + --field visibility=public diff --git a/.github/workflows/releaser.yml b/.github/workflows/releaser.yml index a40edfb..0085bac 100644 --- a/.github/workflows/releaser.yml +++ b/.github/workflows/releaser.yml @@ -5,7 +5,51 @@ on: tags: - 'v*' +permissions: + contents: write + id-token: write + packages: write + jobs: + image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + + - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 + + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f + + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve runtime image tag + id: image + run: echo "tag=runtime-$(sha256sum Dockerfile | cut -c1-16)" >> "$GITHUB_OUTPUT" + + - name: Publish runtime image + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ghcr.io/codefly-dev/service-postgres:${{ github.ref_name }} + ghcr.io/codefly-dev/service-postgres:postgres-17.10-pgvector-0.8.5-alpine3.24 + ghcr.io/codefly-dev/service-postgres:${{ steps.image.outputs.tag }} + + - name: Make runtime image public + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api --method PATCH \ + /orgs/codefly-dev/packages/container/service-postgres \ + --field visibility=public + release: + needs: image uses: codefly-dev/core/.github/workflows/go-service-release.yml@main secrets: inherit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..66748bb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +# syntax=docker/dockerfile:1.7 + +ARG POSTGRES_IMAGE=postgres:17.10-alpine3.24@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193 + +FROM ${POSTGRES_IMAGE} AS pgvector-builder + +ARG PGVECTOR_VERSION=0.8.5 +ARG PGVECTOR_SHA256=6f88a5cbdde31666f4b6c1a6b75c51dcbeffe58f9a7d2b26e502d5a6e5e14d44 + +RUN apk add --no-cache build-base clang21 llvm21-dev +ADD --checksum=sha256:${PGVECTOR_SHA256} \ + https://github.com/pgvector/pgvector/archive/refs/tags/v${PGVECTOR_VERSION}.tar.gz \ + /tmp/pgvector.tar.gz +RUN mkdir /tmp/pgvector /tmp/pgvector-install && \ + tar -xzf /tmp/pgvector.tar.gz -C /tmp/pgvector --strip-components=1 && \ + make -C /tmp/pgvector OPTFLAGS="" && \ + make -C /tmp/pgvector DESTDIR=/tmp/pgvector-install install + +FROM ${POSTGRES_IMAGE} AS runtime + +RUN apk add --no-cache su-exec && \ + cp /sbin/su-exec /usr/local/bin/gosu +COPY --from=pgvector-builder /tmp/pgvector-install/ / + +FROM scratch + +COPY --from=runtime / / + +LABEL org.opencontainers.image.source="https://github.com/codefly-dev/service-postgres" + +ENV LANG=en_US.utf8 +ENV PG_MAJOR=17 +ENV PG_VERSION=17.10 +ENV PGDATA=/var/lib/postgresql/data + +VOLUME ["/var/lib/postgresql/data"] +ENTRYPOINT ["docker-entrypoint.sh"] +STOPSIGNAL SIGINT +EXPOSE 5432 +CMD ["postgres"] diff --git a/builder.go b/builder.go index 03f886f..aa03a02 100644 --- a/builder.go +++ b/builder.go @@ -67,9 +67,7 @@ func (s *Builder) Sync(ctx context.Context, req *builderv0.SyncRequest) (*builde return s.Builder.SyncResponse() } -// Audit scans the postgres image for known CVEs (HIGH/CRITICAL) via -// trivy. The image tag comes from the package-level `image` var -// (postgres:16.1-alpine by default). +// Audit scans the configured postgres image for known HIGH/CRITICAL CVEs. func (s *Builder) Audit(ctx context.Context, req *builderv0.AuditRequest) (*builderv0.AuditResponse, error) { defer s.Wool.Catch() ctx = s.Wool.Inject(ctx) @@ -82,10 +80,7 @@ func (s *Builder) SBOM(ctx context.Context, _ *builderv0.SBOMRequest) (*builderv return s.Builder.SBOMContainer(ctx, s.dockerImage().FullName()) } -// Upgrade reports a tag bump from the current postgres image (e.g. -// 16.1-alpine → 16.4 within major 16; or 17.0 if --major). Persisting -// the new tag is left to the caller — postgres has no lockfile to -// rewrite, the image var lives in the agent code. +// Upgrade reports an available tag bump for the managed postgres image. func (s *Builder) Upgrade(ctx context.Context, req *builderv0.UpgradeRequest) (*builderv0.UpgradeResponse, error) { defer s.Wool.Catch() ctx = s.Wool.Inject(ctx) diff --git a/main.go b/main.go index dde404c..7ac2345 100644 --- a/main.go +++ b/main.go @@ -98,17 +98,12 @@ type MigrationSource struct { const HotReload = "hot-reload" const DatabaseName = "database-name" -// pgvector/pgvector:pg17 is the official Postgres 17 image with the pgvector -// extension preinstalled (same docker-entrypoint as the stock postgres image, -// so the full contrib set — pgcrypto, uuid-ossp, pg_trgm, citext, btree_gin, … -// — is available too). Required so `CREATE EXTENSION vector` works — consumers -// like Mind's knowledge-graph migration (vector(1024) column) depend on it. The -// nix runtime gets pgvector via nix/flake.nix; this keeps both runtimes at -// parity. Override per-service via Settings.DockerImage (e.g. for PostGIS). +// The managed image adds pgvector to the official Postgres 17 Alpine image +// while preserving its entrypoint and contrib extensions. The nix runtime gets +// pgvector via nix/flake.nix, keeping both runtimes at parity. var image = &resources.DockerImage{ - Name: "pgvector/pgvector", - Tag: "pg17", - Digest: "sha256:d2ef61f42ef767baa5a1475393303cc235bcd92febd9d7014eddb48b41f3bad0", + Name: "ghcr.io/codefly-dev/service-postgres", + Tag: "runtime-9ae3b680a1cbf2d3", } type DeploymentTemplateParameters struct { diff --git a/main_test.go b/main_test.go index 507cbca..45f4c36 100644 --- a/main_test.go +++ b/main_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "crypto/sha256" "fmt" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" @@ -25,6 +26,15 @@ import ( // TODO: Add tests // - migrations: up/down +func TestDefaultImageUsesHardenedRuntime(t *testing.T) { + dockerfile, err := os.ReadFile("Dockerfile") + require.NoError(t, err) + sum := sha256.Sum256(dockerfile) + require.Equal(t, fmt.Sprintf( + "ghcr.io/codefly-dev/service-postgres:runtime-%x", sum[:8], + ), image.FullName()) +} + // TestCreateToRunDocker runs the full agent lifecycle against the explicitly // selected container backend. Using free here would only test backend // auto-selection and could silently fall back to Nix. diff --git a/templates/deployment/kustomize/base/stateful-set.yaml.tmpl b/templates/deployment/kustomize/base/stateful-set.yaml.tmpl index 4f5d2d7..d161648 100644 --- a/templates/deployment/kustomize/base/stateful-set.yaml.tmpl +++ b/templates/deployment/kustomize/base/stateful-set.yaml.tmpl @@ -33,8 +33,8 @@ spec: - name: postgres # Supplied by main.go's managed-image declaration (or the service # override), including its digest when one is configured. - # pgvector/pgvector is the official Postgres image with pgvector - # preinstalled (same entrypoint env: POSTGRES_USER / + # The managed image is the official Alpine Postgres image with + # pgvector preinstalled (same entrypoint env: POSTGRES_USER / # POSTGRES_PASSWORD / POSTGRES_DB at first-boot); after that the data # dir is authoritative. image: {{ .Deployment.Parameters.ManagedImage }} From 75a2d6894f5d43ce54d69a939cfd19f581887349 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Mon, 27 Jul 2026 09:29:15 +0200 Subject: [PATCH 2/6] Pin published runtime image --- .github/workflows/ci.yml | 9 --------- .github/workflows/releaser.yml | 8 -------- main.go | 5 +++-- main_test.go | 6 +++++- 4 files changed, 8 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dad7f8f..10b7be9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,12 +115,3 @@ jobs: platforms: linux/amd64,linux/arm64 push: true tags: ghcr.io/codefly-dev/service-postgres:${{ steps.image.outputs.tag }} - - - name: Make runtime image public - if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh api --method PATCH \ - /orgs/codefly-dev/packages/container/service-postgres \ - --field visibility=public diff --git a/.github/workflows/releaser.yml b/.github/workflows/releaser.yml index 0085bac..8aa8304 100644 --- a/.github/workflows/releaser.yml +++ b/.github/workflows/releaser.yml @@ -41,14 +41,6 @@ jobs: ghcr.io/codefly-dev/service-postgres:postgres-17.10-pgvector-0.8.5-alpine3.24 ghcr.io/codefly-dev/service-postgres:${{ steps.image.outputs.tag }} - - name: Make runtime image public - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh api --method PATCH \ - /orgs/codefly-dev/packages/container/service-postgres \ - --field visibility=public - release: needs: image uses: codefly-dev/core/.github/workflows/go-service-release.yml@main diff --git a/main.go b/main.go index 7ac2345..7b8d5c3 100644 --- a/main.go +++ b/main.go @@ -102,8 +102,9 @@ const DatabaseName = "database-name" // while preserving its entrypoint and contrib extensions. The nix runtime gets // pgvector via nix/flake.nix, keeping both runtimes at parity. var image = &resources.DockerImage{ - Name: "ghcr.io/codefly-dev/service-postgres", - Tag: "runtime-9ae3b680a1cbf2d3", + Name: "ghcr.io/codefly-dev/service-postgres", + Tag: "runtime-9ae3b680a1cbf2d3", + Digest: "sha256:22499f49815f37adc9118b9028fffba71b49e64bf293cbf08b445042e55d8b65", } type DeploymentTemplateParameters struct { diff --git a/main_test.go b/main_test.go index 45f4c36..1f659c2 100644 --- a/main_test.go +++ b/main_test.go @@ -32,7 +32,11 @@ func TestDefaultImageUsesHardenedRuntime(t *testing.T) { sum := sha256.Sum256(dockerfile) require.Equal(t, fmt.Sprintf( "ghcr.io/codefly-dev/service-postgres:runtime-%x", sum[:8], - ), image.FullName()) + ), image.Name+":"+image.Tag) + require.Equal(t, + "sha256:22499f49815f37adc9118b9028fffba71b49e64bf293cbf08b445042e55d8b65", + image.Digest, + ) } // TestCreateToRunDocker runs the full agent lifecycle against the explicitly From bf848f3d22c067f001da0c2e8d3782a945ced077 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Mon, 27 Jul 2026 09:36:38 +0200 Subject: [PATCH 3/6] Run lifecycle test against runtime image --- .github/workflows/ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10b7be9..5064735 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,3 +115,13 @@ jobs: platforms: linux/amd64,linux/arm64 push: true tags: ghcr.io/codefly-dev/service-postgres:${{ steps.image.outputs.tag }} + + - name: Set up Go + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff + with: + go-version-file: go.mod + + - name: Test agent lifecycle against published image + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + run: go test ./... From bb98020cc17448cb2043330676d8fb697c8cf5b8 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Mon, 27 Jul 2026 09:44:27 +0200 Subject: [PATCH 4/6] Run unit tests in image workflow --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5064735..3cb1540 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,8 +120,9 @@ jobs: if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff with: + cache: false go-version-file: go.mod - - name: Test agent lifecycle against published image + - name: Run unit tests if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository - run: go test ./... + run: go test ./... -skip '^TestCreateToRunDocker$' From ac081ecfb6c48e7f52aeedec2aaf160565a8dc1b Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Mon, 27 Jul 2026 09:55:06 +0200 Subject: [PATCH 5/6] Keep runtime image digest stable --- .github/workflows/ci.yml | 18 +++++++++++++++--- .github/workflows/releaser.yml | 5 ----- main.go | 2 +- main_test.go | 2 +- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cb1540..ca0e5cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,14 +101,26 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + - name: Check for published runtime image + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + id: published + env: + RUNTIME_IMAGE: ghcr.io/codefly-dev/service-postgres:${{ steps.image.outputs.tag }} + run: | + if docker manifest inspect "$RUNTIME_IMAGE" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - if: (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) && steps.published.outputs.exists != 'true' uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 - - if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + - if: (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) && steps.published.outputs.exists != 'true' uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f - name: Publish runtime image for agent tests - if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + if: (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) && steps.published.outputs.exists != 'true' uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 with: context: . diff --git a/.github/workflows/releaser.yml b/.github/workflows/releaser.yml index e72cbe4..7ebbf22 100644 --- a/.github/workflows/releaser.yml +++ b/.github/workflows/releaser.yml @@ -33,10 +33,6 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Resolve runtime image tag - id: image - run: echo "tag=runtime-$(sha256sum Dockerfile | cut -c1-16)" >> "$GITHUB_OUTPUT" - - name: Publish runtime image uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 with: @@ -46,7 +42,6 @@ jobs: tags: | ghcr.io/codefly-dev/service-postgres:${{ github.ref_name }} ghcr.io/codefly-dev/service-postgres:postgres-17.10-pgvector-0.8.5-alpine3.24 - ghcr.io/codefly-dev/service-postgres:${{ steps.image.outputs.tag }} release: if: github.event_name == 'push' diff --git a/main.go b/main.go index 7b8d5c3..d45bdbe 100644 --- a/main.go +++ b/main.go @@ -104,7 +104,7 @@ const DatabaseName = "database-name" var image = &resources.DockerImage{ Name: "ghcr.io/codefly-dev/service-postgres", Tag: "runtime-9ae3b680a1cbf2d3", - Digest: "sha256:22499f49815f37adc9118b9028fffba71b49e64bf293cbf08b445042e55d8b65", + Digest: "sha256:a5bb05518fd2f054884282f389577028c6304337bcf9d65363810ef1ad9e8c6c", } type DeploymentTemplateParameters struct { diff --git a/main_test.go b/main_test.go index 1f659c2..8f4dddb 100644 --- a/main_test.go +++ b/main_test.go @@ -34,7 +34,7 @@ func TestDefaultImageUsesHardenedRuntime(t *testing.T) { "ghcr.io/codefly-dev/service-postgres:runtime-%x", sum[:8], ), image.Name+":"+image.Tag) require.Equal(t, - "sha256:22499f49815f37adc9118b9028fffba71b49e64bf293cbf08b445042e55d8b65", + "sha256:a5bb05518fd2f054884282f389577028c6304337bcf9d65363810ef1ad9e8c6c", image.Digest, ) } From 6228103860612a06e5466267fc8a050986d2f3d2 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Tue, 28 Jul 2026 16:21:33 +0200 Subject: [PATCH 6/6] Make runtime image publication verifiable --- .github/workflows/ci.yml | 133 +++++++++++++++++--------- .github/workflows/releaser.yml | 55 +++++++---- Dockerfile | 18 ++-- main.go | 42 ++++++++- main_test.go | 14 --- runtime-image.json | 5 + runtime_image_test.go | 38 ++++++++ workflow_test.go | 165 +++++++++++++++++++++++++++++++++ 8 files changed, 383 insertions(+), 87 deletions(-) create mode 100644 runtime-image.json create mode 100644 runtime_image_test.go create mode 100644 workflow_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca0e5cf..0e6d91c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,24 +12,47 @@ concurrency: permissions: contents: read - packages: write jobs: ci: needs: image - uses: codefly-dev/core/.github/workflows/go-service-ci.yml@main + permissions: + contents: read + uses: codefly-dev/core/.github/workflows/go-service-ci.yml@25e267bc5b7e346ef6b8439c9a032f115c4795a6 image: + permissions: + contents: read + packages: write runs-on: ubuntu-latest steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - - name: Resolve runtime image tag - id: image - run: echo "tag=runtime-$(sha256sum Dockerfile | cut -c1-16)" >> "$GITHUB_OUTPUT" + - name: Resolve runtime image + id: runtime + shell: bash + run: | + set -euo pipefail + name=$(jq -er '.name' runtime-image.json) + tag=$(jq -er '.tag' runtime-image.json) + digest=$(jq -er '.digest' runtime-image.json) + echo "name=$name" >> "$GITHUB_OUTPUT" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "digest=$digest" >> "$GITHUB_OUTPUT" + echo "reference=$name@$digest" >> "$GITHUB_OUTPUT" + echo "tag_reference=$name:$tag" >> "$GITHUB_OUTPUT" + + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff + with: + cache: false + go-version-file: go.mod + + - name: Run unit tests + run: go test ./... -skip '^TestCreateToRunDocker$' - name: Build runtime image - run: docker build --tag service-postgres:test . + run: docker build --build-arg SOURCE_DATE_EPOCH=0 --tag service-postgres:test . - name: Smoke test runtime image shell: bash @@ -82,17 +105,6 @@ jobs: --user 70:70 \ --tmpfs /var/lib/postgresql/data:uid=70,gid=70 - - name: Scan runtime image - run: | - docker save --output /tmp/service-postgres-image.tar service-postgres:test - mkdir -p /tmp/trivy-cache - docker run --rm \ - --volume /tmp/trivy-cache:/root/.cache/trivy \ - --volume /tmp/service-postgres-image.tar:/image.tar:ro \ - aquasec/trivy@sha256:cffe3f5161a47a6823fbd23d985795b3ed72a4c806da4c4df16266c02accdd6f \ - image --quiet --format table --severity HIGH,CRITICAL \ - --exit-code 1 --input /image.tar - - name: Log in to GitHub Container Registry if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 @@ -101,40 +113,77 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Check for published runtime image - if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository - id: published - env: - RUNTIME_IMAGE: ghcr.io/codefly-dev/service-postgres:${{ steps.image.outputs.tag }} - run: | - if docker manifest inspect "$RUNTIME_IMAGE" >/dev/null 2>&1; then - echo "exists=true" >> "$GITHUB_OUTPUT" - else - echo "exists=false" >> "$GITHUB_OUTPUT" - fi - - - if: (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) && steps.published.outputs.exists != 'true' + - if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 - - if: (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) && steps.published.outputs.exists != 'true' + - if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f + with: + driver-opts: image=moby/buildkit@sha256:2f5adac4ecd194d9f8c10b7b5d7bceb5186853db1b26e5abd3a657af0b7e26ec - - name: Publish runtime image for agent tests - if: (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) && steps.published.outputs.exists != 'true' + - name: Publish runtime image candidate + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + id: candidate uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 with: + build-args: SOURCE_DATE_EPOCH=0 context: . + outputs: type=image,name=${{ steps.runtime.outputs.name }},push-by-digest=true,name-canonical=true,push=true,rewrite-timestamp=true platforms: linux/amd64,linux/arm64 - push: true - tags: ghcr.io/codefly-dev/service-postgres:${{ steps.image.outputs.tag }} + provenance: false + sbom: false - - name: Set up Go + - name: Verify runtime image candidate if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff - with: - cache: false - go-version-file: go.mod + env: + ACTUAL_DIGEST: ${{ steps.candidate.outputs.digest }} + EXPECTED_DIGEST: ${{ steps.runtime.outputs.digest }} + run: | + set -euo pipefail + if [[ "$ACTUAL_DIGEST" != "$EXPECTED_DIGEST" ]]; then + echo "built digest $ACTUAL_DIGEST does not match runtime-image.json $EXPECTED_DIGEST" >&2 + exit 1 + fi - - name: Run unit tests + - name: Verify published runtime image + env: + RUNTIME_IMAGE: ${{ steps.runtime.outputs.reference }} + run: | + set -euo pipefail + anonymous_docker_config=$(mktemp -d) + trap 'rm -rf -- "$anonymous_docker_config"' EXIT + manifest=$(docker --config "$anonymous_docker_config" manifest inspect "$RUNTIME_IMAGE") + jq -e ' + [.manifests[].platform | "\(.os)/\(.architecture)"] | sort == + ["linux/amd64", "linux/arm64"] + ' <<< "$manifest" + docker --config "$anonymous_docker_config" pull "$RUNTIME_IMAGE" + + - name: Scan published runtime image + env: + RUNTIME_IMAGE: ${{ steps.runtime.outputs.reference }} + run: | + docker save --output /tmp/service-postgres-image.tar "$RUNTIME_IMAGE" + mkdir -p /tmp/trivy-cache + docker run --rm \ + --volume /tmp/trivy-cache:/root/.cache/trivy \ + --volume /tmp/service-postgres-image.tar:/image.tar:ro \ + aquasec/trivy@sha256:cffe3f5161a47a6823fbd23d985795b3ed72a4c806da4c4df16266c02accdd6f \ + image --quiet --format table --severity HIGH,CRITICAL \ + --exit-code 1 --input /image.tar + + - name: Tag verified runtime image if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository - run: go test ./... -skip '^TestCreateToRunDocker$' + env: + EXPECTED_DIGEST: ${{ steps.runtime.outputs.digest }} + RUNTIME_IMAGE: ${{ steps.runtime.outputs.reference }} + RUNTIME_TAG: ${{ steps.runtime.outputs.tag_reference }} + run: | + set -euo pipefail + docker buildx imagetools create --tag "$RUNTIME_TAG" "$RUNTIME_IMAGE" + tagged_digest=$(docker buildx imagetools inspect "$RUNTIME_TAG" | + awk '$1 == "Digest:" { print $2; exit }') + if [[ "$tagged_digest" != "$EXPECTED_DIGEST" ]]; then + echo "tagged digest $tagged_digest does not match $EXPECTED_DIGEST" >&2 + exit 1 + fi diff --git a/.github/workflows/releaser.yml b/.github/workflows/releaser.yml index 7ebbf22..cb1fa10 100644 --- a/.github/workflows/releaser.yml +++ b/.github/workflows/releaser.yml @@ -12,20 +12,21 @@ on: type: string permissions: - contents: write - id-token: write - packages: write + contents: read jobs: image: if: github.event_name == 'push' + permissions: + contents: read + packages: write runs-on: ubuntu-latest steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 - - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f + with: + driver-opts: image=moby/buildkit@sha256:2f5adac4ecd194d9f8c10b7b5d7bceb5186853db1b26e5abd3a657af0b7e26ec - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 with: @@ -33,29 +34,45 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Publish runtime image - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: | - ghcr.io/codefly-dev/service-postgres:${{ github.ref_name }} - ghcr.io/codefly-dev/service-postgres:postgres-17.10-pgvector-0.8.5-alpine3.24 + - name: Publish release image tags + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + name=$(jq -er '.name' runtime-image.json) + tag=$(jq -er '.tag' runtime-image.json) + digest=$(jq -er '.digest' runtime-image.json) + RUNTIME_IMAGE="$name@$digest" + docker buildx imagetools create \ + --tag "$name:$RELEASE_TAG" \ + --tag "$name:$tag" \ + "$RUNTIME_IMAGE" + for published_tag in "$name:$RELEASE_TAG" "$name:$tag"; do + published_digest=$(docker buildx imagetools inspect "$published_tag" | + awk '$1 == "Digest:" { print $2; exit }') + if [[ "$published_digest" != "$digest" ]]; then + echo "$published_tag resolves to $published_digest, want $digest" >&2 + exit 1 + fi + done release: if: github.event_name == 'push' needs: image - uses: codefly-dev/core/.github/workflows/go-service-release.yml@main + permissions: + contents: read + uses: codefly-dev/core/.github/workflows/go-service-release.yml@25e267bc5b7e346ef6b8439c9a032f115c4795a6 secrets: inherit backfill: if: github.event_name == 'workflow_dispatch' + permissions: + contents: read runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - name: Check out release source - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 with: fetch-depth: 0 ref: ${{ inputs.tag }} @@ -64,7 +81,7 @@ jobs: env: RELEASE_TAG: ${{ inputs.tag }} run: .github/scripts/release-backfill.sh validate-tag release-source - - uses: actions/setup-go@v5 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff with: go-version-file: release-source/go.mod cache-dependency-path: release-source/go.sum @@ -80,7 +97,7 @@ jobs: env: GOFLAGS: -modfile=${{ runner.temp }}/release.mod - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a with: version: '~> v2' args: release --clean --skip=publish,announce diff --git a/Dockerfile b/Dockerfile index 66748bb..5d55fb1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,17 @@ -# syntax=docker/dockerfile:1.7 +# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e ARG POSTGRES_IMAGE=postgres:17.10-alpine3.24@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193 +ARG SOURCE_DATE_EPOCH=0 FROM ${POSTGRES_IMAGE} AS pgvector-builder ARG PGVECTOR_VERSION=0.8.5 ARG PGVECTOR_SHA256=6f88a5cbdde31666f4b6c1a6b75c51dcbeffe58f9a7d2b26e502d5a6e5e14d44 -RUN apk add --no-cache build-base clang21 llvm21-dev +RUN apk add --no-cache \ + build-base=0.5-r4 \ + clang21=21.1.8-r3 \ + llvm21-dev=21.1.8-r1 ADD --checksum=sha256:${PGVECTOR_SHA256} \ https://github.com/pgvector/pgvector/archive/refs/tags/v${PGVECTOR_VERSION}.tar.gz \ /tmp/pgvector.tar.gz @@ -18,14 +22,12 @@ RUN mkdir /tmp/pgvector /tmp/pgvector-install && \ FROM ${POSTGRES_IMAGE} AS runtime -RUN apk add --no-cache su-exec && \ - cp /sbin/su-exec /usr/local/bin/gosu +RUN rm /usr/local/bin/gosu +RUN apk add --no-cache su-exec=0.3-r0 && \ + ln -s /sbin/su-exec /usr/local/bin/gosu && \ + rm /var/log/apk.log COPY --from=pgvector-builder /tmp/pgvector-install/ / -FROM scratch - -COPY --from=runtime / / - LABEL org.opencontainers.image.source="https://github.com/codefly-dev/service-postgres" ENV LANG=en_US.utf8 diff --git a/main.go b/main.go index d45bdbe..fda5fdb 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,9 @@ package main import ( "context" "embed" + "encoding/hex" + "encoding/json" + "fmt" "net/url" "strings" @@ -101,10 +104,38 @@ const DatabaseName = "database-name" // The managed image adds pgvector to the official Postgres 17 Alpine image // while preserving its entrypoint and contrib extensions. The nix runtime gets // pgvector via nix/flake.nix, keeping both runtimes at parity. -var image = &resources.DockerImage{ - Name: "ghcr.io/codefly-dev/service-postgres", - Tag: "runtime-9ae3b680a1cbf2d3", - Digest: "sha256:a5bb05518fd2f054884282f389577028c6304337bcf9d65363810ef1ad9e8c6c", +var image = shared.Must(parseRuntimeImageLock(runtimeImageLockJSON)) + +type runtimeImageLock struct { + Name string `json:"name"` + Tag string `json:"tag"` + Digest string `json:"digest"` +} + +func parseRuntimeImageLock(content []byte) (*resources.DockerImage, error) { + var lock runtimeImageLock + if err := json.Unmarshal(content, &lock); err != nil { + return nil, fmt.Errorf("parse runtime image lock: %w", err) + } + if lock.Name == "" { + return nil, fmt.Errorf("runtime image name is required") + } + if lock.Tag == "" { + return nil, fmt.Errorf("runtime image tag is required") + } + if lock.Digest == "" { + return nil, fmt.Errorf("runtime image digest is required") + } + algorithm, encoded, found := strings.Cut(lock.Digest, ":") + decoded, err := hex.DecodeString(encoded) + if !found || algorithm != "sha256" || err != nil || len(decoded) != 32 { + return nil, fmt.Errorf("runtime image digest must be a sha256 digest") + } + return &resources.DockerImage{ + Name: lock.Name, + Tag: lock.Tag, + Digest: lock.Digest, + }, nil } type DeploymentTemplateParameters struct { @@ -288,3 +319,6 @@ var infoFS embed.FS //go:embed templates/agent var readmeFS embed.FS + +//go:embed runtime-image.json +var runtimeImageLockJSON []byte diff --git a/main_test.go b/main_test.go index 8f4dddb..507cbca 100644 --- a/main_test.go +++ b/main_test.go @@ -2,7 +2,6 @@ package main import ( "context" - "crypto/sha256" "fmt" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" @@ -26,19 +25,6 @@ import ( // TODO: Add tests // - migrations: up/down -func TestDefaultImageUsesHardenedRuntime(t *testing.T) { - dockerfile, err := os.ReadFile("Dockerfile") - require.NoError(t, err) - sum := sha256.Sum256(dockerfile) - require.Equal(t, fmt.Sprintf( - "ghcr.io/codefly-dev/service-postgres:runtime-%x", sum[:8], - ), image.Name+":"+image.Tag) - require.Equal(t, - "sha256:a5bb05518fd2f054884282f389577028c6304337bcf9d65363810ef1ad9e8c6c", - image.Digest, - ) -} - // TestCreateToRunDocker runs the full agent lifecycle against the explicitly // selected container backend. Using free here would only test backend // auto-selection and could silently fall back to Nix. diff --git a/runtime-image.json b/runtime-image.json new file mode 100644 index 0000000..f7a2cd1 --- /dev/null +++ b/runtime-image.json @@ -0,0 +1,5 @@ +{ + "name": "ghcr.io/codefly-dev/service-postgres", + "tag": "postgres-17.10-pgvector-0.8.5-alpine3.24", + "digest": "sha256:eb55334a89f89c080b7dff59781b5186cb80413198c142c4ef29b0ca6892a0ce" +} diff --git a/runtime_image_test.go b/runtime_image_test.go new file mode 100644 index 0000000..c87fd1b --- /dev/null +++ b/runtime_image_test.go @@ -0,0 +1,38 @@ +package main + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseRuntimeImageLock(t *testing.T) { + got, err := parseRuntimeImageLock([]byte(`{ + "name": "ghcr.io/codefly-dev/service-postgres", + "tag": "postgres-17.10-pgvector-0.8.5-alpine3.24", + "digest": "sha256:a5bb05518fd2f054884282f389577028c6304337bcf9d65363810ef1ad9e8c6c" + }`)) + require.NoError(t, err) + require.Equal(t, + "ghcr.io/codefly-dev/service-postgres@sha256:a5bb05518fd2f054884282f389577028c6304337bcf9d65363810ef1ad9e8c6c", + got.FullName(), + ) + require.Equal(t, "postgres-17.10-pgvector-0.8.5-alpine3.24", got.Tag) +} + +func TestParseRuntimeImageLockRejectsIncompleteReference(t *testing.T) { + _, err := parseRuntimeImageLock([]byte(`{ + "name": "ghcr.io/codefly-dev/service-postgres", + "tag": "postgres-17.10-pgvector-0.8.5-alpine3.24" + }`)) + require.EqualError(t, err, "runtime image digest is required") +} + +func TestDefaultImageMatchesRuntimeImageLock(t *testing.T) { + lock, err := os.ReadFile("runtime-image.json") + require.NoError(t, err) + expected, err := parseRuntimeImageLock(lock) + require.NoError(t, err) + require.Equal(t, expected, image) +} diff --git a/workflow_test.go b/workflow_test.go new file mode 100644 index 0000000..240bd99 --- /dev/null +++ b/workflow_test.go @@ -0,0 +1,165 @@ +package main + +import ( + "os" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +type workflowDefinition struct { + Permissions map[string]string `yaml:"permissions"` + Jobs map[string]workflowJob `yaml:"jobs"` +} + +type workflowJob struct { + Permissions map[string]string `yaml:"permissions"` + Steps []workflowStep `yaml:"steps"` + Uses string `yaml:"uses"` +} + +type workflowStep struct { + Name string `yaml:"name"` + If string `yaml:"if"` + Uses string `yaml:"uses"` + Run string `yaml:"run"` + With map[string]any `yaml:"with"` +} + +func TestCIWorkflowValidatesLockedImageForEveryPullRequest(t *testing.T) { + workflow := readWorkflow(t, ".github/workflows/ci.yml") + require.Equal(t, map[string]string{"contents": "read"}, workflow.Permissions) + require.Equal(t, map[string]string{"contents": "read"}, workflow.Jobs["ci"].Permissions) + require.Equal(t, map[string]string{ + "contents": "read", + "packages": "write", + }, workflow.Jobs["image"].Permissions) + require.Regexp(t, `@[0-9a-f]{40}$`, workflow.Jobs["ci"].Uses) + + unitTests := findWorkflowStep(t, workflow.Jobs["image"], "Run unit tests") + require.Empty(t, unitTests.If) + + verify := findWorkflowStep(t, workflow.Jobs["image"], "Verify published runtime image") + require.Contains(t, verify.Run, `--config "$anonymous_docker_config"`) + require.Contains(t, verify.Run, `"$RUNTIME_IMAGE"`) + require.Contains(t, verify.Run, `"linux/amd64"`) + require.Contains(t, verify.Run, `"linux/arm64"`) + + imageJob := workflow.Jobs["image"] + candidateIndex, candidate := findWorkflowStepAt(t, imageJob, "Publish runtime image candidate") + verifyCandidateIndex, verifyCandidate := findWorkflowStepAt(t, imageJob, "Verify runtime image candidate") + publishedIndex, _ := findWorkflowStepAt(t, imageJob, "Verify published runtime image") + scanIndex, scan := findWorkflowStepAt(t, imageJob, "Scan published runtime image") + tagIndex, tag := findWorkflowStepAt(t, imageJob, "Tag verified runtime image") + require.Less(t, candidateIndex, verifyCandidateIndex) + require.Less(t, verifyCandidateIndex, publishedIndex) + require.Less(t, publishedIndex, scanIndex) + require.Less(t, scanIndex, tagIndex) + require.Equal(t, + "type=image,name=${{ steps.runtime.outputs.name }},push-by-digest=true,name-canonical=true,push=true,rewrite-timestamp=true", + candidate.With["outputs"], + ) + require.Equal(t, false, candidate.With["provenance"]) + require.Equal(t, false, candidate.With["sbom"]) + require.Contains(t, verifyCandidate.Run, `"$ACTUAL_DIGEST" != "$EXPECTED_DIGEST"`) + require.Contains(t, scan.Run, `docker save --output /tmp/service-postgres-image.tar "$RUNTIME_IMAGE"`) + require.Contains(t, tag.Run, `docker buildx imagetools create --tag "$RUNTIME_TAG" "$RUNTIME_IMAGE"`) + + buildx := findWorkflowAction(t, imageJob, "docker/setup-buildx-action") + require.Equal(t, + "image=moby/buildkit@sha256:2f5adac4ecd194d9f8c10b7b5d7bceb5186853db1b26e5abd3a657af0b7e26ec", + buildx.With["driver-opts"], + ) +} + +func TestReleaseWorkflowRetagsLockedImageWithLeastPrivilege(t *testing.T) { + workflow := readWorkflow(t, ".github/workflows/releaser.yml") + require.Equal(t, map[string]string{"contents": "read"}, workflow.Permissions) + require.Equal(t, map[string]string{ + "contents": "read", + "packages": "write", + }, workflow.Jobs["image"].Permissions) + require.Equal(t, map[string]string{"contents": "read"}, workflow.Jobs["release"].Permissions) + require.Equal(t, map[string]string{"contents": "read"}, workflow.Jobs["backfill"].Permissions) + require.Regexp(t, `@[0-9a-f]{40}$`, workflow.Jobs["release"].Uses) + + imageJob := workflow.Jobs["image"] + for _, step := range imageJob.Steps { + require.NotContains(t, step.Uses, "docker/build-push-action") + } + publish := findWorkflowStep(t, imageJob, "Publish release image tags") + require.Contains(t, publish.Run, `"$RUNTIME_IMAGE"`) + require.Contains(t, publish.Run, "docker buildx imagetools create") + buildx := findWorkflowAction(t, imageJob, "docker/setup-buildx-action") + require.Equal(t, + "image=moby/buildkit@sha256:2f5adac4ecd194d9f8c10b7b5d7bceb5186853db1b26e5abd3a657af0b7e26ec", + buildx.With["driver-opts"], + ) + + for _, step := range workflow.Jobs["backfill"].Steps { + if step.Uses != "" { + require.Regexp(t, `@[0-9a-f]{40}$`, step.Uses) + } + } +} + +func TestRuntimeDockerfilePinsReproducibleBuildInputs(t *testing.T) { + content, err := os.ReadFile("Dockerfile") + require.NoError(t, err) + dockerfile := string(content) + require.Contains(t, dockerfile, "# syntax=docker/dockerfile:1.7@sha256:") + require.Contains(t, dockerfile, "ARG POSTGRES_IMAGE=postgres:17.10-alpine3.24@sha256:") + require.Contains(t, dockerfile, "ARG SOURCE_DATE_EPOCH=0") + require.Contains(t, dockerfile, "build-base=0.5-r4") + require.Contains(t, dockerfile, "clang21=21.1.8-r3") + require.Contains(t, dockerfile, "llvm21-dev=21.1.8-r1") + require.Contains(t, dockerfile, "su-exec=0.3-r0") + require.Contains(t, dockerfile, "RUN rm /usr/local/bin/gosu") + require.Contains(t, dockerfile, "ln -s /sbin/su-exec /usr/local/bin/gosu") + require.Contains(t, dockerfile, "rm /var/log/apk.log") + require.NotContains(t, dockerfile, "FROM scratch") + require.NotContains(t, dockerfile, "COPY --from=runtime / /") +} + +func readWorkflow(t *testing.T, path string) workflowDefinition { + t.Helper() + content, err := os.ReadFile(path) + require.NoError(t, err) + var workflow workflowDefinition + require.NoError(t, yaml.Unmarshal(content, &workflow)) + return workflow +} + +func findWorkflowStep(t *testing.T, job workflowJob, name string) workflowStep { + t.Helper() + _, step := findWorkflowStepAt(t, job, name) + return step +} + +func findWorkflowStepAt(t *testing.T, job workflowJob, name string) (int, workflowStep) { + t.Helper() + for index, step := range job.Steps { + if step.Name == name { + return index, step + } + } + var names []string + for _, step := range job.Steps { + names = append(names, step.Name) + } + t.Fatalf("workflow step %q not found in %s", name, strings.Join(names, ", ")) + return -1, workflowStep{} +} + +func findWorkflowAction(t *testing.T, job workflowJob, action string) workflowStep { + t.Helper() + for _, step := range job.Steps { + if strings.Contains(step.Uses, action) { + return step + } + } + t.Fatalf("workflow action %q not found", action) + return workflowStep{} +}