From 1aca4d281e030bb8268b0a38066d734fa52b308e Mon Sep 17 00:00:00 2001 From: Jamie Magee Date: Thu, 10 Sep 2026 23:34:07 -0700 Subject: [PATCH 1/4] Generate proxy image attestations --- .github/workflows/ghcr.yml | 98 +++++++++++++++++++++++- README.md | 26 +++++++ ghcr_workflow_test.go | 147 ++++++++++++++++++++++++++++++++++++ script/_common | 1 - script/cibuild-publish-ghcr | 32 -------- 5 files changed, 268 insertions(+), 36 deletions(-) create mode 100644 ghcr_workflow_test.go delete mode 100755 script/cibuild-publish-ghcr diff --git a/.github/workflows/ghcr.yml b/.github/workflows/ghcr.yml index ad21e0f6..c17ac823 100644 --- a/.github/workflows/ghcr.yml +++ b/.github/workflows/ghcr.yml @@ -4,22 +4,114 @@ on: # yamllint disable-line rule:truthy branches: - main +env: + REMOTE_IMAGE: ghcr.io/dependabot/proxy + permissions: {} jobs: publish: name: Build and publish Docker images runs-on: ubuntu-latest + if: github.repository == 'dependabot/proxy' permissions: contents: write packages: write + id-token: write + attestations: write + artifact-metadata: write steps: - name: Check out code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + + - 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: Build and push image by digest + id: build + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + platforms: linux/amd64 + build-args: GIT_COMMIT=${{ github.sha }} + cache-to: type=inline + outputs: type=image,name=${{ env.REMOTE_IMAGE }},push-by-digest=true,name-canonical=true,push=true,oci-mediatypes=false + # Generate signed provenance separately without changing the image manifest. + provenance: false + sbom: false + + - name: Validate image digest + id: digest + env: + IMAGE_DIGEST: ${{ steps.build.outputs.digest }} + run: | + set -euo pipefail + if ! [[ "$IMAGE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::Build did not return a valid SHA-256 image manifest digest." + exit 1 + fi + + - name: Generate artifact attestation + id: attest + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-name: ${{ env.REMOTE_IMAGE }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true + create-storage-record: true + + - name: Set version tag + id: version + run: | + set -euo pipefail + VERSION="v2.0.$(date -u +%Y%m%d%H%M%S)" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Publish image tags + id: tags + env: + IMAGE_DIGEST: ${{ steps.build.outputs.digest }} + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + docker buildx imagetools create \ + --prefer-index=false \ + --tag "${REMOTE_IMAGE}:${VERSION}" \ + --tag "${REMOTE_IMAGE}:latest" \ + "${REMOTE_IMAGE}@${IMAGE_DIGEST}" + + - name: Create Git tag + id: git_tag + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" \ + -f "ref=refs/tags/${VERSION}" \ + -f "sha=${GITHUB_SHA}" \ + --silent - - name: Publish Docker - run: script/cibuild-publish-ghcr + - name: Summarize publication env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + IMAGE_DIGEST: ${{ steps.build.outputs.digest }} + VERSION: ${{ steps.version.outputs.version }} + ATTESTATION_URL: ${{ steps.attest.outputs.attestation-url }} + run: | + { + printf 'Published `%s:%s` and `%s:latest`\n\n' "$REMOTE_IMAGE" "$VERSION" "$REMOTE_IMAGE" + printf 'Digest: `%s`\n\n' "$IMAGE_DIGEST" + printf 'Source commit: `%s`\n\n' "$GITHUB_SHA" + printf 'Attestation: %s\n' "$ATTESTATION_URL" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/README.md b/README.md index a23fd4dd..f5ba24bc 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,32 @@ To build and run the proxy, you need to have the following installed: - [Go][go] (version 1.26 or later) - [Docker][docker] +## Image provenance + +The `main` publish workflow builds `ghcr.io/dependabot/proxy` for `linux/amd64`. +It pushes the image by digest and publishes GitHub build provenance to GitHub and GHCR before assigning release tags. +Each publication creates a `v2.0.YYYYMMDDHHMMSS` image tag and updates `latest`. +The matching Git tag points to the source commit. + +To verify an image, replace the placeholders with its digest and the expected source commit: + +```bash +IMAGE_DIGEST='sha256:' +SOURCE_SHA='' + +gh attestation verify "oci://ghcr.io/dependabot/proxy@${IMAGE_DIGEST}" \ + --repo dependabot/proxy \ + --signer-workflow dependabot/proxy/.github/workflows/ghcr.yml \ + --source-ref refs/heads/main \ + --source-digest "${SOURCE_SHA}" +``` + +The publication summary includes the digest, source commit and attestation link. +Add `--bundle-from-oci` to retrieve the attestation from GHCR instead of the GitHub API. + +These attestations cover newly built container images. The workflow does not backfill historical images. +Runtime verification in the CLI and Action, and attestations for the native CodeQL archives, are separate work. + ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/ghcr_workflow_test.go b/ghcr_workflow_test.go new file mode 100644 index 00000000..eb0ee417 --- /dev/null +++ b/ghcr_workflow_test.go @@ -0,0 +1,147 @@ +package main + +import ( + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +type ghcrWorkflow struct { + On map[string]map[string][]string `yaml:"on"` + Env map[string]string `yaml:"env"` + Permissions map[string]string `yaml:"permissions"` + Jobs map[string]ghcrWorkflowJob `yaml:"jobs"` +} + +type ghcrWorkflowJob struct { + If string `yaml:"if"` + Permissions map[string]string `yaml:"permissions"` + Steps []ghcrWorkflowStep `yaml:"steps"` +} + +type ghcrWorkflowStep struct { + ID string `yaml:"id"` + Uses string `yaml:"uses"` + If string `yaml:"if"` + ContinueOnError bool `yaml:"continue-on-error"` + With map[string]string `yaml:"with"` + Env map[string]string `yaml:"env"` + Run string `yaml:"run"` +} + +func ghcrStepWithID(t *testing.T, steps []ghcrWorkflowStep, id string) (int, ghcrWorkflowStep) { + t.Helper() + for index, step := range steps { + if step.ID == id { + return index, step + } + } + t.Fatalf("missing publication step %q", id) + return -1, ghcrWorkflowStep{} +} + +func TestGHCRPublicationWorkflow(t *testing.T) { + content, err := os.ReadFile(".github/workflows/ghcr.yml") + require.NoError(t, err) + + var workflow ghcrWorkflow + require.NoError(t, yaml.Unmarshal(content, &workflow)) + publish, ok := workflow.Jobs["publish"] + require.True(t, ok) + + t.Run("restricts publication and credentials", func(t *testing.T) { + assert.Equal(t, map[string]map[string][]string{"push": {"branches": {"main"}}}, workflow.On) + assert.Equal(t, "github.repository == 'dependabot/proxy'", publish.If) + assert.Empty(t, workflow.Permissions) + assert.Equal(t, "ghcr.io/dependabot/proxy", workflow.Env["REMOTE_IMAGE"]) + assert.Equal(t, map[string]string{ + "contents": "write", + "packages": "write", + "id-token": "write", + "attestations": "write", + "artifact-metadata": "write", + }, publish.Permissions) + + for _, step := range publish.Steps { + if step.Uses != "" { + assert.Regexp(t, `^[^@]+@[0-9a-f]{40}$`, step.Uses) + } + if strings.HasPrefix(step.Uses, "actions/checkout@") { + assert.Equal(t, "false", step.With["persist-credentials"]) + } + } + }) + + t.Run("pushes the production image without release tags", func(t *testing.T) { + _, build := ghcrStepWithID(t, publish.Steps, "build") + assert.True(t, strings.HasPrefix(build.Uses, "docker/build-push-action@")) + assert.Equal(t, ".", build.With["context"]) + assert.Equal(t, "linux/amd64", build.With["platforms"]) + assert.Empty(t, build.With["target"]) + assert.Empty(t, build.With["tags"]) + assert.Equal(t, "false", build.With["provenance"]) + assert.Equal(t, "false", build.With["sbom"]) + assert.Equal(t, "type=inline", build.With["cache-to"]) + assert.Contains(t, build.With["build-args"], "GIT_COMMIT=${{ github.sha }}") + assert.ElementsMatch(t, []string{ + "type=image", + "name=${{ env.REMOTE_IMAGE }}", + "push-by-digest=true", + "name-canonical=true", + "push=true", + "oci-mediatypes=false", + }, strings.Split(build.With["outputs"], ",")) + }) + + t.Run("requires a digest-bound attestation before release tags", func(t *testing.T) { + buildIndex, _ := ghcrStepWithID(t, publish.Steps, "build") + digestIndex, digest := ghcrStepWithID(t, publish.Steps, "digest") + attestIndex, attest := ghcrStepWithID(t, publish.Steps, "attest") + versionIndex, version := ghcrStepWithID(t, publish.Steps, "version") + tagsIndex, tags := ghcrStepWithID(t, publish.Steps, "tags") + gitTagIndex, gitTag := ghcrStepWithID(t, publish.Steps, "git_tag") + + assert.Less(t, buildIndex, digestIndex) + assert.Less(t, digestIndex, attestIndex) + assert.Less(t, attestIndex, versionIndex) + assert.Less(t, versionIndex, tagsIndex) + assert.Less(t, tagsIndex, gitTagIndex) + + assert.Equal(t, "${{ steps.build.outputs.digest }}", digest.Env["IMAGE_DIGEST"]) + assert.Contains(t, digest.Run, "^sha256:[0-9a-f]{64}$") + assert.Contains(t, digest.Run, "exit 1") + + assert.True(t, strings.HasPrefix(attest.Uses, "actions/attest@")) + assert.Equal(t, "${{ env.REMOTE_IMAGE }}", attest.With["subject-name"]) + assert.Equal(t, "${{ steps.build.outputs.digest }}", attest.With["subject-digest"]) + assert.Equal(t, "true", attest.With["push-to-registry"]) + assert.Equal(t, "true", attest.With["create-storage-record"]) + assert.Empty(t, attest.With["subject-path"]) + + assert.Contains(t, version.Run, "v2.0.") + assert.Contains(t, version.Run, "%Y%m%d%H%M%S") + assert.Equal(t, "${{ steps.build.outputs.digest }}", tags.Env["IMAGE_DIGEST"]) + assert.Equal(t, "${{ steps.version.outputs.version }}", tags.Env["VERSION"]) + assert.Contains(t, tags.Run, "docker buildx imagetools create") + assert.Contains(t, tags.Run, "--prefer-index=false") + assert.Contains(t, tags.Run, `"${REMOTE_IMAGE}:${VERSION}"`) + assert.Contains(t, tags.Run, `"${REMOTE_IMAGE}:latest"`) + assert.Contains(t, tags.Run, `"${REMOTE_IMAGE}@${IMAGE_DIGEST}"`) + + assert.Equal(t, "${{ steps.version.outputs.version }}", gitTag.Env["VERSION"]) + assert.Equal(t, "${{ secrets.GITHUB_TOKEN }}", gitTag.Env["GH_TOKEN"]) + assert.Contains(t, gitTag.Run, "gh api --method POST") + assert.Contains(t, gitTag.Run, "repos/${GITHUB_REPOSITORY}/git/refs") + assert.Contains(t, gitTag.Run, "ref=refs/tags/${VERSION}") + assert.Contains(t, gitTag.Run, "sha=${GITHUB_SHA}") + + for _, step := range publish.Steps { + assert.False(t, step.ContinueOnError, "step %q must propagate failure", step.ID) + assert.Empty(t, step.If, "step %q must use the default success condition", step.ID) + } + }) +} diff --git a/script/_common b/script/_common index 6ce1d86b..5b53e0ee 100644 --- a/script/_common +++ b/script/_common @@ -1,7 +1,6 @@ export PROXY_IMAGE=dependabot/proxy export TESTS_IMAGE=dependabot/proxy-builder-tests export REMOTE_IMAGE=ghcr.io/dependabot/proxy -export DEFAULT_BRANCH=refs/heads/main function docker_build() { [[ -n "$SKIP_BUILD" ]] && return diff --git a/script/cibuild-publish-ghcr b/script/cibuild-publish-ghcr deleted file mode 100755 index defff0c6..00000000 --- a/script/cibuild-publish-ghcr +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/bash - -set -e -cd "$(dirname "$0")/.." -ROOT="$(pwd)" -source "${ROOT}/script/_common" - -function main() { - docker_build - push_to_ghcr -} - -function push_to_ghcr() { - if [[ -z "$GITHUB_TOKEN" ]]; then - echo "Skipping GHCR publish as this isn't Actions. Set GITHUB_TOKEN= to override." - return - fi - - echo "$GITHUB_TOKEN" | docker login ghcr.io -u x --password-stdin - - if [ "$GITHUB_REF" == "$DEFAULT_BRANCH" ]; then - DATE_BASED_VERSION=v2.0.$(date +%Y%m%d%H%M%S) - docker tag "$PROXY_IMAGE" "$REMOTE_IMAGE:$DATE_BASED_VERSION" - docker tag "$PROXY_IMAGE" "$REMOTE_IMAGE:latest" - docker push "$REMOTE_IMAGE:$DATE_BASED_VERSION" - docker push "$REMOTE_IMAGE:latest" - git tag "$DATE_BASED_VERSION" - git push origin "$DATE_BASED_VERSION" - fi -} - -main From a3bfa034d10c7dbfff50172ecdf65dd455be1833 Mon Sep 17 00:00:00 2001 From: Jamie Magee Date: Thu, 10 Sep 2026 23:39:06 -0700 Subject: [PATCH 2/4] Remove GHCR workflow tests --- ghcr_workflow_test.go | 147 ------------------------------------------ 1 file changed, 147 deletions(-) delete mode 100644 ghcr_workflow_test.go diff --git a/ghcr_workflow_test.go b/ghcr_workflow_test.go deleted file mode 100644 index eb0ee417..00000000 --- a/ghcr_workflow_test.go +++ /dev/null @@ -1,147 +0,0 @@ -package main - -import ( - "os" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" -) - -type ghcrWorkflow struct { - On map[string]map[string][]string `yaml:"on"` - Env map[string]string `yaml:"env"` - Permissions map[string]string `yaml:"permissions"` - Jobs map[string]ghcrWorkflowJob `yaml:"jobs"` -} - -type ghcrWorkflowJob struct { - If string `yaml:"if"` - Permissions map[string]string `yaml:"permissions"` - Steps []ghcrWorkflowStep `yaml:"steps"` -} - -type ghcrWorkflowStep struct { - ID string `yaml:"id"` - Uses string `yaml:"uses"` - If string `yaml:"if"` - ContinueOnError bool `yaml:"continue-on-error"` - With map[string]string `yaml:"with"` - Env map[string]string `yaml:"env"` - Run string `yaml:"run"` -} - -func ghcrStepWithID(t *testing.T, steps []ghcrWorkflowStep, id string) (int, ghcrWorkflowStep) { - t.Helper() - for index, step := range steps { - if step.ID == id { - return index, step - } - } - t.Fatalf("missing publication step %q", id) - return -1, ghcrWorkflowStep{} -} - -func TestGHCRPublicationWorkflow(t *testing.T) { - content, err := os.ReadFile(".github/workflows/ghcr.yml") - require.NoError(t, err) - - var workflow ghcrWorkflow - require.NoError(t, yaml.Unmarshal(content, &workflow)) - publish, ok := workflow.Jobs["publish"] - require.True(t, ok) - - t.Run("restricts publication and credentials", func(t *testing.T) { - assert.Equal(t, map[string]map[string][]string{"push": {"branches": {"main"}}}, workflow.On) - assert.Equal(t, "github.repository == 'dependabot/proxy'", publish.If) - assert.Empty(t, workflow.Permissions) - assert.Equal(t, "ghcr.io/dependabot/proxy", workflow.Env["REMOTE_IMAGE"]) - assert.Equal(t, map[string]string{ - "contents": "write", - "packages": "write", - "id-token": "write", - "attestations": "write", - "artifact-metadata": "write", - }, publish.Permissions) - - for _, step := range publish.Steps { - if step.Uses != "" { - assert.Regexp(t, `^[^@]+@[0-9a-f]{40}$`, step.Uses) - } - if strings.HasPrefix(step.Uses, "actions/checkout@") { - assert.Equal(t, "false", step.With["persist-credentials"]) - } - } - }) - - t.Run("pushes the production image without release tags", func(t *testing.T) { - _, build := ghcrStepWithID(t, publish.Steps, "build") - assert.True(t, strings.HasPrefix(build.Uses, "docker/build-push-action@")) - assert.Equal(t, ".", build.With["context"]) - assert.Equal(t, "linux/amd64", build.With["platforms"]) - assert.Empty(t, build.With["target"]) - assert.Empty(t, build.With["tags"]) - assert.Equal(t, "false", build.With["provenance"]) - assert.Equal(t, "false", build.With["sbom"]) - assert.Equal(t, "type=inline", build.With["cache-to"]) - assert.Contains(t, build.With["build-args"], "GIT_COMMIT=${{ github.sha }}") - assert.ElementsMatch(t, []string{ - "type=image", - "name=${{ env.REMOTE_IMAGE }}", - "push-by-digest=true", - "name-canonical=true", - "push=true", - "oci-mediatypes=false", - }, strings.Split(build.With["outputs"], ",")) - }) - - t.Run("requires a digest-bound attestation before release tags", func(t *testing.T) { - buildIndex, _ := ghcrStepWithID(t, publish.Steps, "build") - digestIndex, digest := ghcrStepWithID(t, publish.Steps, "digest") - attestIndex, attest := ghcrStepWithID(t, publish.Steps, "attest") - versionIndex, version := ghcrStepWithID(t, publish.Steps, "version") - tagsIndex, tags := ghcrStepWithID(t, publish.Steps, "tags") - gitTagIndex, gitTag := ghcrStepWithID(t, publish.Steps, "git_tag") - - assert.Less(t, buildIndex, digestIndex) - assert.Less(t, digestIndex, attestIndex) - assert.Less(t, attestIndex, versionIndex) - assert.Less(t, versionIndex, tagsIndex) - assert.Less(t, tagsIndex, gitTagIndex) - - assert.Equal(t, "${{ steps.build.outputs.digest }}", digest.Env["IMAGE_DIGEST"]) - assert.Contains(t, digest.Run, "^sha256:[0-9a-f]{64}$") - assert.Contains(t, digest.Run, "exit 1") - - assert.True(t, strings.HasPrefix(attest.Uses, "actions/attest@")) - assert.Equal(t, "${{ env.REMOTE_IMAGE }}", attest.With["subject-name"]) - assert.Equal(t, "${{ steps.build.outputs.digest }}", attest.With["subject-digest"]) - assert.Equal(t, "true", attest.With["push-to-registry"]) - assert.Equal(t, "true", attest.With["create-storage-record"]) - assert.Empty(t, attest.With["subject-path"]) - - assert.Contains(t, version.Run, "v2.0.") - assert.Contains(t, version.Run, "%Y%m%d%H%M%S") - assert.Equal(t, "${{ steps.build.outputs.digest }}", tags.Env["IMAGE_DIGEST"]) - assert.Equal(t, "${{ steps.version.outputs.version }}", tags.Env["VERSION"]) - assert.Contains(t, tags.Run, "docker buildx imagetools create") - assert.Contains(t, tags.Run, "--prefer-index=false") - assert.Contains(t, tags.Run, `"${REMOTE_IMAGE}:${VERSION}"`) - assert.Contains(t, tags.Run, `"${REMOTE_IMAGE}:latest"`) - assert.Contains(t, tags.Run, `"${REMOTE_IMAGE}@${IMAGE_DIGEST}"`) - - assert.Equal(t, "${{ steps.version.outputs.version }}", gitTag.Env["VERSION"]) - assert.Equal(t, "${{ secrets.GITHUB_TOKEN }}", gitTag.Env["GH_TOKEN"]) - assert.Contains(t, gitTag.Run, "gh api --method POST") - assert.Contains(t, gitTag.Run, "repos/${GITHUB_REPOSITORY}/git/refs") - assert.Contains(t, gitTag.Run, "ref=refs/tags/${VERSION}") - assert.Contains(t, gitTag.Run, "sha=${GITHUB_SHA}") - - for _, step := range publish.Steps { - assert.False(t, step.ContinueOnError, "step %q must propagate failure", step.ID) - assert.Empty(t, step.If, "step %q must use the default success condition", step.ID) - } - }) -} From 8b2caf9ad63130011110048bfad94ab9c0ba90ed Mon Sep 17 00:00:00 2001 From: Jamie Magee Date: Thu, 10 Sep 2026 23:49:20 -0700 Subject: [PATCH 3/4] Simplify proxy image publication workflow --- .github/workflows/ghcr.yml | 39 +++++--------------------------------- 1 file changed, 5 insertions(+), 34 deletions(-) diff --git a/.github/workflows/ghcr.yml b/.github/workflows/ghcr.yml index c17ac823..e60481f9 100644 --- a/.github/workflows/ghcr.yml +++ b/.github/workflows/ghcr.yml @@ -51,67 +51,38 @@ jobs: provenance: false sbom: false - - name: Validate image digest - id: digest + - name: Require image digest env: IMAGE_DIGEST: ${{ steps.build.outputs.digest }} - run: | - set -euo pipefail - if ! [[ "$IMAGE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then - echo "::error::Build did not return a valid SHA-256 image manifest digest." - exit 1 - fi + run: ': "${IMAGE_DIGEST:?Build returned no image digest}"' - name: Generate artifact attestation - id: attest uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: subject-name: ${{ env.REMOTE_IMAGE }} subject-digest: ${{ steps.build.outputs.digest }} push-to-registry: true - create-storage-record: true - - - name: Set version tag - id: version - run: | - set -euo pipefail - VERSION="v2.0.$(date -u +%Y%m%d%H%M%S)" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - name: Publish image tags - id: tags + - name: Publish image and Git tags env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} IMAGE_DIGEST: ${{ steps.build.outputs.digest }} - VERSION: ${{ steps.version.outputs.version }} run: | set -euo pipefail + VERSION="v2.0.$(date -u +%Y%m%d%H%M%S)" docker buildx imagetools create \ --prefer-index=false \ --tag "${REMOTE_IMAGE}:${VERSION}" \ --tag "${REMOTE_IMAGE}:latest" \ "${REMOTE_IMAGE}@${IMAGE_DIGEST}" - - name: Create Git tag - id: git_tag - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ steps.version.outputs.version }} - run: | - set -euo pipefail gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" \ -f "ref=refs/tags/${VERSION}" \ -f "sha=${GITHUB_SHA}" \ --silent - - name: Summarize publication - env: - IMAGE_DIGEST: ${{ steps.build.outputs.digest }} - VERSION: ${{ steps.version.outputs.version }} - ATTESTATION_URL: ${{ steps.attest.outputs.attestation-url }} - run: | { printf 'Published `%s:%s` and `%s:latest`\n\n' "$REMOTE_IMAGE" "$VERSION" "$REMOTE_IMAGE" printf 'Digest: `%s`\n\n' "$IMAGE_DIGEST" printf 'Source commit: `%s`\n\n' "$GITHUB_SHA" - printf 'Attestation: %s\n' "$ATTESTATION_URL" } >> "$GITHUB_STEP_SUMMARY" From 7537eb9bd8a8217db224a595306af168669af268 Mon Sep 17 00:00:00 2001 From: Jamie Magee Date: Fri, 11 Sep 2026 21:54:57 -0700 Subject: [PATCH 4/4] Build and publish proxy image tags together --- .github/workflows/ghcr.yml | 22 ++++++++++++---------- README.md | 7 ++++--- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ghcr.yml b/.github/workflows/ghcr.yml index e60481f9..256600a3 100644 --- a/.github/workflows/ghcr.yml +++ b/.github/workflows/ghcr.yml @@ -38,15 +38,24 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push image by digest + - name: Set version tag + run: | + VERSION="v2.0.$(date -u +%Y%m%d%H%M%S)" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + + - name: Build and push image id: build uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . + push: true + tags: | + ${{ env.REMOTE_IMAGE }}:${{ env.VERSION }} + ${{ env.REMOTE_IMAGE }}:latest platforms: linux/amd64 build-args: GIT_COMMIT=${{ github.sha }} cache-to: type=inline - outputs: type=image,name=${{ env.REMOTE_IMAGE }},push-by-digest=true,name-canonical=true,push=true,oci-mediatypes=false + outputs: type=image,oci-mediatypes=false # Generate signed provenance separately without changing the image manifest. provenance: false sbom: false @@ -63,19 +72,12 @@ jobs: subject-digest: ${{ steps.build.outputs.digest }} push-to-registry: true - - name: Publish image and Git tags + - name: Create Git tag env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} IMAGE_DIGEST: ${{ steps.build.outputs.digest }} run: | set -euo pipefail - VERSION="v2.0.$(date -u +%Y%m%d%H%M%S)" - docker buildx imagetools create \ - --prefer-index=false \ - --tag "${REMOTE_IMAGE}:${VERSION}" \ - --tag "${REMOTE_IMAGE}:latest" \ - "${REMOTE_IMAGE}@${IMAGE_DIGEST}" - gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" \ -f "ref=refs/tags/${VERSION}" \ -f "sha=${GITHUB_SHA}" \ diff --git a/README.md b/README.md index f5ba24bc..d432cfb4 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,10 @@ To build and run the proxy, you need to have the following installed: ## Image provenance The `main` publish workflow builds `ghcr.io/dependabot/proxy` for `linux/amd64`. -It pushes the image by digest and publishes GitHub build provenance to GitHub and GHCR before assigning release tags. -Each publication creates a `v2.0.YYYYMMDDHHMMSS` image tag and updates `latest`. -The matching Git tag points to the source commit. +The build pushes a `v2.0.YYYYMMDDHHMMSS` image tag and updates `latest`. +The workflow then publishes signed build provenance for the image digest to GitHub and GHCR. +After attestation succeeds, it creates the matching Git tag at the source commit. +If attestation fails, the image tags remain published and the workflow fails. To verify an image, replace the placeholders with its digest and the expected source commit: