From d1558d8672792510689fea1572e9f57097587896 Mon Sep 17 00:00:00 2001 From: LunaStev Date: Mon, 17 Aug 2026 14:29:55 +0900 Subject: [PATCH] Add multi-platform release automation Signed-off-by: LunaStev --- .github/workflows/release.yml | 192 +++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 3 +- MAINTAINERS | 3 +- Makefile | 33 ------ README.md | 19 +++- RELEASING.md | 124 +++++++++++++++++++++ tests/xpy/test_release_tool.py | 60 ++++++++++- x.py | 75 ++++++++++++- 8 files changed, 469 insertions(+), 40 deletions(-) create mode 100644 .github/workflows/release.yml delete mode 100644 Makefile create mode 100644 RELEASING.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..03ecb3d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,192 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + tag: + description: Existing annotated release tag (for example, v0.0.1) + required: true + type: string + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +concurrency: + group: release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + cancel-in-progress: false + +jobs: + validate: + name: Validate release source + runs-on: ubuntu-24.04 + outputs: + tag: ${{ steps.release.outputs.tag }} + steps: + - name: Check out release tag + uses: actions/checkout@v7 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} + fetch-depth: 0 + + - name: Install Rust toolchain + run: | + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + + - name: Install Python + uses: actions/setup-python@v7 + with: + python-version: "3.11" + + - name: Verify tag and source tree + id: release + shell: bash + run: | + python x.py verify-release + version="$(python x.py --version | awk '{print $2}')" + echo "tag=v${version}" >> "$GITHUB_OUTPUT" + + - name: Run release validation + run: python x.py check + + build: + name: Package / ${{ matrix.name }} + needs: validate + strategy: + fail-fast: false + matrix: + include: + - name: Linux amd64 + os: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + extension: tar.gz + - name: Linux arm64 + os: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + extension: tar.gz + - name: Windows x64 + os: windows-2025 + target: x86_64-pc-windows-msvc + extension: zip + - name: macOS Intel + os: macos-15-intel + target: x86_64-apple-darwin + extension: tar.gz + - name: macOS Apple Silicon + os: macos-15 + target: aarch64-apple-darwin + extension: tar.gz + - name: Linux RISC-V + os: ubuntu-24.04 + target: riscv64gc-unknown-linux-gnu + extension: tar.gz + runs-on: ${{ matrix.os }} + steps: + - name: Check out release tag + uses: actions/checkout@v7 + with: + ref: ${{ needs.validate.outputs.tag }} + fetch-depth: 0 + + - name: Install RISC-V system tools + if: matrix.target == 'riscv64gc-unknown-linux-gnu' + run: | + sudo apt-get update + sudo apt-get install --yes gcc-riscv64-linux-gnu qemu-user + + - name: Install Rust toolchain + run: | + rustup toolchain install stable --profile minimal --target ${{ matrix.target }} + rustup default stable + + - name: Install Python + uses: actions/setup-python@v7 + with: + python-version: "3.11" + + - name: Build release binary + env: + CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: ${{ matrix.target == 'riscv64gc-unknown-linux-gnu' && 'riscv64-linux-gnu-gcc' || '' }} + run: python x.py build ${{ matrix.target }} + + - name: Package and smoke test release binary + run: python x.py package ${{ matrix.target }} + + - name: Upload release archive + uses: actions/upload-artifact@v7 + with: + name: release-${{ matrix.target }} + path: dist/vex-*-${{ matrix.target }}.${{ matrix.extension }} + if-no-files-found: error + retention-days: 14 + + draft-release: + name: Attest and create draft release + needs: + - validate + - build + runs-on: ubuntu-24.04 + permissions: + attestations: write + contents: write + id-token: write + steps: + - name: Check out release tag + uses: actions/checkout@v7 + with: + ref: ${{ needs.validate.outputs.tag }} + fetch-depth: 0 + + - name: Install Python + uses: actions/setup-python@v7 + with: + python-version: "3.11" + + - name: Download release archives + uses: actions/download-artifact@v8 + with: + pattern: release-* + path: dist + merge-multiple: true + + - name: Verify complete archive set and write checksums + shell: bash + run: | + python x.py checksum \ + x86_64-unknown-linux-gnu \ + aarch64-unknown-linux-gnu \ + x86_64-pc-windows-msvc \ + x86_64-apple-darwin \ + aarch64-apple-darwin \ + riscv64gc-unknown-linux-gnu + cd dist + sha256sum --check SHA256SUMS + + - name: Generate build provenance attestations + uses: actions/attest@v4 + with: + subject-path: | + dist/vex-*.tar.gz + dist/vex-*.zip + dist/SHA256SUMS + + - name: Create draft GitHub Release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.validate.outputs.tag }} + shell: bash + run: | + gh release create "$RELEASE_TAG" \ + --verify-tag \ + --draft \ + --generate-notes \ + --title "Vex $RELEASE_TAG" \ + dist/vex-*.tar.gz \ + dist/vex-*.zip \ + dist/SHA256SUMS diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 868ae9c..0261172 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,7 +85,8 @@ remote-tracking refs remain unchanged. Release packages are created with `python3 x.py build` followed by `python3 x.py package`. Do not hand-edit `dist/` artifacts. The stricter `python3 x.py release` command is reserved for a clean commit carrying the exact -`v` tag. +annotated `v` tag. Maintainers must follow [RELEASING.md](RELEASING.md) +for multi-platform artifact aggregation, attestation, and publication. ## Pull requests diff --git a/MAINTAINERS b/MAINTAINERS index e27dd16..a4a8355 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -27,8 +27,9 @@ M: LunaStev F: .github/ F: Cargo.toml F: Cargo.lock -F: Makefile F: x.py +F: tests/xpy/ +F: RELEASING.md [Documentation and Community] M: LunaStev diff --git a/Makefile b/Makefile deleted file mode 100644 index 2029c19..0000000 --- a/Makefile +++ /dev/null @@ -1,33 +0,0 @@ -VERSION := $(shell grep '^version' Cargo.toml | sed 's/version = "\(.*\)"/\1/') -TARGET_DIR := ./target -BINARY_NAME := vex - -TARGETS := \ - x86_64-unknown-linux-gnu \ - -install: - rustup target add $(TARGETS) - -build: - @for target in $(TARGETS); do \ - cargo build --target $$target --release; \ - done - -package: - @for target in $(TARGETS); do \ - target_dir="$(TARGET_DIR)/$$target/release"; \ - output_name="$(BINARY_NAME)"; \ - formatted_target=$$(echo $$target | sed 's/-unknown//'); \ - if echo $$target | grep -q "windows"; then \ - cp "$$target_dir/$$output_name.exe" .; \ - zip $(BINARY_NAME)-v$(VERSION)-$$formatted_target.zip $$output_name.exe; \ - rm $$output_name.exe; \ - else \ - tar -czvf $(BINARY_NAME)-v$(VERSION)-$$formatted_target.tar.gz -C "$$target_dir" $$output_name; \ - fi; \ - done - -release: build package - -clean: - rm -rf $(TARGET_DIR) *.zip *.tar.gz \ No newline at end of file diff --git a/README.md b/README.md index 24baa1f..0850b5d 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,9 @@ python3 x.py package # Build or package one or more explicit targets. python3 x.py build x86_64-unknown-linux-gnu python3 x.py package x86_64-unknown-linux-gnu + +# Verify an assembled target set and regenerate its checksums. +python3 x.py checksum x86_64-unknown-linux-gnu ``` Archives contain the Vex executable together with `README.md`, `LICENSE`, @@ -188,8 +191,12 @@ Cross-target builds still require the corresponding Rust target and native linker to be installed. `VEX_RELEASE_HOST` exists for release infrastructure that must override host-target detection; normal development should not set it. -The existing `Makefile` remains available during the transition, but new -release automation should use `x.py` so local builds and CI share one contract. +`python3 x.py verify-release` checks that the source tree is clean and `HEAD` +has the annotated `v` tag required by the release workflow. The +workflow builds every release target before creating one checksum manifest and +a draft GitHub Release. It also generates GitHub build-provenance attestations; +publishing the reviewed draft remains a separate maintainer action. See +[RELEASING.md](RELEASING.md) for the complete procedure. Verify downloaded archives from the directory containing `SHA256SUMS`: @@ -197,6 +204,13 @@ Verify downloaded archives from the directory containing `SHA256SUMS`: sha256sum --check SHA256SUMS ``` +Official release attestations can also be verified with GitHub CLI: + +```sh +gh attestation verify vex-v0.0.1-x86_64-unknown-linux-gnu.tar.gz \ + --repo wavefnd/Vex +``` + ## License [MPL 2.0 LICENSE](LICENSE) @@ -207,6 +221,7 @@ sha256sum --check SHA256SUMS - [Code of Conduct](CODE_OF_CONDUCT.md) - [Maintainers](MAINTAINERS) - [Security Policy](SECURITY.md) +- [Release Process](RELEASING.md) - [Copyright](COPYRIGHT) - [Notice](NOTICE) - [AI Usage Policy](ai.txt) diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..8d21525 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,124 @@ +# Releasing Vex + +This document is the maintainer procedure for producing an official Vex +release. The release workflow builds reproducible archives from an existing +annotated version tag, creates provenance attestations, and prepares a draft +GitHub Release. It never publishes a release automatically. + +## Release contract + +- `Cargo.toml` is the single source of truth for the Vex version. +- The release tag must be the exact `v` tag, point at the release + commit, and be annotated. A signed annotated tag is preferred. +- The release commit and committed `Cargo.lock` must be used without changes. +- Every configured target must build and package successfully before a draft + release is created. Partial releases are not supported. +- Release archives and `SHA256SUMS` receive GitHub build-provenance + attestations. +- Publishing the reviewed draft is a separate, intentional maintainer action. + +## 1. Prepare the release commit + +Start from the current `wavefnd/Vex:master`. Complete the release-candidate +checklist before tagging: + +1. Update `CHANGELOG.md` and user-facing release notes. +2. Confirm the supported platform table and compatible `wavec` contract. +3. Confirm that `Cargo.toml` contains the intended version and that + `Cargo.lock` is committed. +4. Run the complete local validation suite: + + ```sh + python3 x.py check + ``` + +5. Merge the release-candidate pull request and wait for every required CI + check on `master` to pass. + +Do not create a release tag from a feature branch, a dirty checkout, or a +commit that has not passed the required checks. + +## 2. Create and push the version tag + +Fetch the authoritative repository and verify the commit before tagging: + +```sh +git remote add upstream https://github.com/wavefnd/Vex.git # if not already configured +git fetch upstream +git switch master +git merge --ff-only upstream/master +git status --short --branch +git log -1 --oneline +python3 x.py check +git tag -s v0.0.1 -m "Vex v0.0.1" +python3 x.py verify-release +git push upstream v0.0.1 +``` + +If signed tags are not available in the maintainer environment, `git tag -a` +meets the automation's minimum annotated-tag requirement. Record that exception +in the release notes. Never replace or move a published release tag. + +Pushing `v*` to `wavefnd/Vex` starts `.github/workflows/release.yml`. Pushing a +tag only to a personal fork does not create the official release. A maintainer +can rerun the same workflow manually with an existing annotated tag; the +workflow still checks that the tag matches `Cargo.toml` and the checked-out +commit. + +## 3. Review the draft release + +The workflow packages these targets: + +- `x86_64-unknown-linux-gnu` +- `aarch64-unknown-linux-gnu` +- `x86_64-pc-windows-msvc` +- `x86_64-apple-darwin` +- `aarch64-apple-darwin` +- `riscv64gc-unknown-linux-gnu` + +Each target is built and smoke-tested on its native runner, except RISC-V, +which is cross-built and executed with QEMU. The final job runs only after the +complete matrix succeeds. It rejects missing or unexpected archives, writes a +single `SHA256SUMS`, generates provenance attestations, and creates a draft +GitHub Release. + +Download the draft assets into an empty directory and verify them: + +```sh +gh release download v0.0.1 --repo wavefnd/Vex --dir vex-v0.0.1 +cd vex-v0.0.1 +sha256sum --check SHA256SUMS +gh attestation verify vex-v0.0.1-x86_64-unknown-linux-gnu.tar.gz \ + --repo wavefnd/Vex +``` + +Repeat attestation verification for every archive and `SHA256SUMS`. Extract at +least one native archive in a clean environment and run `vex --version` and +`vex --help`. Complete the documented Wave project smoke test with a compatible +`wavec` before publication. + +## 4. Publish deliberately + +Review the generated notes, platform status, compatibility requirements, +checksums, and attached assets in the draft. Only then publish it through the +GitHub Releases interface or with: + +```sh +gh release edit v0.0.1 --repo wavefnd/Vex --draft=false +``` + +After publication, repeat the checksum, attestation, version, help, and Wave +project smoke tests using assets downloaded from the public release. + +## Failure and recovery + +- A failed matrix does not create a GitHub Release. Fix the problem in a new + commit and use a new pre-release version or tag; do not move a public tag. +- If the workflow fails before the draft is created, inspect the failed target, + correct the release commit, and restart the release process with an + appropriate new tag. +- If review finds a problem in an unpublished draft, delete the draft and its + unadvertised tag only after confirming no user depends on it, then prepare a + corrected release commit and tag. +- Never publish a partial set of target archives or hand-edit generated + archives and checksums. diff --git a/tests/xpy/test_release_tool.py b/tests/xpy/test_release_tool.py index b44253f..8237a4f 100644 --- a/tests/xpy/test_release_tool.py +++ b/tests/xpy/test_release_tool.py @@ -8,6 +8,7 @@ import hashlib import importlib.util import os +import re import subprocess import sys import tarfile @@ -156,11 +157,46 @@ def test_checksums_are_sorted_and_limited_to_requested_archives(self) -> None: ) self.assertNotIn(str(dist), "\n".join(lines)) + def test_collect_release_archives_requires_exact_requested_set(self) -> None: + linux = release_tool.SUPPORTED_TARGETS["x86_64-unknown-linux-gnu"] + windows = release_tool.SUPPORTED_TARGETS["x86_64-pc-windows-msvc"] + with tempfile.TemporaryDirectory() as temporary: + dist = Path(temporary) + linux_archive = release_tool.archive_path("0.0.1", linux, dist) + windows_archive = release_tool.archive_path("0.0.1", windows, dist) + linux_archive.write_bytes(b"linux") + + with self.assertRaisesRegex( + release_tool.ReleaseError, + rf"missing: {re.escape(windows_archive.name)}", + ): + release_tool.collect_release_archives( + [linux, windows], "0.0.1", dist + ) + + windows_archive.write_bytes(b"windows") + self.assertEqual( + release_tool.collect_release_archives( + [linux, windows], "0.0.1", dist + ), + [linux_archive, windows_archive], + ) + + extra = dist / "vex-v0.0.1-imaginary-target.tar.gz" + extra.write_bytes(b"extra") + with self.assertRaisesRegex( + release_tool.ReleaseError, + rf"unexpected: {re.escape(extra.name)}", + ): + release_tool.collect_release_archives( + [linux, windows], "0.0.1", dist + ) + def test_release_requires_version_tag_at_head(self) -> None: with mock.patch.object( release_tool, "capture_command", - return_value="v0.0.1\nother-tag", + side_effect=["v0.0.1\nother-tag", "tag"], ): release_tool.require_release_tag("0.0.1") @@ -171,6 +207,28 @@ def test_release_requires_version_tag_at_head(self) -> None: ): release_tool.require_release_tag("0.0.1") + with mock.patch.object( + release_tool, + "capture_command", + side_effect=["v0.0.1", "commit"], + ): + with self.assertRaisesRegex( + release_tool.ReleaseError, + "must be annotated, not lightweight", + ): + release_tool.require_release_tag("0.0.1") + + def test_release_workflow_covers_every_supported_target(self) -> None: + workflow = (ROOT / ".github/workflows/release.yml").read_text( + encoding="utf-8" + ) + for target in release_tool.SUPPORTED_TARGETS: + self.assertIn(f"target: {target}", workflow) + self.assertGreaterEqual(workflow.count(target), 2) + self.assertIn("python x.py checksum", workflow) + self.assertIn("uses: actions/attest@v4", workflow) + self.assertIn("--draft", workflow) + @staticmethod def make_package_inputs(root: Path, target: object, binary: bytes) -> None: executable_name = target.executable_name diff --git a/x.py b/x.py index e0bb7cb..7aa4912 100755 --- a/x.py +++ b/x.py @@ -195,6 +195,11 @@ def package_name(version: str, target: Target) -> str: return f"{BINARY_NAME}-v{version}-{target.triple}" +def archive_path(version: str, target: Target, dist_dir: Path = DIST_DIR) -> Path: + extension = ".zip" if target.archive == "zip" else ".tar.gz" + return dist_dir / f"{package_name(version, target)}{extension}" + + def source_date_epoch() -> int: configured = os.environ.get("SOURCE_DATE_EPOCH") if configured is not None: @@ -425,6 +430,36 @@ def write_checksums(archives: Iterable[Path], dist_dir: Path = DIST_DIR) -> Path return checksum_path +def collect_release_archives( + targets: Iterable[Target], version: str, dist_dir: Path = DIST_DIR +) -> list[Path]: + expected = [archive_path(version, target, dist_dir) for target in targets] + missing = [path.name for path in expected if not path.is_file()] + expected_names = {path.name for path in expected} + candidates = { + path.name + for pattern in ( + f"{BINARY_NAME}-v{version}-*.tar.gz", + f"{BINARY_NAME}-v{version}-*.zip", + ) + for path in dist_dir.glob(pattern) + if path.is_file() + } + unexpected = sorted(candidates - expected_names) + if missing or unexpected: + details = [] + if missing: + details.append(f"missing: {', '.join(sorted(missing))}") + if unexpected: + details.append(f"unexpected: {', '.join(unexpected)}") + raise ReleaseError( + "release archive set is incomplete or inconsistent " + f"({'; '.join(details)})\n" + "help: build and package exactly the requested release targets" + ) + return expected + + def package_targets(targets: Iterable[Target], version: str, host: str) -> list[Path]: epoch = source_date_epoch() archives: list[Path] = [] @@ -465,6 +500,18 @@ def require_release_tag(version: str) -> None: f"official release must run from tag `{expected}`\n" f"help: create and check out the annotated `{expected}` tag" ) + tag_type = capture_command(["git", "cat-file", "-t", f"refs/tags/{expected}"]) + if tag_type != "tag": + raise ReleaseError( + f"official release tag `{expected}` must be annotated, not lightweight\n" + f"help: recreate `{expected}` with `git tag -s {expected}` or " + f"`git tag -a {expected}`" + ) + + +def verify_release_source(version: str) -> None: + require_clean_tree() + require_release_tag(version) def run_check_suite() -> None: @@ -493,10 +540,23 @@ def command_package(args: argparse.Namespace) -> None: package_targets(targets, load_version(), detect_host_target()) +def command_checksum(args: argparse.Namespace) -> None: + targets = select_targets(args.targets) + checksum_path = write_checksums( + collect_release_archives(targets, load_version()), + ) + status("Checksums", str(checksum_path.relative_to(ROOT))) + + +def command_verify_release(_: argparse.Namespace) -> None: + version = load_version() + verify_release_source(version) + status("Verified", f"clean source at annotated tag v{version}") + + def command_release(args: argparse.Namespace) -> None: version = load_version() - require_clean_tree() - require_release_tag(version) + verify_release_source(version) targets = select_targets(args.targets) run_check_suite() build_targets(targets) @@ -562,6 +622,17 @@ def create_parser(version: str) -> argparse.ArgumentParser: add_target_arguments(package) package.set_defaults(handler=command_package) + checksum = commands.add_parser( + "checksum", help="verify a complete archive set and write SHA256SUMS" + ) + add_target_arguments(checksum) + checksum.set_defaults(handler=command_checksum) + + verify_release = commands.add_parser( + "verify-release", help="verify clean source and the annotated version tag" + ) + verify_release.set_defaults(handler=command_verify_release) + release = commands.add_parser( "release", help="validate, build, and package an official tagged release" )