diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df1c1c38..49daf298 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,8 @@ jobs: run: | rustup toolchain install "${{ needs.rust-version.outputs.version }}" --profile minimal --target x86_64-unknown-linux-musl --component llvm-tools-preview rustup default "${{ needs.rust-version.outputs.version }}" + - name: Test release version validation + run: python3 -m unittest scripts/test_check_release_version.py - name: Format, check, and test examples run: ./scripts/test.py --operation validate diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 35db9a8b..8a75d4db 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,9 +29,39 @@ jobs: version=$(sed -n 's/^channel = "\(.*\)".*/\1/p' rust-toolchain.toml) echo "version=$version" >> "$GITHUB_OUTPUT" + release-info: + name: Validate release request + runs-on: ubuntu-24.04 + outputs: + release_version: ${{ steps.validate.outputs.release_version }} + docs_version: ${{ steps.validate.outputs.docs_version }} + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Validate release request + id: validate + uses: roc-lang/release-package/actions/validate-release@d2560ead9f724bfaabfbc8134b8895e120171064 + with: + release_version: ${{ github.event_name == 'workflow_dispatch' && inputs.release_version || '' }} + dry_run: ${{ github.event_name != 'workflow_dispatch' }} + github_token: ${{ github.token }} + + - name: Validate release branch + if: github.event_name == 'workflow_dispatch' + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + if [ "${GITHUB_REF_TYPE:-}" != "branch" ] || [ "$GITHUB_REF_NAME" != "$DEFAULT_BRANCH" ]; then + echo "Releases must be dispatched from the default branch: $DEFAULT_BRANCH" >&2 + exit 1 + fi + build-windows-host: name: Build Windows x64 host - needs: rust-version + needs: [rust-version, release-info] runs-on: windows-2025 steps: @@ -44,6 +74,13 @@ jobs: rustup toolchain install "${{ needs.rust-version.outputs.version }}" --profile minimal --target x86_64-pc-windows-msvc rustup default "${{ needs.rust-version.outputs.version }}" + - name: Prepare Cargo package version + if: github.event_name == 'workflow_dispatch' + shell: bash + env: + RELEASE_VERSION: ${{ needs.release-info.outputs.release_version }} + run: python scripts/check_release_version.py "$RELEASE_VERSION" --update + - name: Build Windows host shell: pwsh run: python scripts/build.py @@ -58,13 +95,13 @@ jobs: build: name: Build release bundle - needs: [rust-version, build-windows-host] + needs: [rust-version, release-info, build-windows-host] # macOS cross-compiles the Apple and Linux musl hosts. Windows is built # natively above because the Rust dependencies require the Windows SDK. runs-on: macos-15 outputs: - release_version: ${{ steps.validate.outputs.release_version }} - docs_version: ${{ steps.validate.outputs.docs_version }} + release_version: ${{ needs.release-info.outputs.release_version }} + docs_version: ${{ needs.release-info.outputs.docs_version }} test_matrix: ${{ steps.bundles.outputs.test_matrix }} steps: @@ -96,23 +133,11 @@ jobs: name: windows-x64-host path: platform/targets/x64win - - name: Validate release request - id: validate - uses: roc-lang/release-package/actions/validate-release@d2560ead9f724bfaabfbc8134b8895e120171064 - with: - release_version: ${{ github.event_name == 'workflow_dispatch' && inputs.release_version || '' }} - dry_run: ${{ github.event_name != 'workflow_dispatch' }} - github_token: ${{ github.token }} - - - name: Validate release branch + - name: Prepare Cargo package version if: github.event_name == 'workflow_dispatch' env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - run: | - if [ "${GITHUB_REF_TYPE:-}" != "branch" ] || [ "$GITHUB_REF_NAME" != "$DEFAULT_BRANCH" ]; then - echo "Releases must be dispatched from the default branch: $DEFAULT_BRANCH" >&2 - exit 1 - fi + RELEASE_VERSION: ${{ needs.release-info.outputs.release_version }} + run: python3 scripts/check_release_version.py "$RELEASE_VERSION" --update - name: Build platform for all targets run: ./scripts/build.py --all @@ -136,7 +161,7 @@ jobs: - name: Check version bump uses: roc-lang/release-package/actions/run-bump-check@d2560ead9f724bfaabfbc8134b8895e120171064 with: - release_version: ${{ steps.validate.outputs.release_version }} + release_version: ${{ needs.release-info.outputs.release_version }} dry_run: ${{ github.event_name != 'workflow_dispatch' }} bump_check: warn bump_entrypoint: platform/main.roc @@ -273,6 +298,7 @@ jobs: publish-release: name: Publish GitHub release needs: + - rust-version - build - build-docs - test-bundles @@ -280,6 +306,9 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + pull-requests: write + outputs: + release_source_commit: ${{ steps.release-source.outputs.commit_sha || github.sha }} steps: - name: Checkout @@ -287,6 +316,35 @@ jobs: with: fetch-depth: 0 + - name: Install Rust + run: | + rustup toolchain install "${{ needs.rust-version.outputs.version }}" --profile minimal + rustup default "${{ needs.rust-version.outputs.version }}" + + - name: Prepare Cargo package version + env: + RELEASE_VERSION: ${{ needs.build.outputs.release_version }} + run: python3 scripts/check_release_version.py "$RELEASE_VERSION" --update + + - name: Open release source follow-up PR + id: release-source + uses: roc-lang/release-package/actions/create-followup-pr@d2560ead9f724bfaabfbc8134b8895e120171064 + with: + release_version: ${{ needs.build.outputs.release_version }} + paths: | + Cargo.toml + Cargo.lock + branch_prefix: release-followup + base_branch: ${{ github.event.repository.default_branch }} + commit_message: Update sources for ${{ needs.build.outputs.release_version }} + pr_title: Update sources for ${{ needs.build.outputs.release_version }} + pr_body: | + Release follow-up for ${{ needs.build.outputs.release_version }}. + + Updates the Cargo package version to match the published platform release. + The published example URLs will be added after the release is available. + github_token: ${{ github.token }} + - name: Download release bundles uses: actions/download-artifact@v8 with: @@ -308,6 +366,7 @@ jobs: uses: roc-lang/release-package/actions/make-release-notes@d2560ead9f724bfaabfbc8134b8895e120171064 with: release_version: ${{ needs.build.outputs.release_version }} + target: ${{ steps.release-source.outputs.commit_sha || github.sha }} docs_url: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/${{ needs.build.outputs.docs_version }}/ github_token: ${{ github.token }} @@ -315,6 +374,7 @@ jobs: uses: roc-lang/release-package/actions/publish-release@48ccc909bd8f6ba60feb1d33d8b6ac1dc1c7af36 with: release_version: ${{ needs.build.outputs.release_version }} + target: ${{ steps.release-source.outputs.commit_sha || github.sha }} additional_assets: docs.tar.gz github_token: ${{ github.token }} @@ -337,6 +397,9 @@ jobs: steps: - name: Checkout uses: actions/checkout@v7 + with: + ref: ${{ needs.publish-release.outputs.release_source_commit }} + fetch-depth: 0 - name: Install Roc uses: roc-lang/setup-roc@bd311e2fb815a3d2255f7ee14a922f0b736e020b @@ -417,15 +480,18 @@ jobs: with: release_version: ${{ needs.build.outputs.release_version }} paths: | + Cargo.toml + Cargo.lock examples branch_prefix: release-followup base_branch: ${{ github.event.repository.default_branch }} - commit_message: Update examples for ${{ needs.build.outputs.release_version }} - pr_title: Update examples for ${{ needs.build.outputs.release_version }} + commit_message: Update sources for ${{ needs.build.outputs.release_version }} + pr_title: Update sources for ${{ needs.build.outputs.release_version }} pr_body: | Release follow-up for ${{ needs.build.outputs.release_version }}. - Updates checked-in examples to the published platform bundle. + Updates the Cargo package version and checked-in examples to match + the published platform release. github_token: ${{ github.token }} - name: Create Pages tree diff --git a/Cargo.lock b/Cargo.lock index ca55744e..1521b55b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -470,7 +470,7 @@ dependencies = [ [[package]] name = "roc_host" -version = "0.0.1" +version = "0.21.0-rc4" dependencies = [ "bytes", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index 95925836..389c7c8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "roc_host" -version = "0.0.1" +version = "0.21.0-rc4" authors = ["The Roc Contributors"] edition = "2021" rust-version = "1.83" diff --git a/scripts/check_release_version.py b/scripts/check_release_version.py new file mode 100644 index 00000000..23f896d8 --- /dev/null +++ b/scripts/check_release_version.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""Synchronize or check Cargo against a validated release version.""" + +import argparse +import json +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def read_text(path: Path) -> str: + with path.open("r", encoding="utf-8", newline="") as file: + return file.read() + + +def write_text(path: Path, content: str) -> None: + with path.open("w", encoding="utf-8", newline="") as file: + file.write(content) + + +def replace_version_line(line: str, release_version: str) -> str: + key, separator, _value = line.partition("=") + if not separator or key.strip() != "version": + raise RuntimeError("invalid Cargo version line") + if line.endswith("\r\n"): + newline = "\r\n" + elif line.endswith("\n"): + newline = "\n" + else: + newline = "" + return f'{key}= "{release_version}"{newline}' + + +def update_cargo_package_version(manifest_path: Path, release_version: str) -> None: + lines = read_text(manifest_path).splitlines(keepends=True) + in_package_section = False + updated = False + + for index, line in enumerate(lines): + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + in_package_section = stripped == "[package]" + elif in_package_section and stripped.startswith("version"): + if line.partition("=")[0].strip() != "version": + continue + lines[index] = replace_version_line(line, release_version) + updated = True + break + + if not updated: + raise RuntimeError(f"could not find [package].version in {manifest_path}") + + write_text(manifest_path, "".join(lines)) + + +def update_lockfile_package_version( + lock_path: Path, package_name: str, release_version: str +) -> None: + lines = read_text(lock_path).splitlines(keepends=True) + package_starts = [ + index for index, line in enumerate(lines) if line.strip() == "[[package]]" + ] + package_starts.append(len(lines)) + matching_version_lines: list[int] = [] + + for start, end in zip(package_starts, package_starts[1:]): + block = lines[start:end] + if any(line.strip().startswith("source =") for line in block): + continue + if f'name = "{package_name}"' not in (line.strip() for line in block): + continue + matching_version_lines.extend( + start + offset + for offset, line in enumerate(block) + if line.partition("=")[0].strip() == "version" + ) + + if len(matching_version_lines) != 1: + raise RuntimeError( + f"expected one local {package_name} package in {lock_path}, " + f"found {len(matching_version_lines)}" + ) + + version_line = matching_version_lines[0] + lines[version_line] = replace_version_line(lines[version_line], release_version) + write_text(lock_path, "".join(lines)) + + +def cargo_package(manifest_path: Path) -> tuple[str, str]: + manifest_path = manifest_path.resolve() + result = subprocess.run( + [ + "cargo", + "metadata", + "--no-deps", + "--format-version", + "1", + "--manifest-path", + str(manifest_path), + ], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + diagnostic = result.stderr.strip() or result.stdout.strip() + raise RuntimeError(f"cargo metadata failed: {diagnostic}") + + try: + metadata = json.loads(result.stdout) + package = next( + package + for package in metadata["packages"] + if Path(package["manifest_path"]).resolve() == manifest_path + ) + package_name = package["name"] + package_version = package["version"] + if not isinstance(package_name, str) or not isinstance(package_version, str): + raise KeyError + return package_name, package_version + except (json.JSONDecodeError, KeyError, StopIteration) as error: + raise RuntimeError( + f"cargo metadata did not contain the package at {manifest_path}" + ) from error + + +def cargo_package_version(manifest_path: Path) -> str: + _package_name, package_version = cargo_package(manifest_path) + return package_version + + +def check_release_version( + release_version: str, manifest_path: Path, update: bool = False +) -> None: + if update: + package_name, _package_version = cargo_package(manifest_path) + update_cargo_package_version(manifest_path, release_version) + update_lockfile_package_version( + manifest_path.with_name("Cargo.lock"), package_name, release_version + ) + + package_version = cargo_package_version(manifest_path) + if package_version != release_version: + raise RuntimeError( + "release version mismatch: " + f"Cargo package version is {package_version}, " + f"but the validated release version is {release_version}" + ) + + print(f"Cargo package version {package_version} matches the release version") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("release_version", help="validated release version") + parser.add_argument( + "--update", + action="store_true", + help="update the Cargo package before verifying it", + ) + parser.add_argument( + "--manifest-path", + type=Path, + default=ROOT / "Cargo.toml", + help="Cargo manifest to inspect (default: repository root Cargo.toml)", + ) + args = parser.parse_args() + + try: + check_release_version(args.release_version, args.manifest_path, args.update) + except (OSError, RuntimeError) as error: + print(f"error: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test_check_release_version.py b/scripts/test_check_release_version.py new file mode 100644 index 00000000..86b1cbec --- /dev/null +++ b/scripts/test_check_release_version.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Tests for check_release_version.py.""" + +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).with_name("check_release_version.py") + + +class CheckReleaseVersionTests(unittest.TestCase): + def run_check( + self, + package_version: str, + release_version: str, + update: bool = False, + line_ending: str = "\n", + ) -> tuple[subprocess.CompletedProcess[str], str, str]: + with tempfile.TemporaryDirectory() as directory: + manifest_path = Path(directory) / "Cargo.toml" + source_path = Path(directory) / "src" + source_path.mkdir() + (source_path / "lib.rs").write_text("", encoding="utf-8") + manifest = ( + "[package]\n" + 'name = "version-check-fixture"\n' + f'version = "{package_version}"\n' + 'edition = "2021"\n' + ) + manifest_path.write_bytes(manifest.replace("\n", line_ending).encode()) + lockfile = ( + "# This file is automatically @generated by Cargo.\n" + "# It is not intended for manual editing.\n" + "version = 4\n\n" + "[[package]]\n" + 'name = "version-check-fixture"\n' + f'version = "{package_version}"\n' + ) + (Path(directory) / "Cargo.lock").write_bytes( + lockfile.replace("\n", line_ending).encode() + ) + command = [ + sys.executable, + str(SCRIPT), + release_version, + "--manifest-path", + str(manifest_path), + ] + if update: + command.append("--update") + result = subprocess.run( + command, + check=False, + capture_output=True, + text=True, + ) + lock_path = Path(directory) / "Cargo.lock" + lock_text = ( + lock_path.read_bytes().decode() if lock_path.exists() else "" + ) + return result, manifest_path.read_bytes().decode(), lock_text + + def test_matching_prerelease_succeeds(self) -> None: + result, _manifest, _lock = self.run_check("0.21.0-rc4", "0.21.0-rc4") + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn( + "Cargo package version 0.21.0-rc4 matches the release version", + result.stdout, + ) + + def test_mismatched_version_has_clear_diagnostic(self) -> None: + result, _manifest, _lock = self.run_check("0.21.0-rc4", "0.21.0") + + self.assertEqual(result.returncode, 1) + self.assertIn( + "error: release version mismatch: Cargo package version is 0.21.0-rc4, " + "but the validated release version is 0.21.0", + result.stderr, + ) + + def test_update_synchronizes_manifest_and_lockfile(self) -> None: + result, manifest, lockfile = self.run_check( + "0.21.0-rc4", "0.21.0-rc5", update=True, line_ending="\r\n" + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('version = "0.21.0-rc5"', manifest) + self.assertIn('version = "0.21.0-rc5"', lockfile) + self.assertNotIn("\r\r\n", manifest) + self.assertNotIn("\r\r\n", lockfile) + + +if __name__ == "__main__": + unittest.main()