diff --git a/.github/workflows/publish-gateway-image.yml b/.github/workflows/publish-gateway-image.yml index 57470a7..d55e789 100644 --- a/.github/workflows/publish-gateway-image.yml +++ b/.github/workflows/publish-gateway-image.yml @@ -1,5 +1,10 @@ name: publish-gateway-image +# STOPGAP (owner-approved hack): keep the job in this repo so GHCR grants its +# GITHUB_TOKEN package write access; a job defined in core was denied. Replace +# this copy with core's composite action in 0.18.2 once core#770 ships: +# https://github.com/codefly-dev/core/issues/770 + # Builds the gateway image and pushes it by digest, carrying no tag. Nothing # resolves it yet: release.yml creates the tags consumers use, from the digest # gateway-image.json records. @@ -17,15 +22,105 @@ on: required: true type: string +env: + IMAGE: ghcr.io/codefly-dev/service-object-storage + jobs: publish: + runs-on: ubuntu-latest permissions: contents: read packages: write - uses: codefly-dev/core/.github/workflows/publish-service-image.yml@ea49e7a968ef5f2fd325b1c41f823a47eb20141c # v0.18.1 - with: - version: ${{ inputs.version }} - lock-file: gateway-image.json - platforms: linux/amd64,linux/arm64 - secrets: - registry-token: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: require the commit to be on the default branch, or refuse + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + # Private repositories need authentication for these two remote + # reads. Command-scoped config disappears with git; neither the token + # nor a credential-bearing config file enters the Docker context. + git_remote() { + local authorization + authorization=$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 | tr -d '\n') + git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${authorization}" "$@" + } + + # The authority is the remote's own HEAD, and there is no input that + # can name it. A declaration the caller supplies could be omitted, + # defaulted or disagreed with, and each of those is a way for the + # caller to choose which history counts as reviewed. Whoever may + # approve a publication is this repository's decision, so the only + # source is the repository. + authority=$(git_remote ls-remote --symref origin HEAD \ + | awk '$1 == "ref:" && $3 == "HEAD" { sub("refs/heads/", "", $2); print $2 }') + if [ -z "$authority" ]; then + echo "::error::cannot read this repository's default branch from origin; refusing to publish." >&2 + exit 1 + fi + + git_remote fetch --quiet --no-tags --force origin \ + "+refs/heads/${authority}:refs/remotes/origin/${authority}" + + # Fully qualified: a bare `origin/` is an ambiguous rev that a tag + # of the same name can win. + if ! git merge-base --is-ancestor "$GITHUB_SHA" "refs/remotes/origin/${authority}"; then + echo "::error::${GITHUB_REF_NAME} points at ${GITHUB_SHA}, which is not reachable from refs/remotes/origin/${authority}; refusing to publish it." >&2 + echo "Publishing builds this repository's Dockerfile with a registry credential, so the commit has to be one that was merged." >&2 + exit 1 + fi + + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Validate the upcoming agent version + id: version + env: + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if [[ ! "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "version must be a stable semantic version without a v prefix" >&2 + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + # The version label is how the release tells this image apart from the one + # published for an earlier version: both are digests in the same + # repository, and a lock left unrefreshed would otherwise publish the + # previous release's bytes under the new tag. + - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + id: build + with: + context: . + platforms: linux/amd64,linux/arm64 + labels: org.opencontainers.image.version=${{ steps.version.outputs.version }} + outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true + - name: Record the digest + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + set -euo pipefail + mkdir -p release-evidence + jq -n --arg name "$IMAGE" --arg digest "$DIGEST" '{name: $name, digest: $digest}' > release-evidence/gateway-image.json + { + # shellcheck disable=SC2016 # Literal Markdown, not command substitution. + echo 'Commit this as `gateway-image.json`, then tag the release:' + echo + echo '```json' + cat release-evidence/gateway-image.json + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: gateway-image.json-${{ steps.version.outputs.version }} + path: release-evidence/gateway-image.json + if-no-files-found: error diff --git a/AGENTS.md b/AGENTS.md index 66dbc13..5ae90e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,7 +112,7 @@ one workflow, in that order. The tag must match `version:` in that file resolves the *previous* image, which exists, so nothing fails at run time and the evidence describes an image the agent never runs. The shipped platform list lives in `internal/imageevidence` and a test asserts it against -the inputs to core's reusable image publisher. Core's agent-release workflow +the build step in `publish-gateway-image.yml`. Core's agent-release workflow accepts only pushed tags whose commits are reachable from the default branch. Prepare the gateway before publishing the agent: use `codefly publish --dry-run` @@ -120,10 +120,16 @@ to determine the next version, then dispatch `publish-gateway-image.yml` with that explicit `version` (without `v`). Adopt its `gateway-image.json-` artifact as `gateway-image.json` in a reviewed PR. Only then run normal `codefly publish`; the tag gate still checks the image label against the bumped -agent version and refuses a stale lock. The reusable publisher checks out with +agent version and refuses a stale lock. The publisher checks out with `persist-credentials: false` and refuses a commit outside the default branch's history. It changes no release tag or lock in this repository. +The repository-owned publishing job is an owner-approved stopgap (hack): GHCR +denied package writes from core's reusable workflow because core defined the +job. Replace the local copy with the composite action planned for core 0.18.2 +in [core#770](https://github.com/codefly-dev/core/issues/770) once it ships. +Keep the default-branch refusal and `persist-credentials: false` when adopting it. + The gateway lock accepts only its expected registry/name, a lowercase SHA-256 digest, and the two known JSON keys; an empty digest means unpublished. MinIO's source metadata has its own schema but shares core's digest validation. Registry diff --git a/README.md b/README.md index 8e888f4..7ee4a4c 100644 --- a/README.md +++ b/README.md @@ -333,9 +333,13 @@ The agent runs that image by digest, not by tag: `gateway-image.json` records the digest and the agent pins it, so the container a release runs — and reports evidence for — is the one that was built for it rather than whatever the tag serves now. The digest only exists once the image is built, so the image is -published ahead of the release tag by `publish-gateway-image.yml`. That caller -uses core's `publish-service-image.yml`, pinned by commit SHA, with the -upcoming stable version. It checks out without persisting credentials and +published ahead of the release tag by `publish-gateway-image.yml`, with the +upcoming stable version. This repository owns the publishing job as an +owner-approved stopgap (hack): GHCR denied the push from core's reusable +workflow because the job was defined in core. Replace this copy with the +composite action planned for core 0.18.2 in +[core#770](https://github.com/codefly-dev/core/issues/770) once it ships. +The job checks out without persisting credentials and refuses a commit not reachable from this repository's default branch. Adopt its `gateway-image.json-` artifact in a reviewed PR before the coordinator runs `codefly publish`; never hand-tag. `release.yml` creates the @@ -399,8 +403,8 @@ was written in. Every release runs the same command and publishes the documents and an `index.txt` naming each digest and platform, as build artifacts of `.github/workflows/release.yml`. The platform list lives only in `internal/imageevidence`, which now contains only the service declaration and -calls core. A test asserts the platforms against the reusable publisher's -inputs. Document filenames include repository, platform and digest; each index +calls core. A test asserts the platforms against the publisher's build step. +Document filenames include repository, platform and digest; each index row contains platform (`-` if unstated), repository plus scanned digest, filename and canonical inventory checksum (not the checksum of the formatted file). diff --git a/image_release_preparation_test.go b/image_release_preparation_test.go index b7532bc..8e1e082 100644 --- a/image_release_preparation_test.go +++ b/image_release_preparation_test.go @@ -1,6 +1,10 @@ package main import ( + "os" + "os/exec" + "path/filepath" + "strings" "testing" "github.com/stretchr/testify/require" @@ -17,19 +21,132 @@ func TestImagePreparationRequiresTheUpcomingVersion(t *testing.T) { } `yaml:"workflow_dispatch"` } Jobs map[string]struct { - Uses string - With, Secrets, Permissions map[string]string + Steps []struct { + ID, Uses, Run string + Env, With map[string]string + } } } require.NoError(t, yaml.Unmarshal([]byte(workflowBody(t, "publish-gateway-image.yml")), &workflow)) require.True(t, workflow.On.Dispatch.Inputs["version"].Required) + var validation string + artifact := false + for _, step := range workflow.Jobs["publish"].Steps { + if step.ID == "version" { + require.Equal(t, "${{ inputs.version }}", step.Env["VERSION"]) + validation = step.Run + } + if strings.HasPrefix(step.Uses, "actions/upload-artifact@") { + require.Equal(t, "gateway-image.json-${{ steps.version.outputs.version }}", step.With["name"]) + require.Equal(t, "release-evidence/gateway-image.json", step.With["path"]) + require.Equal(t, "error", step.With["if-no-files-found"]) + artifact = true + } + } + require.NotEmpty(t, validation) + require.True(t, artifact) + for _, tc := range []struct { + version string + valid bool + }{ + {"9.8.7", true}, + {"0.0.0", true}, + {"", false}, + {"v9.8.7", false}, + {"9.8", false}, + {"09.8.7", false}, + {"9.8.7\nother=value", false}, + } { + t.Run(tc.version, func(t *testing.T) { + output := filepath.Join(t.TempDir(), "output") + command := exec.Command("bash", "-c", validation) + command.Env = append(os.Environ(), "VERSION="+tc.version, "GITHUB_OUTPUT="+output) + payload, err := command.CombinedOutput() + require.Equal(t, tc.valid, err == nil, "%s: %s", err, payload) + if tc.valid { + actual, err := os.ReadFile(output) + require.NoError(t, err) + require.Equal(t, "version="+tc.version+"\n", string(actual)) + } + }) + } +} + +func TestImagePreparationRefusesUnmergedCommitsBeforePublishing(t *testing.T) { + var workflow struct { + On map[string]interface{} + Jobs map[string]struct { + Permissions map[string]string + Steps []struct { + Name, Uses, Run string + Env, With map[string]string + } + } + } + require.NoError(t, yaml.Unmarshal([]byte(workflowBody(t, "publish-gateway-image.yml")), &workflow)) + require.Len(t, workflow.On, 1, "publishing must remain dispatch-only") + require.Contains(t, workflow.On, "workflow_dispatch") job := workflow.Jobs["publish"] - // Core owns input validation and lock writing. This repo owns the wiring: - // the version must reach that workflow and the artifact must name our lock. - require.Equal(t, "codefly-dev/core/.github/workflows/publish-service-image.yml@ea49e7a968ef5f2fd325b1c41f823a47eb20141c", job.Uses) - require.Equal(t, "${{ inputs.version }}", job.With["version"]) - require.Equal(t, "gateway-image.json", job.With["lock-file"]) - require.Equal(t, "${{ secrets.GITHUB_TOKEN }}", job.Secrets["registry-token"]) - require.Equal(t, "read", job.Permissions["contents"]) - require.Equal(t, "write", job.Permissions["packages"]) + require.Equal(t, map[string]string{"contents": "read", "packages": "write"}, job.Permissions) + require.GreaterOrEqual(t, len(job.Steps), 2) + require.True(t, strings.HasPrefix(job.Steps[0].Uses, "actions/checkout@")) + require.Equal(t, "false", job.Steps[0].With["persist-credentials"]) + require.Equal(t, "0", job.Steps[0].With["fetch-depth"]) + refusal := job.Steps[1] + require.Equal(t, "require the commit to be on the default branch, or refuse", refusal.Name) + require.Equal(t, "${{ github.token }}", refusal.Env["GH_TOKEN"]) + require.NotEmpty(t, refusal.Run) + // Checkout and refusal must precede every login or build action. + for i, step := range job.Steps { + if strings.HasPrefix(step.Uses, "docker/") { + require.Greater(t, i, 1) + } + } + + git := func(dir string, args ...string) string { + t.Helper() + command := exec.Command("git", args...) + command.Dir = dir + command.Env = append(os.Environ(), "GIT_AUTHOR_NAME=publisher-test", "GIT_AUTHOR_EMAIL=test@example.invalid", + "GIT_COMMITTER_NAME=publisher-test", "GIT_COMMITTER_EMAIL=test@example.invalid") + output, err := command.CombinedOutput() + require.NoError(t, err, "%s", output) + return strings.TrimSpace(string(output)) + } + // Use a real remote with a default branch other than main. A similarly + // named tag must not let an unmerged commit masquerade as reviewed history. + origin := t.TempDir() + git(origin, "init", "-b", "release") + git(origin, "-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "reviewed") + reviewed := git(origin, "rev-parse", "HEAD") + git(origin, "checkout", "-b", "unmerged") + git(origin, "-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "unreviewed") + unmerged := git(origin, "rev-parse", "HEAD") + git(origin, "checkout", "release") + checkout := t.TempDir() + git(checkout, "clone", "--no-hardlinks", origin, ".") + git(checkout, "-c", "tag.gpgsign=false", "tag", "origin/release", unmerged) + + for _, tc := range []struct { + name, sha string + accepted bool + }{ + {"reviewed", reviewed, true}, + {"unmerged", unmerged, false}, + } { + t.Run(tc.name, func(t *testing.T) { + command := exec.Command("bash", "-c", refusal.Run) + command.Dir = checkout + command.Env = append(os.Environ(), "GH_TOKEN=publisher-test-token", "GITHUB_SHA="+tc.sha, "GITHUB_REF_NAME="+tc.name) + output, err := command.CombinedOutput() + if tc.accepted { + require.NoError(t, err, "%s", output) + } else { + require.Error(t, err, "%s", output) + require.Contains(t, string(output), "not reachable from refs/remotes/origin/release; refusing to publish it") + } + config := git(checkout, "config", "--local", "--list") + require.NotContains(t, config, "extraheader", "authentication must not persist into the Docker build context") + }) + } } diff --git a/internal/imageevidence/imageevidence.go b/internal/imageevidence/imageevidence.go index 9ecdee0..367d607 100644 --- a/internal/imageevidence/imageevidence.go +++ b/internal/imageevidence/imageevidence.go @@ -16,7 +16,7 @@ import ( // Platforms are the platforms the release pipeline publishes the gateway image // for, and therefore the platforms evidence has to cover: a multi-architecture // image is not inventoried by scanning one of its children. It is asserted -// against the reusable workflow inputs in publish-gateway-image.yml. +// against the build step in publish-gateway-image.yml. var Platforms = []string{"linux/amd64", "linux/arm64"} // Role is the gateway's purpose within the service, carried on every subject. diff --git a/sbom_test.go b/sbom_test.go index cc0b6a3..35ea0a4 100644 --- a/sbom_test.go +++ b/sbom_test.go @@ -202,7 +202,7 @@ func TestReleaseEvidenceComesFromTheSharedCommand(t *testing.T) { t.Error("the release workflow loops over its own platform list; it must read imageevidence.Platforms through cmd/image-sbom") } if strings.Contains(workflowBody(t, "publish-gateway-image.yml"), "for platform in") { - t.Error("the publish workflow loops over its own platform list; the platforms it builds are passed to the reusable workflow") + t.Error("the publish workflow loops over its own platform list; read the platforms from its build step") } } @@ -223,23 +223,25 @@ func expectedFromReleasePipeline(t *testing.T, reference string) []*builderv0.Im return expected } -// publishedPlatforms reads the platforms passed to core's image publisher, +// publishedPlatforms reads the platforms passed to the image build step, // independently of the service declaration used to derive evidence subjects. func publishedPlatforms(t *testing.T) []string { t.Helper() var workflow struct { Jobs map[string]struct { - With struct{ Platforms string } + Steps []releaseStep } } if err := yaml.Unmarshal([]byte(workflowBody(t, "publish-gateway-image.yml")), &workflow); err != nil { t.Fatalf("parse publish workflow: %v", err) } - platforms := workflow.Jobs["publish"].With.Platforms - if platforms == "" { - t.Fatal("the publish workflow declares no platforms") + for _, step := range workflow.Jobs["publish"].Steps { + if strings.HasPrefix(step.Uses, "docker/build-push-action@") && step.With.Platforms != "" { + return strings.Split(step.With.Platforms, ",") + } } - return strings.Split(platforms, ",") + t.Fatal("the publish workflow declares no build platforms") + return nil } func releaseWorkflow(t *testing.T) string {