diff --git a/.github/workflows/build-aarch64.yml b/.github/workflows/build-aarch64.yml new file mode 100644 index 000000000..3321c2793 --- /dev/null +++ b/.github/workflows/build-aarch64.yml @@ -0,0 +1,164 @@ +name: aarch64 package repo + +# Build an aarch64 Omarchy repository for manual or reusable workflows. + +on: + workflow_dispatch: + inputs: + packages: + description: >- + Space-separated recipe directory names (empty = every eligible package). + Include required recipes from this repository; dependencies are not added automatically. + type: string + default: "" + mirror: + description: "edge, rc or stable" + type: string + default: edge + workflow_call: + inputs: + pkgs_repository: + description: "owner/name of the omarchy-pkgs checkout to build" + type: string + required: true + pkgs_ref: + description: "branch, tag or SHA of that repository" + type: string + required: true + packages: + description: >- + Space-separated recipe directory names (empty = every eligible package). + Include required recipes from this repository; dependencies are not added automatically. + type: string + default: "" + mirror: + type: string + default: edge + outputs: + artifact: + description: "Name of the uploaded repository artifact" + value: ${{ jobs.build.outputs.artifact }} + +jobs: + build: + runs-on: ubuntu-24.04-arm + timeout-minutes: 360 + permissions: + contents: read + outputs: + artifact: ${{ steps.meta.outputs.artifact }} + env: + ARCH: aarch64 + INPUT_MIRROR: ${{ inputs.mirror }} + INPUT_PACKAGES: ${{ inputs.packages }} + steps: + - name: Checkout omarchy-pkgs + uses: actions/checkout@v4 + with: + repository: ${{ inputs.pkgs_repository || github.repository }} + ref: ${{ inputs.pkgs_ref || github.ref }} + persist-credentials: false + + - name: Resolve inputs + id: meta + run: | + MIRROR="${INPUT_MIRROR:-edge}" + PACKAGES="$INPUT_PACKAGES" + case "$MIRROR" in edge|rc|stable) ;; *) echo "invalid mirror: $MIRROR" >&2; exit 1 ;; esac + re='^[A-Za-z0-9._+ -]*$' + if ! [[ $PACKAGES =~ $re ]]; then echo "invalid package list" >&2; exit 1; fi + read -r -a packages <<< "$PACKAGES" + for package in "${packages[@]}"; do + if ! [[ $package =~ ^[A-Za-z0-9][A-Za-z0-9._+-]*$ && -f pkgbuilds/$package/PKGBUILD ]]; then + echo "unknown recipe: $package" >&2 + exit 1 + fi + done + PACKAGES="${packages[*]}" + { + echo "MIRROR=$MIRROR" + echo "PACKAGES=$PACKAGES" + echo "REPO_DIR=pkgs.omarchy.org/$MIRROR/$ARCH" + echo "OUTPUT_DIR=build-output/$MIRROR/$ARCH" + } >> "$GITHUB_ENV" + echo "artifact=omarchy-repo-$ARCH-$MIRROR" >> "$GITHUB_OUTPUT" + echo "packages_key=$(printf '%s\0' "${packages[@]}" | sha256sum | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + + # Reuse packages whose build inputs have not changed. + - name: Restore repository tree + id: cache + uses: actions/cache@v4 + with: + path: ${{ env.REPO_DIR }} + key: omarchy-repo-v2-${{ env.ARCH }}-${{ env.MIRROR }}-${{ steps.meta.outputs.packages_key }}-${{ hashFiles('pkgbuilds/**', 'build/**', 'helpers/**', 'bin/**') }} + + # The builder and runner use different user IDs. + - name: Prepare output directories + run: | + mkdir -p "$OUTPUT_DIR" "$REPO_DIR" src logs + chmod -R a+rwX build-output pkgs.omarchy.org src + + - name: Build plan + run: | + read -r -a packages <<< "$PACKAGES" + bin/repo build --arch "$ARCH" --mirror "$MIRROR" --dry-run --package "${packages[@]}" + + - name: Build packages + run: | + read -r -a packages <<< "$PACKAGES" + bin/repo build --arch "$ARCH" --mirror "$MIRROR" --package "${packages[@]}" + + # Replaces bin/repo sign + promote, which need the repository host's key. + - name: Publish into the repository tree and write omarchy.db + run: | + shopt -s nullglob + files=("$OUTPUT_DIR"/*.pkg.tar.zst "$OUTPUT_DIR"/*.pkg.tar.xz) + echo "new package files: ${#files[@]}" + [[ ${#files[@]} -gt 0 ]] && cp -v "${files[@]}" "$REPO_DIR/" + chmod -R a+rwX pkgs.omarchy.org + bin/repo update --arch "$ARCH" --mirror "$MIRROR" + + - name: Verify repository + id: verify + run: | + source helpers/package-metadata.sh + pkgbuilds="$PWD/pkgbuilds" + cd "$REPO_DIR" + dbdir=$(mktemp -d) + trap 'rm -rf "$dbdir"' EXIT + tar --use-compress-program=unzstd -xf omarchy.db.tar.zst -C "$dbdir" + for desc in "$dbdir"/*/desc; do + awk ' + /^%BASE%$/ { getline; base=$0 } + /^%NAME%$/ { getline; name=$0 } + END { print (base != "" ? base : name) } + ' "$desc" + done > "$dbdir/pkgbases" + echo "== pkgbases in omarchy.db ==" + sort -u "$dbdir/pkgbases" + missing=0 + read -r -a packages <<< "$PACKAGES" + for package in "${packages[@]}"; do + pkgbase=$(package_pkgbuild_var "$pkgbuilds/$package" pkgbase) + [[ -n $pkgbase ]] || pkgbase=$(package_pkgbuild_var "$pkgbuilds/$package" pkgname) + grep -Fxq "$pkgbase" "$dbdir/pkgbases" || { echo "missing pkgbase: $pkgbase ($package)" >&2; missing=1; } + done + du -sh . + exit $missing + + - name: Upload repository artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.meta.outputs.artifact }} + path: ${{ env.REPO_DIR }} + if-no-files-found: error + retention-days: 7 + + - name: Upload build logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: build-logs-${{ env.ARCH }}-${{ env.MIRROR }} + path: logs/ + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9fc270f3b..106fe6aac 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,9 +25,16 @@ jobs: -w /workspace \ archlinux:base-devel bash -lc ' set -euo pipefail - pacman -Syu --noconfirm git jq + pacman -Syu --noconfirm git jq python-yaml xz zstd ./bin/sync-upstream self-test ./bin/sync-rebuilds --self-test ./bin/omarchy-pkgs self-test ./bin/omarchy-release self-test + shopt -s nullglob + for test in pkgbuilds/*/test.sh; do + bash "$test" + done + if [[ -d test ]]; then + runuser -u nobody -- python -m unittest discover -s test -v + fi ' diff --git a/README.md b/README.md index d57fcb1a1..04f56c5e9 100644 --- a/README.md +++ b/README.md @@ -839,6 +839,7 @@ bin/repo release --package my-package - Same workflow, just add `--arch aarch64`; the scheduled pipeline runs it automatically once `aarch64` is in `PUBLISHED_ARCHES` - Packages whose `arch=()` lacks `aarch64` are skipped, not failed +- For native CI builds, run the **aarch64 package repo** workflow. ### Building for Both Architectures diff --git a/test/test_build_aarch64.py b/test/test_build_aarch64.py new file mode 100644 index 000000000..3db307446 --- /dev/null +++ b/test/test_build_aarch64.py @@ -0,0 +1,121 @@ +"""Exercise workflow shell steps. Requires Bash, GNU tar, zstd and PyYAML.""" + +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +import yaml + + +WORKFLOW = yaml.safe_load( + (Path(__file__).resolve().parents[1] / ".github/workflows/build-aarch64.yml").read_text() +) +STEPS = {step["id"]: step for step in WORKFLOW["jobs"]["build"]["steps"] if "id" in step} + + +class BuildAarch64Test(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + package = self.root / "pkgbuilds/example/PKGBUILD" + package.parent.mkdir(parents=True) + package.write_text("pkgname=example\n") + helpers = self.root / "helpers" + helpers.mkdir() + shutil.copy(Path(__file__).resolve().parents[1] / "helpers/package-metadata.sh", helpers) + + def run_step(self, name, **env): + return subprocess.run( + ["bash", "-euo", "pipefail", "-c", STEPS[name]["run"]], + cwd=self.root, + env={ + **os.environ, + "ARCH": "aarch64", + "INPUT_MIRROR": "edge", + "INPUT_PACKAGES": "", + "GITHUB_ENV": str(self.root / "env"), + "GITHUB_OUTPUT": str(self.root / "output"), + **env, + }, + capture_output=True, + text=True, + ) + + def test_empty_package_set_is_valid(self): + result = self.run_step("meta") + self.assertEqual(result.returncode, 0, result.stderr) + + def test_checkout_does_not_persist_credentials(self): + checkout = next(step for step in WORKFLOW["jobs"]["build"]["steps"] + if step.get("uses", "").startswith("actions/checkout@")) + self.assertIs(checkout["with"].get("persist-credentials"), False) + + def test_known_package_and_rc_mirror(self): + result = self.run_step("meta", INPUT_PACKAGES=" example ", INPUT_MIRROR="rc") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("PACKAGES=example\n", (self.root / "env").read_text()) + + def test_options_paths_unknown_packages_and_newlines_are_rejected(self): + for value in ("--dry-run", "..", "../example", "missing", "example\ninjected=value"): + with self.subTest(value=value): + result = self.run_step("meta", INPUT_PACKAGES=value) + self.assertNotEqual(result.returncode, 0) + + def test_split_package_is_verified_by_pkgbase(self): + (self.root / "pkgbuilds/example/PKGBUILD").write_text("pkgbase=example-source\npkgname=(example-libs)\n") + repo = self.root / "repo" + desc = repo / "example-libs-1-1/desc" + desc.parent.mkdir(parents=True) + desc.write_text("%NAME%\nexample-libs\n\n%BASE%\nexample-source\n") + subprocess.run( + ["tar", "--zstd", "-cf", "omarchy.db.tar.zst", desc.parent.name], + cwd=repo, check=True, + ) + result = self.run_step("verify", REPO_DIR=str(repo), PACKAGES="example") + self.assertEqual(result.returncode, 0, result.stderr) + # A recipe without pkgbase uses pkgname, read for the target architecture. + (self.root / "pkgbuilds/example/PKGBUILD").write_text( + '[[ $CARCH == aarch64 ]] || return 1\npkgname=example-source\n' + ) + result = self.run_step("verify", REPO_DIR=str(repo), PACKAGES="example") + self.assertEqual(result.returncode, 0, result.stderr) + (self.root / "pkgbuilds/example/PKGBUILD").write_text("pkgbase=exampl.-source\npkgname=(example-libs)\n") + result = self.run_step("verify", REPO_DIR=str(repo), PACKAGES="example") + self.assertNotEqual(result.returncode, 0) + + def test_cache_includes_sources_and_package_selection(self): + cache = STEPS["cache"]["with"] + self.assertNotIn("restore-keys", cache) + self.assertIn("packages_key", cache["key"]) + for path in ("pkgbuilds/**", "build/**", "helpers/**", "bin/**"): + self.assertIn(path, cache["key"]) + + @unittest.skipUnless(shutil.which("makepkg") and shutil.which("repo-add"), "requires Arch packaging tools") + def test_real_repository_records_pkgbase(self): + recipe = self.root / "pkgbuilds/example" + (recipe / "PKGBUILD").write_text('''pkgbase=example-source +pkgname=(example-libs) +pkgver=1 +pkgrel=1 +arch=(any) +license=(MIT) +package() { + install -Dm644 "$startdir/PKGBUILD" "$pkgdir/usr/share/example/PKGBUILD" +} +''') + subprocess.run(["makepkg", "--nodeps", "--noconfirm"], cwd=recipe, check=True, + stdout=subprocess.DEVNULL) + packages = list(recipe.glob("*.pkg.tar.zst")) + self.assertEqual(len(packages), 1) + subprocess.run(["repo-add", "omarchy.db.tar.zst", packages[0].name], cwd=recipe, + check=True, stdout=subprocess.DEVNULL) + result = self.run_step("verify", REPO_DIR=str(recipe), PACKAGES="example") + self.assertEqual(result.returncode, 0, result.stderr) + + +if __name__ == "__main__": + unittest.main()