Skip to content

feat: add noarch package architecture support - #2645

Open
lioneloh wants to merge 12 commits into
chainguard-dev:mainfrom
lioneloh:feat/noarch-architecture
Open

feat: add noarch package architecture support#2645
lioneloh wants to merge 12 commits into
chainguard-dev:mainfrom
lioneloh:feat/noarch-architecture

Conversation

@lioneloh

@lioneloh lioneloh commented Sep 3, 2026

Copy link
Copy Markdown

Summary

Add support for building and indexing noarch packages.

How it works

  • Detect whether noarch is set on package.
  • Spawn one runner using host architecture.
  • Runner builds package and subpackages once, then copies them into each architecture-specific output directory.
  • Packages retain noarch architecture in PKGINFO and SBOM.
  • Runner indexes packages separately per architecture, replacing noarch with corresponding concrete architecture in each APKINDEX.
  • Linter rejects executables linked to a specific architecture.

This reproduces existing Alpine lddtree behavior:

https://pkgs.alpinelinux.org/package/edge/main/x86/lddtree

Validation

  • go test ./pkg/build ./pkg/index ./pkg/config ./pkg/linter ./pkg/cli
  • git diff --check origin/main...HEAD

Uploaded logs from restic and gzip build from wolfi os ditribution
melange_restic.txt
melange_gzip.txt

AI assistance

PR prepared with assistance from Claude Sonnet 5 and GPT-5.6-Luna.

Add support for a "noarch" architecture, mirroring Alpine APKBUILD's
arch=noarch: a package that does not depend on host-specific binary
code (Python programs, Java bytecode, etc.) is compiled once using
the host's native architecture and its apk output is replicated and
indexed into every requested per-architecture output directory as
arch=noarch, instead of being rebuilt once per --arch.

Implemented per docs/plans/noarch-architecture.md:

- Step 1 (pkg/config): reuse target-architecture: [noarch] as a third
  sentinel alongside "all"; enforce it must be the sole entry via a
  hard parse error; add Package.IsNoArch().

- Step 2 (pkg/build, pkg/cli): force the guest/toolchain arch to
  runtime.GOARCH for a noarch package regardless of --arch; collapse
  BuildCmd/TestCmd to construct exactly one *build.Build/*build.Test
  per invocation instead of one per requested arch; fix a pre-existing
  gap where `melange test` silently skipped every noarch package.

- Step 3 (pkg/build): add Build.PackageArch() (returns "noarch" for
  metadata/output while the real build arch stays untouched) and
  Build.ReplicateArchs/replicateTargets(); build once into a staging
  directory (a unique per-process os.MkdirTemp dir, not a fixed path,
  to avoid collisions between concurrent invocations sharing an
  --out-dir) and copy the result into every target arch's output
  directory; generalize the inline APKINDEX generation to loop over
  the replication targets (behaviorally identical to before for
  non-noarch builds).

- Step 4 (pkg/index): accept arch=noarch packages into any
  --arch-scoped `melange index` run via a small matchesExpectedArch
  helper, instead of rejecting them on a strict string mismatch.

- Step 5 (pkg/linter): new "noarch" linter (default: Require) that
  flags any ELF binary found in a package declared noarch; a
  guaranteed no-op for every other package.

Each step built with a worker/reviewer subagent pass; the reviewer
caught and the worker fixed two real bugs along the way (the
melange-test skip gap in step 2, and a staging-directory
collision/leak-on-failure pair in step 3).

Remaining per the plan: step 6 (SBOM arch, likely already covered by
step 3's PackageArch() wiring), step 7 (rebuild.go host-arch handling
for arch=noarch), and step 8 (end-to-end tests).
Step 6 of docs/plans/noarch-architecture.md (SBOM/PURL arch) turned out
to already be fully satisfied by step 3's Build.PackageArch() wiring:
BuildPackage's SBOM GeneratorContext.Arch already uses PackageArch(),
which flows into both the SPDX package's Arch field and its PURL arch
qualifier, and the SBOM is embedded inside the apk's data section (not
a sidecar file), so it's already covered by step 3's copy-replication.
No change needed there.

Reviewing turned up one adjacent, previously-missed spot: the FDO
package-metadata linker template (embedded via --package-metadata /
-Xlinker and the .note.package ELF note, consumed by SBOM/CVE
scanners for statically-linked or vendored code) still used
.Arch.ToAPK for its "architecture" field. Since the template's root
object is *Build itself, .PackageArch is a direct drop-in replacement.

This can't affect a *passing* noarch build in practice, since the new
"noarch" linter (step 5) already rejects any ELF binary in a package
declared noarch -- but it's an in-scope consistency fix, so apply it
here. Verified by exercising the template (not just compiling it,
since text/template method resolution isn't caught by go
build/vet): TestCreateFdoNoteHeader's literal "architecture":"x86_64"
assertion still passes unchanged for a normal build.
Both were verified via reviewer-only investigation rather than a
worker pass, since neither required a code change:

- Step 6 (SBOM arch) was already fully satisfied by step 3's
  Build.PackageArch() wiring; only a small adjacent fix (compiler_config.go's
  FDO package-metadata template) was needed, already committed
  separately.

- Step 7 (rebuild.go) is already handled end-to-end by step 2's
  shared build.New noarch-override, which fires identically for
  RebuildCmd's call path (via WithConfiguration setting the real
  embedded original config before the override runs) as it does for
  a normal `melange build --arch`.

Record the trace for both in the plan doc.
Add e2e-tests/noarch-build-test.yaml: a minimal, self-contained,
greeter-style package declaring target-architecture: [noarch], with a
real test: pipeline assertion (not a no-op) following the existing
tester-blob pattern.

Uses the existing -build-test filename convention (no run-tests/README
changes) so CI runs it exactly like every other e2e test, on the real
melange binary against a real runner. Its content is pure shell script
(no compiled/ELF binaries), so it also proves the new "noarch" linter
doesn't false-positive on legitimate content.

Verified the fixture parses through melange's real
config.ParseConfiguration (not just generic YAML syntax): IsNoArch()
is true, and both the build and test pipelines parse with the
expected step counts.

This is item 1 of the two-part step 8 e2e plan; item 2 (multi-arch
replication fan-out, requiring a small run-tests change) follows
separately.
…em 2)

Add e2e-tests/noarch-multiarch-build.yaml plus a small, additive
e2e-tests/run-tests change: a new *-multiarch-build filename
convention (matched before the existing generic *-build case) that
builds a noarch package with --arch=x86_64,aarch64 in one invocation,
then asserts both packages/x86_64/ and packages/aarch64/ received the
apk and their own APKINDEX.tar.gz.

This closes the one gap unit tests structurally couldn't reach: the
real CLI -> BuildCmd -> BuildPackage -> real runner -> real apk/index
write path for the "one build, N replicated arch directories" fan-out
added in an earlier step. Every other existing e2e-tests/*.yaml is
unaffected -- arch_flag defaults to ${ARCH} exactly as before unless a
file matches the new suffix, and the vrc success/failure control flow
is behaviorally identical to the prior one-line form.

Costs nothing extra in CI: ReplicateArchs (grepped, confirmed) is never
consumed by any container/runner/kernel-selection code, since the real
guest build for a noarch package always runs on the CI host's actual
native architecture regardless of how many arches are requested -- the
second "aarch64" arch here is purely a directory-copy target, no QEMU
emulation or kernel fetch involved.

Per review: the assertion hardcodes the exact fixture filename
(noarch-multiarch-test-1.0-r0.apk) rather than a glob. Kept as-is
deliberately -- it fails loud (not silently) if the fixture and script
ever drift out of sync, and a looser glob would risk passing against
stale leftover artifacts from a prior run.
…nd-break

BuildCmd used to always call build.New with WithArch(archs[0]) first,
learning only afterwards (via bc.Configuration.Package.IsNoArch())
whether to break out of the per-arch loop. Since archs defaults to
apko_types.AllArchs when --arch is omitted, and AllArchs[0] is "386",
a plain `melange build` on a noarch package logged a misleading
"ignoring requested architecture x86 for noarch package" -- the user
never requested x86, it was purely an artifact of loop ordering.

Extract New's config-resolution block (config-file auto-detection,
the ConfigFile/ConfigFileRepositoryURL/ConfigFileRepositoryCommit
validation, and the config.ParseConfiguration call) into
Build.resolveConfiguration, called by New at the exact same point the
inline block used to run -- a pure extraction, New's observable
behavior and error precedence are unchanged.

Add a narrow exported build.PeekIsNoArch(ctx, opts...) that resolves
just enough configuration to answer the one question BuildCmd needs
before deciding how many build contexts to construct, without any of
New's heavier setup. BuildCmd now branches explicitly: noarch builds
exactly one context with the real host arch supplied from the start
(runtime.GOARCH, not archs[0]), everything else keeps the original
per-arch loop (including ErrSkipThisArch handling) untouched.

Scoped to BuildCmd only, per discussion -- TestCmd/NewTest have an
analogous loop-and-break pattern with the same log-message
inaccuracy, left as a separate, intentional follow-up.
…loop

replicateTargets() only ever reads Build.ReplicateArchs when
Configuration.Package.IsNoArch() is true. The non-noarch branch of
BuildCmd only runs when build.PeekIsNoArch already returned false for
this exact configuration, so every *build.Build constructed there is
guaranteed non-noarch -- ReplicateArchs can never be consulted for
them. Setting it was inert; drop it so the branch doesn't imply
replication behavior that never applies here.
Same shape as the earlier BuildCmd refactor (560dd5c): NewTest always
parsed config unconditionally and only learned via
Configuration.Package.IsNoArch() after the fact whether to override
Arch to the host arch, and TestCmd looped over archs breaking early
once it hit a noarch config -- the same misleading "ignoring requested
architecture x86" log bug as BuildCmd had, since archs[0] defaults to
AllArchs[0] ("386") when --arch is omitted.

Extract NewTest's config-loading into Test.resolveConfiguration
(simpler than Build's -- no file auto-detection, no
ConfigFileRepositoryURL/Commit validation, those fields don't exist on
Test). Add build.PeekIsNoArchTest mirroring PeekIsNoArch. TestCmd now
peeks first and branches explicitly: noarch builds exactly one
*build.Test with the real host arch supplied last (after baseOpts, so
it always wins over anything baseOpts might set -- matching
BuildCmd's option ordering), everything else keeps the original
per-arch loop.

Also remove dead code found along the way: TestCmd's
errors.Is(err, build.ErrSkipThisArch) handling around each
build.NewTest call could never trigger -- that sentinel is defined and
returned only by build.New's TargetArchitecture filter, which NewTest
never implemented. The actual "is this arch in scope" decision for
melange test happens later, per already-constructed *Test, inside
TestPackage's own inarchs check.

Explicitly left alone: TestPackage's inarchs logic has a separate,
pre-existing, unrelated quirk where target-architecture: ["all"]
causes apko_types.ParseArchitecture("all") to never equal a real arch,
silently skipping all tests for that deprecated sentinel value. Not
introduced or touched by this change.
A noarch package's own .PKGINFO correctly and permanently says
arch = noarch -- that's provenance about how it was built. But an
APKINDEX.tar.gz describes one specific architecture's repository, and
every entry in e.g. packages/x86_64/APKINDEX.tar.gz should report
arch: x86_64, not the literal string "noarch", so apk clients
resolving against that arch-specific index see an ordinary matching
architecture without needing any noarch-aware special-casing.

In pkg/index/index.go's UpdateIndex, after the existing
matchesExpectedArch filter passes (filtering semantics unchanged),
rewrite the in-memory *apk.Package's Arch field from "noarch" to
idx.ExpectedArch when one was given. Standalone `melange index *.apk`
with no --arch flag has no concrete arch to stamp, so it's left as
literally "noarch" as before. This only mutates the in-memory struct
used for index serialization -- the apk file on disk and its embedded
.PKGINFO are never touched.

This alone couldn't fix a real `melange build`, though: the inline
per-arch index generation in pkg/build/build.go's BuildPackage never
set index.WithExpectedArch at all, so idx.ExpectedArch was always
empty there. Added index.WithExpectedArch(arch.ToAPK()) to that loop's
options -- a no-op for non-noarch builds (replicateTargets() returns
only the package's own single real arch there, which already matches
trivially), and the fix that actually makes every per-arch index
directory generated by a real build correctly stamp its noarch
packages now.

Tested by generalizing the existing real-fixture-mangling test helper
(mangleApk -> mangleApkControl) to byte-rewrite libcap-2.69-r0.apk's
actual embedded control section (arch = aarch64 -> arch = noarch,
same-length swap, no control-section corruption), producing a genuine
noarch apk run through the real UpdateIndex end-to-end -- not a
synthetic struct test.
@lioneloh

lioneloh commented Sep 3, 2026

Copy link
Copy Markdown
Author

could resolve #1002

@mosabua

mosabua commented Sep 3, 2026

Copy link
Copy Markdown
Member

I approved the workflow to run and added some engineers to review in more detail. I also pinged them on our internal slack

@mosabua
mosabua requested review from egibs, wlynch and xnox September 3, 2026 17:45
@xnox xnox self-assigned this Sep 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants