diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d83ab0d..fab14c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,7 +65,9 @@ jobs: - name: Checkout Pexrc uses: actions/checkout@v6 - name: Build pexrc binary for all targets. - run: cargo run -p package -- --profile release -o dist ${{ matrix.targets }} + run: cargo run -p package -- --color always --profile release -o dist ${{ matrix.targets }} + - name: Generate Release Hashes Manifest. + run: uv run dev-cmd generate-release-hashes -- -i dist tests: name: "${{ matrix.name }} tests" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..778b733 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,142 @@ +name: Release +on: + push: + tags: + - v[0-9]+.[0-9]+.[0-9]+ + workflow_dispatch: + inputs: + tag: + description: The tag to manually run a deploy for. + required: true +defaults: + run: + shell: bash +env: + PEXRC_INSTALL_TOOLS: 1 + +jobs: + setup: + name: Check GitHub Organization + if: github.repository_owner == 'pex-tool' + runs-on: ubuntu-slim + steps: + - name: Noop + if: false + run: echo "This is a dummy step that will never run." + + cross-build: + name: "Cross-build pexrc" + needs: setup + runs-on: ubuntu-24.04-arm + # N.B.: We break these up just to save wall time; they all can be built on 1 machine in ~40 + # minutes; this gets us to a ~20 minute long-pole. + strategy: + matrix: + include: + - artifact-name: pexrc-shard1 + targets: >- + --target aarch64-unknown-linux-gnu + --target aarch64-unknown-linux-musl + - artifact-name: pexrc-shard2 + targets: >- + --target armv7-unknown-linux-gnueabihf + --target powerpc64le-unknown-linux-gnu + - artifact-name: pexrc-shard3 + targets: >- + --target riscv64gc-unknown-linux-gnu + --target s390x-unknown-linux-gnu + - artifact-name: pexrc-shard4 + targets: >- + --target x86_64-unknown-linux-gnu + --target x86_64-unknown-linux-musl + - artifact-name: pexrc-shard5 + targets: >- + --target aarch64-apple-darwin + --target x86_64-apple-darwin + - artifact-name: pexrc-shard6 + targets: >- + --target aarch64-pc-windows-gnullvm + --target x86_64-pc-windows-gnu + steps: + - name: Checkout Pexrc + uses: actions/checkout@v6 + - name: Build pexrc binary for all targets. + run: cargo run -p package -- --color always -o dist --profile release ${{ matrix.targets }} + - name: Upload pexrc artifacts + uses: actions/upload-artifact@v7 + with: + name: ${{ matrix.artifact-name }} + path: dist/pexrc* + + determine-tag: + name: Determine the release tag to operate against. + needs: setup + runs-on: ubuntu-slim + outputs: + release-tag: ${{ steps.determine-tag.outputs.release-tag }} + release-version: ${{ steps.determine-tag.outputs.release-version }} + steps: + - name: Determine Tag + id: determine-tag + run: | + if [[ -n "${{ github.event.inputs.tag }}" ]]; then + RELEASE_TAG=${{ github.event.inputs.tag }} + else + RELEASE_TAG=${GITHUB_REF#refs/tags/} + fi + if [[ "${RELEASE_TAG}" =~ ^v[0-9]+.[0-9]+.[0-9]+$ ]]; then + echo "release-tag=${RELEASE_TAG}" >> $GITHUB_OUTPUT + echo "release-version=${RELEASE_TAG#v}" >> $GITHUB_OUTPUT + else + echo "::error::Release tag '${RELEASE_TAG}' must match 'v\d+.\d+.\d+'." + exit 1 + fi + + github-release: + name: "Create Github Release" + needs: + - cross-build + - determine-tag + runs-on: ubuntu-24.04-arm + environment: Release + permissions: + id-token: write + attestations: write + artifact-metadata: write + contents: write + discussions: write + steps: + - name: Checkout Pexrc + uses: actions/checkout@v6 + - uses: actions/download-artifact@v7 + with: + merge-multiple: true + - name: Generate Release Hashes Manifest. + run: uv run dev-cmd generate-release-hashes -- -i dist -o dist/hashes.md + - name: Generate pexrc ${{ needs.determine-tag.outputs.release-tag }} artifact attestations + uses: actions/attest@v4 + with: + subject-path: dist/pexrc* + - name: Prepare Changelog + id: prepare-changelog + uses: a-scie/actions/changelog@v1.6 + with: + changelog-file: ${{ github.workspace }}/CHANGES.md + version: ${{ needs.determine-tag.outputs.release-version }} + - name: Append Hashes to Changelog + run: | + changelog_tmp="$(mktemp)" + cat "${{ steps.prepare-changelog.outputs.changelog-file }}" <(echo '***') dist/hashes.md \ + > "${changelog_tmp}" + mv "${changelog_tmp}" "${{ steps.prepare-changelog.outputs.changelog-file }}" + - name: Create ${{ needs.determine-tag.outputs.release-tag }} Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.determine-tag.outputs.release-tag }} + name: pexrc ${{ needs.determine-tag.outputs.release-version }} + body_path: ${{ steps.prepare-changelog.outputs.changelog-file }} + draft: false + prerelease: false + files: dist/pexrc* + fail_on_unmatched_files: true + discussion_category_name: Announcements \ No newline at end of file diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..c0bdb64 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,6 @@ +# Release Notes + +## 0.1.0 + +Initial release. + diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..7487959 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,47 @@ +# Release Process + +## Preparation + +### Version Bump and Changelog + +1. Bump the version in [`Cargo.toml`](Cargo.toml). +2. Run `cargo run -p package` to update [`Cargo.lock`](Cargo.lock) with the new version + and as a sanity check on the state of the project. +3. Update [`CHANGES.md`](CHANGES.md) with any changes that are likely to be useful to consumers. +4. Open a PR with these changes and land it on https://github.com/pex-tool/pexrc main. + +## Release + +### Push Release Tag + +Sync a local branch with https://github.com/pex-tool/pexrc main and confirm it has the version bump +and changelog update as the tip commit: + +```console +:; git log --stat -1 HEAD +commit 4fc958e47826392f9db1f55d227b047e7c701e32 (HEAD -> release/setup) +Author: John Sirois +Date: Thu Mar 26 08:01:21 2026 -0700 + + Prepare the 0.1.0 release. + + .github/workflows/ci.yml | 2 +- + .github/workflows/release.yml | 140 ++++++++++++++++++++++++++++++++++++++++++++++++++ + CHANGES.md | 6 +++ + RELEASE.md | 44 ++++++++++++++++ + crates/package/src/main.rs | 4 +- + pyproject.toml | 16 ++++-- + scripts/generate-release-hashes.py | 79 ++++++++++++++++++++++++++++ + 7 files changed, 284 insertions(+), 7 deletions(-) +``` + +Tag the release as `v` and push the tag to https://github.com/pex-tool/pexrc main: +```console +$ git tag --sign -am 'Release 0.1.0' v0.1.0 +$ git push --tags https://github.com/pex-tool/pexrc HEAD:main +``` + +The release is automated and will create a GitHub Release page at +[https://github.com/pex-tool/pexrc/releases/tag/v<version>]( +https://github.com/pex-tool/pexrc/releases) with binaries for Linux, Mac and Windows. + diff --git a/crates/package/src/main.rs b/crates/package/src/main.rs index 7a6eb21..22a9e24 100644 --- a/crates/package/src/main.rs +++ b/crates/package/src/main.rs @@ -218,7 +218,7 @@ fn main() -> anyhow::Result<()> { fs::create_dir_all(&dist_dir)?; let count = built.len(); anstream::println!( - "Built {count} {binaries} to {dist_dir}:", + "Packaging {count} {binaries} in {dist_dir}:", binaries = if count == 1 { "binary" } else { "binaries" }, dist_dir = dist_dir.display() ); @@ -229,7 +229,7 @@ fn main() -> anyhow::Result<()> { fs::remove_file(&dst)?; } let (size, fingerprint) = hash_file(src)?; - platform::link_or_copy(src, &dst)?; + platform::symlink_or_link_or_copy(src, &dst, true)?; fs::write( dst.with_added_extension("sha256"), format!( diff --git a/pyproject.toml b/pyproject.toml index a0fd657..6ac3d9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,9 +31,9 @@ check-fmt = ["ruff", "format", "--diff"] lint = ["ruff", "check", "--fix"] check-lint = ["ruff", "check"] -[tool.dev-cmd.commands.type-check.factors] +[tool.dev-cmd.commands.type-check-pexrc.factors] py = "The Python version to type check in . form; i.e.: 3.13." -[tool.dev-cmd.commands.type-check] +[tool.dev-cmd.commands.type-check-pexrc] args = [ "mypy", "--python-version", "{-py:{markers.python_version}}", @@ -41,6 +41,14 @@ args = [ "python", ] +[tool.dev-cmd.commands.type-check-scripts] +args = ["mypy", "scripts"] + +[tool.dev-cmd.commands.generate-release-hashes] +when = "python_version >= '3.10'" +args = ["scripts/generate-release-hashes.py"] +accepts-extra-args = true + [tool.dev-cmd.commands.test] args = ["python/scripts/run-tests.py"] accepts-extra-args = true @@ -52,14 +60,14 @@ steps = [ "lint", # Parallelizing the type checks is safe (they don't modify files), and it nets a ~3x # speedup over running them all serially. - ["type-check-py3.{8..14}"], + ["type-check-pexrc-py3.{8..14}", "type-check-scripts"], "test", ] [tool.dev-cmd.tasks.ci] description = "Runs all checks used for CI." # None of the CI checks modify files; so they can all be run in parallel which nets a ~1.5x speedup. -steps = [["check-fmt", "check-lint", "type-check", "test"]] +steps = [["check-fmt", "check-lint", "type-check-pexrc", "type-check-scripts", "test"]] [tool.dev-cmd] default = "checks" diff --git a/scripts/generate-release-hashes.py b/scripts/generate-release-hashes.py new file mode 100644 index 0000000..66a4e4a --- /dev/null +++ b/scripts/generate-release-hashes.py @@ -0,0 +1,79 @@ +# Copyright 2026 Pex project contributors. +# SPDX-License-Identifier: Apache-2.0 + +import hashlib +import io +import os.path +import sys +from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Iterator, TextIO + + +class HashVerifyError(Exception): + pass + + +def generate_release_hashes(releases_dir: Path, output: TextIO) -> None: + print("|file|sha256|size|", file=output) + print("|----|------|----|", file=output) + for hash_file in sorted(releases_dir.glob("*.sha256")): + expected_sha256, file_name = hash_file.read_text().split(" ", maxsplit=1) + path = releases_dir / file_name.lstrip("*") + + digest = hashlib.sha256() + with path.open("rb") as fp: + for chunk in iter(lambda: fp.read(io.DEFAULT_BUFFER_SIZE), b""): + digest.update(chunk) + actual_sha256 = digest.hexdigest() + if actual_sha256 != expected_sha256: + raise HashVerifyError( + f"Invalid sha256 hash for {path}:\n" + f"expected: {expected_sha256}\n" + f"found: {actual_sha256}" + ) + + print(f"|{path.name}|{actual_sha256}|{os.path.getsize(path)}|", file=output) + + +@contextmanager +def output(output_file: Path | None = None) -> Iterator[TextIO]: + if output_file: + with output_file.open("w") as fp: + yield fp + else: + yield sys.stdout + + +def main() -> Any: + parser = ArgumentParser( + description="Generate a markdown table or release artifact sizes and hashes", + formatter_class=ArgumentDefaultsHelpFormatter, + ) + parser.add_argument( + "-i", + "--releases-dir", + dest="releases_dir", + default="dist", + type=Path, + help="The directory containing the releases to hash.", + ) + parser.add_argument( + "-o", + "--output-file", + dest="output_file", + default=None, + type=Path, + help="A file path to emit the markdown table to. If not specified, defaults to stdout.", + ) + options = parser.parse_args() + with output(options.output_file) as fp: + generate_release_hashes(options.releases_dir, fp) + + +if __name__ == "__main__": + try: + sys.exit(main()) + except HashVerifyError as e: + sys.exit(str(e))