commit 940e2c245b4543acbc62b88ecbb796df085e9985
parent dc86b047637d6a93eb7bd3e4b17559450c3a6deb
Author: Robin Bron <robin.bron@yourhosting.nl>
Date: Thu, 24 Sep 2026 23:30:34 +0200
Keys, packages, pipeline, etc
Diffstat:
56 files changed, 2196 insertions(+), 539 deletions(-)
diff --git a/.conductor.yml b/.conductor.yml
@@ -0,0 +1,399 @@
+# GENERATED by mk/gen-pipeline.sh -- do not edit by hand.
+#
+# Regenerate with: ./mk/gen-pipeline.sh --write
+# Verified by: tests/unit/test_deps.sh
+#
+# One task per package, with `needs` taken from each template's makedepends,
+# so a package never builds before something it links against. The list is
+# written out rather than discovered because the conductor treats this file as
+# data and will not execute repository code to build its graph.
+#
+# No mounts are provided and artifacts are not shared between tasks. Each build
+# therefore seeds its sysroot from the published S3 repo (mk/sysroot.sh) and
+# publishes its own result back (mk/publish.sh). `needs` is what guarantees a
+# dependency is already published by the time a dependent starts.
+#
+# Credentials arrive as environment: BUCKET_ACCESS_KEY, BUCKET_SECRET_KEY,
+# BUCKET_NAME, BUCKET_ENDPOINT (URL), BUCKET_REGION (region name).
+#
+# Signing comes from the unos-sign-key worker feature, which mounts the key and
+# exports UNOS_KEY_APK_RSA_PRI. Each worker holds a distinct key, so the signer
+# varies with scheduling and packages/unos-keys must trust all of them.
+
+version: 1
+
+defaults:
+ image: debian:bookworm-slim
+ timeout: 2h
+ env:
+ DEBIAN_FRONTEND: noninteractive
+
+tasks:
+ check:
+ timeout: 30m
+ script:
+ - ./mk/ci-setup.sh
+ # Before deps.sh check, not after: muon is a declared hostmakedepends of
+ # apk-tools but is built here rather than installed from a distribution,
+ # so the check reports it missing until bootstrap has produced it.
+ - ./mk/bootstrap-host.sh
+ - ./mk/deps.sh check
+ - ./mk/gen-pipeline.sh --check
+ - ./tests/run.sh --unit
+
+ pkg-zlib:
+ arch: [x86_64]
+ requires: [unos-sign-key]
+ script:
+ - ./mk/ci-setup.sh
+ - ./mk/bootstrap-host.sh
+ - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" zlib
+ - ./mk/check-revision.sh zlib
+ - ./mk/build.sh zlib
+ - ./mk/publish.sh --arch "$ARCH" zlib
+
+ pkg-openssl:
+ arch: [x86_64]
+ requires: [unos-sign-key]
+ script:
+ - ./mk/ci-setup.sh
+ - ./mk/bootstrap-host.sh
+ - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" openssl
+ - ./mk/check-revision.sh openssl
+ - ./mk/build.sh openssl
+ - ./mk/publish.sh --arch "$ARCH" openssl
+
+ pkg-apk-tools:
+ needs:
+ - pkg-zlib
+ - pkg-openssl
+ arch: [x86_64]
+ requires: [unos-sign-key]
+ script:
+ - ./mk/ci-setup.sh
+ - ./mk/bootstrap-host.sh
+ - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" apk-tools
+ - ./mk/check-revision.sh apk-tools
+ - ./mk/build.sh apk-tools
+ - ./mk/publish.sh --arch "$ARCH" apk-tools
+
+ pkg-base-files:
+ arch: [x86_64]
+ requires: [unos-sign-key]
+ script:
+ - ./mk/ci-setup.sh
+ - ./mk/bootstrap-host.sh
+ - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" base-files
+ - ./mk/check-revision.sh base-files
+ - ./mk/build.sh base-files
+ - ./mk/publish.sh --arch "$ARCH" base-files
+
+ pkg-busybox:
+ arch: [x86_64]
+ requires: [unos-sign-key]
+ script:
+ - ./mk/ci-setup.sh
+ - ./mk/bootstrap-host.sh
+ - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" busybox
+ - ./mk/check-revision.sh busybox
+ - ./mk/build.sh busybox
+ - ./mk/publish.sh --arch "$ARCH" busybox
+
+ pkg-curl:
+ needs:
+ - pkg-openssl
+ - pkg-zlib
+ arch: [x86_64]
+ requires: [unos-sign-key]
+ script:
+ - ./mk/ci-setup.sh
+ - ./mk/bootstrap-host.sh
+ - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" curl
+ - ./mk/check-revision.sh curl
+ - ./mk/build.sh curl
+ - ./mk/publish.sh --arch "$ARCH" curl
+
+ pkg-dosfstools:
+ arch: [x86_64]
+ requires: [unos-sign-key]
+ script:
+ - ./mk/ci-setup.sh
+ - ./mk/bootstrap-host.sh
+ - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" dosfstools
+ - ./mk/check-revision.sh dosfstools
+ - ./mk/build.sh dosfstools
+ - ./mk/publish.sh --arch "$ARCH" dosfstools
+
+ pkg-e2fsprogs:
+ arch: [x86_64]
+ requires: [unos-sign-key]
+ script:
+ - ./mk/ci-setup.sh
+ - ./mk/bootstrap-host.sh
+ - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" e2fsprogs
+ - ./mk/check-revision.sh e2fsprogs
+ - ./mk/build.sh e2fsprogs
+ - ./mk/publish.sh --arch "$ARCH" e2fsprogs
+
+ pkg-efivar:
+ arch: [x86_64]
+ requires: [unos-sign-key]
+ script:
+ - ./mk/ci-setup.sh
+ - ./mk/bootstrap-host.sh
+ - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" efivar
+ - ./mk/check-revision.sh efivar
+ - ./mk/build.sh efivar
+ - ./mk/publish.sh --arch "$ARCH" efivar
+
+ pkg-popt:
+ arch: [x86_64]
+ requires: [unos-sign-key]
+ script:
+ - ./mk/ci-setup.sh
+ - ./mk/bootstrap-host.sh
+ - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" popt
+ - ./mk/check-revision.sh popt
+ - ./mk/build.sh popt
+ - ./mk/publish.sh --arch "$ARCH" popt
+
+ pkg-efibootmgr:
+ needs:
+ - pkg-efivar
+ - pkg-popt
+ arch: [x86_64]
+ requires: [unos-sign-key]
+ script:
+ - ./mk/ci-setup.sh
+ - ./mk/bootstrap-host.sh
+ - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" efibootmgr
+ - ./mk/check-revision.sh efibootmgr
+ - ./mk/build.sh efibootmgr
+ - ./mk/publish.sh --arch "$ARCH" efibootmgr
+
+ pkg-glibc:
+ arch: [x86_64]
+ requires: [unos-sign-key]
+ script:
+ - ./mk/ci-setup.sh
+ - ./mk/bootstrap-host.sh
+ - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" glibc
+ - ./mk/check-revision.sh glibc
+ - ./mk/build.sh glibc
+ - ./mk/publish.sh --arch "$ARCH" glibc
+
+ pkg-libstdcxx:
+ arch: [x86_64]
+ requires: [unos-sign-key]
+ script:
+ - ./mk/ci-setup.sh
+ - ./mk/bootstrap-host.sh
+ - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" libstdc++
+ - ./mk/check-revision.sh libstdc++
+ - ./mk/build.sh libstdc++
+ - ./mk/publish.sh --arch "$ARCH" libstdc++
+
+ pkg-gptfdisk:
+ needs:
+ - pkg-e2fsprogs
+ - pkg-popt
+ - pkg-libstdcxx
+ arch: [x86_64]
+ requires: [unos-sign-key]
+ script:
+ - ./mk/ci-setup.sh
+ - ./mk/bootstrap-host.sh
+ - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" gptfdisk
+ - ./mk/check-revision.sh gptfdisk
+ - ./mk/build.sh gptfdisk
+ - ./mk/publish.sh --arch "$ARCH" gptfdisk
+
+ pkg-xz:
+ arch: [x86_64]
+ requires: [unos-sign-key]
+ script:
+ - ./mk/ci-setup.sh
+ - ./mk/bootstrap-host.sh
+ - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" xz
+ - ./mk/check-revision.sh xz
+ - ./mk/build.sh xz
+ - ./mk/publish.sh --arch "$ARCH" xz
+
+ pkg-grub:
+ needs:
+ - pkg-xz
+ arch: [x86_64]
+ requires: [unos-sign-key]
+ script:
+ - ./mk/ci-setup.sh
+ - ./mk/bootstrap-host.sh
+ - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" grub
+ - ./mk/check-revision.sh grub
+ - ./mk/build.sh grub
+ - ./mk/publish.sh --arch "$ARCH" grub
+
+ pkg-libmnl:
+ arch: [x86_64]
+ requires: [unos-sign-key]
+ script:
+ - ./mk/ci-setup.sh
+ - ./mk/bootstrap-host.sh
+ - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" libmnl
+ - ./mk/check-revision.sh libmnl
+ - ./mk/build.sh libmnl
+ - ./mk/publish.sh --arch "$ARCH" libmnl
+
+ pkg-libcap:
+ arch: [x86_64]
+ requires: [unos-sign-key]
+ script:
+ - ./mk/ci-setup.sh
+ - ./mk/bootstrap-host.sh
+ - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" libcap
+ - ./mk/check-revision.sh libcap
+ - ./mk/build.sh libcap
+ - ./mk/publish.sh --arch "$ARCH" libcap
+
+ pkg-iproute2:
+ needs:
+ - pkg-libmnl
+ - pkg-libcap
+ arch: [x86_64]
+ requires: [unos-sign-key]
+ script:
+ - ./mk/ci-setup.sh
+ - ./mk/bootstrap-host.sh
+ - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" iproute2
+ - ./mk/check-revision.sh iproute2
+ - ./mk/build.sh iproute2
+ - ./mk/publish.sh --arch "$ARCH" iproute2
+
+ pkg-linkd:
+ arch: [x86_64]
+ requires: [unos-sign-key]
+ script:
+ - ./mk/ci-setup.sh
+ - ./mk/bootstrap-host.sh
+ - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" linkd
+ - ./mk/check-revision.sh linkd
+ - ./mk/build.sh linkd
+ - ./mk/publish.sh --arch "$ARCH" linkd
+
+ pkg-linux-longterm:
+ arch: [x86_64]
+ requires: [unos-sign-key]
+ script:
+ - ./mk/ci-setup.sh
+ - ./mk/bootstrap-host.sh
+ - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" linux-longterm
+ - ./mk/check-revision.sh linux-longterm
+ - ./mk/build.sh linux-longterm
+ - ./mk/publish.sh --arch "$ARCH" linux-longterm
+
+ pkg-linux-mainline:
+ arch: [x86_64]
+ requires: [unos-sign-key]
+ script:
+ - ./mk/ci-setup.sh
+ - ./mk/bootstrap-host.sh
+ - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" linux-mainline
+ - ./mk/check-revision.sh linux-mainline
+ - ./mk/build.sh linux-mainline
+ - ./mk/publish.sh --arch "$ARCH" linux-mainline
+
+ pkg-linux-stable:
+ arch: [x86_64]
+ requires: [unos-sign-key]
+ script:
+ - ./mk/ci-setup.sh
+ - ./mk/bootstrap-host.sh
+ - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" linux-stable
+ - ./mk/check-revision.sh linux-stable
+ - ./mk/build.sh linux-stable
+ - ./mk/publish.sh --arch "$ARCH" linux-stable
+
+ pkg-smartmontools:
+ needs:
+ - pkg-libstdcxx
+ arch: [x86_64]
+ requires: [unos-sign-key]
+ script:
+ - ./mk/ci-setup.sh
+ - ./mk/bootstrap-host.sh
+ - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" smartmontools
+ - ./mk/check-revision.sh smartmontools
+ - ./mk/build.sh smartmontools
+ - ./mk/publish.sh --arch "$ARCH" smartmontools
+
+ pkg-tinyssh:
+ arch: [x86_64]
+ requires: [unos-sign-key]
+ script:
+ - ./mk/ci-setup.sh
+ - ./mk/bootstrap-host.sh
+ - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" tinyssh
+ - ./mk/check-revision.sh tinyssh
+ - ./mk/build.sh tinyssh
+ - ./mk/publish.sh --arch "$ARCH" tinyssh
+
+ pkg-unos-firstboot:
+ arch: [x86_64]
+ requires: [unos-sign-key]
+ script:
+ - ./mk/ci-setup.sh
+ - ./mk/bootstrap-host.sh
+ - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" unos-firstboot
+ - ./mk/check-revision.sh unos-firstboot
+ - ./mk/build.sh unos-firstboot
+ - ./mk/publish.sh --arch "$ARCH" unos-firstboot
+
+ pkg-unos-keys:
+ arch: [x86_64]
+ requires: [unos-sign-key]
+ script:
+ - ./mk/ci-setup.sh
+ - ./mk/bootstrap-host.sh
+ - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" unos-keys
+ - ./mk/check-revision.sh unos-keys
+ - ./mk/build.sh unos-keys
+ - ./mk/publish.sh --arch "$ARCH" unos-keys
+
+ index:
+ needs:
+ - pkg-zlib
+ - pkg-openssl
+ - pkg-apk-tools
+ - pkg-base-files
+ - pkg-busybox
+ - pkg-curl
+ - pkg-dosfstools
+ - pkg-e2fsprogs
+ - pkg-efivar
+ - pkg-popt
+ - pkg-efibootmgr
+ - pkg-glibc
+ - pkg-libstdcxx
+ - pkg-gptfdisk
+ - pkg-xz
+ - pkg-grub
+ - pkg-libmnl
+ - pkg-libcap
+ - pkg-iproute2
+ - pkg-linkd
+ - pkg-linux-longterm
+ - pkg-linux-mainline
+ - pkg-linux-stable
+ - pkg-smartmontools
+ - pkg-tinyssh
+ - pkg-unos-firstboot
+ - pkg-unos-keys
+ arch: [x86_64]
+ requires: [unos-sign-key]
+ script:
+ - ./mk/ci-setup.sh
+ - ./mk/bootstrap-host.sh
+ # Pull every published package down so the index describes the whole
+ # repo, not just whatever this container happened to build.
+ - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH"
+ - ./mk/repo-index.sh
+ - ./mk/publish.sh --arch "$ARCH" --index-only
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,13 @@
+# build trees (sources, work dirs, sysroot, host tools, output repo)
+/build/
+
+# local package-signing key selection (see mk/keymgmt.sh use)
+/.sign-key
+
+# compiled driver helper
+/mk/pax-tar
+
+# editor / os noise
+*.swp
+*~
+.DS_Store
diff --git a/README.md b/README.md
@@ -0,0 +1,135 @@
+unos-repository
+===============
+
+The UNOS package repository: every `.apk` that makes up the OS, the tooling
+that builds and signs them, and the CI pipeline that publishes them.
+
+This repo produces a signed apk repository. It does not assemble a rootfs, an
+image or an installer -- that is the `unos` repo, which consumes what is
+published here over HTTP. The `unosd` daemon's source lives in its own repo and
+arrives here as a tag-pinned tarball like any other dependency.
+
+Published to `https://repo.unos.finwo.net/<arch>`.
+
+
+Layout
+------
+
+ packages/<name>/template build recipe (sourced by mk/build.sh)
+ packages/<name>/files/ static files installed by the template
+ packages/<name>/patches/ *.patch, applied in order to WRKSRC
+ mk/ the build driver and its tooling
+ .conductor.yml CI pipeline, GENERATED from the dep graph
+
+
+Building
+--------
+
+ ./mk/bootstrap-host.sh build pinned muon + apk into build/host/bin
+ ./mk/build.sh <pkg> build one package
+ ./mk/build-all.sh build everything in dependency order
+ ./mk/repo-index.sh (re)generate and sign APKINDEX.tar.gz
+
+`mk/bootstrap-host.sh` must run first: `apk-tools` declares `muon` as a
+hostmakedepends, and it is built here rather than installed from a
+distribution, so `mk/deps.sh check` reports it missing until bootstrap has
+produced it.
+
+See `mk/README.md` for the full tooling reference and `packages/README.md` for
+the template format.
+
+
+Where source comes from
+-----------------------
+
+Two mutually exclusive mechanisms, kept apart deliberately:
+
+`distfiles=` is third-party upstream source. It **must** carry a matching
+`checksum=` sha256; `mk/build.sh` refuses to build without one. We are trusting
+a server nobody here controls, so the tarball is pinned by content.
+
+`srcfiles=` is our own source, fetched by tag from `git.finwo.net`. It carries
+no checksum: the tag is the pin and TLS is the transport, and a forge
+regenerates tag archives, so they are not byte-stable and a sha256 would fail
+for reasons indistinguishable from tampering. Only `unosd` uses this today.
+
+Keeping them separate means "no checksum" can never silently apply to an
+upstream tarball.
+
+Cached source lives in `build/work/sources/`, and is only fetched when absent.
+`srcfiles` entries are cached under `<pkg>-<basename>` because these URLs end
+in the tag, so the bare basename is often just `v0.1.0.tar.gz` and two
+first-party packages at the same version would otherwise share one entry.
+
+**While git.finwo.net does not yet serve tag archives**, seed the cache by
+hand and the fetch is skipped:
+
+ git -C ../unosd archive --format=tar --prefix=unosd/ HEAD \
+ | gzip -n > build/work/sources/unosd-v0.1.0.tar.gz
+
+
+Signing
+-------
+
+Packages and the index are signed with RSA-4096 (apk mandates RSA). The key is
+resolved by `mk/sign-key.inc`, in order:
+
+ $UNOS_KEY_APK_RSA_PRI provided by the unos-sign-key CI worker feature
+ $UNOS_SIGN_KEY manual override
+ ./.sign-key local selection, via mk/keymgmt.sh use
+ the single *.rsa in ~/.unos-keys/
+
+`mk/build.sh` names the signature record after the key that signed it
+(`.SIGN.RSA.<keyname>.rsa.pub`), and the apk client verifies it against
+`/etc/apk/keys/<keyname>.rsa.pub`, shipped by `packages/unos-keys`.
+
+**Every CI worker has a distinct keypair**, so the signer is whichever worker
+the conductor scheduled the build on. `packages/unos-keys` therefore trusts all
+four. Ship fewer and verification fails for an arbitrary subset of packages
+that changes run to run -- a failure that looks like corruption rather than a
+missing key. Adding a worker means publishing `unos-keys` *before* that worker
+builds anything.
+
+Any one worker can consequently sign packages every client trusts. The intended
+end state is a release step that re-signs with a single key and merely accepts
+these as inputs; it does not exist yet.
+
+
+CI
+--
+
+`.conductor.yml` is **generated** -- do not edit it:
+
+ ./mk/gen-pipeline.sh --write regenerate
+ ./mk/gen-pipeline.sh --check fail if stale (run by tests/unit/test_deps.sh)
+
+The conductor treats the pipeline as data and will not execute repository code
+to build its task graph, so the per-package `needs` edges have to be written
+out literally. Generating them from `makedepends` keeps the templates the
+single source of truth; drift is a test failure rather than a package building
+against a stale dependency.
+
+Tasks run in ephemeral containers with no mounts and no artifact sharing, so
+each package seeds its sysroot from the published repo (`mk/sysroot.sh`) and
+publishes its own result (`mk/publish.sh`). `needs` provides ordering only.
+
+The pipeline requires a worker advertising the **`unos-sign-key`** feature,
+which mounts the signing key and exports `UNOS_KEY_APK_RSA_PRI`,
+`UNOS_KEY_APK_RSA_PUB`, `UNOS_KEY_SUPERCOP_PRI` and `UNOS_KEY_SUPERCOP_PUB`.
+Feature names are global to the conductor, which hosts more than UNOS, hence
+the project prefix. A task requiring a feature no worker advertises is never
+scheduled -- it does not fail, it silently never runs.
+
+S3 credentials arrive as `BUCKET_ACCESS_KEY`, `BUCKET_SECRET_KEY`,
+`BUCKET_NAME`, `BUCKET_ENDPOINT` (a URL) and `BUCKET_REGION` (a region name).
+
+
+Testing
+-------
+
+ ./tests/run.sh
+
+Graph validation, pipeline freshness, applet policy and packaging layout. Tests
+that need a package built will skip rather than fail when it is absent, so the
+suite is meaningful in a fresh container; anything needing a booted machine
+lives in the `unos` repo.
diff --git a/mk/README.md b/mk/README.md
@@ -10,9 +10,28 @@ The whole of `build/` is generated output. Only `mk/` is source.
a single amalgamated C file, so the chain terminates at a C compiler) and
`apk`. `--check` verifies presence *and version* and is the gate CI should
run first. Everything else assumes these exist.
-- `deps.sh {list|vars|graph|order|check}` -- the build-dependency graph, read
- from each template's `makedepends`. `order` is a topological sort; `check`
- validates it (dangling deps, self-deps, cycles, missing hostmakedepends).
+- `deps.sh {list|vars|graph|order|rdeps|changed|check}` -- the build-dependency
+ graph, read from each template's `makedepends`. `order` is a topological
+ sort; `check` validates it (dangling deps, self-deps, cycles, missing
+ hostmakedepends).
+- `gen-pipeline.sh [--write|--check]` -- emits `.conductor.yml` from that
+ graph. The pipeline is data (the conductor will not run repository code to
+ build its task graph), so the per-package `needs` edges must be written out
+ literally; generating them keeps `makedepends` the single source of truth.
+ `--check` is run by `tests/unit/test_deps.sh`, so a template change that is
+ not reflected in the pipeline fails the suite.
+- `ci-setup.sh [--check]` -- installs the build toolchain into an ephemeral CI
+ container, then verifies every `hostmakedepends` in the graph resolves. That
+ verification is the point: it stops the apt list falling behind a template.
+- `sysroot.sh` -- seed `build/sysroot` from the published apk repo, so a CI
+ container can build one package without rebuilding the other 26.
+- `publish.sh [--arch A] [--index-only] [pkg...]` -- upload `.apk` files and
+ `APKINDEX.tar.gz` to the S3 bucket serving the public repo, using
+ `curl --aws-sigv4` (no awscli or rclone). Reads `BUCKET_ACCESS_KEY`,
+ `BUCKET_SECRET_KEY`, `BUCKET_NAME`, `BUCKET_ENDPOINT` (URL) and
+ `BUCKET_REGION` (region name); it rejects those last two transposed, since
+ the natural symptom is an opaque signature mismatch. The index is always
+ uploaded last, so it never advertises packages that are not there yet.
- `build-all.sh [--dry-run] [--skip-built] [pkg...]` -- build everything, or
one package and its closure, in `deps.sh order`.
- `build.sh <pkg>` -- the pipeline: source template -> check
@@ -98,7 +117,6 @@ index. Current keys (dev estate, rotate before any release):
## Host dependencies
sh, cc, curl or wget, tar, gzip, openssl, sha256sum, install, patch.
-`unshare` (user namespaces) for `chroot.sh` and `rootfs.sh`.
apk-tools and muon are **not** host prerequisites -- `bootstrap-host.sh` builds
them into `build/host/bin` from pinned sources.
@@ -116,20 +134,15 @@ A clean build from nothing is therefore:
./mk/bootstrap-host.sh # muon + apk into build/host/bin
./mk/build-all.sh # every package, in dependency order
./mk/repo-index.sh # signed APKINDEX
-./mk/rootfs.sh --force # assemble rootfs/
+./mk/publish.sh # upload packages + index to S3
```
-## Rootfs (`rootfs.sh`, `chroot.sh`)
+## Assembling an OS
-`mk/rootfs.sh [--force]` assembles `rootfs/` (ignored) with static apk
-alone - no xbps, no foreign content: `unos-keys` via one explicit
-`--allow-untrusted` (the trust seed), then `base-files`, `glibc`,
-`busybox`, `unos-firstboot` fully trusted.
-`mk/chroot.sh [--] <cmd>` enters it via `unshare --user --map-root-user
---mount --pid` with host `/proc` `/sys` `/dev` bound; everything evaporates
-on exit, no root needed. Standing acceptance: ash runs, required applets
-resolve, `ip` absent, `runsvdir` supervises (`sv status`/`sv stop`),
-`tcpsvd` accepts, `firstboot` is `sh -n` clean.
+Not here. `rootfs.sh`, `chroot.sh`, `make-img.sh`, `run-qemu.sh` and the
+installer live in the `unos` repo, which consumes the published repository over
+HTTP rather than reaching into `build/repo` on a shared filesystem. This repo
+produces packages and stops there.
## Host apk-tools v3 (bootstrap input, pinned)
@@ -141,15 +154,16 @@ meson** (Python is banned from our tooling; upstream supports muon):
- dynamic, prefix `build/host/` (`-Dlua=disabled -Dpython=disabled
-Dhelp=disabled -Ddocs=disabled`, rpath `$ORIGIN/../lib`):
- `build/host/bin/apk`, used by `repo-index.sh` for index generation and by
- `rootfs.sh` for assembly.
+ `build/host/bin/apk`, used by `repo-index.sh` for index generation. The
+ `unos` repo bootstraps its own copy for rootfs assembly.
There is deliberately **no `apk.static`**. An earlier revision built one and
justified it here as "the future target binary - no libcrypto needed on the
switch". That was already wrong: UNOS packages `openssl`, so `libcrypto.so.3`
and `libssl.so.3` are present on the switch and the target `apk` links them
-dynamically. Its only consumer was `rootfs.sh`, which produces a byte-identical
-rootfs with the dynamic binary. Building it cost 10.6MB against 2.0MB and
+dynamically. Its only consumer was rootfs assembly, which produces a
+byte-identical rootfs with the dynamic binary. Building it cost 10.6MB against
+2.0MB and
required `libcrypto.a`/`libssl.a`/`libz.a`/`libzstd.a` on the build host --
static libraries that most CI containers do not ship, turning a cosmetic
preference into a hard bootstrap failure. If a static apk is ever wanted for
diff --git a/mk/bootstrap-host.sh b/mk/bootstrap-host.sh
@@ -7,14 +7,19 @@
#
# Produces:
# build/host/bin/muon pinned muon, bootstrapped from C (no Python)
-# build/host/bin/apk dynamic apk v3, used by repo-index.sh/rootfs.sh
+# build/host/bin/apk dynamic apk v3, used by repo-index.sh
#
+# This file is duplicated in the unos repo, which needs the same pinned apk to
+# assemble a rootfs. The alternatives -- a submodule, or fetching a bootstrap
+# script over the network in order to bootstrap -- are both worse than two
+# copies of a file that only changes when a pin changes.
+#
# WHY THIS EXISTS
# ---------------
-# These three binaries are the input to every other build: mk/build.sh puts
-# build/host/bin on PATH, repo-index.sh generates APKINDEX with `apk index`,
-# and rootfs.sh installs with `apk add`. Until now they were built by hand
+# These binaries are the input to every other build: mk/build.sh puts
+# build/host/bin on PATH and repo-index.sh generates APKINDEX with
+# `apk index`. Until now they were built by hand
# following prose in mk/README.md, which meant a fresh checkout or a CI
# container could not reproduce the toolchain at all. This script is that
# prose, executable.
@@ -197,7 +202,7 @@ msg "apk -> ${HOSTDIR}/bin/apk"
# - no libcrypto needed on the switch". That justification was already false:
# UNOS packages openssl, so libcrypto.so.3/libssl.so.3 are on the switch and
# the target apk links them dynamically like everything else. Its only real
-# consumer was mk/rootfs.sh, which works identically with the dynamic binary
+# consumer was rootfs assembly, which works identically with the dynamic binary
# (verified: same 24 packages, same rootfs).
#
# Building it cost 10.6MB versus 2.0MB and, worse, required libcrypto.a,
diff --git a/mk/build-all.sh b/mk/build-all.sh
@@ -17,16 +17,22 @@ ROOT=$(cd "${HERE}/.." && pwd)
DRY=0
SKIP=0
+ARCH="x86_64"
PKGS=""
for a in "$@"; do
case "$a" in
--dry-run) DRY=1 ;;
--skip-built) SKIP=1 ;;
+ --arch) ARCH="$2"; shift 2; continue ;;
+ --arch=*) ARCH="${a#--arch=}"; continue ;;
-h|--help) sed -n '2,10p' "$0"; exit 0 ;;
-*) echo "build-all.sh: unknown option: $a" >&2; exit 1 ;;
*) PKGS="${PKGS} $a" ;;
esac
done
+case "${ARCH}" in arm64) ARCH=aarch64 ;; esac
+case "${ARCH}" in x86_64|aarch64) ;; *) die "unsupported ARCH: ${ARCH}" >&2; exit 1 ;; esac
+export ARCH
msg() { printf '==> %s\n' "$*"; }
die() { printf 'build-all.sh: error: %s\n' "$*" >&2; exit 1; }
@@ -50,15 +56,15 @@ skipped=0
for p in ${ORDER}; do
n=$((n + 1))
- if [ "${SKIP}" = "1" ] && ls "${ROOT}/build/repo/x86_64/${p}"-*.apk >/dev/null 2>&1; then
+ if [ "${SKIP}" = "1" ] && ls "${ROOT}/build/repo/${ARCH}/${p}"-*.apk >/dev/null 2>&1; then
msg "[${n}/${total}] ${p}: already built, skipping"
skipped=$((skipped + 1))
continue
fi
- msg "[${n}/${total}] ${p}"
- "${ROOT}/mk/build.sh" "${p}" || die "${p} failed"
+ msg "[${n}/${total}] ${p} (${ARCH})"
+ "${ROOT}/mk/build.sh" "${p}" "${ARCH}" || die "${p} failed"
built=$((built + 1))
done
-msg "built ${built}, skipped ${skipped}, of ${total}"
-msg "now run ./mk/repo-index.sh to refresh APKINDEX"
+msg "built ${built}, skipped ${skipped}, of ${total} (ARCH=${ARCH})"
+msg "now run ./mk/repo-index.sh ${ARCH} to refresh APKINDEX"
diff --git a/mk/build.sh b/mk/build.sh
@@ -26,6 +26,13 @@ license=
homepage=
distfiles=
checksum=
+# srcfiles: first-party source fetched by immutable ref (tag) from our own
+# forge. Deliberately NOT checksummed -- the tag is the pin and TLS is the
+# transport. distfiles= stays reserved for third-party upstream releases,
+# which must always carry a sha256, because there we are trusting a server
+# nobody here controls. Keeping the two apart means "no checksum" can never
+# silently apply to an upstream tarball.
+srcfiles=
depends=
# makedepends: other UNOS packages whose headers/libs must be in build/sysroot
# before this one compiles. Determines build order (see mk/deps.sh).
@@ -74,8 +81,18 @@ vmkdir() { install -d "${DESTDIR}/$1"; }
[ -n "${short_desc}" ] || die "template sets no short_desc"
PKGVER="${version}-r${revision}"
-ARCH="x86_64"
+# ARCH: target arch, default x86_64. Conductor dispatches per-arch; local
+# `ARCH=aarch64 ./mk/build.sh <pkg>` or `./mk/build.sh <pkg> aarch64` both work.
+# Normalise arm64 -> aarch64.
+if [ $# -ge 2 ]; then
+ case "$2" in x86_64|aarch64|arm64) ARCH="$2";; *) ARCH="${ARCH:-x86_64}";; esac
+else
+ ARCH="${ARCH:-x86_64}"
+fi
+case "${ARCH}" in arm64) ARCH=aarch64 ;; esac
+case "${ARCH}" in x86_64|aarch64) ;; *) die "unsupported ARCH: ${ARCH} (want x86_64 or aarch64)" ;; esac
OUTDIR="${ROOT}/build/repo/${ARCH}"
+export ARCH
mkdir -p "${SRCDEST}" "${WORK}" "${OUTDIR}" "${SYSROOT}"
@@ -100,18 +117,48 @@ for _m in ${makedepends}; do
# Presence of a built .apk is the signal that its sysroot seed has been
# installed; templates that other packages build against install twice
# (DESTDIR + SYSROOT), so one implies the other.
+ # When sysroot is seeded via mk/sysroot.sh (CI), the .apk may not be
+ # present locally but its headers are in SYSROOT; check there as fallback.
if ! ls "${OUTDIR}/${_m}"-*.apk >/dev/null 2>&1; then
- die "makedepends '${_m}' has not been built.
+ # Fallback: if sysroot already has the dep's headers/.pc, allow it.
+ # This is the CI incremental path (sysroot seeded from published repo).
+ if [ -d "${SYSROOT}/usr/include" ] && [ -f "${SYSROOT}/usr/lib/pkgconfig/${_m}.pc" ]; then
+ msg "makedepends '${_m}': not built locally, but found in sysroot (CI seed)"
+ elif ls "${SYSROOT}/usr/include/${_m}" >/dev/null 2>&1; then
+ msg "makedepends '${_m}': not built locally, but found in sysroot"
+ else
+ die "makedepends '${_m}' has not been built.
Build it first: ./mk/build.sh ${_m}
- Or build in order: ./mk/build-all.sh ${pkgname}"
+ Or build in order: ./mk/build-all.sh ${pkgname}
+ Or seed sysroot: ./mk/sysroot.sh ${pkgname}"
+ fi
fi
done
+# --- revision-bump guard ---------------------------------------------------
+# Enforce "revision bump required" policy: if this exact version-rrev already
+# exists in the repo, refuse unless UNOS_ALLOW_OVERWRITE=1 (local iteration).
+if [ "${UNOS_ALLOW_OVERWRITE:-0}" != "1" ]; then
+ if [ -f "${OUTDIR}/${pkgname}-${PKGVER}.apk" ]; then
+ die "${pkgname}-${PKGVER}.apk already exists in ${OUTDIR}/
+ bump revision in packages/${PKG}/template before rebuilding
+ (or UNOS_ALLOW_OVERWRITE=1 ./mk/build.sh ${PKG} to overwrite locally)"
+ fi
+fi
+
# fresh staging every build: templates must be idempotent AND ghost-free
rm -rf "${DESTDIR}"
mkdir -p "${DESTDIR}"
# --- fetch + verify ---
+# A template uses one or the other, never both: whichever is set provides the
+# primary tarball that WRKSRC is derived from, and having two sources would
+# make that choice silent and arbitrary.
+if [ -n "${distfiles}" ] && [ -n "${srcfiles}" ]; then
+ die "template sets both distfiles and srcfiles; use srcfiles for our own
+ tag-pinned source, distfiles for checksummed third-party releases"
+fi
+
if [ -n "${distfiles}" ]; then
# checksums are space-separated, in the same order as distfiles
set -- ${checksum}
@@ -127,11 +174,33 @@ if [ -n "${distfiles}" ]; then
done
fi
+if [ -n "${srcfiles}" ]; then
+ [ -z "${checksum}" ] || die "srcfiles is tag-pinned and must not carry a checksum
+ (a tag archive is regenerated by the forge and is not byte-stable)"
+ for url in ${srcfiles}; do
+ # Namespaced by package on purpose. These URLs end in the tag, so the
+ # basename is typically just 'v0.1.0.tar.gz' -- two first-party packages
+ # at the same version would otherwise silently share one cache entry and
+ # build each other's source.
+ f=${SRCDEST}/${PKG}-$(basename "${url}")
+ if [ ! -f "${f}" ]; then
+ msg "fetching $(basename "${url}") for ${PKG} (first-party, tag-pinned)"
+ curl -fL -o "${f}" "${url}" || wget -O "${f}" "${url}"
+ fi
+ done
+fi
+
# --- extract (first tarball) + patches ---
-if [ -n "${distfiles}" ]; then
- set -- ${distfiles}
+if [ -n "${distfiles}" ] || [ -n "${srcfiles}" ]; then
+ if [ -n "${srcfiles}" ]; then
+ set -- ${srcfiles}
+ _tarball=${SRCDEST}/${PKG}-$(basename "$1")
+ else
+ set -- ${distfiles}
+ _tarball=${SRCDEST}/$(basename "$1")
+ fi
mkdir -p "${WORK}/src"
- tar -xf "${SRCDEST}/$(basename "$1")" -C "${WORK}/src"
+ tar -xf "${_tarball}" -C "${WORK}/src"
# WRKSRC: single top-level dir if the tarball has exactly one, else src/
n=$(ls -A "${WORK}/src" | wc -l)
if [ "${n}" = "1" ] && [ -d "${WORK}/src/$(ls -A "${WORK}/src")" ]; then
@@ -163,17 +232,19 @@ msg "${pkgname}: do_install"
do_install
# --- sysroot .pc fixup (central convention; templates stop hand-rolling it)
-# A sysroot .pc must point at the sysroot, not /. Rewriting only prefix= is
-# not enough: many upstream .pc files spell libdir=/includedir= literally
-# (efivar does; the build of efibootmgr then picked host headers). Fix
-# every level, every .pc, every build. Idempotent.
-for _pc in "${SYSROOT}"/usr/lib/pkgconfig/*.pc; do
+# Delegates to mk/pc-fixup.sh so the same logic is reused by mk/sysroot.sh.
+"${HERE}/pc-fixup.sh" "${SYSROOT}"
+
+# Verify it took, for every .pc rather than the two packages that used to
+# assert it themselves. Those assertions lived in do_install, which runs
+# *before* this fixup, so they could only ever pass on a sysroot left dirty by
+# an earlier build -- they passed locally for months and failed on the first
+# clean tree. A consumer that picks up an unfixed .pc silently compiles
+# against host headers, which is the failure this guards.
+for _pc in "${SYSROOT}"/usr/lib/pkgconfig/*.pc "${SYSROOT}"/usr/lib64/pkgconfig/*.pc; do
[ -e "${_pc}" ] || continue
- sed -i \
- -e "s|^prefix=.*|prefix=${SYSROOT}/usr|" \
- -e "s|^libdir=.*|libdir=${SYSROOT}/usr/lib|" \
- -e "s|^includedir=.*|includedir=${SYSROOT}/usr/include|" \
- "${_pc}"
+ grep -q "^prefix=${SYSROOT}/usr\$" "${_pc}" \
+ || die "sysroot .pc not fixed: ${_pc} (mk/pc-fixup.sh did not rewrite prefix=)"
done
# --- signing key: UNOS_SIGN_KEY > .sign-key > single key in ~/.unos-keys ---
@@ -224,7 +295,7 @@ emit_apk() {
printf 'builddate = %s\n' "${SOURCE_DATE_EPOCH}"
printf 'packager = %s\n' "UNOS build driver (mk/build.sh)"
printf 'size = %s\n' "$(find "${_stage}" -type f -printf '%s\n' | awk '{s+=$1} END {print s+0}')"
- printf 'arch = %s\n' "x86_64"
+ printf 'arch = %s\n' "${ARCH}"
printf 'origin = %s\n' "${pkgname}"
printf 'maintainer = %s\n' "${maintainer}"
printf 'license = %s\n' "${license}"
diff --git a/mk/check-keysize.sh b/mk/check-keysize.sh
@@ -0,0 +1,43 @@
+#!/bin/sh
+# mk/check-keysize.sh - enforce 4096-bit minimum for RSA signing keys.
+#
+# ./mk/check-keysize.sh <pubkey-or-privkey> [minbits]
+# ./mk/check-keysize.sh --check <pubkey> # for conductor allowlist insert
+#
+# Used by the conductor admin API and by mk/keymgmt.sh new (defence in depth).
+# Rejects keys < 4096 bits; accepts >= 4096.
+set -eu
+
+MINBITS="${2:-4096}"
+KEY="${1:-}"
+
+if [ "${KEY}" = "--check" ]; then
+ KEY="${2:-}"
+ MINBITS="${3:-4096}"
+fi
+
+[ -n "${KEY}" ] || { echo "usage: check-keysize.sh <keyfile> [minbits]" >&2; exit 1; }
+[ -f "${KEY}" ] || { echo "check-keysize.sh: not found: ${KEY}" >&2; exit 1; }
+
+# Detect whether this is a private or public key by trying both.
+bits=""
+if openssl rsa -in "${KEY}" -text -noout 2>/dev/null | grep -q "Private-Key"; then
+ bits=$(openssl rsa -in "${KEY}" -text -noout 2>/dev/null | sed -n 's/.*Private-Key: (\([0-9]*\) bit.*/\1/p' | head -1)
+elif openssl rsa -pubin -in "${KEY}" -text -noout 2>/dev/null | grep -q "Public-Key"; then
+ bits=$(openssl rsa -pubin -in "${KEY}" -text -noout 2>/dev/null | sed -n 's/.*Public-Key: (\([0-9]*\) bit.*/\1/p' | head -1)
+else
+ # Try pkey (handles both)
+ bits=$(openssl pkey -in "${KEY}" -text -noout 2>/dev/null | sed -n 's/.*Private-Key: (\([0-9]*\) bit.*/\1/p; s/.*Public-Key: (\([0-9]*\) bit.*/\1/p' | head -1)
+fi
+
+if [ -z "${bits}" ]; then
+ echo "check-keysize.sh: could not determine key size for ${KEY}" >&2
+ exit 1
+fi
+
+if [ "${bits}" -lt "${MINBITS}" ]; then
+ echo "check-keysize.sh: key too small: ${bits} bits < ${MINBITS} minimum: ${KEY}" >&2
+ exit 1
+fi
+
+echo "check-keysize.sh: OK ${bits} bits >= ${MINBITS}: ${KEY}"
diff --git a/mk/check-revision.sh b/mk/check-revision.sh
@@ -0,0 +1,89 @@
+#!/bin/sh
+# mk/check-revision.sh - refuse to build if version-rrev already published.
+#
+# ./mk/check-revision.sh <pkg> [arch] [--repo <repo-url>]
+#
+# Checks whether <pkg>-<version>-r<revision>.apk already exists in the
+# published repo. If it does, the template must bump revision before it can
+# be built/published. This enforces the "revision bump required" policy.
+#
+# Repo resolution: --repo flag, else local build/repo/<arch>, else
+# https://repo.unos.finwo.net/<arch> (if reachable).
+#
+# Used by mk/build.sh (gate) and by the conductor (pre-dispatch check).
+set -eu
+
+HERE=$(cd "$(dirname "$0")" && pwd)
+ROOT=$(cd "${HERE}/.." && pwd)
+
+PKG=""
+ARCH="x86_64"
+REPO=""
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --arch) ARCH="$2"; shift 2 ;;
+ --arch=*) ARCH="${1#--arch=}"; shift ;;
+ --repo) REPO="$2"; shift 2 ;;
+ --repo=*) REPO="${1#--repo=}"; shift ;;
+ -h|--help) sed -n '1,25p' "$0"; exit 0 ;;
+ -*) echo "check-revision.sh: unknown option: $1" >&2; exit 1 ;;
+ *) if [ -z "${PKG}" ]; then PKG="$1"; else
+ case "$1" in x86_64|aarch64|arm64) ARCH="$1";; *) REPO="$1";; esac
+ fi; shift ;;
+ esac
+done
+
+[ -n "${PKG}" ] || { echo "usage: check-revision.sh <pkg> [arch] [--repo <url>]" >&2; exit 1; }
+
+case "${ARCH}" in arm64) ARCH=aarch64 ;; esac
+case "${ARCH}" in x86_64|aarch64) ;; *) echo "check-revision.sh: unsupported ARCH ${ARCH}" >&2; exit 1 ;; esac
+
+TEMPLATE="${ROOT}/packages/${PKG}/template"
+[ -f "${TEMPLATE}" ] || { echo "check-revision.sh: no template: ${TEMPLATE}" >&2; exit 1; }
+
+# Read version/revision from template (same subshell pattern as deps.sh)
+eval "$( (
+ version=; revision=0
+ SYSROOT=; DESTDIR=; WRKSRC=; FILESDIR=; WORK=; SRCDEST=; UNOS_MKDIR=
+ die() { :; } ; msg() { :; } ; vinstall() { :; } ; vmkdir() { :; }
+ . "${TEMPLATE}" >/dev/null 2>&1 || true
+ printf 'VERSION=%s\nREVISION=%s\n' "${version}" "${revision}"
+) )"
+
+[ -n "${VERSION}" ] || { echo "check-revision.sh: template sets no version" >&2; exit 1; }
+
+PKGVER="${VERSION}-r${REVISION}"
+APK_NAME="${PKG}-${PKGVER}.apk"
+
+# Check local repo first
+if [ -z "${REPO}" ]; then
+ if [ -f "${ROOT}/build/repo/${ARCH}/${APK_NAME}" ]; then
+ echo "check-revision.sh: ${APK_NAME} already exists in build/repo/${ARCH}/" >&2
+ echo " bump revision in packages/${PKG}/template before rebuilding" >&2
+ exit 1
+ fi
+ # Also check published repo via local path if it exists
+ REPO="${ROOT}/build/repo/${ARCH}"
+fi
+
+# If REPO is a URL, use curl HEAD; if local dir, check file existence.
+case "${REPO}" in
+ http://*|https://*)
+ url="${REPO%/}/${APK_NAME}"
+ if curl -fsI "${url}" >/dev/null 2>&1; then
+ echo "check-revision.sh: ${APK_NAME} already published at ${url}" >&2
+ echo " bump revision in packages/${PKG}/template" >&2
+ exit 1
+ fi
+ ;;
+ *)
+ if [ -f "${REPO}/${APK_NAME}" ]; then
+ echo "check-revision.sh: ${APK_NAME} already exists in ${REPO}/" >&2
+ echo " bump revision in packages/${PKG}/template before rebuilding" >&2
+ exit 1
+ fi
+ ;;
+esac
+
+echo "check-revision.sh: OK ${APK_NAME} not yet published"
diff --git a/mk/chroot.sh b/mk/chroot.sh
@@ -1,25 +0,0 @@
-#!/bin/sh
-# mk/chroot.sh - enter rootfs/ in a user+mount namespace and run a command.
-#
-# Usage: ./mk/chroot.sh [--] <cmd> [args...] (default: /bin/busybox sh)
-#
-# Unprivileged: unshare maps us to root inside, mounts are namespace-private
-# and evaporate on exit. /proc /sys /dev come from the host via binds.
-set -eu
-
-HERE=$(cd "$(dirname "$0")" && pwd)
-ROOT=$(cd "${HERE}/.." && pwd)
-ROOTFS="${ROOT}/rootfs"
-
-[ -d "${ROOTFS}/bin" ] || { echo "chroot.sh: no rootfs (run mk/rootfs.sh)" >&2; exit 1; }
-if [ "${1:-}" = "--" ]; then shift; fi
-if [ $# = 0 ]; then set -- /bin/busybox sh; fi
-
-exec unshare --user --map-root-user --mount --propagation private --pid --fork \
- sh -c '
- R="$1"; shift
- mount -t proc proc "$R/proc"
- mount --rbind /sys "$R/sys"
- mount --rbind /dev "$R/dev"
- exec chroot "$R" "$@"
- ' chroot-sh "${ROOTFS}" "$@"
diff --git a/mk/ci-setup.sh b/mk/ci-setup.sh
@@ -0,0 +1,90 @@
+#!/bin/sh
+# mk/ci-setup.sh - install the build toolchain into an ephemeral CI container.
+#
+# ./mk/ci-setup.sh install, then verify the graph is satisfied
+# ./mk/ci-setup.sh --check verify only, install nothing
+#
+# Pipelines run in a stock debian image with no mounts, so every task starts
+# from nothing and installs what it needs. Keeping that here rather than
+# inlining apt lines in .conductor.yml means one place to change when a
+# template gains a hostmakedepends, and it can verify itself afterwards.
+#
+# Two groups, for different reasons:
+#
+# toolchain what packages/*/template declare as hostmakedepends. The
+# authority is mk/deps.sh, and the check at the end proves this
+# list still covers it.
+# host libs what mk/bootstrap-host.sh links the host `apk` against
+# (openssl, zlib, zstd). Not hostmakedepends of any package --
+# they are needed before any package builds at all.
+set -eu
+
+HERE=$(cd "$(dirname "$0")" && pwd)
+ROOT=$(cd "${HERE}/.." && pwd)
+
+CHECK_ONLY=0
+[ "${1:-}" = "--check" ] && CHECK_ONLY=1
+
+msg() { printf '==> %s\n' "$*"; }
+die() { printf 'ci-setup.sh: error: %s\n' "$*" >&2; exit 1; }
+
+# `dep` (https://github.com/finwo/dep) is not packaged by any distribution;
+# linkd uses it to resolve its own C dependencies. Pinned deliberately.
+DEP_VERSION="${DEP_VERSION:-0.1.0}"
+
+if [ "${CHECK_ONLY}" = "0" ]; then
+ command -v apt-get >/dev/null 2>&1 \
+ || die "no apt-get; this script targets the debian image the pipeline uses"
+
+ msg "installing build toolchain"
+ apt-get update -qq
+ apt-get install -y -qq --no-install-recommends \
+ build-essential \
+ pkg-config \
+ ca-certificates \
+ curl \
+ wget \
+ git \
+ xz-utils \
+ bzip2 \
+ file \
+ bc \
+ bison \
+ flex \
+ gawk \
+ make \
+ perl \
+ python3 \
+ sed \
+ libssl-dev \
+ zlib1g-dev \
+ libzstd-dev \
+ >/dev/null
+ rm -rf /var/lib/apt/lists/*
+
+ if ! command -v dep >/dev/null 2>&1; then
+ msg "installing dep ${DEP_VERSION}"
+ curl -fsSL "https://github.com/finwo/dep/releases/download/v${DEP_VERSION}/dep-linux-x64" \
+ -o /usr/local/bin/dep || die "could not fetch dep ${DEP_VERSION}"
+ chmod 0755 /usr/local/bin/dep
+ fi
+fi
+
+# Verify rather than assume: every hostmakedepends in the graph must now
+# resolve. This is what stops the list above silently falling behind a
+# template that gained a new build tool.
+msg "verifying hostmakedepends are satisfied"
+missing=""
+for p in $("${ROOT}/mk/deps.sh" list); do
+ for h in $("${ROOT}/mk/deps.sh" vars "$p" | sed -n 's/^hostmakedepends: *//p'); do
+ # muon is built by bootstrap-host.sh, not installed from a distribution.
+ [ "${h}" = "muon" ] && continue
+ command -v "${h}" >/dev/null 2>&1 || missing="${missing} ${h}"
+ done
+done
+if [ -n "${missing}" ]; then
+ die "hostmakedepends not satisfied:${missing}
+ Add them to the apt list in mk/ci-setup.sh."
+fi
+
+msg "toolchain ready"
diff --git a/mk/deps.sh b/mk/deps.sh
@@ -5,6 +5,8 @@
# ./mk/deps.sh vars <pkg> that package's declared dependencies
# ./mk/deps.sh order [pkg...] topological build order (all if no args)
# ./mk/deps.sh graph machine-readable "pkg<TAB>dep" edges
+# ./mk/deps.sh rdeps <pkg> reverse makedepends (who needs <pkg>)
+# ./mk/deps.sh changed <base> <head> packages changed between two refs
# ./mk/deps.sh check validate the whole graph, exit 1 if bad
#
# WHY THIS EXISTS
@@ -63,6 +65,8 @@ read_vars() {
SYSROOT=; DESTDIR=; WRKSRC=; FILESDIR=; WORK=; SRCDEST=; UNOS_MKDIR=
die() { :; }
msg() { :; }
+ vinstall() { :; }
+ vmkdir() { :; }
# shellcheck disable=SC1090
. "${t}" >/dev/null 2>&1 || true
printf '%s|%s|%s\n' "${depends}" "${makedepends}" "${hostmakedepends}"
@@ -88,6 +92,69 @@ cmd_graph() {
done
}
+cmd_rdeps() {
+ # reverse dependencies: which packages have makedepends on <pkg>
+ [ $# -ge 1 ] || die "usage: deps.sh rdeps <pkg>"
+ target="$1"
+ for p in $(list_pkgs); do
+ for d in $(field "${p}" 2); do
+ if [ "${d}" = "${target}" ]; then
+ echo "${p}"
+ break
+ fi
+ done
+ done
+}
+
+cmd_changed() {
+ # changed packages between two git refs: maps git diff paths to package names.
+ # ./mk/deps.sh changed <base> <head>
+ #
+ # Only packages/ and mk/ are considered, because that is all this repo
+ # holds. First-party source (linkd) now arrives as a tag-pinned tarball, so
+ # editing it produces no diff here at all: bumping the template's version
+ # is what marks it changed, and that is a packages/ path like any other.
+ # Compares <base>...<head> (three-dot, like GitHub PR diff). Falls back to
+ # two-dot if base is not an ancestor.
+ base="${1:-}"
+ head_ref="${2:-}"
+ [ -n "${base}" ] || die "usage: deps.sh changed <base> <head>"
+ [ -n "${head_ref}" ] || die "usage: deps.sh changed <base> <head>"
+
+ # git diff --name-only base...head, or base..head if ... yields nothing due
+ # to non-ancestor base.
+ changed_files=$(git -C "${ROOT}" diff --name-only "${base}...${head_ref}" 2>/dev/null || true)
+ if [ -z "${changed_files}" ]; then
+ changed_files=$(git -C "${ROOT}" diff --name-only "${base}..${head_ref}" 2>/dev/null || true)
+ fi
+
+ pkgs=""
+ mk_changed=0
+
+ for f in ${changed_files}; do
+ case "${f}" in
+ packages/*)
+ # packages/<name>/template, patches/*, files/*
+ pkg=$(printf '%s' "${f}" | cut -d/ -f2)
+ if [ -f "${PKGDIR}/${pkg}/template" ]; then
+ case " ${pkgs} " in *" ${pkg} "*) ;; *) pkgs="${pkgs} ${pkg}" ;; esac
+ fi
+ ;;
+ mk/*)
+ mk_changed=1
+ ;;
+ esac
+ done
+
+ for p in ${pkgs}; do echo "${p}"; done
+
+ # mk/ changes do not automatically trigger a full rebuild; they signal the
+ # conductor to warn. Emit a sentinel on stderr so the caller can decide.
+ if [ "${mk_changed}" = "1" ]; then
+ echo "note: mk/ changed - full rebuild not triggered automatically" >&2
+ fi
+}
+
# Depth-first topological sort. State lives in a temp dir because POSIX sh has
# no associative arrays: <tmp>/seen.<pkg> marks done, <tmp>/path.<pkg> marks
# "currently on the recursion stack", which is how cycles are detected.
@@ -171,6 +238,8 @@ case "${1:-}" in
vars) shift; cmd_vars "$@" ;;
graph) cmd_graph ;;
order) shift; cmd_order "$@" ;;
+ rdeps) shift; cmd_rdeps "$@" ;;
+ changed) shift; cmd_changed "$@" ;;
check) cmd_check ;;
- *) sed -n '2,10p' "$0"; exit 1 ;;
+ *) sed -n '2,12p' "$0"; exit 1 ;;
esac
diff --git a/mk/gen-pipeline.sh b/mk/gen-pipeline.sh
@@ -0,0 +1,160 @@
+#!/bin/sh
+# mk/gen-pipeline.sh - emit .conductor.yml from the dependency graph.
+#
+# ./mk/gen-pipeline.sh print the pipeline
+# ./mk/gen-pipeline.sh --write write it to .conductor.yml
+# ./mk/gen-pipeline.sh --check exit 1 if .conductor.yml is out of date
+#
+# WHY GENERATED
+# -------------
+# The conductor does not run repository code while building its task graph --
+# the pipeline file is data, read at the pushed commit. So the per-package
+# dependency edges have to be written out literally. Maintaining ~30 tasks and
+# their `needs` by hand against packages/*/template guarantees they drift, and
+# the failure mode is a package building before the library it links against.
+#
+# Generating instead means makedepends stays the single source of truth, and
+# tests/unit/test_deps.sh fails if the committed file no longer matches.
+set -eu
+
+HERE=$(cd "$(dirname "$0")" && pwd)
+ROOT=$(cd "${HERE}/.." && pwd)
+OUT="${ROOT}/.conductor.yml"
+
+MODE=print
+case "${1:-}" in
+ --write) MODE=write ;;
+ --check) MODE=check ;;
+ -h|--help) sed -n '2,8p' "$0"; exit 0 ;;
+ "") ;;
+ *) echo "gen-pipeline.sh: unknown argument: $1" >&2; exit 1 ;;
+esac
+
+# Task names allow letters, digits, underscore, dot and hyphen, so `libstdc++`
+# cannot be used directly. `+` becomes `x` (libstdc++ -> libstdcxx, the
+# conventional spelling) rather than a hyphen, which would render it
+# `libstdc--` and read like a typo. Anything else unexpected becomes a hyphen.
+task_name() {
+ printf 'pkg-%s' "$(printf '%s' "$1" | sed 's/+/x/g' | tr -c 'A-Za-z0-9_.-' '-')"
+}
+
+emit() {
+ cat <<'HEADER'
+# GENERATED by mk/gen-pipeline.sh -- do not edit by hand.
+#
+# Regenerate with: ./mk/gen-pipeline.sh --write
+# Verified by: tests/unit/test_deps.sh
+#
+# One task per package, with `needs` taken from each template's makedepends,
+# so a package never builds before something it links against. The list is
+# written out rather than discovered because the conductor treats this file as
+# data and will not execute repository code to build its graph.
+#
+# No mounts are provided and artifacts are not shared between tasks. Each build
+# therefore seeds its sysroot from the published S3 repo (mk/sysroot.sh) and
+# publishes its own result back (mk/publish.sh). `needs` is what guarantees a
+# dependency is already published by the time a dependent starts.
+#
+# Credentials arrive as environment: BUCKET_ACCESS_KEY, BUCKET_SECRET_KEY,
+# BUCKET_NAME, BUCKET_ENDPOINT (URL), BUCKET_REGION (region name).
+#
+# Signing comes from the unos-sign-key worker feature, which mounts the key and
+# exports UNOS_KEY_APK_RSA_PRI. Each worker holds a distinct key, so the signer
+# varies with scheduling and packages/unos-keys must trust all of them.
+
+version: 1
+
+defaults:
+ image: debian:bookworm-slim
+ timeout: 2h
+ env:
+ DEBIAN_FRONTEND: noninteractive
+
+tasks:
+ check:
+ timeout: 30m
+ script:
+ - ./mk/ci-setup.sh
+ # Before deps.sh check, not after: muon is a declared hostmakedepends of
+ # apk-tools but is built here rather than installed from a distribution,
+ # so the check reports it missing until bootstrap has produced it.
+ - ./mk/bootstrap-host.sh
+ - ./mk/deps.sh check
+ - ./mk/gen-pipeline.sh --check
+ - ./tests/run.sh --unit
+
+HEADER
+
+ # One task per package, in dependency order so the file reads top-down.
+ for p in $(cd "${ROOT}" && ./mk/deps.sh order); do
+ deps=$(cd "${ROOT}" && ./mk/deps.sh vars "$p" | sed -n 's/^makedepends: *//p')
+ printf ' %s:\n' "$(task_name "$p")"
+ if [ -n "${deps}" ]; then
+ printf ' needs:\n'
+ for d in ${deps}; do
+ printf ' - %s\n' "$(task_name "$d")"
+ done
+ fi
+ # arch is declared so $ARCH exists in the script; it is only injected for
+ # tasks that declare it. One arch today, but adding aarch64 is then a
+ # single edit here rather than a rewrite.
+ printf ' arch: [x86_64]\n'
+ # The signing key lives on the worker and is never seen by the conductor;
+ # the task asks for it by capability name. Project-scoped on purpose: the
+ # feature namespace is global to the conductor, which hosts more than UNOS.
+ # The feature provides UNOS_KEY_APK_RSA_PRI, which mk/sign-key.inc reads.
+ printf ' requires: [unos-sign-key]\n'
+ printf ' script:\n'
+ printf ' - ./mk/ci-setup.sh\n'
+ printf ' - ./mk/bootstrap-host.sh\n'
+ printf ' - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" %s\n' "$p"
+ printf ' - ./mk/check-revision.sh %s\n' "$p"
+ printf ' - ./mk/build.sh %s\n' "$p"
+ printf ' - ./mk/publish.sh --arch "$ARCH" %s\n' "$p"
+ printf '\n'
+ done
+
+ # The index is rewritten once, after every package has been published.
+ # Doing it per-package would race: two packages finishing together would
+ # each rebuild the index from their own partial view of the repo.
+ cat <<'FOOTER'
+ index:
+ needs:
+FOOTER
+ for p in $(cd "${ROOT}" && ./mk/deps.sh order); do
+ printf ' - %s\n' "$(task_name "$p")"
+ done
+ cat <<'FOOTER'
+ arch: [x86_64]
+ requires: [unos-sign-key]
+ script:
+ - ./mk/ci-setup.sh
+ - ./mk/bootstrap-host.sh
+ # Pull every published package down so the index describes the whole
+ # repo, not just whatever this container happened to build.
+ - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH"
+ - ./mk/repo-index.sh
+ - ./mk/publish.sh --arch "$ARCH" --index-only
+FOOTER
+}
+
+case "${MODE}" in
+ print) emit ;;
+ write) emit > "${OUT}"; echo "wrote ${OUT}" ;;
+ check)
+ tmp=$(mktemp)
+ trap 'rm -f "${tmp}"' EXIT
+ emit > "${tmp}"
+ if [ ! -f "${OUT}" ]; then
+ echo "gen-pipeline.sh: .conductor.yml is missing; run ./mk/gen-pipeline.sh --write" >&2
+ exit 1
+ fi
+ if ! diff -u "${OUT}" "${tmp}" >/dev/null 2>&1; then
+ echo "gen-pipeline.sh: .conductor.yml is out of date" >&2
+ diff -u "${OUT}" "${tmp}" | head -40 >&2
+ echo "gen-pipeline.sh: run ./mk/gen-pipeline.sh --write" >&2
+ exit 1
+ fi
+ echo "gen-pipeline.sh: .conductor.yml is current"
+ ;;
+esac
diff --git a/mk/kernel.inc b/mk/kernel.inc
@@ -36,8 +36,12 @@
kernel_configure() {
cd "${WRKSRC}"
[ -f scripts/config ] || die "kernel: scripts/config missing"
- msg "kernel: defconfig"
- make ARCH=x86_64 defconfig >/dev/null || die "kernel: defconfig failed"
+ # ARCH defaults to x86_64 for the primary target; aarch64 when cross-building
+ # (the conductor dispatches per-arch, builder calls ARCH=aarch64 make).
+ : "${ARCH:=x86_64}"
+ case "${ARCH}" in arm64) ARCH=aarch64 ;; esac
+ msg "kernel: defconfig (ARCH=${ARCH})"
+ make ARCH="${ARCH}" defconfig >/dev/null || die "kernel: defconfig failed"
for s in ${KDELTA_ENABLE:-}; do
./scripts/config --enable "CONFIG_${s}" || die "kernel: cannot enable CONFIG_${s}"
done
@@ -48,7 +52,7 @@ kernel_configure() {
./scripts/config --disable "CONFIG_${s}" || die "kernel: cannot disable CONFIG_${s}"
done
if [ -n "${KDELTA_ENABLE:-}${KDELTA_MODULE:-}${KDELTA_DISABLE:-}" ]; then
- make ARCH=x86_64 olddefconfig >/dev/null || die "kernel: olddefconfig failed"
+ make ARCH="${ARCH}" olddefconfig >/dev/null || die "kernel: olddefconfig failed"
fi
for s in ${KASSERT_Y:-}; do
grep -qx "CONFIG_${s}=y" .config || die "kernel: CONFIG_${s} is not =y"
@@ -59,7 +63,7 @@ kernel_configure() {
for s in ${KASSERT_N:-}; do
grep -qx "# CONFIG_${s} is not set" .config || die "kernel: CONFIG_${s} is set"
done
- KREL=$(make -s ARCH=x86_64 kernelrelease) || die "kernel: kernelrelease failed"
+ KREL=$(make -s ARCH="${ARCH}" kernelrelease) || die "kernel: kernelrelease failed"
[ -n "${KREL}" ] || die "kernel: empty KREL"
export KREL
msg "kernel: release ${KREL}"
@@ -67,25 +71,36 @@ kernel_configure() {
kernel_build() {
cd "${WRKSRC}"
- make ARCH=x86_64 -j"$(nproc)" \
+ : "${ARCH:=x86_64}"
+ case "${ARCH}" in arm64) ARCH=aarch64 ;; esac
+ make ARCH="${ARCH}" -j"$(nproc)" \
KBUILD_BUILD_USER=unos \
KBUILD_BUILD_HOST=unos \
KBUILD_BUILD_TIMESTAMP="$(date -u -d "@${SOURCE_DATE_EPOCH}" '+%Y-%m-%d %H:%M:%S')" \
bzImage modules || die "kernel: build failed"
- [ -f arch/x86/boot/bzImage ] || die "kernel: bzImage missing"
+ # bzImage path varies by arch
+ case "${ARCH}" in
+ x86_64) [ -f arch/x86/boot/bzImage ] || die "kernel: bzImage missing" ;;
+ aarch64) [ -f arch/arm64/boot/Image ] || die "kernel: Image missing (aarch64)" ;;
+ esac
}
kernel_install() {
cd "${WRKSRC}"
- install -D -m 644 arch/x86/boot/bzImage "${DESTDIR}/boot/vmlinuz-${KREL}"
+ : "${ARCH:=x86_64}"
+ case "${ARCH}" in arm64) ARCH=aarch64 ;; esac
+ case "${ARCH}" in
+ x86_64) install -D -m 644 arch/x86/boot/bzImage "${DESTDIR}/boot/vmlinuz-${KREL}" ;;
+ aarch64) install -D -m 644 arch/arm64/boot/Image "${DESTDIR}/boot/vmlinuz-${KREL}" ;;
+ esac
ln -sf "vmlinuz-${KREL}" "${DESTDIR}/boot/vmlinuz"
install -D -m 644 System.map "${DESTDIR}/boot/System.map-${KREL}"
install -D -m 644 .config "${DESTDIR}/boot/config-${KREL}"
- make ARCH=x86_64 INSTALL_MOD_PATH="${DESTDIR}" INSTALL_MOD_STRIP=1 \
+ make ARCH="${ARCH}" INSTALL_MOD_PATH="${DESTDIR}" INSTALL_MOD_STRIP=1 \
modules_install >/dev/null || die "kernel: modules_install failed"
# absolute build-tree symlinks must never ship
rm -f "${DESTDIR}/lib/modules/${KREL}/build" "${DESTDIR}/lib/modules/${KREL}/source"
- make ARCH=x86_64 INSTALL_HDR_PATH="${DESTDIR}/usr" \
+ make ARCH="${ARCH}" INSTALL_HDR_PATH="${DESTDIR}/usr" \
headers_install >/dev/null || die "kernel: headers_install failed"
# dependency maps at build time (explicit version: uname lies in userns)
command -v depmod >/dev/null 2>&1 || die "kernel: host depmod missing"
diff --git a/mk/keymgmt.sh b/mk/keymgmt.sh
@@ -75,6 +75,8 @@ cmd_new() {
rsa)
openssl genrsa -out "${KEYDIR}/${priv}" 4096 2>/dev/null
openssl rsa -in "${KEYDIR}/${priv}" -pubout -out "${KEYDIR}/${pub}" 2>/dev/null
+ # Enforce minimum (defence in depth, even though we just generated 4096)
+ "${HERE}/check-keysize.sh" "${KEYDIR}/${priv}" 4096 >/dev/null
;;
supercop)
supercop generate -k "${KEYDIR}/${priv}"
@@ -107,6 +109,8 @@ cmd_use() {
fi
openssl rsa -in "${path}" -check -noout >/dev/null 2>&1 \
|| die "not an RSA private key: ${path}"
+ "${HERE}/check-keysize.sh" "${path}" 4096 >/dev/null \
+ || die "key too small (4096-bit minimum): ${path}"
printf '%s\n' "${path}" > "${ROOT}/.sign-key"
msg "signing key: ${path}"
}
diff --git a/mk/make-img.sh b/mk/make-img.sh
@@ -1,144 +0,0 @@
-#!/bin/sh
-# mk/make-img.sh - build a bootable UNOS image that carries its own installer.
-#
-# ./mk/make-img.sh # build with defaults
-# ./mk/make-img.sh --version 0.1.0 --size 3072
-# ./mk/make-img.sh --authorized-keys ~/.ssh/id_ed25519.pub
-#
-# Output: build/work/unos-<version>-x86_64.img
-# GPT + ESP (FAT, EFI/BOOT/BOOTX64.EFI) + ext4 root, bootable by firmware
-# with no -kernel shortcut, carrying /opt/unos/unos-<version>-x86_64.bin.
-#
-# WHY A VM BUILDS THIS
-# --------------------
-# Laying down a partitioned, bootable image needs loop devices, mount, and
-# grub-install into a mounted ESP -- all of which need real root. We have none
-# (user namespaces cannot set up loop devices). So instead of reimplementing
-# partitioning on the host with mtools and grub-mkimage, this boots a UNOS live
-# VM and runs installer/install.sh against a blank second disk. That path is
-# already covered by tests/integration/test_qemu_install.sh, so the image
-# builder and the installer cannot drift apart: fixing one fixes the other.
-#
-# WHY THE PAYLOAD IS COPIED IN AFTERWARDS
-# ---------------------------------------
-# The .bin contains a tarball of the rootfs. If the rootfs being packed already
-# contained the .bin, the image would contain a copy of itself -- so the
-# installer is built from the plain rootfs, installed, and only then copied
-# onto the installed system as a separate step.
-set -eu
-
-HERE=$(cd "$(dirname "$0")" && pwd)
-ROOT=$(cd "${HERE}/.." && pwd)
-BUILD="${ROOT}/build/work"
-
-VERSION="${UNOS_VERSION:-0.1.0}"
-SIZE_MB=3072
-OUT=""
-AUTH_KEYS=""
-PORT="${MAKEIMG_SSH_PORT:-2295}"
-
-while [ $# -gt 0 ]; do
- case "$1" in
- --version) VERSION="$2"; shift ;;
- --out) OUT="$2"; shift ;;
- --size) SIZE_MB="$2"; shift ;;
- --authorized-keys) AUTH_KEYS="$2"; shift ;;
- -h|--help) sed -n '2,12p' "$0"; exit 0 ;;
- *) echo "make-img.sh: unknown argument: $1" >&2; exit 1 ;;
- esac
- shift
-done
-
-[ -n "${OUT}" ] || OUT="${BUILD}/unos-${VERSION}-x86_64.img"
-BIN="${BUILD}/unos-${VERSION}-x86_64.bin"
-LIVE_LOG="${BUILD}/make-img-live.log"
-
-msg() { printf '==> %s\n' "$*"; }
-die() { printf 'make-img.sh: error: %s\n' "$*" >&2; exit 1; }
-
-cleanup() {
- for p in $(pgrep -f 'qemu-sys[t]em-x86_64' 2>/dev/null); do kill "$p" 2>/dev/null || true; done
-}
-trap cleanup EXIT
-
-[ -d "${ROOT}/rootfs" ] || die "no rootfs/; run ./mk/rootfs.sh first"
-
-KEY=$(ls "${HOME}"/.ssh/id_ed25519 2>/dev/null | head -1 || true)
-[ -n "${KEY}" ] || KEY=$(ls "${HOME}"/.ssh/*.pub 2>/dev/null | head -1 | sed 's/.pub$//' || true)
-[ -n "${KEY}" ] && [ -f "${KEY}" ] || die "no usable ssh key in ~/.ssh (needed to drive the build VM)"
-
-ssh_vm() {
- ssh -i "${KEY}" -p "${PORT}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
- -o LogLevel=ERROR -o ConnectTimeout=5 -o BatchMode=yes root@localhost "$@" 2>&1
-}
-
-wait_ssh() {
- i=1
- while [ "$i" -le 90 ]; do
- if ssh_vm true >/dev/null 2>&1; then return 0; fi
- sleep 1
- i=$((i + 1))
- done
- return 1
-}
-
-# --- 1. the payload --------------------------------------------------------
-# Built from the plain rootfs. Optionally bake authorized_keys in, because an
-# image nobody can log into is only useful over a serial console.
-STAGE="${BUILD}/stage-img-root"
-rm -rf "${STAGE}"
-mkdir -p "${STAGE}"
-msg "staging rootfs"
-(cd "${ROOT}/rootfs" && tar -cf - .) | (cd "${STAGE}" && tar -xf -)
-
-if [ -n "${AUTH_KEYS}" ]; then
- [ -f "${AUTH_KEYS}" ] || die "no such authorized_keys file: ${AUTH_KEYS}"
- mkdir -p "${STAGE}/root/.ssh"
- cat "${AUTH_KEYS}" > "${STAGE}/root/.ssh/authorized_keys"
- chmod 700 "${STAGE}/root/.ssh"
- chmod 600 "${STAGE}/root/.ssh/authorized_keys"
- msg "baked authorized_keys from ${AUTH_KEYS}"
-else
- msg "no --authorized-keys given: image will be console-only"
-fi
-
-msg "building installer payload"
-"${ROOT}/installer/mkinstaller.sh" --version "${VERSION}" \
- --rootfs "${STAGE}" --out "${BIN}" >/dev/null || die "mkinstaller failed"
-[ -s "${BIN}" ] || die "installer payload not produced"
-msg "payload: $(basename "${BIN}") ($(du -h "${BIN}" | cut -f1))"
-
-# --- 2. build VM -----------------------------------------------------------
-rm -f "${OUT}" "${BUILD}/ovmf-vars.fd"
-cleanup
-
-msg "booting build VM (target ${SIZE_MB}M)"
-UEFI=1 IMG_SIZE_MB=1536 QEMU_MEM=1536 QEMU_SSH_PORT="${PORT}" \
- TARGET_DISK="${OUT}" TARGET_SIZE_MB="${SIZE_MB}" \
- EXTRA_FILES="${BIN}:/root/unos.bin" \
- "${ROOT}/mk/run-qemu.sh" run > "${LIVE_LOG}" 2>&1 &
-
-wait_ssh || { tail -20 "${LIVE_LOG}" >&2; die "build VM did not come up"; }
-
-# --- 3. install onto the blank disk ---------------------------------------
-msg "installing onto /dev/vdb"
-out=$(ssh_vm 'sh /root/unos.bin --mode generic --disk /dev/vdb --force; echo "RC=$?"')
-case "${out}" in
- *RC=0*) ;;
- *) printf '%s\n' "${out}" >&2; die "installer failed inside the build VM" ;;
-esac
-
-# The installer payload is deliberately NOT copied into the installed root.
-# The rootfs must be byte-identical whether it was installed normally or as a
-# rescue image; embedding an 85MB copy of the installer would bloat every
-# installed switch with a copy of itself, and would mean the image contains its
-# own payload which contains the image.
-ssh_vm 'sync' >/dev/null 2>&1 || true
-cleanup
-
-[ -s "${OUT}" ] || die "no image produced"
-msg "done: ${OUT} ($(du -h "${OUT}" | cut -f1))"
-msg ""
-msg " boot it: ./mk/run-qemu.sh boot-installed ${OUT} (UEFI=1)"
-msg " install: curl -O http://.../unos-installer.bin && sh unos-installer.bin --disk /dev/sdX --force"
-msg " write it: dd if=${OUT} of=/dev/sdX bs=4M conv=fsync"
diff --git a/mk/pc-fixup.sh b/mk/pc-fixup.sh
@@ -0,0 +1,29 @@
+#!/bin/sh
+# mk/pc-fixup.sh - rewrite .pc prefix/libdir/includedir to point at SYSROOT.
+#
+# Usage: ./mk/pc-fixup.sh [sysroot]
+# sysroot defaults to build/sysroot when run from repo root.
+#
+# This is the sole implementation; mk/build.sh and mk/sysroot.sh both call it.
+# Rewriting only prefix= is insufficient: many upstream .pc files spell
+# libdir=/includedir= literally (efivar does; efibootmgr then picks host
+# headers). Fix every level, every .pc, every build. Idempotent.
+set -eu
+
+HERE=$(cd "$(dirname "$0")" && pwd)
+ROOT=$(cd "${HERE}/.." && pwd)
+
+SYSROOT="${1:-${SYSROOT:-${ROOT}/build/sysroot}}"
+
+[ -d "${SYSROOT}" ] || exit 0
+
+for _pc in "${SYSROOT}"/usr/lib/pkgconfig/*.pc "${SYSROOT}"/usr/lib64/pkgconfig/*.pc; do
+ [ -e "${_pc}" ] || continue
+ # Only fix if it looks like a sysroot pc (prefix=/usr or similar); leave
+ # host pkgconfigs alone when SYSROOT happens to be /.
+ sed -i \
+ -e "s|^prefix=.*|prefix=${SYSROOT}/usr|" \
+ -e "s|^libdir=.*|libdir=${SYSROOT}/usr/lib|" \
+ -e "s|^includedir=.*|includedir=${SYSROOT}/usr/include|" \
+ "${_pc}"
+done
diff --git a/mk/publish.sh b/mk/publish.sh
@@ -0,0 +1,133 @@
+#!/bin/sh
+# mk/publish.sh - upload built packages and the index to the S3 repo.
+#
+# ./mk/publish.sh publish every .apk plus APKINDEX for $ARCH
+# ./mk/publish.sh zlib popt publish only those packages (plus index)
+# ./mk/publish.sh --index-only re-upload APKINDEX.tar.gz and nothing else
+# ./mk/publish.sh --dry-run print what would be uploaded
+#
+# Environment (supplied by the CI runner; no mounts are provided):
+#
+# BUCKET_ACCESS_KEY S3 access key id
+# BUCKET_SECRET_KEY S3 secret access key
+# BUCKET_NAME bucket to write into
+# BUCKET_ENDPOINT base URL of the S3 service, e.g. https://s3.finwo.net
+# BUCKET_REGION region name, e.g. eu-west1
+#
+# Uses `curl --aws-sigv4` rather than awscli or rclone: curl is already needed
+# to fetch sources, signs SigV4 natively since 7.75, and pulling in a Python or
+# Go client for four PUTs is not worth the image size or the supply chain.
+set -eu
+
+HERE=$(cd "$(dirname "$0")" && pwd)
+ROOT=$(cd "${HERE}/.." && pwd)
+
+ARCH="${ARCH:-x86_64}"
+DRY=0
+INDEX_ONLY=0
+PKGS=""
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --arch) ARCH="$2"; shift 2 ;;
+ --arch=*) ARCH="${1#--arch=}"; shift ;;
+ --dry-run) DRY=1; shift ;;
+ --index-only) INDEX_ONLY=1; shift ;;
+ -h|--help) sed -n '2,20p' "$0"; exit 0 ;;
+ -*) echo "publish.sh: unknown option: $1" >&2; exit 1 ;;
+ *) PKGS="${PKGS} $1"; shift ;;
+ esac
+done
+
+msg() { printf '==> %s\n' "$*"; }
+die() { printf 'publish.sh: error: %s\n' "$*" >&2; exit 1; }
+
+REPODIR="${ROOT}/build/repo/${ARCH}"
+[ -d "${REPODIR}" ] || die "no such repo dir: ${REPODIR}"
+
+if [ "${DRY}" != "1" ]; then
+ for v in BUCKET_ACCESS_KEY BUCKET_SECRET_KEY BUCKET_NAME BUCKET_ENDPOINT BUCKET_REGION; do
+ eval "val=\${${v}:-}"
+ [ -n "${val}" ] || die "${v} is not set"
+ done
+
+ # These two are easy to transpose, and transposing them produces a signature
+ # mismatch rather than anything that names the real problem. Check the shape
+ # instead of trusting the order they were exported in.
+ case "${BUCKET_ENDPOINT}" in
+ http://*|https://*) ;;
+ *) die "BUCKET_ENDPOINT must be a URL (e.g. https://s3.finwo.net), got '${BUCKET_ENDPOINT}'.
+ Note BUCKET_REGION is the region name (e.g. eu-west1); the two look swapped." ;;
+ esac
+ case "${BUCKET_REGION}" in
+ http://*|https://*) die "BUCKET_REGION must be a region name (e.g. eu-west1), not a URL.
+ Note BUCKET_ENDPOINT is the URL; the two look swapped." ;;
+ esac
+
+ command -v curl >/dev/null 2>&1 || die "curl is required"
+fi
+
+# put <localfile> <key>
+put() {
+ src="$1"
+ key="$2"
+ # Built after the dry-run check on purpose: --dry-run must work with no
+ # credentials in the environment at all, and set -u would abort here.
+ if [ "${DRY}" = "1" ]; then
+ printf ' would PUT %s -> %s\n' "${src}" "${key}"
+ return 0
+ fi
+ url="${BUCKET_ENDPOINT%/}/${BUCKET_NAME}/${key}"
+ code=$(curl -sS -o /dev/null -w '%{http_code}' \
+ --aws-sigv4 "aws:amz:${BUCKET_REGION}:s3" \
+ --user "${BUCKET_ACCESS_KEY}:${BUCKET_SECRET_KEY}" \
+ -X PUT --upload-file "${src}" \
+ "${url}") || die "upload failed: ${key}"
+ case "${code}" in
+ 2*) printf ' %s (%s)\n' "${key}" "${code}" ;;
+ *) die "upload of ${key} returned HTTP ${code}" ;;
+ esac
+}
+
+# Which .apk files to publish.
+if [ "${INDEX_ONLY}" = "1" ]; then
+ FILES=""
+elif [ -n "${PKGS}" ]; then
+ FILES=""
+ for p in ${PKGS}; do
+ found=""
+ for f in "${REPODIR}/${p}"-*.apk; do
+ [ -e "${f}" ] || continue
+ # `zlib-1.3.1-r0.apk` matches zlib, but `zlib-doc-...` must not, so
+ # require the character after the name to start a version field.
+ base=$(basename "${f}")
+ rest=${base#"${p}"-}
+ case "${rest}" in
+ [0-9]*) found="${found} ${f}" ;;
+ esac
+ done
+ [ -n "${found}" ] || die "no built .apk for '${p}' in ${REPODIR}"
+ FILES="${FILES}${found}"
+ done
+else
+ FILES=$(ls "${REPODIR}"/*.apk 2>/dev/null || true)
+ [ -n "${FILES}" ] || die "no .apk files in ${REPODIR}"
+fi
+
+msg "publishing to ${BUCKET_NAME:-<dry-run>}/${ARCH}"
+for f in ${FILES}; do
+ put "${f}" "${ARCH}/$(basename "${f}")"
+done
+
+# The index goes last, always. It is the file clients read to discover what
+# exists, so publishing it before its packages would advertise packages that
+# are not there yet; publishing it after is merely briefly stale.
+IDX="${REPODIR}/APKINDEX.tar.gz"
+if [ -f "${IDX}" ]; then
+ put "${IDX}" "${ARCH}/APKINDEX.tar.gz"
+else
+ [ "${INDEX_ONLY}" = "1" ] && die "no APKINDEX.tar.gz in ${REPODIR}; run ./mk/repo-index.sh"
+ msg "note: no APKINDEX.tar.gz to publish (run ./mk/repo-index.sh first)"
+fi
+
+msg "done"
diff --git a/mk/repo-index.sh b/mk/repo-index.sh
@@ -10,11 +10,25 @@ set -eu
HERE=$(cd "$(dirname "$0")" && pwd)
ROOT=$(cd "${HERE}/.." && pwd)
-ARCH="${1:-x86_64}"
+# ARCH from $ARCH env or first arg, normalise arm64 -> aarch64
+ARCH="${ARCH:-${1:-x86_64}}"
+if [ $# -ge 1 ]; then case "$1" in x86_64|aarch64|arm64) ARCH="$1";; esac; fi
+case "${ARCH}" in arm64) ARCH=aarch64 ;; esac
+case "${ARCH}" in x86_64|aarch64) ;; *) echo "repo-index.sh: unsupported ARCH ${ARCH}" >&2; exit 1 ;; esac
REPODIR="${ROOT}/build/repo/${ARCH}"
[ -d "${REPODIR}" ] || { echo "repo-index.sh: no such dir: ${REPODIR}" >&2; exit 1; }
+# The apk we built must win over any system one, exactly as mk/build.sh does.
+# Without this the bare `apk index` below resolves against whatever the host
+# happens to have -- which works on a developer box that installed apk-tools
+# and fails in a clean CI container, where nothing provides it at all.
+export PATH="${ROOT}/build/host/bin:${PATH}"
+command -v apk >/dev/null 2>&1 || {
+ echo "repo-index.sh: no apk on PATH; run ./mk/bootstrap-host.sh first" >&2
+ exit 1
+}
+
. "${HERE}/sign-key.inc"
KEY=$(resolve_sign_key) || { echo "repo-index.sh: no signing key (mk/keymgmt.sh use)" >&2; exit 1; }
KEYNAME=$(basename "${KEY}" .rsa)
diff --git a/mk/rootfs.sh b/mk/rootfs.sh
@@ -1,58 +0,0 @@
-#!/bin/sh
-# mk/rootfs.sh - assemble rootfs/ for chroot testing.
-#
-# Usage: ./mk/rootfs.sh [--force]
-#
-# Pure apk: static apk installs unos-keys with one explicit
-# --allow-untrusted (the trust seed), then everything else - base-files,
-# glibc, busybox, unos-firstboot - installs fully trusted. No xbps, no
-# foreign repo content anywhere in the loop. Refuses to clobber an
-# existing rootfs/ unless --force.
-set -eu
-
-HERE=$(cd "$(dirname "$0")" && pwd)
-ROOT=$(cd "${HERE}/.." && pwd)
-ROOTFS="${ROOT}/rootfs"
-APK="${ROOT}/build/host/bin/apk"
-CACHE="${ROOT}/build/work/apk-cache"
-REPO="file://${ROOT}/build/repo"
-
-if [ -d "${ROOTFS}" ]; then
- [ "${1:-}" = "--force" ] || { echo "rootfs.sh: ${ROOTFS} exists (use --force)" >&2; exit 1; }
- rm -rf "${ROOTFS}"
-fi
-mkdir -p "${ROOTFS}" "${CACHE}" "${ROOTFS}/proc"
-
-# apk executes control scripts chrooted via memfd (/proc/self/fd), so the
-# target needs /proc mounted or every post-install fails with ENOENT -
-# even though install order/dep wiring is otherwise correct. Bind host
-# /proc in a private namespace for the transactions (same unshare pattern
-# as mk/chroot.sh; the --pid flag is what makes the proc mount permitted).
-# Namespace evaporates on exit; repo/cache stay visible (same fs view).
-apk_ns() {
- unshare --user --map-root-user --mount --propagation private --pid --fork \
- sh -c 'mount -t proc proc "$1/proc" && shift && "$@"' \
- _ "${ROOTFS}" "$@"
-}
-
-echo "==> trust seed: unos-keys via explicit --allow-untrusted"
-# NOTE: no --usermode here (apk refuses it as root, and we are mapped root
-# inside the namespace). Ownership maps back to the invoking user outside,
-# and no shipped package carries device nodes or setuid bits that would
-# behave differently - if one ever does, assembly needs a second look.
-apk_ns "${APK}" --root "${ROOTFS}" --initdb \
- --cache-dir "${CACHE}" --repository "${REPO}" \
- --allow-untrusted add unos-keys
-
-echo "==> UNOS system (fully trusted from here on)"
-apk_ns "${APK}" --root "${ROOTFS}" \
- --cache-dir "${CACHE}" --repository "${REPO}" \
- add base-files glibc busybox unos-firstboot tinyssh libmnl zlib openssl iproute2 apk-tools linux-longterm grub popt efivar efibootmgr unosd libstdc++ e2fsprogs gptfdisk smartmontools dosfstools curl
-
-# Deterministic loader cache: package post-installs refresh it during the
-# transaction, but assembly must not depend on script-execution order.
-# Runs OUR ldconfig inside the namespace (writes rootfs/etc/ld.so.cache).
-echo "==> loader cache"
-./mk/chroot.sh /sbin/ldconfig
-
-echo "==> done: ${ROOTFS}"
diff --git a/mk/run-qemu.sh b/mk/run-qemu.sh
@@ -1,195 +0,0 @@
-#!/bin/sh
-# mk/run-qemu.sh - build a bootable UNOS image with injected SSH keys and run it in QEMU.
-# No hardcoding of key names: discovers pubkeys at runtime.
-set -eu
-
-HERE=$(cd "$(dirname "$0")" && pwd)
-ROOT=$(cd "${HERE}/.." && pwd)
-BUILD="${ROOT}/build/work"
-STAGE="${BUILD}/stage-qemu-root"
-IMG="${BUILD}/unos-qemu.img"
-IMG_SIZE_MB="${IMG_SIZE_MB:-512}"
-QEMU_SSH_PORT="${QEMU_SSH_PORT:-2222}"
-QEMU_MEM="${QEMU_MEM:-512}"
-KERNEL="${ROOT}/rootfs/boot/vmlinuz"
-
-# UEFI support. OVMF's QemuKernelLoaderFsDxe makes `-kernel` work under UEFI
-# firmware, so a direct-kernel live VM still gets a real /sys/firmware/efi --
-# which the installer needs for firmware detection and for efibootmgr.
-UEFI="${UEFI:-0}"
-OVMF_CODE="${OVMF_CODE:-/usr/share/qemu/edk2-x86_64-code.fd}"
-OVMF_VARS_SRC="${OVMF_VARS_SRC:-/usr/share/qemu/edk2-i386-vars.fd}"
-OVMF_VARS="${BUILD}/ovmf-vars.fd"
-# Optional second disk, presented as /dev/vdb (install target).
-TARGET_DISK="${TARGET_DISK:-}"
-TARGET_SIZE_MB="${TARGET_SIZE_MB:-2048}"
-# Extra files to copy into the staged rootfs: "src:dstpath" pairs.
-EXTRA_FILES="${EXTRA_FILES:-}"
-
-uefi_args() {
- [ "${UEFI}" = "1" ] || return 0
- [ -f "${OVMF_CODE}" ] || { echo "run-qemu.sh: OVMF code not found: ${OVMF_CODE}" >&2; exit 1; }
- # vars must be writable and per-run, otherwise boot entries persist between tests
- [ -f "${OVMF_VARS}" ] || cp "${OVMF_VARS_SRC}" "${OVMF_VARS}"
- printf '%s' "-drive if=pflash,format=raw,readonly=on,file=${OVMF_CODE} -drive if=pflash,format=raw,file=${OVMF_VARS}"
-}
-
-reset_uefi_vars() {
- rm -f "${OVMF_VARS}"
-}
-
-# --- collect authorized_keys (automated, no hardcoding) ---
-collect_keys() {
- tmp=$(mktemp)
- # all .pub files
- for f in "${HOME}"/.ssh/*.pub; do
- [ -e "$f" ] || continue
- cat "$f" >> "$tmp" 2>/dev/null || true
- done
- # authorized_keys if present (may contain certs, deduplicated via sort -u)
- if [ -f "${HOME}/.ssh/authorized_keys" ]; then
- cat "${HOME}/.ssh/authorized_keys" >> "$tmp" 2>/dev/null || true
- fi
- # keep only non-empty, non-comment lines? Keep as-is but deduplicate
- # filter blank lines, let sort handle dedupe
- if [ -s "$tmp" ]; then
- sort -u "$tmp" -o "$tmp"
- # drop empty lines
- grep -v '^[[:space:]]*$' "$tmp" > "$tmp.filtered" 2>/dev/null || true
- mv "$tmp.filtered" "$tmp"
- fi
- # require at least one key
- if [ ! -s "$tmp" ]; then
- echo "run-qemu.sh: no pubkeys found in ~/.ssh/*.pub or ~/.ssh/authorized_keys" >&2
- rm -f "$tmp"
- return 1
- fi
- echo "$tmp"
-}
-
-# --- stage rootfs with injected keys ---
-stage_rootfs() {
- keys_file="$1"
- echo "==> staging rootfs"
- rm -rf "${STAGE}"
- mkdir -p "${STAGE}"
- # copy via tar to preserve perms; rootfs is unprivileged dir tree
- (cd "${ROOT}/rootfs" && tar -cf - .) | (cd "${STAGE}" && tar -xf -)
-
- # inject root's authorized_keys (automated)
- mkdir -p "${STAGE}/root/.ssh"
- cat "$keys_file" > "${STAGE}/root/.ssh/authorized_keys"
- chmod 700 "${STAGE}/root/.ssh"
- chmod 600 "${STAGE}/root/.ssh/authorized_keys"
-
- # Extra payloads, "src:dst" pairs separated by whitespace. Used by the
- # installer test to drop unos-*.bin into the live VM.
- for pair in ${EXTRA_FILES}; do
- src="${pair%%:*}"
- dst="${pair#*:}"
- [ -e "${src}" ] || { echo "run-qemu.sh: EXTRA_FILES source missing: ${src}" >&2; exit 1; }
- mkdir -p "${STAGE}$(dirname "${dst}")"
- cp -a "${src}" "${STAGE}${dst}"
- echo "==> staged ${src} -> ${dst}"
- done
- chown -R 0:0 "${STAGE}/root/.ssh" 2>/dev/null || true
- echo "==> injected $(wc -l < "$keys_file") key(s) into /root/.ssh/authorized_keys"
-}
-
-build_image() {
- echo "==> building ${IMG} (${IMG_SIZE_MB}M)"
- rm -f "${IMG}"
- # Injected authorized_keys is created as $USER, fix ownership via fake-root ns
- # before mkfs (which copies ownership verbatim).
- if command -v unshare >/dev/null 2>&1; then
- unshare --user --map-root-user --mount -- sh -c '
- chown -R 0:0 "$1/root/.ssh" 2>/dev/null || true
- mkfs.ext4 -L UNOS -m 0 -d "$1" "$2" "$3" >/dev/null
- ' sh "${STAGE}" "${IMG}" "${IMG_SIZE_MB}M" 2>/dev/null || mkfs.ext4 -L UNOS -m 0 -d "${STAGE}" "${IMG}" "${IMG_SIZE_MB}M" >/dev/null
- else
- mkfs.ext4 -L UNOS -m 0 -d "${STAGE}" "${IMG}" "${IMG_SIZE_MB}M" >/dev/null
- fi
- echo "==> image ready: ${IMG} ($(du -h "${IMG}" | cut -f1))"
- # sanity: label
- tune2fs -l "${IMG}" 2>/dev/null | grep -E "Filesystem volume name|Block count" | head -2
-}
-
-run_qemu() {
- [ -f "${KERNEL}" ] || { echo "run-qemu.sh: kernel not found: ${KERNEL} (run mk/rootfs.sh first)" >&2; exit 1; }
- [ -f "${IMG}" ] || { echo "run-qemu.sh: image not found: ${IMG}" >&2; exit 1; }
- tgt=""
- if [ -n "${TARGET_DISK}" ]; then
- [ -f "${TARGET_DISK}" ] || truncate -s "${TARGET_SIZE_MB}M" "${TARGET_DISK}"
- tgt="-drive file=${TARGET_DISK},format=raw,if=virtio"
- echo " target: ${TARGET_DISK} (/dev/vdb, ${TARGET_SIZE_MB}M)"
- fi
- echo "==> launching QEMU (ssh -p ${QEMU_SSH_PORT} root@localhost)"
- echo " kernel: ${KERNEL}"
- echo " drive: ${IMG}"
- echo " mem: ${QEMU_MEM}M"
- echo " uefi: ${UEFI}"
- echo " press Ctrl-a c then 'quit' to exit QEMU"
- echo ""
- # shellcheck disable=SC2086
- exec qemu-system-x86_64 -enable-kvm -m "${QEMU_MEM}" \
- $(uefi_args) \
- -kernel "${KERNEL}" \
- -drive file="${IMG}",format=raw,if=virtio \
- ${tgt} \
- -append "console=ttyS0 root=/dev/vda rw" \
- -nographic \
- -netdev user,id=net0,hostfwd=tcp::${QEMU_SSH_PORT}-:22 \
- -device e1000,netdev=net0
-}
-
-# Boot a disk that was written by the installer: no -kernel, no -append. The
-# firmware must find the ESP, run GRUB, and GRUB must find the kernel. This is
-# the only mode that actually proves the install worked.
-boot_installed() {
- disk="$1"
- [ -f "${disk}" ] || { echo "run-qemu.sh: no such image: ${disk}" >&2; exit 1; }
- tgt=""
- if [ -n "${TARGET_DISK}" ]; then
- [ -f "${TARGET_DISK}" ] || truncate -s "${TARGET_SIZE_MB}M" "${TARGET_DISK}"
- tgt="-drive file=${TARGET_DISK},format=raw,if=virtio"
- echo " target: ${TARGET_DISK} (/dev/vdb, ${TARGET_SIZE_MB}M)"
- fi
- echo "==> booting installed disk ${disk} (no -kernel; firmware -> GRUB -> UNOS)"
- echo " uefi: ${UEFI}"
- # shellcheck disable=SC2086
- exec qemu-system-x86_64 -enable-kvm -m "${QEMU_MEM}" \
- $(uefi_args) \
- -drive file="${disk}",format=raw,if=virtio \
- ${tgt} \
- -nographic \
- -netdev user,id=net0,hostfwd=tcp::${QEMU_SSH_PORT}-:22 \
- -device e1000,netdev=net0
-}
-
-# --- main ---
-mode="${1:-run}"
-case "$mode" in
- build)
- kf=$(collect_keys)
- stage_rootfs "$kf"
- build_image
- rm -f "$kf"
- echo "==> done (build only). Run '$0 run' to start QEMU."
- ;;
- run|"")
- kf=$(collect_keys)
- stage_rootfs "$kf"
- build_image
- rm -f "$kf"
- run_qemu
- ;;
- boot-installed)
- reset_uefi_vars
- boot_installed "${2:-${BUILD}/unos-target.img}"
- ;;
- *)
- echo "usage: $0 [build|run|boot-installed [img]]" >&2
- echo " env: UEFI=1 TARGET_DISK=<path> TARGET_SIZE_MB=<n> QEMU_SSH_PORT=<n>" >&2
- exit 1
- ;;
-esac
diff --git a/mk/sign-key.inc b/mk/sign-key.inc
@@ -1,9 +1,24 @@
# mk/sign-key.inc - resolve_sign_key(): print the RSA package-signing key.
#
-# Precedence: $UNOS_SIGN_KEY > $ROOT/.sign-key > single key in ~/.unos-keys/.
+# Precedence:
+# $UNOS_KEY_APK_RSA_PRI the CI worker's unos-sign-key feature sets this
+# $UNOS_SIGN_KEY manual override
+# $ROOT/.sign-key local developer selection (mk/keymgmt.sh use)
+# single *.rsa in ~/.unos-keys/
+#
+# CI comes first because a worker that mounted a key intends that key to be
+# used; a stale .sign-key checked into a runner image should not be able to
+# quietly outrank it.
+#
+# Note each worker carries its own distinct keypair, so KEYNAME (and therefore
+# the .SIGN.RSA.<name>.rsa.pub record in every .apk) varies with scheduling.
+# packages/unos-keys must ship every worker's public half or verification
+# fails for whichever packages happened to land elsewhere.
+#
# Source this file ($ROOT must be set), then: KEY=$(resolve_sign_key).
resolve_sign_key() {
- _k="${UNOS_SIGN_KEY:-}"
+ _k="${UNOS_KEY_APK_RSA_PRI:-}"
+ [ -n "${_k}" ] || _k="${UNOS_SIGN_KEY:-}"
if [ -z "${_k}" ] && [ -n "${ROOT:-}" ] && [ -f "${ROOT}/.sign-key" ]; then
_k=$(cat "${ROOT}/.sign-key")
fi
@@ -12,7 +27,9 @@ resolve_sign_key() {
if [ $# = 1 ] && [ -f "$1" ]; then
_k=$1
else
- echo "sign-key: UNOS_SIGN_KEY unset, no ${ROOT}/.sign-key, no single key in ~/.unos-keys (see mk/keymgmt.sh)" >&2
+ echo "sign-key: no key. Set UNOS_KEY_APK_RSA_PRI (CI: the unos-sign-key
+ worker feature provides it) or UNOS_SIGN_KEY, or write one to
+ ${ROOT}/.sign-key, or keep a single *.rsa in ~/.unos-keys (mk/keymgmt.sh)" >&2
return 1
fi
fi
diff --git a/mk/sysroot.sh b/mk/sysroot.sh
@@ -0,0 +1,208 @@
+#!/bin/sh
+# mk/sysroot.sh - seed build/sysroot from the published apk repo.
+#
+# ./mk/sysroot.sh seed for all packages (full graph)
+# ./mk/sysroot.sh zlib seed only transitive makedepends of zlib
+# ./mk/sysroot.sh --arch aarch64 zlib specific arch (default x86_64)
+# ./mk/sysroot.sh --from repo+staging use repo + staging repos (for CI level-1 overlay)
+#
+# Why this exists: mk/build.sh:100 treats "a built .apk exists" as proof the
+# sysroot was seeded. An ephemeral CI container building only zlib therefore
+# had to rebuild all 38 packages. This script populates the sysroot by
+# `apk --root build/sysroot --initdb add` from the published repo, resolving
+# transitive makedepends via mk/deps.sh. It is the gate on per-package
+# incremental builds.
+#
+# Requires: apk (host via build/host/bin/apk) and a repo to pull from.
+# Repo URLs may be local dirs (build/repo/x86_64) or http(s) (Garage via Caddy).
+# Supports overlay: staging/<run> layered over repo/x86_64 so a level-1 build
+# compiles against the freshly built library from the same push.
+set -eu
+
+HERE=$(cd "$(dirname "$0")" && pwd)
+ROOT=$(cd "${HERE}/.." && pwd)
+
+ARCH="x86_64"
+REPOS=""
+PKGS=""
+# default repos: local build/repo/<arch> if it exists, else nothing (caller
+# must supply --repo). CI supplies --repo https://repo.unos.finwo.net/x86_64
+# and optionally --repo <staging-url>.
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --arch) ARCH="$2"; shift 2 ;;
+ --arch=*) ARCH="${1#--arch=}"; shift ;;
+ --repo) REPOS="${REPOS} $2"; shift 2 ;;
+ --repo=*) REPOS="${REPOS} ${1#--repo=}"; shift ;;
+ -h|--help)
+ sed -n '1,45p' "$0"
+ exit 0
+ ;;
+ --) shift; break ;;
+ -*) echo "sysroot.sh: unknown option: $1" >&2; exit 1 ;;
+ *) PKGS="${PKGS} $1"; shift ;;
+ esac
+done
+# remaining args are also pkgs
+for a in "$@"; do PKGS="${PKGS} $a"; done
+
+case "${ARCH}" in arm64) ARCH=aarch64 ;; esac
+case "${ARCH}" in x86_64|aarch64) ;; *) echo "sysroot.sh: unsupported ARCH ${ARCH}" >&2; exit 1 ;; esac
+
+SYSROOT="${ROOT}/build/sysroot"
+# When seeding for a specific ARCH, keep arch-specific sysroots separate so
+# x86_64 and aarch64 headers don't clobber each other. For x86_64 the
+# historical path `build/sysroot` is kept as the default (symlink or same).
+if [ "${ARCH}" != "x86_64" ]; then
+ SYSROOT="${SYSROOT}-${ARCH}"
+fi
+
+APK="${ROOT}/build/host/bin/apk"
+if [ ! -x "${APK}" ]; then
+ APK="apk"
+ command -v apk >/dev/null 2>&1 || {
+ echo "sysroot.sh: apk not found (run mk/bootstrap-host.sh)" >&2
+ exit 1
+ }
+fi
+
+# Default repo: local build/repo/<arch> if it exists
+if [ -z "${REPOS}" ]; then
+ if [ -d "${ROOT}/build/repo/${ARCH}" ] && ls "${ROOT}/build/repo/${ARCH}"/*.apk >/dev/null 2>&1; then
+ REPOS="${ROOT}/build/repo/${ARCH}"
+ else
+ echo "sysroot.sh: no --repo given and no local build/repo/${ARCH} with .apk files" >&2
+ echo " hint: --repo https://repo.unos.finwo.net/${ARCH} or --repo /path/to/repo" >&2
+ exit 1
+ fi
+fi
+
+# Resolve transitive makedepends.
+# deps.sh order <pkgs> already gives the topological closure including the
+# requested packages themselves; filter to just the dependencies.
+if [ -z "${PKGS}" ] || [ "${PKGS}" = " " ]; then
+ # all packages: seed everything (used for full sysroot)
+ ORDER=$("${ROOT}/mk/deps.sh" order) || exit 1
+else
+ # shellcheck disable=SC2086
+ ORDER=$("${ROOT}/mk/deps.sh" order ${PKGS}) || exit 1
+fi
+
+# Build set of needed packages: transitive makedepends of requested pkgs.
+# If PKGS is empty, we seeded all, but for sysroot we want only libs that
+# other packages build against -- still safe to install all.
+# For incremental, we install the closure minus the targets themselves if
+# targets are leaves? Simpler: install the whole order - apk will handle
+# already-installed. Filter to keep it small: if PKGS given, use order
+# but the conductor will call with the full level's closure anyway.
+PKGS_TO_INSTALL=""
+for p in ${ORDER}; do
+ # Skip if this is a leaf requested package that hasn't been built yet and
+ # would not be in the repo. But if it's already in the repo, installing it
+ # is fine (provides headers). So we try to install everything in order;
+ # apk will skip what's not in the repo with an error - filter via repo.
+ PKGS_TO_INSTALL="${PKGS_TO_INSTALL} $p"
+done
+
+# When PKGS is given, the ORDER includes deps first then targets. Installing
+# all is correct for level-1 overlay: level-1 needs the level-0 libs that are
+# already in the repo plus the level-0 staging apks.
+# If the target itself is not yet in any repo, apk add will fail; so we
+# attempt and allow missing targets.
+# Strategy: try to install all, but if apk fails due to missing pkg, retry
+# without the leaf targets.
+
+REPO_ARGS=""
+for r in ${REPOS}; do
+ case "${r}" in
+ http://*|https://*|file://*) REPO_ARGS="${REPO_ARGS} --repository ${r}" ;;
+ /*) REPO_ARGS="${REPO_ARGS} --repository file://${r}" ;;
+ *) # relative local path like build/repo/x86_64 -> use base
+ # If it already ends with /x86_64 or /aarch64, strip to base
+ case "${r}" in */x86_64|*/aarch64|*/arm64)
+ base=$(dirname "${r}")
+ REPO_ARGS="${REPO_ARGS} --repository file://${ROOT}/${base}"
+ ;;
+ *) REPO_ARGS="${REPO_ARGS} --repository file://${ROOT}/${r}" ;;
+ esac
+ ;;
+ esac
+done
+# If REPOS was a single arch-specific local path, also allow base form
+# e.g. build/repo/x86_64 -> file://.../build/repo
+
+# Allow apk to fetch from multiple repos; --allow-untrusted for local unsigned
+# staging during dev. Published repo is signed; apk will verify via keys in
+# the sysroot if present, but --allow-untrusted is harmless for seeding.
+# Use --initdb if sysroot is empty. --no-scripts avoids ldconfig etc in sysroot.
+
+# Non-root builds (local dev) need --usermode; CI runs as root in Docker.
+USERMODE=""
+if [ "$(id -u)" != "0" ]; then
+ USERMODE="--usermode"
+fi
+
+SYSROOT_EXISTS=0
+[ -f "${SYSROOT}/lib/apk/db/installed" ] && SYSROOT_EXISTS=1
+[ -f "${SYSROOT}/etc/apk/world" ] && SYSROOT_EXISTS=1
+# Fallback: legacy build/sysroot via DESTDIR has no apk db at all -- treat as
+# not-exists so we init it. Content already there will be preserved by apk
+# (it merges).
+
+echo "==> sysroot: ARCH=${ARCH} SYSROOT=${SYSROOT}"
+echo "==> sysroot: repos:${REPOS}"
+echo "==> sysroot: installing:${PKGS_TO_INSTALL}"
+
+# shellcheck disable=SC2086
+if [ "${SYSROOT_EXISTS}" = "0" ]; then
+ # shellcheck disable=SC2086
+ "${APK}" ${USERMODE} --root "${SYSROOT}" --initdb ${REPO_ARGS} --allow-untrusted --no-scripts add ${PKGS_TO_INSTALL} || {
+ rc=$?
+ # Retry without the requested leaf packages (they may not be in repo yet)
+ if [ -n "${PKGS}" ]; then
+ echo "==> sysroot: retry without leaf targets"
+ FILTERED=""
+ for p in ${PKGS_TO_INSTALL}; do
+ skip=0
+ for t in ${PKGS}; do [ "${p}" = "${t}" ] && skip=1 && break; done
+ [ "${skip}" = "0" ] && FILTERED="${FILTERED} $p"
+ done
+ if [ -z "${FILTERED}" ]; then
+ echo "==> sysroot: nothing to install after filtering (target not in repo yet)"
+ mkdir -p "${SYSROOT}/etc/apk" "${SYSROOT}/usr/lib/pkgconfig"
+ else
+ # shellcheck disable=SC2086
+ "${APK}" ${USERMODE} --root "${SYSROOT}" --initdb ${REPO_ARGS} --allow-untrusted --no-scripts add ${FILTERED} || exit $rc
+ fi
+ else
+ exit $rc
+ fi
+ }
+else
+ # shellcheck disable=SC2086
+ "${APK}" ${USERMODE} --root "${SYSROOT}" ${REPO_ARGS} --allow-untrusted --no-scripts add ${PKGS_TO_INSTALL} || {
+ rc=$?
+ if [ -n "${PKGS}" ]; then
+ echo "==> sysroot: retry without leaf targets"
+ FILTERED=""
+ for p in ${PKGS_TO_INSTALL}; do
+ skip=0
+ for t in ${PKGS}; do [ "${p}" = "${t}" ] && skip=1 && break; done
+ [ "${skip}" = "0" ] && FILTERED="${FILTERED} $p"
+ done
+ if [ -z "${FILTERED}" ]; then
+ echo "==> sysroot: nothing to install after filtering"
+ else
+ # shellcheck disable=SC2086
+ "${APK}" ${USERMODE} --root "${SYSROOT}" ${REPO_ARGS} --allow-untrusted --no-scripts add ${FILTERED} || exit $rc
+ fi
+ else
+ exit $rc
+ fi
+ }
+fi
+
+# Fix up .pc files (same logic as mk/pc-fixup.sh, but arch-aware)
+"${HERE}/pc-fixup.sh" "${SYSROOT}"
+
+echo "==> sysroot: done ${SYSROOT} ($(find "${SYSROOT}" -type f | wc -l | tr -d ' ') files)"
diff --git a/packages/README.md b/packages/README.md
@@ -46,6 +46,17 @@ directory is ever built by hand on a target switch.
(see `gptfdisk`).
- **Pin everything.** Explicit versions and recorded checksums. No moving
targets, no floating branches.
+- **Third-party source uses `distfiles=` and must carry `checksum=`.** The
+ driver refuses to build a template that sets `distfiles` without a matching
+ sha256, because there we are trusting a server nobody here controls.
+- **Our own source uses `srcfiles=` and carries no checksum.** It is fetched by
+ tag from `git.finwo.net`; the tag is the pin and TLS is the transport. A
+ forge regenerates tag archives on demand, so they are not byte-stable and a
+ recorded sha256 would eventually fail for reasons indistinguishable from
+ tampering. Setting `checksum=` alongside `srcfiles=` is an error, as is
+ setting both `srcfiles=` and `distfiles=` -- whichever is present supplies
+ the tarball `WRKSRC` is derived from, and two sources would make that choice
+ silent and arbitrary. Only `linkd` uses this today.
- **Never vendor upstream source into this repository.** Sources are fetched at
build time from a pinned revision. This keeps history small and keeps our
licensing story unambiguous.
diff --git a/packages/base-files/files/linkd.cnf b/packages/base-files/files/linkd.cnf
@@ -0,0 +1,20 @@
+# linkd configuration.
+#
+# The socket path differs from linkd's own default (/var/run/linkd.sock):
+# UNOS mounts a tmpfs on /run and ships no /var/run, so the daemon and every
+# client are pointed at the path that exists here.
+
+config_ports /etc/network/ports /etc/network/ports.d/*.cnf
+config_iface /etc/network/interfaces /etc/network/interfaces.d/*.cnf
+
+listen unix:///run/linkd.sock
+
+# TCP listeners need `authfile` set, and linkd refuses to bind one without it.
+# Generate entries with: linkctl hash <user>
+#
+# authfile /etc/linkd.passwd
+# listen tcp://127.0.0.1:6789
+
+# Hardware dataplanes are declared here; the openbcm package drops one in.
+#
+# plugin /usr/lib/linkd/bcm
diff --git a/packages/base-files/files/network/ports b/packages/base-files/files/network/ports
@@ -15,7 +15,7 @@
# admin state .......................... auto / allow-hotplug / ifdown
# vlans ................................ iface swp1.100
#
-# unosd learns all of that from netlink and mirrors it into the ASIC, so
+# linkd learns all of that from netlink and mirrors it into the ASIC, so
# configuring it here as well would create two sources of truth for one value.
# Directives belonging to ifupdown are rejected rather than ignored.
#
diff --git a/packages/base-files/template b/packages/base-files/template
@@ -1,7 +1,7 @@
# Template file for 'base-files'
pkgname=base-files
version=0.1.0
-revision=5
+revision=6
short_desc="UNOS filesystem skeleton - passwd, profile, nsswitch, fstab"
maintainer="finwo <finwo@pm.me>"
license="GPL-2.0-only"
@@ -29,6 +29,7 @@ do_install() {
vinstall ${FILESDIR}/rcS 755 etc/init.d rcS
vinstall ${FILESDIR}/network/ports 644 etc/network ports
vinstall ${FILESDIR}/network/interfaces 644 etc/network interfaces
+ vinstall ${FILESDIR}/linkd.cnf 644 etc linkd.cnf
install -d "${DESTDIR}/etc/network/ports.d"
install -d "${DESTDIR}/etc/network/interfaces.d"
install -d -m700 "${DESTDIR}/root"
diff --git a/packages/busybox/template b/packages/busybox/template
@@ -26,7 +26,7 @@ depends=
# otherwise; six symbols gate them in bbunzip.c, all six go; kernel
# initramfs xz stays independent via CONFIG_RD_XZ),
# udhcpd (dnsmasq covers the server side), httpd/inetd (tcpsvd is our
-# super-server), IFUP (unosd owns /etc/network/interfaces via unosc, busybox
+# super-server), IFUP (linkd owns /etc/network/interfaces via linkctl, busybox
# ifupdown would conflict and its `iface <name>` parser is for old
# `iface <name> inet static` only -- cumulus `iface <name>` fails there),
# MKFS_VFAT/MKDOSFS (dosfstools owns mkfs.vfat; two providers of one command
diff --git a/packages/libmnl/template b/packages/libmnl/template
@@ -38,5 +38,6 @@ do_install() {
make install DESTDIR="${SYSROOT}"
# .la files are build-time libtool metadata, never shipped
rm -f "${DESTDIR}"/usr/lib/*.la "${SYSROOT}"/usr/lib/*.la
- grep -q "^prefix=${SYSROOT}/usr$" "${SYSROOT}/usr/lib/pkgconfig/libmnl.pc" || die "libmnl: sysroot .pc not fixed"
+ # The sysroot .pc prefix is rewritten and verified centrally by mk/build.sh,
+ # which runs pc-fixup.sh after this function returns.
}
diff --git a/packages/linkd/files/linkd-check b/packages/linkd/files/linkd-check
@@ -0,0 +1,3 @@
+#!/bin/sh
+# runit check interface over the ready-file mechanism.
+[ -e /run/linkd.ready ] && exit 0 || exit 1
diff --git a/packages/linkd/files/linkd-log-run b/packages/linkd/files/linkd-log-run
@@ -0,0 +1,4 @@
+#!/bin/sh
+# svlogd companion: runit captures stdout/stderr, so linkd needs no --log file
+# when supervised.
+exec svlogd -tt /var/log/linkd
diff --git a/packages/linkd/files/linkd-run b/packages/linkd/files/linkd-run
@@ -0,0 +1,9 @@
+#!/bin/sh
+#
+# linkd runit service.
+#
+# Readiness contract: linkd publishes /run/linkd.ready after its initial
+# resync (see --ready-file). frr's run script blocks on `sv check linkd`
+# so zebra never starts before the swpN netdevs exist. The daemon itself
+# defaults to no ready-file; the supervised package opts in here.
+exec linkd --ready-file /run/linkd.ready
diff --git a/packages/linkd/template b/packages/linkd/template
@@ -0,0 +1,36 @@
+# Template file for 'linkd'
+pkgname=linkd
+version=0.1.0
+revision=1
+short_desc="Network interface manager - ifupdown replacement with RESP plugins"
+maintainer="finwo <finwo@pm.me>"
+license="GPL-2.0-only"
+homepage="https://git.finwo.net/app/linkd"
+depends="busybox iproute2"
+hostmakedepends="dep"
+# First-party source, pinned by tag rather than sha256: see srcfiles in
+# mk/build.sh. Until git.finwo.net serves tag archives, seed the cache by
+# dropping the tarball at build/work/sources/linkd-v0.1.0.tar.gz -- the fetch
+# is skipped whenever that file already exists.
+srcfiles="https://git.finwo.net/app/linkd/archives/tags/v${version}.tar.gz"
+
+do_build() {
+ # linkd's Makefile assembles build/<triple>/ and runs `dep install` there,
+ # which needs network access to git.finwo.net for its vendored libraries.
+ # They are branch tips, so this build is not reproducible across upstream
+ # changes; the tag above pins linkd's own code only.
+ make -C "${WRKSRC}" -j"$(nproc)"
+ [ -x "${WRKSRC}/build/linux-glibc-amd64/linkd" ] || die "linkd binary missing after build"
+}
+
+do_install() {
+ vinstall "${WRKSRC}/build/linux-glibc-amd64/linkd" 755 usr/bin linkd
+ # multicall: linkd dispatches on basename(argv[0])
+ for link in linkctl ifup ifdown ifquery ifreload; do
+ ln -sfn linkd "${DESTDIR}/usr/bin/${link}"
+ done
+ vinstall ${FILESDIR}/linkd-run 755 etc/sv/linkd run
+ vinstall ${FILESDIR}/linkd-check 755 etc/sv/linkd check
+ vinstall ${FILESDIR}/linkd-log-run 755 etc/sv/linkd/log run
+ install -d "${DESTDIR}/var/log/linkd"
+}
diff --git a/packages/openssl/template b/packages/openssl/template
@@ -28,7 +28,12 @@ hostmakedepends="perl"
do_configure() {
cd "${WRKSRC}"
- perl ./Configure --prefix=/usr --libdir=lib no-docs linux-x86_64
+ case "${ARCH:-x86_64}" in
+ x86_64) _ossl_target=linux-x86_64 ;;
+ aarch64|arm64) _ossl_target=linux-aarch64 ;;
+ *) die "openssl: unsupported ARCH ${ARCH}" ;;
+ esac
+ perl ./Configure --prefix=/usr --libdir=lib no-docs "${_ossl_target}"
}
do_build() {
diff --git a/packages/unos-firstboot/files/unos-firstboot b/packages/unos-firstboot/files/unos-firstboot
@@ -59,7 +59,7 @@ detect_bcm_switch() {
install_pkg() {
log "installing $1"
- if ! xbps-install -y "$1"; then
+ if ! apk add "$1"; then
log "failed to install $1"
return 1
fi
@@ -72,7 +72,7 @@ if detect_bcm_switch; then
install_pkg openbcm || exit 1
else
log "platform: generic, no switching ASIC detected"
- log "unosd will use the built-in kernel dataplane"
+ log "linkd will use the built-in kernel dataplane"
fi
date -u +%Y-%m-%dT%H:%M:%SZ > "${MARKER}"
diff --git a/packages/unos-firstboot/template b/packages/unos-firstboot/template
@@ -1,7 +1,7 @@
# Template file for 'unos-firstboot'
pkgname=unos-firstboot
version=0.1.0
-revision=1
+revision=2
short_desc="One-time platform provisioning for UNOS"
maintainer="finwo <finwo@pm.me>"
license="GPL-2.0-only"
diff --git a/packages/unos-keys/files/apk/unos-ci-62a2ab39@rtr.rsa.pub b/packages/unos-keys/files/apk/unos-ci-62a2ab39@rtr.rsa.pub
@@ -0,0 +1,14 @@
+-----BEGIN PUBLIC KEY-----
+MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA3mlzqXdhdBGqT5USlAsS
+U9innXqoOp1mwmGRQ6355httKrVEdF0C7pQn/i1TBci72Dl3PaE3dHGcBSpW7GFT
+FfuDwEQTx5jszs3EcQVvejgUkukDIh8Jvm8NgZ7ZM+ZnYjFeiop1KNpKzGkM0f43
+HL7pxwVeRN3Tmc0kJTmbOcNEdo7nWqA9xkM0foHTkNqDqeQ6BTWZ20hfEgV4M1G5
+xLXPukIZGFqggm+qNELuf8lx+khYquJdLoc4+h9xBU7+TR769APXScXH+s3y6Xkl
+eRYZE8EGEQ6Y3spCDUd8r14GR/l+lOItWZmGp/CvOvNi3r/N5LSzQ3ugvfEYpuNJ
+yP7gI/xfjvx5+q+clyT/YIvwtoSTnc7FHDUrOtstd7F68I2DI8KKY494qH4ky7Td
+nvk2/7VRszTiiaHWmeEq8zjxI+6zC7Lk5O9uovxeL8aaWxBSWtAnJsu8HFOMzxnA
+5X2yVwCFLL2PUCmF3VdXudF73Ifuaez4tpSCknVej5S5uCKEd+szLxNYCTVnGeJT
+BWTzN3fY5WLjoY8FMXNd4mGoCXmo8KwtNqS2NL0WzTFT6kmOZBXEqBmhmkPhVL6J
+REk7J27Ma8/sR0TQfP3LRqqAwzr8KrHT5QS0SLshEEuuohx7PMuKCkTHij288E32
+C/VjonHVUbU7nk7nhShEI48CAwEAAQ==
+-----END PUBLIC KEY-----
diff --git a/packages/unos-keys/files/apk/unos-ci-62a2ab39@sat06.rsa.pub b/packages/unos-keys/files/apk/unos-ci-62a2ab39@sat06.rsa.pub
@@ -0,0 +1,14 @@
+-----BEGIN PUBLIC KEY-----
+MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA5u30fHsCzzaYe6b984V3
+Baf2qRyVBemxeqFx1swEbWNEtumol3wiGlcUoGHqMvmcCicOD5YyYE+i015JciA7
+Vl8Fd1EVt6MQBFhXMHz9dOt9d8inMzbe3qdiolCfiIAhO5Roj3kJ6fNnSRUaTPWM
+J4ym/pNiUtju8yVb+m7WUoSaN+U4o7BG/AvvzllIw1usORY2OrGCAmTXN56ebzSI
+GYYchBPI7g4pwlNxgWwvxgXN51LEG6sw8MGkwdJp7ltKx2nrjC/GZRmWuZEIN9uL
+1R5g+REb9jjF4PC0sCpCJRwr9JtE+b3BqB4E9freNAOu/AsG6HMQoAac8DNyZRZW
+wAI6Bw47ZsfYC94oI2qAEkHV7BoS0r+mDQxo8TIkLNYGP3p3sSec0//WYAXZpITk
++TCGjrhDDbfjPz9WF4e11dQqRl+Om3JxB2b6GL7luIMlnDwU5LUmvDFGSywRthER
+NAWCnTKnOP17/duvWTIUt7svOejZIUz5lP/5Gb9LVb8qhIrbratS8rhQ3+z/Dh3D
+Qjr8H3wNgpC6FaY7AuIKvCAj+VjnEAxc8oz/8Qs06lu55pFcyYGfe0lxuKmeHEG8
+PDyijgZ7UYiJd5xauA7oP1MLbczTOHcHYkNRigV7uRrH1O5VtRp0Z3U173H2NI6b
+cBpWNEqMpvA2G9TpFafTiL8CAwEAAQ==
+-----END PUBLIC KEY-----
diff --git a/packages/unos-keys/files/apk/unos-ci-62a2ab39@sat07.rsa.pub b/packages/unos-keys/files/apk/unos-ci-62a2ab39@sat07.rsa.pub
@@ -0,0 +1,14 @@
+-----BEGIN PUBLIC KEY-----
+MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAo7yaczj88sui/Jns0t5/
+1APsaJF61seXYvssIeIDYjxUVZB2UEgLt3bze/QsUIXUfmdqcWMq1B0cfhfLKy6I
+5jdG63BLwcPhWSyExr80BsOMpVlvqjUGWNoMw5oBVNojhGCJocTFOfn2pZfTt+CS
+AK9M4VslEfwNT7/Um6QHrhosueI+DdakUsV6cN+jdvqx+KIO5cRyQkxqpQLWXjZb
+0Ycn6dZtNXoLFsWCD05MzXI52zniXcIe+N8x5gPfTVAQgQylDhAmC+2K013GfjoL
+uiiCdeoRzSJR2DgmmXQNKI9x+zvZTRSo/kyD/l2Y0VTJCHshemQEZouK8oxBDb+V
+MHapKr66l8w7M0hOehNpPnpbYcXv65pRrwq0QA88aUkR8B/Y7h+I+n26529txBcp
+kQKGjhioWoSqFJv8LIMO3PIvLkB9b1lYLmwcDjIr/o4SOyKv37Fy2fryOTOkYhIm
+O59jXmDLldJhuEH5LXeY8cbrQ+oCBU/xfr4+45ZvH+9Qcmwui7MaYfHEMvjFisDb
+oQ8YrdqHHoOTDOPAebk5wBOych846Qg0+967oIz1mxXMzJu6aAPLDW7BIFRsuMoB
+f3InqMymRCH7+o6qxIdThQy9zMiPSjMGHEU0wNzow6je2NFvfFixRwJOcfRz0yF6
+o31O38aSgPuMH6IhrNhAOlUCAwEAAQ==
+-----END PUBLIC KEY-----
diff --git a/packages/unos-keys/files/apk/unos-ci-62a2ab39@sat13.rsa.pub b/packages/unos-keys/files/apk/unos-ci-62a2ab39@sat13.rsa.pub
@@ -0,0 +1,14 @@
+-----BEGIN PUBLIC KEY-----
+MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEApNbSt/8Dm1Kx4CoaygXh
+Q3DpdBfQIh16dcPx2P+UCUImMYIWShymDNv1QBgfG80XoKNzKPAPOnA7pMIb/MHu
+VwTtX3Ns3I8cWgcFYrm+q0K/hTMkreDgFtevC55npF4eywBUGAuLMYhDEqso5gT5
+3FSFkHBgQh6drAV1NqzMwYMwo/QhJTKaD7CJsOVRBfEA2rUN/YftOXUkieFxF3DB
+Vaztsv5dEi3nCcvOgE/F6yJ46sVHoH5xjphzdUsYOU3J7L7UV9sOUtoPdO4jT+cG
+N1Ig71B/J7Qzf9IRJCXmj6YcNXjx5aZZpvLr6oCTKGzEMReufXLVAuWmP4qmdEN0
+rhjYxV6fr3KtMsFIE3SFRM+E2jbgMmbRaYqcq6gWBksT4K8jpzhcSuKzN0BfyMiu
+2hl5P4PBoPIb/dwViqWGAQQxjcJXXo5hY9oFOkYH2f+bnKVgtDqSSr9CNZ1DVdQd
+enJl6ANACVj9BS/kJndSljsBt+OMQVxMly9LhLaa6v6AAZnQdsPk3L3hADq9kKkL
+Thrw7gFkxECLlRhuyPctUFTaRYwaSq+Zy+q4eIuuCqIWKr3UOJK7H7NPxG+75HLI
+NrEp0yf1KCwwRnyHPVDs1oGuxT8XeWI3ZjztZRfXdpPpx+exWXlSNuvaaVKJdJ0T
+UZdRCLs6OXOD2w42CMzQiK0CAwEAAQ==
+-----END PUBLIC KEY-----
diff --git a/packages/unos-keys/files/unos/unos-ci-62a2ab39@rtr.ed25519.pub b/packages/unos-keys/files/unos/unos-ci-62a2ab39@rtr.ed25519.pub
@@ -0,0 +1,3 @@
+-----BEGIN SUPERCOP PUBLIC KEY-----
+tJAx/bL45SsPn+GJIVSsetIMpI3K3MhZVH7IwY/yk6c=
+-----END SUPERCOP PUBLIC KEY-----
diff --git a/packages/unos-keys/files/unos/unos-ci-62a2ab39@sat06.ed25519.pub b/packages/unos-keys/files/unos/unos-ci-62a2ab39@sat06.ed25519.pub
@@ -0,0 +1,3 @@
+-----BEGIN SUPERCOP PUBLIC KEY-----
+IUPZQyCdN8bEIHcqVnlAls2pYnfabCz70Pg7QGdd2mE=
+-----END SUPERCOP PUBLIC KEY-----
diff --git a/packages/unos-keys/files/unos/unos-ci-62a2ab39@sat07.ed25519.pub b/packages/unos-keys/files/unos/unos-ci-62a2ab39@sat07.ed25519.pub
@@ -0,0 +1,3 @@
+-----BEGIN SUPERCOP PUBLIC KEY-----
+Zya5i0FVG8iQGl3RSn8GAFKVXuq9OHiSc3r7zuLD7AY=
+-----END SUPERCOP PUBLIC KEY-----
diff --git a/packages/unos-keys/files/unos/unos-ci-62a2ab39@sat13.ed25519.pub b/packages/unos-keys/files/unos/unos-ci-62a2ab39@sat13.ed25519.pub
@@ -0,0 +1,3 @@
+-----BEGIN SUPERCOP PUBLIC KEY-----
+OoYlU5+0fTMZNUxHh6dg+YXQdDiMjT1rPncLxvkjKZY=
+-----END SUPERCOP PUBLIC KEY-----
diff --git a/packages/unos-keys/template b/packages/unos-keys/template
@@ -1,15 +1,34 @@
# Template file for 'unos-keys'
pkgname=unos-keys
version=0.1.0
-revision=1
+revision=2
short_desc="UNOS trust anchors - apk and installer verification keys"
maintainer="finwo <finwo@pm.me>"
license="GPL-2.0-only"
homepage="https://unos.finwo.dev"
-# Key ceremony: private halves live in ~/.unos-keys/ and never enter git.
-# Public halves are committed here by mk/keymgmt.sh (public by design):
+# Key ceremony: private halves live in ~/.unos-keys/ (developer) or on the CI
+# workers under the unos-sign-key feature, and never enter git. Public halves
+# are committed here (public by design):
# files/apk/* -> /etc/apk/keys/, files/unos/* -> /etc/unos/keys/.
-# Rotation = add the new .pub files and bump version.
+# Rotation = add the new .pub files and bump revision.
+#
+# EVERY CI worker's public key must be here. Each of the four workers holds a
+# distinct keypair, and mk/build.sh names the signature record after whichever
+# key signed it (.SIGN.RSA.<keyname>.rsa.pub), so the signer is decided by
+# wherever the conductor happened to schedule the build. Ship fewer than all of
+# them and verification fails for an arbitrary, run-to-run-varying subset of
+# packages -- the failure looks like corruption, not like a missing key.
+#
+# Consequences worth knowing before adding a fifth worker:
+# - publish this package BEFORE that worker builds anything, or existing
+# installs cannot verify its output;
+# - any one worker can sign packages every client trusts, so these four are
+# jointly as trusted as the repo itself. The intended end state is a
+# release step that re-signs with a single key and merely *accepts* these
+# as inputs; that does not exist yet.
+#
+# unos-dev@finwo.dev-096b7b41 is the developer key, kept so locally built
+# packages still install. It should be dropped once CI is publishing.
do_install() {
for k in "${FILESDIR}"/apk/*; do
[ -e "${k}" ] || break
diff --git a/packages/unosd/files/unosd-check b/packages/unosd/files/unosd-check
@@ -1,3 +0,0 @@
-#!/bin/sh
-# runit check interface over the ready-file mechanism.
-[ -e /run/unosd.ready ] && exit 0 || exit 1
diff --git a/packages/unosd/files/unosd-log-run b/packages/unosd/files/unosd-log-run
@@ -1,4 +0,0 @@
-#!/bin/sh
-# svlogd companion: runit captures stdout/stderr, so unosd needs no --log file
-# when supervised.
-exec svlogd -tt /var/log/unosd
diff --git a/packages/unosd/files/unosd-run b/packages/unosd/files/unosd-run
@@ -1,9 +0,0 @@
-#!/bin/sh
-#
-# unosd runit service.
-#
-# Readiness contract: unosd publishes /run/unosd.ready after its initial
-# resync (see --ready-file). frr's run script blocks on `sv check unosd`
-# so zebra never starts before the swpN netdevs exist. The daemon itself
-# defaults to no ready-file; the supervised package opts in here.
-exec unosd --ready-file /run/unosd.ready
diff --git a/packages/unosd/template b/packages/unosd/template
@@ -1,28 +0,0 @@
-# Template file for 'unosd'
-pkgname=unosd
-version=0.1.0
-revision=5
-short_desc="UNOS control plane daemon - kernel netlink mirror into the dataplane"
-maintainer="finwo <finwo@pm.me>"
-license="GPL-2.0-only"
-homepage="https://unos.finwo.dev"
-depends="busybox iproute2"
-hostmakedepends="dep"
-
-do_build() {
- # Single multicall binary (src/main.c dispatch + src/cli/*.c constructors)
- make -C "${UNOS_MKDIR}/../src/unosd" -j"$(nproc)"
- [ -x "${UNOS_MKDIR}/../src/unosd/build/linux-glibc-amd64/unosd" ] || die "unosd binary missing after build"
-}
-
-do_install() {
- vinstall "${UNOS_MKDIR}/../src/unosd/build/linux-glibc-amd64/unosd" 755 usr/bin unosd
- # multicall: unosc and if* are symlinks to the same binary (argv[0] dispatch in src/main.c)
- for link in unosc ifup ifdown ifquery ifreload; do
- ln -sfn unosd "${DESTDIR}/usr/bin/${link}"
- done
- vinstall ${FILESDIR}/unosd-run 755 etc/sv/unosd run
- vinstall ${FILESDIR}/unosd-check 755 etc/sv/unosd check
- vinstall ${FILESDIR}/unosd-log-run 755 etc/sv/unosd/log run
- install -d "${DESTDIR}/var/log/unosd"
-}
diff --git a/packages/zlib/template b/packages/zlib/template
@@ -37,5 +37,6 @@ do_install() {
make install DESTDIR="${DESTDIR}"
make install DESTDIR="${SYSROOT}"
rm -f "${DESTDIR}/usr/lib/libz.a" "${SYSROOT}/usr/lib/libz.a"
- grep -q "^prefix=${SYSROOT}/usr$" "${SYSROOT}/usr/lib/pkgconfig/zlib.pc" || die "zlib: sysroot .pc not fixed"
+ # The sysroot .pc prefix is rewritten and verified centrally by mk/build.sh,
+ # which runs pc-fixup.sh after this function returns.
}
diff --git a/tests/helpers.sh b/tests/helpers.sh
@@ -0,0 +1,79 @@
+#!/bin/sh
+# tests/helpers.sh - shared assertions, source with `. tests/helpers.sh`
+#
+# Assertions only. This repo builds and signs packages; it never boots a
+# machine, so the QEMU and SSH helpers that used to live alongside these
+# belong to the unos OS-assembly repo instead.
+set -eu
+
+assert_eq() {
+ # assert_eq <got> <expected> <msg>
+ if [ "$1" != "$2" ]; then
+ echo "FAIL: $3: expected '$2', got '$1'" >&2
+ return 1
+ fi
+ echo "PASS: $3"
+}
+
+assert_contains() {
+ # assert_contains <haystack> <needle> <msg>
+ case "$1" in
+ *"$2"*) echo "PASS: $3" ;;
+ *) echo "FAIL: $3: expected to contain '$2', got '$1'" >&2; return 1 ;;
+ esac
+}
+
+assert_file_contains() {
+ # assert_file_contains <file> <needle> <msg>
+ # `--` matters: without it a needle starting with '-' (any command-line flag,
+ # e.g. -Dmeson-docs=disabled) is parsed by grep as options and never matches.
+ if grep -qF -- "$2" "$1" 2>/dev/null; then
+ echo "PASS: $3"
+ else
+ echo "FAIL: $3: $1 does not contain '$2'" >&2
+ return 1
+ fi
+}
+
+assert_path_exists() {
+ # assert_path_exists <path> <msg>
+ # Use before grepping a file. A grep that matches nothing because the file
+ # moved looks exactly like one that matches nothing because the code is
+ # correct, and only the latter should pass.
+ if [ -e "$1" ]; then
+ echo "PASS: $2"
+ else
+ echo "FAIL: $2: no such path: $1" >&2
+ return 1
+ fi
+}
+
+# Newest built .apk for a package, or empty if none. Tests must never hardcode
+# a revision: doing so silently tests a stale artifact and then silently skips
+# once that artifact is pruned.
+newest_apk() {
+ # newest_apk <pkg> [arch]
+ _p=$1; _a=${2:-x86_64}; _root=${ROOT:?ROOT must be set}
+ _found=""
+ for f in "${_root}/build/repo/${_a}/${_p}"-*.apk; do
+ [ -e "${f}" ] || continue
+ # `zlib-1.3.1-r0.apk` belongs to zlib; `zlib-doc-...` does not, so require
+ # the character after the name to begin a version field.
+ case "$(basename "${f}")" in
+ "${_p}"-[0-9]*) _found=${f} ;;
+ esac
+ done
+ printf '%s\n' "${_found}"
+}
+
+# Revision a template currently declares, so tests follow the template rather
+# than a copy of it that rots.
+template_pkgver() {
+ # template_pkgver <pkg>
+ _p=$1; _root=${ROOT:?ROOT must be set}
+ _t="${_root}/packages/${_p}/template"
+ [ -f "${_t}" ] || return 1
+ _v=$(sed -n 's/^version=//p' "${_t}" | head -1)
+ _r=$(sed -n 's/^revision=//p' "${_t}" | head -1)
+ printf '%s-r%s\n' "${_v}" "${_r}"
+}
diff --git a/tests/run.sh b/tests/run.sh
@@ -0,0 +1,36 @@
+#!/bin/sh
+# tests/run.sh - run the package-repository test suite.
+#
+# ./tests/run.sh everything
+# ./tests/run.sh --unit same (only unit tests exist here)
+#
+# These tests validate the dependency graph, the generated pipeline, applet
+# policy and packaging layout. None of them boots a machine: anything that
+# needs a running system lives in the unos OS-assembly repo.
+set -eu
+HERE=$(cd "$(dirname "$0")" && pwd)
+
+fail=0
+run_one() {
+ echo
+ echo "=== $1 ==="
+ if sh "$1"; then
+ echo "--- PASS $1"
+ else
+ echo "--- FAIL $1" >&2
+ fail=1
+ fi
+}
+
+for t in "${HERE}"/unit/*.sh; do
+ [ -e "$t" ] || continue
+ run_one "$t"
+done
+
+echo
+if [ "$fail" = "0" ]; then
+ echo "ALL TESTS PASSED"
+else
+ echo "SOME TESTS FAILED" >&2
+fi
+exit "$fail"
diff --git a/tests/unit/test_ascii.sh b/tests/unit/test_ascii.sh
@@ -0,0 +1,36 @@
+#!/bin/sh
+set -eu
+HERE=$(cd "$(dirname "$0")" && pwd)
+ROOT=$(cd "${HERE}/../.." && pwd)
+
+echo "==> test_ascii: source and docs are pure ASCII"
+
+# Non-ASCII is forbidden in tracked source and markdown. No em-dashes (use --),
+# no arrows (use ->), no box-drawing (use |-- and `--), no curly quotes.
+# build/ is generated. Key material is excluded because it is base64 blobs
+# whose content we neither wrote nor control.
+cd "$ROOT"
+hits=$(git ls-files -z \
+ ':!:build/**' \
+ ':!:packages/unos-keys/files/**' \
+ | xargs -0 grep -nIP '[^\x00-\x7F]' 2>/dev/null || true)
+
+if [ -n "$hits" ]; then
+ echo "FAIL: non-ASCII bytes in tracked files:" >&2
+ echo "$hits" >&2
+ exit 1
+fi
+echo "PASS: no non-ASCII in tracked sources"
+
+# Untracked working-tree sources count too, otherwise new files sneak past.
+new=$(git ls-files -zo --exclude-standard \
+ ':!:build/**' \
+ ':!:packages/unos-keys/files/**' \
+ | xargs -0 grep -nIP '[^\x00-\x7F]' 2>/dev/null || true)
+
+if [ -n "$new" ]; then
+ echo "FAIL: non-ASCII bytes in untracked files:" >&2
+ echo "$new" >&2
+ exit 1
+fi
+echo "PASS: no non-ASCII in untracked sources"
diff --git a/tests/unit/test_busybox.sh b/tests/unit/test_busybox.sh
@@ -0,0 +1,79 @@
+#!/bin/sh
+set -eu
+HERE=$(cd "$(dirname "$0")" && pwd)
+ROOT=$(cd "${HERE}/../.." && pwd)
+. "${HERE}/../helpers.sh"
+
+echo "==> test_busybox: applet policy"
+
+# Locate the busybox we built. The version is globbed rather than hardcoded so
+# a template version bump does not silently turn this test into a skip.
+BUSYBOX=""
+for c in "${ROOT}"/build/work/busybox/src/busybox-*/busybox; do
+ [ -x "${c}" ] && BUSYBOX=${c}
+done
+
+# Deliberately no fallback to a host busybox. The previous version fell back to
+# whatever `busybox` was on PATH, which tests a completely unrelated binary's
+# applet policy: on a machine without busybox it reported our required applets
+# missing, and on a machine with a full busybox it would have passed while
+# telling us nothing about the package.
+if [ -z "${BUSYBOX}" ]; then
+ echo "SKIP: busybox not built yet (run ./mk/build.sh busybox)"
+ echo "==> test_busybox done"
+ exit 0
+fi
+
+have=$("$BUSYBOX" --list 2>/dev/null || echo "")
+[ -n "${have}" ] || { echo "FAIL: ${BUSYBOX} --list produced nothing" >&2; exit 1; }
+
+for a in init halt poweroff reboot getty login mount umount switch_root ash runsv runsvdir sv svlogd chpst setuidgid tcpsvd udhcpc; do
+ echo "$have" | grep -qx "$a" || { echo "FAIL: required $a missing" >&2; exit 1; }
+ echo "PASS: busybox has $a"
+done
+
+for a in ip tc ldconfig unlzma lzcat lzma unxz xzcat xz udhcpd httpd inetd ifup ifdown; do
+ if echo "$have" | grep -qx "$a"; then
+ echo "FAIL: forbidden $a present" >&2; exit 1
+ fi
+ echo "PASS: busybox no $a"
+done
+
+# Exactly one package may provide `ip`. The old form of this check looked for
+# rootfs/sbin/ip, but an assembled rootfs is the OS-assembly repo's output and
+# does not exist here; asserting it at the package level tests the same
+# invariant one layer earlier, and apk would refuse the overwrite anyway.
+IPROUTE_APK=$(newest_apk iproute2)
+if [ -n "${IPROUTE_APK}" ]; then
+ if tar -tf "${IPROUTE_APK}" 2>/dev/null | grep -qx "sbin/ip"; then
+ echo "PASS: iproute2 package provides sbin/ip"
+ else
+ echo "FAIL: iproute2 package does not provide sbin/ip" >&2; exit 1
+ fi
+else
+ echo "SKIP: iproute2 not built yet"
+fi
+
+BUSYBOX_APK=$(newest_apk busybox)
+if [ -n "${BUSYBOX_APK}" ]; then
+ if tar -tf "${BUSYBOX_APK}" 2>/dev/null | grep -qE '(^|/)(s?bin)/ip$'; then
+ echo "FAIL: busybox package also provides ip (collides with iproute2)" >&2; exit 1
+ fi
+ echo "PASS: busybox package does not provide ip"
+else
+ echo "SKIP: busybox not built yet"
+fi
+
+# The applet list above proves ifup is absent from this build; the .config
+# check proves it was turned off deliberately rather than dropped by accident.
+# Derived from $BUSYBOX so it cannot point at a different version than the
+# binary we just interrogated.
+BBCONFIG="$(dirname "${BUSYBOX}")/.config"
+assert_path_exists "${BBCONFIG}" "busybox .config present"
+if grep -q "^# CONFIG_IFUP is not set" "${BBCONFIG}"; then
+ echo "PASS: busybox .config IFUP off"
+else
+ echo "FAIL: busybox .config IFUP not off" >&2; exit 1
+fi
+
+echo "==> test_busybox done"
diff --git a/tests/unit/test_deps.sh b/tests/unit/test_deps.sh
@@ -0,0 +1,142 @@
+#!/bin/sh
+# test_deps.sh - the build-dependency graph and host bootstrap stay honest.
+#
+# Both of these were gaps that only bite in CI or on a fresh checkout, which is
+# exactly when nobody is around to debug them:
+# - build/host/bin/{apk,muon} were built by hand from prose in mk/README.md
+# - hostmakedepends/makedepends were documented but read by nothing
+set -eu
+
+HERE=$(cd "$(dirname "$0")" && pwd)
+ROOT=$(cd "${HERE}/../.." && pwd)
+. "${ROOT}/tests/helpers.sh"
+
+echo "==> test_deps: build graph + host bootstrap"
+
+# --- the graph itself ------------------------------------------------------
+out=$("${ROOT}/mk/deps.sh" check 2>&1) || { echo "FAIL: deps.sh check: $out" >&2; exit 1; }
+assert_contains "$out" "graph OK" "dependency graph validates (no cycles, no dangling makedepends)"
+
+# Ordering must be a real topological sort, not just a list. Every package must
+# appear exactly once, and every makedepends strictly before its dependent.
+order=$("${ROOT}/mk/deps.sh" order)
+n_all=$("${ROOT}/mk/deps.sh" list | wc -l | tr -d ' ')
+n_ord=$(printf '%s\n' "$order" | wc -l | tr -d ' ')
+n_uniq=$(printf '%s\n' "$order" | sort -u | wc -l | tr -d ' ')
+assert_eq "$n_ord" "$n_all" "every package appears in the build order"
+assert_eq "$n_uniq" "$n_all" "no package appears twice in the build order"
+
+# This is the property that actually matters, and the one a naive recursive
+# shell function gets wrong (no function-local variables in POSIX sh).
+fail=0
+"${ROOT}/mk/deps.sh" graph | while IFS="$(printf '\t')" read -r pkg dep; do
+ [ -n "$pkg" ] || continue
+ pos_pkg=$(printf '%s\n' "$order" | grep -nxF "$pkg" | cut -d: -f1)
+ pos_dep=$(printf '%s\n' "$order" | grep -nxF "$dep" | cut -d: -f1)
+ if [ -z "$pos_dep" ] || [ -z "$pos_pkg" ] || [ "$pos_dep" -ge "$pos_pkg" ]; then
+ echo "FAIL: $dep must be built before $pkg (positions $pos_dep vs $pos_pkg)" >&2
+ exit 1
+ fi
+done || fail=1
+[ "$fail" = "0" ] || exit 1
+echo "PASS: every makedepends is ordered before its dependent"
+
+# The graph must not be vacuously empty -- that was the original bug report.
+edges=$("${ROOT}/mk/deps.sh" graph | wc -l | tr -d ' ')
+if [ "$edges" -lt 5 ]; then
+ echo "FAIL: dependency graph has only ${edges} edges; templates are not declaring makedepends" >&2
+ exit 1
+fi
+echo "PASS: graph is populated (${edges} edges)"
+
+# --- host bootstrap --------------------------------------------------------
+[ -x "${ROOT}/mk/bootstrap-host.sh" ] || { echo "FAIL: mk/bootstrap-host.sh missing" >&2; exit 1; }
+out=$("${ROOT}/mk/bootstrap-host.sh" --check 2>&1) || { echo "FAIL: host tools not bootstrapped: $out" >&2; exit 1; }
+assert_contains "$out" "host tools present and pinned" "bootstrap-host.sh --check passes"
+
+# --check must verify VERSION, not just existence: a stale binary from an older
+# pin is worse than a missing one because everything downstream still "works".
+assert_file_contains "${ROOT}/mk/bootstrap-host.sh" "MUON_SHA256" "muon is pinned by sha256"
+assert_file_contains "${ROOT}/mk/bootstrap-host.sh" "APK_SHA256" "apk-tools is pinned by sha256"
+
+# The bootstrap must not reach the network beyond its pinned tarballs. muon's
+# setup git-clones meson-docs/meson-tests unless they are disabled.
+assert_file_contains "${ROOT}/mk/bootstrap-host.sh" "-Dmeson-docs=disabled" "muon docs subproject disabled (no build-time git fetch)"
+assert_file_contains "${ROOT}/mk/bootstrap-host.sh" "-Dmeson-tests=disabled" "muon tests subproject disabled (no build-time git fetch)"
+
+# --- the CI pipeline is generated from that same graph ---------------------
+# .conductor.yml carries ~30 tasks and their `needs` edges. Hand-maintaining
+# those against packages/*/template drifts, and the failure mode is a package
+# building before the library it links against -- which usually still succeeds,
+# quietly, against a stale copy.
+[ -f "${ROOT}/.conductor.yml" ] || { echo "FAIL: no .conductor.yml" >&2; exit 1; }
+out=$("${ROOT}/mk/gen-pipeline.sh" --check 2>&1) || {
+ echo "FAIL: .conductor.yml is out of date; run ./mk/gen-pipeline.sh --write" >&2
+ echo "$out" | head -20 >&2
+ exit 1
+}
+echo "PASS: .conductor.yml matches the dependency graph"
+
+# Every package must have a task, or a push would silently not build it.
+npkg=$("${ROOT}/mk/deps.sh" list | wc -l | tr -d ' ')
+ntask=$(grep -c '^ pkg-' "${ROOT}/.conductor.yml")
+assert_eq "$ntask" "$npkg" "every package has a pipeline task"
+
+# The credentials the runner provides, and nothing invented alongside them.
+assert_file_contains "${ROOT}/.conductor.yml" "BUCKET_ENDPOINT" "pipeline uses BUCKET_ENDPOINT"
+assert_file_contains "${ROOT}/.conductor.yml" "BUCKET_NAME" "pipeline uses BUCKET_NAME"
+if grep -q 'REPO_URL' "${ROOT}/.conductor.yml"; then
+ echo "FAIL: pipeline references REPO_URL, which the runner does not provide" >&2
+ exit 1
+fi
+echo "PASS: pipeline references only provided environment"
+
+# $ARCH is only injected into tasks that declare arch; using it without is an
+# empty string and a silently wrong repo URL.
+if grep -q '\$ARCH' "${ROOT}/.conductor.yml"; then
+ nuse=$(grep -c '\$ARCH' "${ROOT}/.conductor.yml")
+ narch=$(grep -c '^ arch: \[' "${ROOT}/.conductor.yml")
+ [ "$narch" -gt 0 ] || { echo "FAIL: pipeline uses \$ARCH but no task declares arch" >&2; exit 1; }
+ echo "PASS: \$ARCH used in ${nuse} places, ${narch} tasks declare arch"
+fi
+
+# Feature names are a global namespace on the conductor, which hosts more than
+# UNOS, so ours are project-scoped. A task asking for a feature no worker
+# advertises is not scheduled at all -- it does not fail loudly, it simply
+# never runs, which is far harder to diagnose than a build error.
+if grep -q 'requires:.*[][ ]sign-key[],]' "${ROOT}/.conductor.yml"; then
+ echo "FAIL: pipeline requires bare 'sign-key'; the feature is 'unos-sign-key'" >&2
+ grep -n 'sign-key' "${ROOT}/.conductor.yml" | head -5 >&2
+ exit 1
+fi
+nsign=$(grep -c 'requires: \[unos-sign-key\]' "${ROOT}/.conductor.yml")
+[ "${nsign}" -gt 0 ] || { echo "FAIL: no task requires unos-sign-key" >&2; exit 1; }
+echo "PASS: ${nsign} tasks require the project-scoped unos-sign-key feature"
+
+# Every task that signs must ask for the key. Building without it fails late,
+# after the whole package has compiled.
+nbuild=$(grep -c 'mk/build.sh' "${ROOT}/.conductor.yml")
+[ "${nsign}" -ge "${nbuild}" ] || {
+ echo "FAIL: ${nbuild} tasks build but only ${nsign} require the signing key" >&2
+ exit 1
+}
+echo "PASS: every building task requires the signing key"
+
+# --- build.sh actually reads the declarations ------------------------------
+assert_file_contains "${ROOT}/mk/build.sh" "hostmakedepends" "build.sh reads hostmakedepends"
+assert_file_contains "${ROOT}/mk/build.sh" "makedepends" "build.sh reads makedepends"
+
+# PATH ordering trap: hostmakedepends is checked before the build phases, so
+# build/host/bin must already be on PATH or a package declaring `muon` fails
+# its own check despite muon being present.
+# Anchor on the executable check (`command -v "${_h}"`), not on the word
+# hostmakedepends, which also appears in comments above it.
+pathline=$(grep -n 'export PATH=.*build/host/bin' "${ROOT}/mk/build.sh" | head -1 | cut -d: -f1)
+checkline=$(grep -n 'command -v "\${_h}"' "${ROOT}/mk/build.sh" | head -1 | cut -d: -f1)
+if [ -z "$pathline" ] || [ -z "$checkline" ] || [ "$pathline" -ge "$checkline" ]; then
+ echo "FAIL: build/host/bin must be on PATH before hostmakedepends is checked (PATH line $pathline, check line $checkline)" >&2
+ exit 1
+fi
+echo "PASS: host tools are on PATH before hostmakedepends is checked"
+
+echo "==> test_deps done"
diff --git a/tests/unit/test_packaging.sh b/tests/unit/test_packaging.sh
@@ -0,0 +1,59 @@
+#!/bin/sh
+# Packaging layout assertions: what ends up inside the .apk.
+#
+# The daemon's own behaviour is tested in the linkd repo. What matters here is
+# that the package presents it correctly -- above all the argv[0] symlinks,
+# since linkd dispatches on basename(argv[0]) and a plain file instead of a
+# symlink turns `ifup` into a daemon launch.
+set -eu
+HERE=$(cd "$(dirname "$0")" && pwd)
+ROOT=$(cd "${HERE}/../.." && pwd)
+. "${HERE}/../helpers.sh"
+
+echo "==> test_packaging: apk contents and symlinks"
+
+APK=$(newest_apk linkd)
+if [ -z "${APK}" ]; then
+ echo "SKIP: no linkd .apk built yet (run ./mk/build.sh linkd)"
+ echo "==> test_packaging done"
+ exit 0
+fi
+
+echo "using $(basename "${APK}")"
+
+# The built artifact must correspond to what the template currently declares.
+# The previous version of this test hardcoded linkd-0.1.0-r4.apk while the
+# template had moved to r5: it kept passing against a stale package, and would
+# have started silently skipping once r4 was pruned.
+want=$(template_pkgver linkd)
+case "$(basename "${APK}")" in
+ "linkd-${want}.apk") echo "PASS: newest .apk matches template revision (${want})" ;;
+ *)
+ echo "FAIL: newest .apk is $(basename "${APK}") but template declares ${want}" >&2
+ echo " rebuild with ./mk/build.sh linkd" >&2
+ exit 1 ;;
+esac
+
+listing=$(tar -tvf "${APK}" 2>&1)
+
+for link in linkctl ifup ifdown ifquery ifreload; do
+ if printf '%s\n' "${listing}" | grep -q "usr/bin/${link} -> linkd"; then
+ echo "PASS: ${link} -> linkd"
+ else
+ echo "FAIL: usr/bin/${link} is not a symlink to linkd" >&2
+ printf '%s\n' "${listing}" | grep -E "usr/bin/(${link}|linkd)" | head -5 >&2 || true
+ exit 1
+ fi
+done
+
+# The runit service dir has to be present or the daemon never starts on boot.
+for f in etc/sv/linkd/run etc/sv/linkd/check etc/sv/linkd/log/run; do
+ if printf '%s\n' "${listing}" | grep -q "${f}"; then
+ echo "PASS: ships ${f}"
+ else
+ echo "FAIL: missing ${f}" >&2
+ exit 1
+ fi
+done
+
+echo "==> test_packaging done"