From 4cc1e89b21c2d3a5f25414eb79115e21a666f803 Mon Sep 17 00:00:00 2001 From: Joseph Salisbury Date: Wed, 12 Aug 2026 17:49:29 +0100 Subject: [PATCH] feat: build images on native architectures --- CHANGELOG.md | 6 + cmd/gen/circleci/flag.go | 9 + cmd/gen/circleci/runner.go | 1 + pkg/gen/input/circleci/circleci.go | 16 ++ pkg/gen/input/circleci/circleci_test.go | 84 ++++++++ .../input/circleci/internal/file/config.go | 1 + .../internal/file/workflows.yml.template | 180 ++++++++++++++++++ .../input/circleci/internal/params/params.go | 7 + 8 files changed, 304 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e04a4a1..bc581ab5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added +- `gen circleci --image-native-arm`: builds the image with one job per architecture on native hardware + instead of one job that emulates the non-host architecture under QEMU. Emits an `architect/build-image-arch` + leg for `linux/amd64` and `linux/arm64` on the branch and tag paths, plus an `architect/merge-image-manifests` + job on the tag path. Off by default. Mutually exclusive with `--image-platforms`. Renames the branch job from + `build-image` to `build-image-amd64`/`build-image-arm64`, so a repo whose `.circleci/custom.yml` has + `requires: build-image` must update it in the same PR. - `release create`: records the containerd version as a `containerd` component and links it in the release notes. It is derived from the release's `os-tooling` version, since that is the version nodes run rather than the one Flatcar embeds. diff --git a/cmd/gen/circleci/flag.go b/cmd/gen/circleci/flag.go index 2b7e0faa..ac71a1b5 100644 --- a/cmd/gen/circleci/flag.go +++ b/cmd/gen/circleci/flag.go @@ -20,6 +20,7 @@ const ( flagImagePreBuildJob = "image-pre-build-job" flagImagePrivateOnly = "image-private-only" flagImageName = "image-name" + flagImageNativeArm = "image-native-arm" flagImagePlatforms = "image-platforms" flagImageDockerfile = "image-dockerfile" flagResourceClass = "resource-class" @@ -44,6 +45,7 @@ type flag struct { ImagePreBuildJob string ImagePrivateOnly bool ImageName string + ImageNativeArm bool ImagePlatforms string ImageDockerfile string ResourceClass string @@ -68,6 +70,7 @@ func (f *flag) Init(cmd *cobra.Command) { cmd.Flags().StringVar(&f.ImagePreBuildJob, flagImagePreBuildJob, "", "Name of a repo-owned job (defined in .circleci/custom.yml) the release image build must wait on. Adds a `requires` entry to push-to-registries-release, which the append-only custom.yml merge cannot inject into a generated job. Used for workspace-handoff pre-steps. Empty for the common case.") cmd.Flags().BoolVar(&f.ImagePrivateOnly, flagImagePrivateOnly, false, "Ship the image to the private registry only (gsociprivate), replacing split-china-push and omitting the sync-china-registry job. Set it for private repos whose image must not land in the public catalog.") cmd.Flags().StringVar(&f.ImageName, flagImageName, "", "Override the `giantswarm/` default image name on the image jobs (push-to-registries / sync-china-registry `image` param). Set it for repos whose published image differs from the repo name (e.g. kserve -> giantswarm/kserve-controller). The append-only custom.yml merge cannot rename a generated job's image. Empty keeps the orb default.") + cmd.Flags().BoolVar(&f.ImageNativeArm, flagImageNativeArm, false, "Build the image with one job per architecture on native hardware, instead of one job that emulates the non-host architecture under QEMU. Emits an architect/build-image-arch job for linux/amd64 and linux/arm64 plus an architect/merge-image-manifests job that assembles the index and signs it. Worth it when the arm64 leg's RUN steps are the critical path -- an apt-heavy image can spend 7-15x longer emulated -- and not worth it when the Dockerfile already avoids emulation (a prebuilt binary plus COPY, or a $BUILDPLATFORM cross-compile), where the split only adds job spin-up. Mutually exclusive with --image-platforms, since the split is fixed to those two architectures. NOTE: this renames the branch job from build-image to build-image-amd64/build-image-arm64, so a repo whose .circleci/custom.yml has `requires: build-image` must update it in the same PR or its pipeline will not resolve.") cmd.Flags().StringVar(&f.ImagePlatforms, flagImagePlatforms, "", "Override the buildx platform list on the image jobs (push-to-registries `platforms` param). Empty lets the orb default apply (linux/amd64,linux/arm64 when no go-build .platforms file). Set it for single-architecture images (e.g. vllm -> linux/arm64, whose amd64 build has no prebuilt wheels).") cmd.Flags().StringVar(&f.ImageDockerfile, flagImageDockerfile, "", "Override the Dockerfile path on the image jobs (push-to-registries `dockerfile` param). Set it for repos whose Dockerfile is not at the repo root (e.g. backstage -> packages/backend/Dockerfile); a non-empty value also turns the image pipeline on, since the root-Dockerfile derivation misses a nested Dockerfile. The append-only custom.yml merge cannot set this on a generated job. Empty keeps the orb default.") cmd.Flags().StringVar(&f.ResourceClass, flagResourceClass, "", `Override the CircleCI resource_class on the cli-flavour go-build job. Empty defaults to "large". Raise it (e.g. "xlarge") for repos that need more RAM/CPU headroom for the cold cross-compile. Only applies to the cli flavour.`) @@ -89,6 +92,12 @@ func (f *flag) Validate() error { if f.ForcePublic && f.ImagePrivateOnly { return microerror.Maskf(invalidFlagError, "--%s and --%s are mutually exclusive", flagForcePublic, flagImagePrivateOnly) } + // The split emits exactly one leg per architecture, hardcoded to amd64 and + // arm64, so an overridden platform list has nowhere to go. Failing here is + // better than silently ignoring one of the two flags. + if f.ImageNativeArm && f.ImagePlatforms != "" { + return microerror.Maskf(invalidFlagError, "--%s and --%s are mutually exclusive", flagImageNativeArm, flagImagePlatforms) + } return nil } diff --git a/cmd/gen/circleci/runner.go b/cmd/gen/circleci/runner.go index f925e82f..babc15b0 100644 --- a/cmd/gen/circleci/runner.go +++ b/cmd/gen/circleci/runner.go @@ -87,6 +87,7 @@ func (r *runner) run(ctx context.Context, _ *cobra.Command, _ []string) error { ImagePreBuildJob: r.flag.ImagePreBuildJob, ImagePrivateOnly: r.flag.ImagePrivateOnly, ImageName: r.flag.ImageName, + ImageNativeArm: r.flag.ImageNativeArm, ImagePlatforms: r.flag.ImagePlatforms, ImageDockerfile: r.flag.ImageDockerfile, ResourceClass: r.flag.ResourceClass, diff --git a/pkg/gen/input/circleci/circleci.go b/pkg/gen/input/circleci/circleci.go index e50923e2..b8997695 100644 --- a/pkg/gen/input/circleci/circleci.go +++ b/pkg/gen/input/circleci/circleci.go @@ -260,6 +260,15 @@ type Config struct { // name (e.g. kserve -> giantswarm/kserve-controller). Empty keeps the orb // default. ImageName string + // ImageNativeArm splits the image build into one job per architecture, each + // on a resource class of that architecture, joined by a manifest-merge job. + // The default single job builds every platform on one machine, so the + // non-host architecture runs its RUN steps under QEMU -- 7-15x slower for an + // apt-heavy image, and the whole critical path. Not worth setting when the + // Dockerfile already avoids emulation (prebuilt binary plus COPY, or a + // $BUILDPLATFORM cross-compile): the emulated leg costs seconds there and the + // split only adds job spin-up. Mutually exclusive with ImagePlatforms. + ImageNativeArm bool // ImagePlatforms overrides the buildx platform list on the image jobs. // Empty lets the orb default apply. Set it for single-architecture images // (e.g. vllm -> linux/arm64). @@ -336,6 +345,12 @@ func New(config Config) (*CircleCI, error) { return nil, microerror.Maskf(invalidConfigError, "ForcePublic and ImagePrivateOnly are mutually exclusive") } + // The split emits one leg per architecture, hardcoded to amd64 and arm64, so + // an overridden platform list has nowhere to go. + if config.ImageNativeArm && config.ImagePlatforms != "" { + return nil, microerror.Maskf(invalidConfigError, "ImageNativeArm and ImagePlatforms are mutually exclusive") + } + appCatalog := config.AppCatalog if appCatalog == "" { appCatalog = DefaultAppCatalog @@ -477,6 +492,7 @@ func New(config Config) (*CircleCI, error) { ImagePreBuildJob: config.ImagePreBuildJob, ImagePrivateOnly: config.ImagePrivateOnly, ImageName: config.ImageName, + ImageNativeArm: config.ImageNativeArm, ImagePlatforms: config.ImagePlatforms, ImageDockerfile: config.ImageDockerfile, ReleaseBinaries: config.shipsBinaries(), diff --git a/pkg/gen/input/circleci/circleci_test.go b/pkg/gen/input/circleci/circleci_test.go index 8e781eb5..2fd4e4b0 100644 --- a/pkg/gen/input/circleci/circleci_test.go +++ b/pkg/gen/input/circleci/circleci_test.go @@ -613,6 +613,90 @@ func Test_ImagePlatforms(t *testing.T) { } } +// Test_ImageNativeArm verifies the split image build: the single +// push-to-registries job on each of the branch and tag paths is replaced by one +// build-image-arch leg per architecture plus, on the tag path, a +// merge-image-manifests job. The branch path gets no merge -- with push: false +// there is no digest to join. +// +// The merge job must keep the name push-to-registries-release, because +// sync-china-registry and any repo-owned custom.yml job `requires` it by that +// name and the append-only merge cannot rewrite a generated job's dependency. +func Test_ImageNativeArm(t *testing.T) { + got := render(t, Config{ + RepoName: "claudebox-image", + Language: gen.Language(""), + Flavours: gen.FlavourSlice{}, + HasDockerfile: true, + ImageNativeArm: true, + }) + + // Two branch legs and two tag legs, each on a class of its own architecture. + for _, want := range []string{ + "name: build-image-amd64", + "name: build-image-arm64", + "name: push-to-registries-release-amd64", + "name: push-to-registries-release-arm64", + "name: push-to-registries-release", + "platform: linux/amd64", + "platform: linux/arm64", + "resource_class: arm.medium", + } { + if !contains(got, want) { + t.Errorf("expected %q in the split shape:\n%s", want, got) + } + } + + // Four legs total: two branch, two tag. + if n := strings.Count(got, "architect/build-image-arch:"); n != 4 { + t.Errorf("expected 4 build-image-arch legs, found %d:\n%s", n, got) + } + // Exactly one merge, on the tag path only. + if n := strings.Count(got, "architect/merge-image-manifests:"); n != 1 { + t.Errorf("expected 1 merge-image-manifests job, found %d:\n%s", n, got) + } + // The single-job path must be gone, or the image would be built twice. + if contains(got, "architect/push-to-registries:") { + t.Errorf("push-to-registries should not be emitted alongside the split:\n%s", got) + } + // sync-china-registry still gates on the merge by its original name. + if !contains(got, "- push-to-registries-release\n") { + t.Errorf("sync-china-registry lost its requires edge to the merge job:\n%s", got) + } + + // Default off: the single-job shape is untouched. + def := render(t, Config{ + RepoName: "claudebox-image", + Language: gen.Language(""), + Flavours: gen.FlavourSlice{}, + HasDockerfile: true, + }) + if contains(def, "build-image-arch") || contains(def, "merge-image-manifests") { + t.Errorf("the split must not be emitted without ImageNativeArm:\n%s", def) + } + if !contains(def, "name: build-image") || !contains(def, "architect/push-to-registries:") { + t.Errorf("the default single-job shape regressed:\n%s", def) + } +} + +// Test_ImageNativeArmRejectsPlatforms verifies the two flags are mutually +// exclusive. The split emits one leg per architecture, hardcoded to amd64 and +// arm64, so an overridden platform list has nowhere to go -- failing is better +// than silently honouring one flag and dropping the other. +func Test_ImageNativeArmRejectsPlatforms(t *testing.T) { + _, err := New(Config{ + RepoName: "vllm", + Language: gen.Language(""), + Flavours: gen.FlavourSlice{}, + HasDockerfile: true, + ImageNativeArm: true, + ImagePlatforms: "linux/arm64", + }) + if err == nil { + t.Error("expected ImageNativeArm + ImagePlatforms to be rejected") + } +} + // Test_ImageDockerfile verifies the dockerfile-path override turns the image // pipeline on even when no root Dockerfile is detected (HasDockerfile false) // and applies the path to the build jobs (build-image and diff --git a/pkg/gen/input/circleci/internal/file/config.go b/pkg/gen/input/circleci/internal/file/config.go index 6fd1dae4..e8b45807 100644 --- a/pkg/gen/input/circleci/internal/file/config.go +++ b/pkg/gen/input/circleci/internal/file/config.go @@ -51,6 +51,7 @@ func NewWorkflowsInput(p params.Params) input.Input { "ImagePreBuildJob": p.ImagePreBuildJob, "ImagePrivateOnly": p.ImagePrivateOnly, "ImageName": p.ImageName, + "ImageNativeArm": p.ImageNativeArm, "ImagePlatforms": p.ImagePlatforms, "ImageDockerfile": p.ImageDockerfile, "ReleaseBinaries": p.ReleaseBinaries, diff --git a/pkg/gen/input/circleci/internal/file/workflows.yml.template b/pkg/gen/input/circleci/internal/file/workflows.yml.template index aa00a3e0..77d65e6a 100644 --- a/pkg/gen/input/circleci/internal/file/workflows.yml.template +++ b/pkg/gen/input/circleci/internal/file/workflows.yml.template @@ -195,6 +195,72 @@ workflows: # job, so the generator carries the dependency. - {{ .ImagePreBuildJob }} {{- end }} +{{- end }} + filters: + branches: + ignore: + - main +{{- else if .ImageNativeArm }} + + # Branches: validate the image build without pushing anything, one job per + # architecture. Each leg runs on a machine of its own architecture, so + # neither is emulated -- the point of the split. No merge job: with + # push: false there is no digest to join and nothing leaves the BuildKit + # cache. + # + # amd64 sits on `small` rather than `medium` because a small docker executor + # is already given a medium remote-docker VM, which is where the build + # actually runs. arm.medium is the floor on the Arm side; there is no + # arm.small. + # + # These two jobs replace `build-image`. A repo whose .circleci/custom.yml + # has `requires: build-image` must point it at one of these names instead. + - architect/build-image-arch: + context: architect + name: build-image-amd64 + platform: linux/amd64 + resource_class: small + push: false +{{- if .ImageDockerfile }} + dockerfile: {{ .ImageDockerfile }} +{{- end }} +{{- if .ImageName }} + image: {{ .ImageName }} +{{- end }} +{{- if or .BuildJobName .ImagePreBuildJob }} + requires: +{{- if .BuildJobName }} + - {{ .BuildJobName }} +{{- end }} +{{- if .ImagePreBuildJob }} + - {{ .ImagePreBuildJob }} +{{- end }} +{{- end }} + filters: + branches: + ignore: + - main + + - architect/build-image-arch: + context: architect + name: build-image-arm64 + platform: linux/arm64 + resource_class: arm.medium + push: false +{{- if .ImageDockerfile }} + dockerfile: {{ .ImageDockerfile }} +{{- end }} +{{- if .ImageName }} + image: {{ .ImageName }} +{{- end }} +{{- if or .BuildJobName .ImagePreBuildJob }} + requires: +{{- if .BuildJobName }} + - {{ .BuildJobName }} +{{- end }} +{{- if .ImagePreBuildJob }} + - {{ .ImagePreBuildJob }} +{{- end }} {{- end }} filters: branches: @@ -243,6 +309,119 @@ workflows: - main {{- end }} +{{- if .ImageNativeArm }} + + # Tag builds, split across architectures: one native leg per platform, then a + # merge that assembles the tagged index and signs it. Both legs build on a + # machine of their own architecture, in parallel, so nothing runs under QEMU + # and wall clock is the slower single leg rather than the sum. + # + # Each leg pushes its platform BY DIGEST to every eligible registry, setting + # no tag -- a single-architecture manifest is not independently useful, and + # tagging one would publish something consumers could pull by accident. The + # merge job creates the tags. + # + # The merge keeps the name push-to-registries-release, so the `requires` + # edges below (sync-china-registry, and any repo-owned custom.yml job) keep + # resolving without knowing the build was split. + - architect/build-image-arch: + context: architect + name: push-to-registries-release-amd64 + platform: linux/amd64 + resource_class: small +{{- if .ForcePublic }} + force-public: true +{{- end }} +{{- if .ImageDockerfile }} + dockerfile: {{ .ImageDockerfile }} +{{- end }} +{{- if .ImageName }} + image: {{ .ImageName }} +{{- end }} +{{- if .ImagePrivateOnly }} + registries-data: |- + private gsociprivate.azurecr.io ACR_GSOCIPRIVATE_USERNAME ACR_GSOCIPRIVATE_PASSWORD true +{{- else }} + # Must match the merge job, or the legs and the merge disagree about + # which registries hold the per-architecture digests. + split-china-push: true +{{- end }} +{{- if or .BuildJobName .ImagePreBuildJob }} + requires: +{{- if .BuildJobName }} + - {{ .BuildJobName }} +{{- end }} +{{- if .ImagePreBuildJob }} + - {{ .ImagePreBuildJob }} +{{- end }} +{{- end }} + filters: + tags: + only: /^v.*/ + branches: + ignore: /.*/ + + - architect/build-image-arch: + context: architect + name: push-to-registries-release-arm64 + platform: linux/arm64 + resource_class: arm.medium +{{- if .ForcePublic }} + force-public: true +{{- end }} +{{- if .ImageDockerfile }} + dockerfile: {{ .ImageDockerfile }} +{{- end }} +{{- if .ImageName }} + image: {{ .ImageName }} +{{- end }} +{{- if .ImagePrivateOnly }} + registries-data: |- + private gsociprivate.azurecr.io ACR_GSOCIPRIVATE_USERNAME ACR_GSOCIPRIVATE_PASSWORD true +{{- else }} + split-china-push: true +{{- end }} +{{- if or .BuildJobName .ImagePreBuildJob }} + requires: +{{- if .BuildJobName }} + - {{ .BuildJobName }} +{{- end }} +{{- if .ImagePreBuildJob }} + - {{ .ImagePreBuildJob }} +{{- end }} +{{- end }} + filters: + tags: + only: /^v.*/ + branches: + ignore: /.*/ + + - architect/merge-image-manifests: + context: architect + name: push-to-registries-release + platforms: "linux/amd64,linux/arm64" +{{- if .ForcePublic }} + force-public: true +{{- end }} +{{- if .ImageName }} + image: {{ .ImageName }} +{{- end }} +{{- if .ImagePrivateOnly }} + registries-data: |- + private gsociprivate.azurecr.io ACR_GSOCIPRIVATE_USERNAME ACR_GSOCIPRIVATE_PASSWORD true +{{- else }} + split-china-push: true +{{- end }} + requires: + - push-to-registries-release-amd64 + - push-to-registries-release-arm64 + filters: + tags: + only: /^v.*/ + branches: + ignore: /.*/ +{{- else }} + # Tag builds: push multi-arch image to gsoci + gsociprivate. Aliyun is # handled by the sync-china-registry job below (orb's split-china-push # mechanism), so the buildx push no longer crosses the Pacific. @@ -295,6 +474,7 @@ workflows: only: /^v.*/ branches: ignore: /.*/ +{{- end }} {{- if not .ImagePrivateOnly }} # Mirror gsoci -> Aliyun via the in-China giantswarm/galaxy-runner. Runs diff --git a/pkg/gen/input/circleci/internal/params/params.go b/pkg/gen/input/circleci/internal/params/params.go index c1178a7a..4885b546 100644 --- a/pkg/gen/input/circleci/internal/params/params.go +++ b/pkg/gen/input/circleci/internal/params/params.go @@ -76,6 +76,13 @@ type Params struct { // append-only custom.yml merge cannot rename a generated job's image, so the // generator carries it. Empty keeps the orb default. ImageName string + // ImageNativeArm builds the image with one architect/build-image-arch job per + // architecture, each on a resource class of that architecture, joined by an + // architect/merge-image-manifests job. The default single push-to-registries + // job builds both platforms on one machine, so the non-host one runs under + // QEMU. Mutually exclusive with ImagePlatforms: the split is fixed to + // linux/amd64 and linux/arm64. + ImageNativeArm bool // ImagePlatforms overrides the buildx platform list for the image build // (the push-to-registries `platforms` param on the build-image and // push-to-registries-release jobs). Empty lets the orb fall back to its