commit 79efaff01b8e8941052e9f17883d1341c90775e5
parent de7f32a67d127620a640ecc60ec2925fbaf61d6a
Author: cowmonk <cowmonk@based.pt>
Date: Mon, 12 Oct 2026 22:07:31 -0700
ci publishes toolchains after core updates
Diffstat:
8 files changed, 582 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
@@ -0,0 +1,116 @@
+name: Publish toolchains
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - sources.list
+ - config.mk
+ - Makefile
+ - scripts/**
+ - files/**
+ - patches/**
+ - tests/hello.*
+ - tests/cmake/**
+ - .github/workflows/release.yml
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: publish-toolchains
+ cancel-in-progress: false
+
+jobs:
+ check:
+ if: github.ref == 'refs/heads/main'
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v4
+ - run: make check
+
+ build:
+ needs: check
+ runs-on: ubuntu-24.04
+ timeout-minutes: 360
+ strategy:
+ fail-fast: false
+ matrix:
+ arch: [x86_64, aarch64]
+ env:
+ ARCH: ${{ matrix.arch }}
+ LLVM_MODE: source
+ HOST_LDFLAGS: -static-libstdc++ -static-libgcc
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install host dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y clang-19 lld-19 llvm-19 llvm-19-dev cmake ninja-build curl xz-utils perl rsync pkg-config qemu-user
+ echo /usr/lib/llvm-19/bin >> "$GITHUB_PATH"
+ - uses: actions/cache@v4
+ with:
+ path: |
+ sources/*.tar.gz
+ sources/*.tar.xz
+ key: release-sources-${{ hashFiles('sources.list') }}
+ - name: Build and package
+ run: |
+ if [ "$ARCH" = aarch64 ]; then
+ echo RUNNER=qemu-aarch64 >> "$GITHUB_ENV"
+ export RUNNER=qemu-aarch64
+ fi
+ make JOBS=2 LINK_JOBS=1
+ make package
+ - name: Verify the unpacked toolchain
+ run: |
+ target="$ARCH-linux-musl"
+ archive="build/releases/musllvm-$target-host-x86_64-linux.tar.xz"
+ unpacked="$RUNNER_TEMP/toolchains"
+ mkdir -p "$unpacked"
+ mv "build/$target" "$RUNNER_TEMP/original-$target"
+ tar -xJf "$archive" -C "$unpacked"
+ make verify PREFIX="$unpacked/musllvm-$target" \
+ BUILD_DIR="$PWD/build/release-verify/$target" JOBS=2
+ - uses: actions/upload-artifact@v4
+ with:
+ name: release-toolchain-${{ matrix.arch }}
+ path: |
+ build/releases/*.tar.xz
+ build/releases/*.sha256
+ if-no-files-found: error
+ compression-level: 0
+ retention-days: 7
+ - name: Save build diagnostics
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: release-diagnostics-${{ matrix.arch }}
+ path: |
+ build/work/**/CMakeCache.txt
+ build/work/**/CMakeFiles/CMakeConfigureLog.yaml
+ build/**/verify/*.header
+ build/**/verify/*.segments
+ build/**/verify/*.dynamic
+ retention-days: 7
+
+ publish:
+ needs: build
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ - uses: actions/download-artifact@v4
+ with:
+ pattern: release-toolchain-*
+ merge-multiple: true
+ path: build/releases
+ - name: Publish the release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ run: sh scripts/publish.sh
diff --git a/Makefile b/Makefile
@@ -26,6 +26,9 @@ headers builtins musl atomic runtimes toolchain configure-llvm:
verify:
sh scripts/verify.sh
+package:
+ sh scripts/package.sh
+
check:
sh scripts/test.sh
@@ -41,9 +44,10 @@ help:
' pull download and verify pinned source archives' \
' verify-sources check downloaded archives without network access' \
' verify compile, inspect, and optionally run C and C++ programs' \
+ ' package archive a completed LLVM_MODE=source toolchain' \
' check run offline build-driver tests' \
' clean remove the selected toolchain and its work directory' \
' configure-llvm configure the optional LLVM source build'
.PHONY: all build pull verify-sources doctor show-config headers builtins musl
-.PHONY: atomic runtimes toolchain configure-llvm verify check clean help
+.PHONY: atomic runtimes toolchain configure-llvm verify package check clean help
diff --git a/README.md b/README.md
@@ -8,6 +8,25 @@ The toolchain includes musl, compiler-rt, libc++, libc++abi,
LLVM libunwind and libatomic. Compiler tools run on the build host.
+downloads
+=========
+
+[Releases](https://github.com/cowmonk/musllvm/releases) provide archives
+for x86_64-linux-musl and aarch64-linux-musl. Both run on x86_64 Linux
+hosts with glibc 2.39 or newer. The compiler tools are included.
+Target programs use musl.
+
+Download an archive and SHA256SUMS from the same release, then check
+the archive you downloaded:
+
+ sha256sum --ignore-missing -c SHA256SUMS
+ tar -xJf musllvm-x86_64-linux-musl-host-x86_64-linux.tar.xz
+ eval "$(musllvm-x86_64-linux-musl/bin/x86_64-linux-musl-env)"
+
+Each archive includes its source pins, licenses and build information under
+share/musllvm/. pkg-config is optional and uses the host installation.
+
+
dependencies
============
@@ -143,8 +162,30 @@ matches the target or RUNNER names an emulator.
make verify ARCH=aarch64 RUNNER=qemu-aarch64
[CI](.github/workflows/ci.yml) runs offline tests and both target builds.
-A manual workflow run can also build LLVM from source. Full toolchain
-build verification is still pending.
+A manual workflow run can also build LLVM from source.
+
+
+publishing
+==========
+
+[The release workflow](.github/workflows/release.yml) runs on pushes to
+main that change sources.list, config.mk, Makefile, scripts/, files/,
+patches/ or the compiler smoke tests. This covers source version updates
+and new core components added to the build. README edits do not publish
+a release. The workflow can also be run manually from main.
+
+Both targets build with LLVM_MODE=source. Each archive is unpacked into
+a new location and tested before upload. The release is published after
+both builds pass, with SHA256SUMS and sources.list attached.
+Tags use musllvm-YYYY-MM-DD-COMMIT, with the first 12 commit characters.
+Rerunning a published commit keeps its release assets.
+
+To package a completed source build yourself:
+
+ make package LLVM_MODE=source ARCH=x86_64
+
+Archives go to build/releases/. Set DIST_DIR to change the output directory.
+The first release still needs verification on GitHub Actions.
credits
diff --git a/scripts/package.py b/scripts/package.py
@@ -0,0 +1,124 @@
+import argparse
+import hashlib
+import io
+import json
+import os
+from pathlib import Path
+import platform
+import subprocess
+import tarfile
+import tempfile
+
+
+ROOT = Path(__file__).resolve().parent.parent
+
+
+def source_licenses(manifest, source_root, root):
+ licenses = {}
+ for line in manifest.decode().splitlines():
+ if not line.strip() or line.lstrip().startswith("#"):
+ continue
+ component, archive, *_ = line.split()
+ for value in (component, archive):
+ if value in (".", "..") or any(character not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-" for character in value):
+ raise ValueError("invalid source name: " + value)
+ directory = source_root / archive.rsplit(".tar.", 1)[0]
+ if not directory.is_dir():
+ raise ValueError("missing extracted source: " + str(directory))
+ for path in sorted(directory.rglob("*")):
+ relative = path.relative_to(directory)
+ if (path.name.upper().startswith(("LICENSE", "COPYING", "COPYRIGHT", "NOTICE"))
+ or relative.parts[0].upper() == "LICENSES") and path.is_file():
+ licenses[root + "/share/musllvm/licenses/" + component + "/" + relative.as_posix()] = path.read_bytes()
+ return licenses
+
+
+def package(prefix, target, sources, output, source_root=None):
+ if not target or any(character not in "abcdefghijklmnopqrstuvwxyz0123456789_-" for character in target):
+ raise ValueError("invalid target: " + target)
+ if platform.system() != "Linux":
+ raise ValueError("packaging requires a Linux host")
+ prefix = prefix.resolve()
+ for required in ("host/bin/clang", "host/bin/ld.lld", "bin/" + target + "-cc",
+ "sysroot/usr/lib/libc.so", "share/musllvm/resource-version"):
+ if not (prefix / required).is_file():
+ raise ValueError("incomplete toolchain: " + required)
+ for path in prefix.rglob("*"):
+ if path.is_symlink():
+ if os.path.isabs(os.readlink(path)):
+ raise ValueError("absolute symlink: " + str(path))
+ try:
+ path.resolve(strict=True).relative_to(prefix)
+ except (ValueError, FileNotFoundError, RuntimeError) as error:
+ raise ValueError("symlink outside the toolchain or missing its target: " + str(path)) from error
+
+ commit = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip()
+ epoch = int(subprocess.check_output(["git", "show", "-s", "--format=%ct", commit], cwd=ROOT, text=True))
+ host = platform.machine() + "-linux"
+ libc_name, libc_version = platform.libc_ver()
+ manifest = sources.read_bytes()
+ metadata = {
+ "commit": commit,
+ "host": host,
+ "host_libc": {"name": libc_name, "version": libc_version},
+ "target": target,
+ "llvm_mode": "source",
+ "source_manifest_sha256": hashlib.sha256(manifest).hexdigest(),
+ }
+ root = "musllvm-" + target
+ generated = {
+ root + "/share/musllvm/sources.list": manifest,
+ root + "/share/musllvm/build-info.json": (json.dumps(metadata, indent=2, sort_keys=True) + "\n").encode(),
+ }
+ generated.update(source_licenses(manifest, source_root or ROOT / "build/src", root))
+
+ def normalize(info):
+ info.uid = info.gid = 0
+ info.uname = info.gname = ""
+ info.mtime = epoch
+ return info
+
+ def installed(info):
+ if info.name == root + "/.musllvm-config" or info.name in generated:
+ return None
+ return normalize(info)
+
+ output.mkdir(parents=True, exist_ok=True)
+ archive = output / (root + "-host-" + host + ".tar.xz")
+ descriptor, temporary = tempfile.mkstemp(prefix=".package-", dir=output)
+ os.close(descriptor)
+ try:
+ with tarfile.open(temporary, "w:xz", dereference=False) as stream:
+ stream.add(prefix, arcname=root, filter=installed)
+ for name, content in generated.items():
+ info = tarfile.TarInfo(name)
+ info.size = len(content)
+ info.mode = 0o644
+ stream.addfile(normalize(info), io.BytesIO(content))
+ os.replace(temporary, archive)
+ finally:
+ if os.path.exists(temporary):
+ os.unlink(temporary)
+ digest = hashlib.sha256()
+ with archive.open("rb") as stream:
+ for block in iter(lambda: stream.read(1024 * 1024), b""):
+ digest.update(block)
+ archive.with_name(archive.name + ".sha256").write_text(digest.hexdigest() + " " + archive.name + "\n")
+ return archive
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Package a toolchain with bundled host tools")
+ parser.add_argument("--prefix", type=Path, required=True)
+ parser.add_argument("--target", required=True)
+ parser.add_argument("--sources", type=Path, required=True)
+ parser.add_argument("--output", type=Path, required=True)
+ args = parser.parse_args()
+ try:
+ print(package(args.prefix, args.target, args.sources, args.output))
+ except (OSError, ValueError, subprocess.CalledProcessError) as error:
+ parser.exit(1, "package: " + str(error) + "\n")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/package.sh b/scripts/package.sh
@@ -0,0 +1,10 @@
+#!/bin/sh
+set -eu
+
+. "$(dirname "$0")/config.sh"
+
+[ "$LLVM_MODE" = source ] || die "publishing requires LLVM_MODE=source"
+[ -f "$BUILD_DIR/stamps/runtimes" ] || die "target runtimes have not finished building"
+need python3
+python3 "$PROOT/scripts/package.py" --prefix "$PREFIX" --target "$TARGET" \
+ --sources "$SOURCES_FILE" --output "${DIST_DIR:-$PROOT/build/releases}"
diff --git a/scripts/publish.sh b/scripts/publish.sh
@@ -0,0 +1,51 @@
+#!/bin/sh
+set -eu
+
+root=$(CDPATH= cd "$(dirname "$0")/.." && pwd -P)
+: "${GITHUB_SHA:?GITHUB_SHA must identify the build commit}"
+[ "$(git -C "$root" rev-parse HEAD)" = "$GITHUB_SHA" ] || {
+ printf '%s\n' 'checkout does not match the build commit' >&2
+ exit 1
+}
+date=$(git -C "$root" show -s --format=%cs "$GITHUB_SHA")
+short=$(printf '%.12s' "$GITHUB_SHA")
+tag="musllvm-$date-$short"
+
+cd "${1:-$root/build/releases}"
+set -- musllvm-x86_64-linux-musl-host-x86_64-linux.tar.xz \
+ musllvm-aarch64-linux-musl-host-x86_64-linux.tar.xz
+: > SHA256SUMS
+for archive do
+ [ -s "$archive" ] && [ -s "$archive.sha256" ] || {
+ printf 'missing release archive or checksum: %s\n' "$archive" >&2
+ exit 1
+ }
+ read -r digest filename extra < "$archive.sha256"
+ [ "$filename" = "$archive" ] && [ -z "$extra" ] || {
+ printf 'checksum does not name its release archive: %s\n' "$archive" >&2
+ exit 1
+ }
+ cat "$archive.sha256" >> SHA256SUMS
+done
+sha256sum -c SHA256SUMS
+cp "$root/sources.list" sources.list
+{
+ printf 'CI built these musllvm toolchains from commit %s.\n\n' "$GITHUB_SHA"
+ printf '%s\n' 'The tools run on x86_64 Linux with glibc 2.39 or newer.' \
+ 'They target x86_64-linux-musl and aarch64-linux-musl.' \
+ 'The archives include Clang, LLD and LLVM utilities.' '' 'core sources' '------------' ''
+ awk 'NF && $1 !~ /^#/ { printf " %s %s\n", $1, $2 }' sources.list
+ printf '\n%s\n' 'Verify downloads with sha256sum --ignore-missing -c SHA256SUMS.'
+} > notes.md
+
+if draft=$(gh release view "$tag" --json isDraft --jq .isDraft); then
+ if [ "$draft" = false ]; then
+ printf 'release already published: %s\n' "$tag"
+ exit
+ fi
+ gh release upload "$tag" "$@" SHA256SUMS sources.list --clobber
+else
+ gh release create "$tag" "$@" SHA256SUMS sources.list --draft \
+ --target "$GITHUB_SHA" --title "musllvm $date" --notes-file notes.md
+fi
+gh release edit "$tag" --notes-file notes.md --draft=false --latest
diff --git a/scripts/test.sh b/scripts/test.sh
@@ -4,4 +4,4 @@ root=$(CDPATH= cd "$(dirname "$0")/.." && pwd -P)
for script in "$root"/scripts/*.sh "$root"/files/toolchain/*.sh; do
sh -n "$script"
done
-PYTHONDONTWRITEBYTECODE=1 python3 "$root/tests/test_driver.py"
+PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s "$root/tests" -p 'test_*.py'
diff --git a/tests/test_release.py b/tests/test_release.py
@@ -0,0 +1,232 @@
+import hashlib
+import importlib.util
+import json
+import os
+from pathlib import Path
+import platform
+import shutil
+import subprocess
+import tarfile
+import tempfile
+import unittest
+from unittest.mock import patch
+
+
+ROOT = Path(__file__).resolve().parent.parent
+SPEC = importlib.util.spec_from_file_location("package", ROOT / "scripts/package.py")
+PACKAGE = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(PACKAGE)
+ARCHIVES = [f"musllvm-{arch}-linux-musl-host-x86_64-linux.tar.xz" for arch in ("x86_64", "aarch64")]
+FAKE_GH = r'''#!/usr/bin/env python3
+import json
+import os
+import sys
+from pathlib import Path
+
+args = sys.argv[1:]
+with open(os.environ["FAKE_GH_LOG"], "a") as stream:
+ stream.write(json.dumps(args) + "\n")
+action = args[1]
+if action == os.environ.get("FAKE_GH_FAIL"):
+ sys.exit(1)
+if action == "view":
+ state = os.environ.get("FAKE_RELEASE", "missing")
+ if state == "missing":
+ sys.exit(1)
+ print("true" if state == "draft" else "false")
+elif action in ("create", "upload"):
+ for name in ("SHA256SUMS", "sources.list"):
+ if not Path(name).is_file():
+ sys.exit(2)
+'''
+FAKE_GIT = r'''#!/usr/bin/env python3
+import os
+import sys
+if "rev-parse" in sys.argv:
+ print(os.environ["FAKE_GIT_SHA"])
+else:
+ print("2026-10-12")
+'''
+
+
+class PackageTests(unittest.TestCase):
+ def setUp(self):
+ self.temporary = tempfile.TemporaryDirectory(prefix="musllvm-package-test-")
+ self.addCleanup(self.temporary.cleanup)
+ self.root = Path(self.temporary.name)
+ self.prefix = self.root / "original"
+ self.output = self.root / "releases"
+ self.target = "x86_64-linux-musl"
+ for relative in ("host/bin/clang", "host/bin/ld.lld", "bin/" + self.target + "-cc",
+ "sysroot/usr/lib/libc.so", "share/musllvm/resource-version", ".musllvm-config"):
+ path = self.prefix / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text("fixture\n")
+ (self.prefix / "host/bin/clang").chmod(0o755)
+ (self.prefix / "libexec").mkdir()
+ (self.prefix / "libexec/clang").symlink_to("../host/bin/clang")
+ (self.prefix / "sysroot/lib").mkdir()
+ (self.prefix / "sysroot/lib/ld-musl-x86_64.so.1").symlink_to("../usr/lib/libc.so")
+ self.sources = self.root / "sources.list"
+ self.sources.write_text("llvm llvm-test.tar.xz " + "0" * 64 + " https://example.invalid/llvm\n")
+ self.source_root = self.root / "src"
+ for relative in ("llvm-test/LICENSE.TXT", "llvm-test/libcxx/LICENSE.TXT",
+ "llvm-test/LICENSES/exceptions/test-exception"):
+ path = self.source_root / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(relative + "\n")
+
+ def package(self, target=None):
+ return PACKAGE.package(self.prefix, target or self.target, self.sources, self.output, self.source_root)
+
+ def test_archive_preserves_links_modes_and_source_metadata(self):
+ archive = self.package()
+ digest, filename = archive.with_name(archive.name + ".sha256").read_text().split()
+ self.assertEqual(digest, hashlib.sha256(archive.read_bytes()).hexdigest())
+ self.assertEqual(filename, archive.name)
+ self.assertIn("host-" + platform.machine() + "-linux", filename)
+ root = "musllvm-" + self.target
+ with tarfile.open(archive) as stream:
+ self.assertEqual(stream.getmember(root + "/libexec/clang").linkname, "../host/bin/clang")
+ self.assertEqual(stream.getmember(root + "/host/bin/clang").mode, 0o755)
+ self.assertNotIn(root + "/.musllvm-config", stream.getnames())
+ self.assertEqual(stream.extractfile(root + "/share/musllvm/sources.list").read(), self.sources.read_bytes())
+ metadata = json.load(stream.extractfile(root + "/share/musllvm/build-info.json"))
+ self.assertEqual(metadata["target"], self.target)
+ self.assertEqual(metadata["llvm_mode"], "source")
+ self.assertEqual(metadata["source_manifest_sha256"], hashlib.sha256(self.sources.read_bytes()).hexdigest())
+ for relative in ("LICENSE.TXT", "libcxx/LICENSE.TXT", "LICENSES/exceptions/test-exception"):
+ self.assertEqual(stream.extractfile(root + "/share/musllvm/licenses/llvm/" + relative).read(),
+ (self.source_root / "llvm-test" / relative).read_bytes())
+ self.assertEqual(len({member.mtime for member in stream}), 1)
+ extracted = self.root / "unpacked"
+ stream.extractall(extracted, filter="data")
+ relocated = extracted / root
+ self.assertEqual((relocated / "libexec/clang").resolve(), relocated / "host/bin/clang")
+ self.assertEqual((relocated / "sysroot/lib/ld-musl-x86_64.so.1").read_bytes(), b"fixture\n")
+
+ def test_archive_is_independent_of_file_timestamps(self):
+ archive = self.package()
+ first = archive.read_bytes()
+ os.utime(self.prefix / "host/bin/clang", (1, 1))
+ self.assertEqual(self.package().read_bytes(), first)
+
+ def test_absolute_or_escaping_links_are_rejected(self):
+ external = self.root / "external"
+ external.write_text("fixture")
+ link = self.prefix / "libexec/bad"
+ for destination in (str(external), str(self.prefix / "host/bin/clang"), "../../external", "missing"):
+ with self.subTest(destination=destination):
+ link.symlink_to(destination)
+ with self.assertRaises(ValueError):
+ self.package()
+ link.unlink()
+ self.assertFalse(self.output.exists())
+
+ def test_incomplete_toolchains_and_unsafe_targets_are_rejected(self):
+ (self.prefix / "host/bin/ld.lld").unlink()
+ with self.assertRaisesRegex(ValueError, "incomplete toolchain"):
+ self.package()
+ with self.assertRaisesRegex(ValueError, "invalid target"):
+ self.package("../../escape")
+
+ def test_failed_packaging_keeps_the_existing_archive(self):
+ archive = self.package()
+ first = archive.read_bytes()
+ with patch.object(PACKAGE.tarfile, "open", side_effect=OSError("fixture write failure")):
+ with self.assertRaises(OSError):
+ self.package()
+ self.assertEqual(archive.read_bytes(), first)
+ self.assertFalse(list(self.output.glob(".package-*")))
+
+ def test_new_core_component_keeps_its_license_and_requires_its_source(self):
+ with self.sources.open("a") as stream:
+ stream.write("pkgconf pkgconf-test.tar.gz " + "1" * 64 + " https://example.invalid/pkgconf\n")
+ with self.assertRaisesRegex(ValueError, "missing extracted source"):
+ self.package()
+ directory = self.source_root / "pkgconf-test"
+ directory.mkdir()
+ (directory / "COPYING").write_text("pkgconf license fixture\n")
+ with tarfile.open(self.package()) as stream:
+ self.assertEqual(stream.extractfile("musllvm-" + self.target + "/share/musllvm/licenses/pkgconf/COPYING").read(),
+ b"pkgconf license fixture\n")
+
+
+class PublishTests(unittest.TestCase):
+ def setUp(self):
+ self.temporary = tempfile.TemporaryDirectory(prefix="musllvm-publish-test-")
+ self.addCleanup(self.temporary.cleanup)
+ self.root = Path(self.temporary.name)
+ (self.root / "scripts").mkdir()
+ shutil.copyfile(ROOT / "scripts/publish.sh", self.root / "scripts/publish.sh")
+ (self.root / "sources.list").write_text("# component archive sha256 url\nllvm llvm-test.tar.xz digest url\npkgconf pkgconf-test.tar.xz digest url\n")
+ self.output = self.root / "build/releases"
+ self.output.mkdir(parents=True)
+ for name in ARCHIVES:
+ content = name.encode()
+ (self.output / name).write_bytes(content)
+ (self.output / (name + ".sha256")).write_text(hashlib.sha256(content).hexdigest() + " " + name + "\n")
+ tools = self.root / "tools"
+ tools.mkdir()
+ for name, content in (("gh", FAKE_GH), ("git", FAKE_GIT)):
+ path = tools / name
+ path.write_text(content)
+ path.chmod(0o755)
+ self.log = self.root / "gh.jsonl"
+ self.env = {**os.environ, "PATH": str(tools) + os.pathsep + os.environ["PATH"],
+ "GITHUB_SHA": "1" * 40, "FAKE_GIT_SHA": "1" * 40,
+ "FAKE_GH_LOG": str(self.log), "FAKE_RELEASE": "missing", "FAKE_GH_FAIL": ""}
+
+ def publish(self, success=True, **environment):
+ result = subprocess.run(["sh", str(self.root / "scripts/publish.sh")],
+ env={**self.env, **environment}, text=True,
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+ self.assertEqual(result.returncode == 0, success, result.stdout)
+ return [json.loads(line) for line in self.log.read_text().splitlines()] if self.log.exists() else []
+
+ def test_new_release_uploads_both_targets_before_publication(self):
+ calls = self.publish()
+ self.assertEqual([call[1] for call in calls], ["view", "create", "edit"])
+ self.assertEqual(calls[1][2], "musllvm-2026-10-12-" + "1" * 12)
+ self.assertIn("--draft", calls[1])
+ self.assertEqual(calls[1][calls[1].index("--target") + 1], self.env["GITHUB_SHA"])
+ for archive in ARCHIVES:
+ self.assertIn(archive, calls[1])
+ self.assertIn("--draft=false", calls[2])
+ self.assertIn("pkgconf-test.tar.xz", (self.output / "notes.md").read_text())
+ self.assertEqual(len((self.output / "SHA256SUMS").read_text().splitlines()), 2)
+
+ def test_published_release_is_not_overwritten(self):
+ calls = self.publish(FAKE_RELEASE="published")
+ self.assertEqual([call[1] for call in calls], ["view"])
+
+ def test_draft_release_resumes_its_upload(self):
+ calls = self.publish(FAKE_RELEASE="draft")
+ self.assertEqual([call[1] for call in calls], ["view", "upload", "edit"])
+ self.assertIn("--clobber", calls[1])
+
+ def test_missing_or_corrupt_archives_prevent_publication(self):
+ archive = self.output / ARCHIVES[0]
+ archive.write_text("corrupt")
+ self.assertEqual(self.publish(success=False), [])
+ archive.unlink()
+ self.assertEqual(self.publish(success=False), [])
+
+ def test_checksum_for_another_archive_prevents_publication(self):
+ shutil.copyfile(self.output / (ARCHIVES[0] + ".sha256"), self.output / (ARCHIVES[1] + ".sha256"))
+ self.assertEqual(self.publish(success=False), [])
+
+ def test_upload_failure_leaves_the_release_unpublished(self):
+ for state, action in (("missing", "create"), ("draft", "upload")):
+ with self.subTest(state=state):
+ if self.log.exists():
+ self.log.unlink()
+ calls = self.publish(success=False, FAKE_RELEASE=state, FAKE_GH_FAIL=action)
+ self.assertNotIn("edit", [call[1] for call in calls])
+
+ def test_wrong_checkout_prevents_publication(self):
+ self.assertEqual(self.publish(success=False, FAKE_GIT_SHA="2" * 40), [])
+
+
+if __name__ == "__main__":
+ unittest.main()