Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 102 additions & 7 deletions .github/workflows/publish-gateway-image.yml
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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/<branch>` 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
10 changes: 8 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,18 +112,24 @@ 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`
to determine the next version, then dispatch `publish-gateway-image.yml` with
that explicit `version` (without `v`). Adopt its `gateway-image.json-<version>`
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
Expand Down
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>` artifact in a reviewed PR before the
coordinator runs `codefly publish`; never hand-tag. `release.yml` creates the
Expand Down Expand Up @@ -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).

Expand Down
137 changes: 127 additions & 10 deletions image_release_preparation_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package main

import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/require"
Expand All @@ -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")
})
}
}
2 changes: 1 addition & 1 deletion internal/imageevidence/imageevidence.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 9 additions & 7 deletions sbom_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}

Expand All @@ -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 {
Expand Down
Loading