musllvm

musl and LLVM cross-toolchain build system
git clone https://git.daat.foo/repos/musllvm.git
Log | Files | Refs | README | LICENSE

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:
A.github/workflows/release.yml | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MMakefile | 6+++++-
MREADME.md | 45+++++++++++++++++++++++++++++++++++++++++++--
Ascripts/package.py | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/package.sh | 10++++++++++
Ascripts/publish.sh | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Mscripts/test.sh | 2+-
Atests/test_release.py | 232+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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()