From eb100927f2ccfe7a470a0a499740d6b5c2c4fb7a Mon Sep 17 00:00:00 2001 From: Liora Milbaum Date: Fri, 2 Oct 2026 14:15:46 +0300 Subject: [PATCH] feat: native-matrix release build and remove QEMU-based buildx path - Rewrite release.yml into three jobs: build (native amd64/arm64 matrix), merge (imagetools create + platform verification, tag events only), and release (cosign sign + gh release, tag events only); CI runs build both arches without login or push to GHCR - Gate login, push, merge, and release on github.ref_type == 'tag' - Delete hack/build-multiarch.sh, the docker-buildx Makefile target, and test/release/ superseded by the native workflow - Remove stale Buildx builder row from docs/migration-to-uck.md Co-Authored-By: Claude Haiku 4.5 --- .github/workflows/release.yml | 137 ++++++++++++++++++------- Makefile | 12 +-- config/manager/manager.yaml | 3 +- docs/development.md | 11 +- docs/migration-to-uck.md | 1 - hack/build-multiarch.sh | 34 ------ test/release/buildx_assertions_test.go | 28 ----- test/release/buildx_status_test.go | 66 ------------ test/release/buildx_test.go | 105 ------------------- 9 files changed, 110 insertions(+), 287 deletions(-) delete mode 100644 hack/build-multiarch.sh delete mode 100644 test/release/buildx_assertions_test.go delete mode 100644 test/release/buildx_status_test.go delete mode 100644 test/release/buildx_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 07281c3..56b3d18 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,40 +2,74 @@ name: Release on: push: + branches: [main] tags: ['v*'] + pull_request: + branches: [main] -permissions: - contents: write - packages: write - id-token: write +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false env: REGISTRY: ghcr.io IMAGE: ghcr.io/polarsquad/uck jobs: - release: - name: Build, sign, publish - runs-on: ubuntu-latest + build: + name: Build ${{ matrix.arch }} (native) + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-24.04 + - arch: arm64 + runner: ubuntu-24.04-arm + permissions: + contents: read + packages: write steps: - name: Clone the code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - name: Setup Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 - with: - go-version-file: go.mod + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 - - name: Set up QEMU - uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 + - name: Log in to the GitHub Container Registry + if: github.ref_type == 'tag' + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: - platforms: amd64,arm64 + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 + - name: Build and push arch image + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + file: Dockerfile + platforms: linux/${{ matrix.arch }} + push: ${{ github.ref_type == 'tag' }} + provenance: false + sbom: false + tags: ${{ env.IMAGE }}:${{ github.ref_type == 'tag' && format('{0}-linux-{1}', github.ref_name, matrix.arch) || format('{0}-linux-{1}', github.sha, matrix.arch) }} + merge: + name: Merge manifest list + needs: build + if: github.ref_type == 'tag' + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: write + outputs: + digest: ${{ steps.publish.outputs.digest }} + steps: - name: Log in to the GitHub Container Registry uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: @@ -43,20 +77,58 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push the multi-arch image + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 + + - name: Create and verify manifest list + id: publish env: TAG: ${{ github.ref_name }} run: | - make docker-buildx IMG=${{ env.IMAGE }}:${TAG} PLATFORMS=linux/amd64,linux/arm64 + set -euo pipefail + docker buildx imagetools create \ + -t "$IMAGE:$TAG" \ + "$IMAGE:$TAG-linux-amd64" \ + "$IMAGE:$TAG-linux-arm64" + platforms=$(docker buildx imagetools inspect "$IMAGE:$TAG" \ + --format '{{range .Manifest.Manifests}}{{.Platform.OS}}/{{.Platform.Architecture}} {{end}}') + for want in linux/amd64 linux/arm64; do + [[ " $platforms " == *" $want "* ]] || { + echo "::error::missing $want (got: $platforms)" + exit 1 + } + done + digest=$(docker buildx imagetools inspect "$IMAGE:$TAG" \ + --format '{{.Manifest.Digest}}') + [ -n "$digest" ] || { echo "::error::empty index digest"; exit 1; } + echo "digest=$digest" >> "$GITHUB_OUTPUT" - - name: Capture the image digest - id: digest - run: | - # Top-level (multi-arch) digest, for precise digest-based signing. - # Fallback: if the template yields nothing, we sign by tag below - # (cosign resolves the tag to its digest). - d=$(docker buildx imagetools inspect ${{ env.IMAGE }}:${{ github.ref_name }} --format '{{.Manifest.Digest}}' 2>/dev/null | tail -1 || true) - echo "digest=${d}" >> "$GITHUB_OUTPUT" + release: + name: Sign and publish + needs: merge + if: github.ref_type == 'tag' + runs-on: ubuntu-24.04 + permissions: + contents: write + packages: write + id-token: write + steps: + - name: Clone the code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: go.mod + + - name: Log in to the GitHub Container Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Build the consolidated installer run: make build-installer IMG=${{ env.IMAGE }}:${{ github.ref_name }} @@ -65,25 +137,18 @@ jobs: uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 - name: Sign the image (keyless via GitHub OIDC) - run: | - if [ -n "${{ steps.digest.outputs.digest }}" ]; then - cosign sign --yes ${{ env.IMAGE }}@${{ steps.digest.outputs.digest }} - else - # Fall back to the tag; cosign resolves it to the manifest list. - cosign sign --yes ${{ env.IMAGE }}:${{ github.ref_name }} - fi + env: + DIGEST: ${{ needs.merge.outputs.digest }} + run: cosign sign --yes "$IMAGE@$DIGEST" - name: Create or update the GitHub release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - # Re-pointing a tag re-triggers this workflow; in that case the - # release already exists, so attach/replace the asset instead. if gh release view "${{ github.ref_name }}" >/dev/null 2>&1; then gh release upload "${{ github.ref_name }}" dist/install.yaml --clobber else args=(--generate-notes) - # rc/alpha/beta tags become pre-releases; plain vX.Y.Z are final. case "${{ github.ref_name }}" in *rc*|*alpha*|*beta*) args+=(--prerelease) ;; esac diff --git a/Makefile b/Makefile index 59d97ce..15bd300 100644 --- a/Makefile +++ b/Makefile @@ -145,6 +145,7 @@ run: manifests generate fmt vet ## Run a controller from your host. # If you wish to build the manager image targeting other platforms you can use the --platform flag. # (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it. # More info: https://docs.docker.com/develop/develop-images/build_enhancements/ +# Tagged releases are built per architecture on native runners by .github/workflows/release.yml. .PHONY: docker-build docker-build: ## Build docker image with the manager. $(CONTAINER_TOOL) build -t ${IMG} . @@ -153,17 +154,6 @@ docker-build: ## Build docker image with the manager. docker-push: ## Push docker image with the manager. $(CONTAINER_TOOL) push ${IMG} -# PLATFORMS defines the target platforms for the manager image be built to provide support to multiple -# architectures. (i.e. make docker-buildx IMG=myregistry/mypoperator:0.0.1). To use this option you need to: -# - be able to use docker buildx. More info: https://docs.docker.com/build/buildx/ -# - have enabled BuildKit. More info: https://docs.docker.com/develop/develop-images/build_enhancements/ -# - be able to push the image to your registry (i.e. if you do not set a valid value via IMG=> then the export will fail) -# To adequately provide solutions that are compatible with multiple platforms, you should consider using this option. -PLATFORMS ?= linux/arm64,linux/amd64,linux/s390x,linux/ppc64le -.PHONY: docker-buildx -docker-buildx: ## Build and push docker image for the manager for cross-platform support - CONTAINER_TOOL="$(CONTAINER_TOOL)" IMG="$(IMG)" PLATFORMS="$(PLATFORMS)" bash hack/build-multiarch.sh - .PHONY: build-installer build-installer: manifests generate kustomize ## Generate a consolidated YAML with CRDs and deployment. mkdir -p dist diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 5512d3e..5dda1f6 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -32,8 +32,7 @@ spec: spec: # TODO(user): Uncomment the following code to configure the nodeAffinity expression # according to the platforms which are supported by your solution. - # It is considered best practice to support multiple architectures. You can - # build your manager image using the makefile target docker-buildx. + # It is considered best practice to support multiple architectures. Release images are published for linux/amd64 and linux/arm64. # affinity: # nodeAffinity: # requiredDuringSchedulingIgnoredDuringExecution: diff --git a/docs/development.md b/docs/development.md index cd965aa..a186f55 100644 --- a/docs/development.md +++ b/docs/development.md @@ -161,10 +161,13 @@ the same group. ## Releasing -Releases are cut from tags; the `Release` workflow builds and pushes the -multi-arch image to `ghcr.io/polarsquad/uck`, signs it keylessly, builds -`dist/install.yaml`, and publishes the GitHub release in -`polarsquad/upcloud-operator`. Only releases cut after the rename use the +Releases are cut from tags; the `Release` workflow builds each architecture +(linux/amd64, linux/arm64) on a native runner without QEMU emulation. The +per-arch tags `-linux-amd64` and `-linux-arm64` are intermediate +artifacts only; only the `` manifest index is signed and supported. The +workflow then signs the image keylessly, builds `dist/install.yaml`, and +publishes the GitHub release in `polarsquad/upcloud-operator`. +Only releases cut after the rename use the UCK image and Kubernetes object names; existing tags are not moved or republished. Choose a new version for `TAG` below. Before the first UCK release, confirm GHCR package ownership, Actions write access and public diff --git a/docs/migration-to-uck.md b/docs/migration-to-uck.md index 28514bc..69f7c80 100644 --- a/docs/migration-to-uck.md +++ b/docs/migration-to-uck.md @@ -24,7 +24,6 @@ are not republished under the new name. | UpCloud ownership label | `managed-by=upcloud-operator` | `managed-by=uck` | | UpCloud HTTP user agent | `upcloud-operator` | `uck` | | Default smoke-test kind cluster | `upcloud-operator-test-e2e` | `uck-test-e2e` | -| Buildx builder | `upcloud-operator-builder` | `uck-builder` | Role, ClusterRole, binding, and optional monitoring object names also use `uck-`. Update external references to those objects, not just the diff --git a/hack/build-multiarch.sh b/hack/build-multiarch.sh deleted file mode 100644 index 2c3de14..0000000 --- a/hack/build-multiarch.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -# Build and publish with an invocation-owned builder; never reuse a failed -# creation or change the caller's default Buildx selection. -set -euo pipefail - -: "${IMG:?IMG is required}" -: "${PLATFORMS:?PLATFORMS is required}" -CONTAINER_TOOL=${CONTAINER_TOOL:-docker} - -tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/uck-build.XXXXXXXXXX") -builder="uck-builder-$$" -created=false -cleanup() { - status=$? - trap - EXIT - if "$created"; then - "$CONTAINER_TOOL" buildx rm "$builder" || true - fi - rm -f "$tmpdir/Dockerfile" || true - rmdir "$tmpdir" || true - exit "$status" -} -trap cleanup EXIT -trap 'exit 130' INT -trap 'exit 143' TERM - -# Only the builder stage runs natively; TARGETOS/TARGETARCH select the output. -sed -e '1 s/\(^FROM\)/FROM --platform=${BUILDPLATFORM}/; t' \ - -e '1,// s//FROM --platform=${BUILDPLATFORM}/' Dockerfile > "$tmpdir/Dockerfile" - -"$CONTAINER_TOOL" buildx create --driver docker-container --name "$builder" -created=true -"$CONTAINER_TOOL" buildx build --builder "$builder" --push \ - --platform="$PLATFORMS" --tag "$IMG" -f "$tmpdir/Dockerfile" . diff --git a/test/release/buildx_assertions_test.go b/test/release/buildx_assertions_test.go deleted file mode 100644 index 830a616..0000000 --- a/test/release/buildx_assertions_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package release - -import ( - "strings" - "testing" -) - -func assertBuildxArguments(t *testing.T, log string) { - t.Helper() - first := strings.Fields(strings.Split(log, "\n")[0]) - builder := first[len(first)-1] - if strings.Contains(strings.Join(first, " "), "--use") { - t.Errorf("creation must not select the builder: %s", log) - } - for line := range strings.SplitSeq(log, "\n") { - if strings.HasPrefix(line, "buildx build ") && - (!strings.Contains(line, "--tag registry.invalid/uck:test") || !strings.HasSuffix(line, " .")) { - t.Errorf("image tag or context changed: %s", line) - } - } - if !strings.Contains(log, "buildx rm "+builder+"\n") || - !strings.Contains(log, "--builder "+builder+" ") { - t.Errorf("build and cleanup must target the created builder: %s", log) - } - if !strings.Contains(log, "--push") || !strings.Contains(log, "--platform=linux/amd64,linux/arm64") { - t.Errorf("publish arguments lost: %s", log) - } -} diff --git a/test/release/buildx_status_test.go b/test/release/buildx_status_test.go deleted file mode 100644 index b42f9a2..0000000 --- a/test/release/buildx_status_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package release - -import ( - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -func TestBuildxHelperPreservesBuildStatus(t *testing.T) { - root, err := filepath.Abs(filepath.Join("..", "..")) - if err != nil { - t.Fatal(err) - } - for _, want := range []int{0, 29} { - t.Run(fmt.Sprintf("build=%d,cleanup=41", want), func(t *testing.T) { - dir := t.TempDir() - shim := filepath.Join(dir, "container-tool") - logPath := filepath.Join(dir, "calls") - const script = `#!/usr/bin/env bash -set -eu -printf '%s\n' "$*" >> "$CALL_LOG" -case "$2" in - build) exit "$BUILD_STATUS" ;; - rm) exit 41 ;; -esac -` - if err := os.WriteFile(shim, []byte(script), 0o700); err != nil { - t.Fatal(err) - } - cmd := exec.Command("bash", "hack/build-multiarch.sh") - cmd.Dir = root - cmd.Env = append(os.Environ(), "CONTAINER_TOOL="+shim, "CALL_LOG="+logPath, - fmt.Sprintf("BUILD_STATUS=%d", want), "TMPDIR="+dir, - "IMG=registry.invalid/uck:probe", "PLATFORMS=linux/amd64,linux/arm64") - out, runErr := cmd.CombinedOutput() - got := 0 - if runErr != nil { - var exitErr *exec.ExitError - if !errors.As(runErr, &exitErr) { - t.Fatal(runErr) - } - got = exitErr.ExitCode() - } - if got != want { - t.Fatalf("cleanup replaced build status: got %d, want %d\n%s", got, want, out) - } - calls, err := os.ReadFile(logPath) - if err != nil || !strings.Contains(string(calls), "buildx rm") { - t.Fatalf("cleanup failure path was not exercised: %v\n%s", err, calls) - } - entries, err := os.ReadDir(dir) - if err != nil { - t.Fatal(err) - } - for _, entry := range entries { - if entry.IsDir() { - t.Errorf("temporary directory survived failed builder cleanup: %s", entry.Name()) - } - } - }) - } -} diff --git a/test/release/buildx_test.go b/test/release/buildx_test.go deleted file mode 100644 index 11c2079..0000000 --- a/test/release/buildx_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package release - -import ( - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -// Run the real Make target with every container operation intercepted. No -// registry, daemon, builder or image is touched by these regression tests. -func TestBuildxExitAndCleanup(t *testing.T) { - for _, step := range []string{"", "create", "build"} { - t.Run("failure="+step, func(t *testing.T) { - dir := t.TempDir() - for _, name := range []string{"Makefile", "Dockerfile", "hack/build-multiarch.sh"} { - data, err := os.ReadFile(filepath.Join("..", "..", name)) - if os.IsNotExist(err) && strings.HasPrefix(name, "hack/") { - continue // The pre-fix Makefile has no helper script. - } - if err != nil { - t.Fatal(err) - } - path := filepath.Join(dir, name) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, data, 0o600); err != nil { - t.Fatal(err) - } - } - shim := filepath.Join(dir, "container-tool") - const script = `#!/usr/bin/env bash -set -eu -printf '%s\n' "$*" >> "$CALL_LOG" -if [ "$1/$2" = "buildx/build" ]; then - previous= - for arg in "$@"; do - if [ "$previous" = "-f" ]; then - cp "$arg" "$DOCKERFILE_SNAPSHOT" - printf '%s' "$arg" > "$DOCKERFILE_LOCATION" - fi - previous="$arg" - done -fi -if [ "$2" = "$FAIL_STEP" ]; then exit 29; fi -` - if err := os.WriteFile(shim, []byte(script), 0o700); err != nil { - t.Fatal(err) - } - logPath := filepath.Join(dir, "calls") - snapshot := filepath.Join(dir, "dockerfile.snapshot") - location := filepath.Join(dir, "dockerfile.location") - cmd := exec.Command("make", "docker-buildx", "CONTAINER_TOOL="+shim, - "IMG=registry.invalid/uck:test", "PLATFORMS=linux/amd64,linux/arm64") - cmd.Dir = dir - cmd.Env = append(os.Environ(), "CALL_LOG="+logPath, "DOCKERFILE_SNAPSHOT="+snapshot, - "DOCKERFILE_LOCATION="+location, "FAIL_STEP="+step, "TMPDIR="+dir) - out, runErr := cmd.CombinedOutput() - calls, err := os.ReadFile(logPath) - if err != nil { - t.Fatalf("container calls were not intercepted: %v\n%s", err, out) - } - if (runErr != nil) != (step != "") { - t.Errorf("failed %q: exit=%v, expected failure=%v\n%s", step, runErr, step != "", out) - } - log := string(calls) - if strings.Contains(log, "buildx use") { - t.Errorf("target changed the caller's default builder: %s", log) - } - entries, err := os.ReadDir(dir) - if err != nil { - t.Fatal(err) - } - for _, entry := range entries { - if entry.IsDir() && entry.Name() != "hack" { - t.Errorf("temporary build directory remains: %s", entry.Name()) - } - } - if step == "create" { - if strings.Contains(log, "buildx build") || strings.Contains(log, "buildx rm") { - t.Errorf("failed builder creation must not build or remove an unowned builder: %s", log) - } - return - } - assertBuildxArguments(t, log) - used, err := os.ReadFile(location) - if err != nil { - t.Fatal(err) - } - if _, err := os.Stat(filepath.Dir(string(used))); !os.IsNotExist(err) { - t.Errorf("actual temporary Dockerfile directory was not removed: %q (%v)", used, err) - } - data, err := os.ReadFile(snapshot) - if err != nil || !strings.Contains(string(data), "FROM --platform=${BUILDPLATFORM} golang:") { - t.Errorf("native build-stage platform was not injected: %v\n%s", err, data) - } - if !strings.Contains(string(data), "FROM gcr.io/distroless/static:nonroot") || - !strings.Contains(string(data), "GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH}") { - t.Errorf("runtime stage or cross-compilation arguments changed: %s", data) - } - }) - } -}