Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,33 @@ jobs:
- name: Run Clippy
run: cargo clippy --locked --all-targets -- -D warnings

release-tooling:
name: Package / Linux amd64
runs-on: ubuntu-24.04
steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Install Rust toolchain
run: |
rustup toolchain install stable --profile minimal --target x86_64-unknown-linux-gnu
rustup default stable

- name: Install Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Build release binary
run: python x.py build x86_64-unknown-linux-gnu

- name: Package and smoke test release binary
run: python x.py package x86_64-unknown-linux-gnu

- name: Verify release checksum
working-directory: dist
run: sha256sum --check SHA256SUMS

test:
name: Test / ${{ matrix.name }}
strategy:
Expand All @@ -53,6 +80,14 @@ jobs:
rustup toolchain install stable --profile minimal
rustup default stable

- name: Install Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Test release tooling
run: python -m unittest discover -s tests/xpy -v

- name: Run unit and integration tests
if: runner.os != 'Windows'
run: cargo test --locked
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,13 @@ vendor/
# Local caches
.cache/
.lock/
__pycache__/
*.py[cod]

# Build directories
CMakeFiles/
build/
/dist/
cmake-build-debug/
cmake-build-release/

Expand Down
14 changes: 14 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ boundaries or a persistent file format.
You need:

- a stable Rust toolchain with `rustfmt` and `clippy`
- Python 3.11 or newer for `x.py` release tooling
- Git for dependency integration tests
- a compatible `wavec` in `PATH` for end-to-end build and run tests

Expand All @@ -41,6 +42,12 @@ cargo clippy --locked --all-targets -- -D warnings
cargo build --locked
```

The same baseline is available through the repository release tool:

```sh
python3 x.py check
```

## Making a change

Create a branch from the current `wavefnd/Vex:master`. Use `feat/<topic>` for
Expand All @@ -62,6 +69,8 @@ Add tests at the same level as the behavior being changed:
- parser and policy details belong in unit tests
- dependency graph and Git behavior belong in integration tests
- compiler invocation changes require dry-run schema and end-to-end smoke tests
- release-tool changes require `python3 -m unittest discover -s tests/xpy -v`
- package changes require archive-content, checksum, and executable smoke tests

Git integration tests must use local fixture repositories and must not require
external network access. Dependency changes should cover direct and transitive
Expand All @@ -71,6 +80,11 @@ graphs, exact locked commits, cycles, source/version/name conflicts, and relevan
When changing selective update behavior, prove that unrelated locked commits and
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<version>` tag.

## Pull requests

Push your branch to a fork and open a pull request against
Expand Down
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Vex is designed to sit above `wavec` in the same way Cargo sits above `rustc`: V
- Rust toolchain for building Vex from source
- `wavec` compatible with the `build --dry-run --error-format=json` schema v1 contract
- `git` when using Git dependencies
- Python 3.11 or newer when using the release tooling

Vex runs `wavec` from `PATH` by default. Set `VEX_WAVEC=/path/to/wavec` to use a specific compiler binary.

Expand Down Expand Up @@ -131,6 +132,50 @@ vex check
VEX_WAVEC=/opt/wave/bin/wavec vex build --dry-run
```

## Development and release tooling

The repository-level `x.py` script is the supported entry point for release
builds and packages. It reads the version from `Cargo.toml`, always builds with
the committed `Cargo.lock`, and writes archives plus `SHA256SUMS` to `dist/`.
Run it with Python 3.11 or newer:

```sh
# Show the host and every supported release target.
python3 x.py list-targets

# Run formatting, release-tool tests, Rust tests, Clippy, and a debug build.
python3 x.py check

# Build and package the native target.
python3 x.py build
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
```

Archives contain the Vex executable together with `README.md`, `LICENSE`,
`NOTICE`, and `COPYRIGHT`. Their file order, permissions, owners, and timestamps
are normalized. Set `SOURCE_DATE_EPOCH` to an explicit non-negative Unix
timestamp when reproducing an artifact outside the tagged source revision.

`python3 x.py release [<target>...]` is intentionally stricter than separate
build and package commands. It runs the complete validation suite and succeeds
only when the working tree is clean and `HEAD` has the exact `v<version>` tag.
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.

Verify downloaded archives from the directory containing `SHA256SUMS`:

```sh
sha256sum --check SHA256SUMS
```

## License

[MPL 2.0 LICENSE](LICENSE)
Expand Down
214 changes: 214 additions & 0 deletions tests/xpy/test_release_tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
# SPDX-License-Identifier: MPL-2.0

from __future__ import annotations

import hashlib
import importlib.util
import os
import subprocess
import sys
import tarfile
import tempfile
import tomllib
import unittest
import zipfile
from pathlib import Path
from unittest import mock


ROOT = Path(__file__).resolve().parents[2]
with (ROOT / "Cargo.toml").open("rb") as manifest_file:
EXPECTED_VERSION = tomllib.load(manifest_file)["package"]["version"]
MODULE_NAME = "vex_release_tool"
SPEC = importlib.util.spec_from_file_location(MODULE_NAME, ROOT / "x.py")
if SPEC is None or SPEC.loader is None: # pragma: no cover - import setup failure
raise RuntimeError("could not load x.py")
release_tool = importlib.util.module_from_spec(SPEC)
sys.modules[MODULE_NAME] = release_tool
SPEC.loader.exec_module(release_tool)


class ReleaseToolTests(unittest.TestCase):
def test_load_version_reads_cargo_manifest(self) -> None:
self.assertEqual(release_tool.load_version(), EXPECTED_VERSION)

def test_load_version_accepts_full_semver(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
manifest = Path(temporary) / "Cargo.toml"
manifest.write_text(
'[package]\nname = "fixture"\nversion = "1.2.3-rc.1+build.7"\n',
encoding="utf-8",
)
self.assertEqual(release_tool.load_version(manifest), "1.2.3-rc.1+build.7")

def test_select_targets_deduplicates_without_reordering(self) -> None:
selected = release_tool.select_targets(
[
"aarch64-unknown-linux-gnu",
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
]
)
self.assertEqual(
[target.triple for target in selected],
["aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu"],
)

def test_select_targets_rejects_unknown_target_with_known_targets(self) -> None:
with self.assertRaisesRegex(
release_tool.ReleaseError,
r"(?s)unsupported target: imaginary-target.*Known targets",
):
release_tool.select_targets(["imaginary-target"])

def test_default_target_uses_explicit_host_override(self) -> None:
with mock.patch.dict(
os.environ,
{"VEX_RELEASE_HOST": "aarch64-apple-darwin"},
clear=False,
):
selected = release_tool.select_targets([])
self.assertEqual(selected, [release_tool.SUPPORTED_TARGETS["aarch64-apple-darwin"]])

def test_source_date_epoch_rejects_invalid_value(self) -> None:
with mock.patch.dict(os.environ, {"SOURCE_DATE_EPOCH": "yesterday"}):
with self.assertRaisesRegex(
release_tool.ReleaseError,
"SOURCE_DATE_EPOCH must be a non-negative integer",
):
release_tool.source_date_epoch()

def test_tar_archive_is_deterministic_and_complete(self) -> None:
target = release_tool.SUPPORTED_TARGETS["x86_64-unknown-linux-gnu"]
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
self.make_package_inputs(root, target, b"linux binary")
stage = release_tool.prepare_stage("0.0.1", target, root=root)
first = root / "first.tar.gz"
second = root / "second.tar.gz"
release_tool.create_tar_archive(stage, first, 1_700_000_000)
for entry in stage.iterdir():
os.utime(entry, (1_800_000_000, 1_800_000_000))
release_tool.create_tar_archive(stage, second, 1_700_000_000)

self.assertEqual(first.read_bytes(), second.read_bytes())
names, binary = release_tool.read_binary_from_tar(
first, f"{stage.name}/{target.executable_name}"
)
self.assertEqual(
names,
release_tool.expected_archive_entries(stage.name, target),
)
self.assertEqual(binary, b"linux binary")
with tarfile.open(first, "r:gz") as packaged:
self.assertEqual(
packaged.getmember(f"{stage.name}/vex").mode,
0o755,
)

def test_zip_archive_is_deterministic_and_complete(self) -> None:
target = release_tool.SUPPORTED_TARGETS["x86_64-pc-windows-msvc"]
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
self.make_package_inputs(root, target, b"windows binary")
stage = release_tool.prepare_stage("0.0.1", target, root=root)
first = root / "first.zip"
second = root / "second.zip"
release_tool.create_zip_archive(stage, first, 1_700_000_000)
release_tool.create_zip_archive(stage, second, 1_700_000_000)

self.assertEqual(first.read_bytes(), second.read_bytes())
names, binary = release_tool.read_binary_from_zip(
first, f"{stage.name}/{target.executable_name}"
)
self.assertEqual(
names,
release_tool.expected_archive_entries(stage.name, target),
)
self.assertEqual(binary, b"windows binary")
with zipfile.ZipFile(first) as packaged:
mode = packaged.getinfo(f"{stage.name}/vex.exe").external_attr >> 16
self.assertEqual(mode, 0o100755)

def test_checksums_are_sorted_and_limited_to_requested_archives(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
dist = Path(temporary)
current_zip = dist / "vex-v0.0.1-z-target.zip"
current_tar = dist / "vex-v0.0.1-a-target.tar.gz"
old_tar = dist / "vex-v0.0.0-old-target.tar.gz"
current_zip.write_bytes(b"zip")
current_tar.write_bytes(b"tar")
old_tar.write_bytes(b"old")

checksum_path = release_tool.write_checksums(
[current_zip, current_tar], dist
)
lines = checksum_path.read_text(encoding="utf-8").splitlines()
self.assertEqual(
lines,
[
f"{hashlib.sha256(b'tar').hexdigest()} {current_tar.name}",
f"{hashlib.sha256(b'zip').hexdigest()} {current_zip.name}",
],
)
self.assertNotIn(str(dist), "\n".join(lines))

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",
):
release_tool.require_release_tag("0.0.1")

with mock.patch.object(release_tool, "capture_command", return_value="other-tag"):
with self.assertRaisesRegex(
release_tool.ReleaseError,
"official release must run from tag `v0.0.1`",
):
release_tool.require_release_tag("0.0.1")

@staticmethod
def make_package_inputs(root: Path, target: object, binary: bytes) -> None:
executable_name = target.executable_name
binary_path = root / "target" / target.triple / "release" / executable_name
binary_path.parent.mkdir(parents=True)
binary_path.write_bytes(binary)
for document in release_tool.PACKAGE_DOCUMENTS:
(root / document).write_text(f"{document}\n", encoding="utf-8")


class ReleaseToolCliTests(unittest.TestCase):
def run_xpy(self, *arguments: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(ROOT / "x.py"), *arguments],
cwd=ROOT,
check=False,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)

def test_version_output_uses_manifest_version(self) -> None:
result = self.run_xpy("--version")
self.assertEqual(result.returncode, 0)
self.assertEqual(result.stdout.strip(), f"x.py {EXPECTED_VERSION}")

def test_list_targets_reports_all_supported_targets(self) -> None:
result = self.run_xpy("list-targets")
self.assertEqual(result.returncode, 0)
for target in release_tool.SUPPORTED_TARGETS:
self.assertIn(target, result.stdout)

def test_unknown_target_is_actionable(self) -> None:
result = self.run_xpy("build", "imaginary-target")
self.assertEqual(result.returncode, 1)
self.assertIn("error: unsupported target: imaginary-target", result.stderr)
self.assertIn("Known targets:", result.stderr)


if __name__ == "__main__":
unittest.main()
Loading
Loading