diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0f2f887..3d2b525 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,81 +1,111 @@ - -name: Auto-version +name: Release version tag on: - pull_request: - types: - - closed + push: + tags: + - "v*.*.*" -jobs: - build: - if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref != 'update-cargo-toml' - runs-on: ubuntu-latest +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false - steps: - - name: Checkout repository - uses: actions/checkout@v2 +permissions: + contents: read - - name: Run Build Script - run: | - git config user.name "GitHub Actions Bot" - git config user.email "<>" - - - name: run script - run: | - python scripts/update.py +jobs: + release: + # Pin the package build environment so the generated .deb does not silently + # raise its minimum glibc version when ubuntu-latest advances. + runs-on: ubuntu-22.04 + permissions: + contents: write + steps: + - name: Check out tagged source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 - - name: Create Pull Request - uses: peter-evans/create-pull-request@v3 + - name: Set up pinned Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: - token: ${{ secrets.GITHUB_TOKEN }} - commit-message: "Updated code" - title: "Update Cargo.toml" - body: "This pull request updates the Cargo.toml file." - branch: update-cargo-toml - reviewers: tapishr + python-version: "3.11" - - name: Build Rust CLI - run: | - cd vibi-dpu - cargo build --release - - - name: Get version from Cargo.toml - id: get_version - run: | - cd vibi-dpu - echo "::set-output name=version::$(grep -Po '(?<=version = ")[\d.]+(?=")' Cargo.toml)" - - - name: Create Release - id: create_release - uses: actions/create-release@v1 + - name: Verify tag, source, manifest, lockfile, and release state + id: version env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ steps.get_version.outputs.version }} - release_name: Release ${{ steps.get_version.outputs.version }} - draft: false - prerelease: false - - - name: cargo deb + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} run: | - cargo install cargo-deb - - - name: build binary + set -euo pipefail + test "$(git rev-parse "${TAG}^{commit}")" = "$GITHUB_SHA" + git merge-base --is-ancestor "$GITHUB_SHA" origin/main + + release_state="absent" + error_file="$(mktemp)" + if is_draft="$(gh release view "$TAG" --json isDraft --jq .isDraft 2>"$error_file")"; then + if [[ "$is_draft" != "true" ]]; then + echo "A published release already exists for $TAG" >&2 + exit 1 + fi + release_state="draft" + elif ! grep -Fxq "release not found" "$error_file"; then + cat "$error_file" >&2 + exit 1 + fi + + version="$(python scripts/verify_release.py --tag "$TAG")" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "release_state=$release_state" >> "$GITHUB_OUTPUT" + + - name: Validate package run: | - cd vibi-dpu - cargo deb - - - name: Rename Debian Package - run: mv ./vibi-dpu/target/debian/*.deb ./vibi-dpu/target/debian/vibi-dpu.deb - - - name: Upload .deb Package - id: upload_deb - uses: actions/upload-release-asset@v1 + set -euo pipefail + cargo metadata --manifest-path vibi-dpu/Cargo.toml --locked --no-deps --format-version 1 >/dev/null + cargo test --manifest-path vibi-dpu/Cargo.toml --locked + cargo build --manifest-path vibi-dpu/Cargo.toml --release --locked + git diff --exit-code + + - name: Install pinned cargo-deb + run: cargo install cargo-deb --version 3.7.0 --locked + + - name: Build, inspect, and checksum Debian package + id: package env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./vibi-dpu/target/debian/vibi-dpu.deb - asset_name: vibi-dpu.deb - asset_content_type: application/octet-stream + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + rm -rf vibi-dpu/target/debian + cargo deb --manifest-path vibi-dpu/Cargo.toml --locked + mapfile -t packages < <(find vibi-dpu/target/debian -maxdepth 1 -type f -name '*.deb') + test "${#packages[@]}" -eq 1 + package="${packages[0]}" + test "$(dpkg-deb --field "$package" Package)" = "vibi-dpu" + test "$(dpkg-deb --field "$package" Version)" = "${VERSION}-1" + test "$(dpkg-deb --field "$package" Architecture)" = "amd64" + dpkg-deb --contents "$package" | grep -Eq '[[:space:]]\./usr/bin/vibi-dpu$' + mkdir -p dist + asset="dist/vibi-dpu_${VERSION}_amd64.deb" + cp "$package" "$asset" + sha256sum "$asset" > "${asset}.sha256" + sha256sum --check "${asset}.sha256" + echo "asset=$asset" >> "$GITHUB_OUTPUT" + - name: Create or resume draft with assets, then publish + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + ASSET: ${{ steps.package.outputs.asset }} + RELEASE_STATE: ${{ steps.version.outputs.release_state }} + run: | + set -euo pipefail + if [[ "$RELEASE_STATE" = "draft" ]]; then + gh release upload "$TAG" "$ASSET" "${ASSET}.sha256" --clobber + else + gh release create "$TAG" \ + --verify-tag \ + --draft \ + --generate-notes \ + --title "vibi-dpu $TAG" \ + "$ASSET" "${ASSET}.sha256" + fi + gh release edit "$TAG" --title "vibi-dpu $TAG" --draft=false diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 0a53fc4..6256a86 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -2,27 +2,30 @@ name: Rust on: push: - branches: [ "main" ] + branches: [main] pull_request: - branches: [ "main" ] + branches: [main] + +permissions: + contents: read env: CARGO_TERM_COLOR: always jobs: build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Test version update script - run: python3 -m unittest scripts/test_update.py - - name: Build - run: | - cd vibi-dpu - cargo build --verbose - - name: Run tests - run: | - cd vibi-dpu - cargo test --verbose + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Set up pinned Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.11" + - name: Test release automation scripts + run: python -m unittest scripts/test_update.py scripts/test_verify_release.py + - name: Check locked metadata + run: cargo metadata --manifest-path vibi-dpu/Cargo.toml --locked --no-deps --format-version 1 >/dev/null + - name: Build + run: cargo build --manifest-path vibi-dpu/Cargo.toml --locked --verbose + - name: Run tests + run: cargo test --manifest-path vibi-dpu/Cargo.toml --locked --verbose diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml new file mode 100644 index 0000000..960468e --- /dev/null +++ b/.github/workflows/version-bump.yml @@ -0,0 +1,82 @@ +name: Propose version bump + +on: + pull_request_target: + types: [closed] + +concurrency: + group: version-bump-main + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + +jobs: + propose: + if: >- + github.event.pull_request.merged == true && + github.event.pull_request.base.ref == 'main' && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.head.ref != 'automation/version-bump' + runs-on: ubuntu-latest + steps: + - name: Check out merged main + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: main + fetch-depth: 0 + + - name: Set up pinned Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.11" + + - name: Update manifest and lockfile + run: python scripts/update.py + + - name: Validate release automation and proposed package metadata + run: | + python -m unittest scripts/test_update.py scripts/test_verify_release.py + cargo metadata --manifest-path vibi-dpu/Cargo.toml --locked --no-deps --format-version 1 >/dev/null + + - name: Open or update version-bump pull request + env: + GH_TOKEN: ${{ github.token }} + MERGED_PR: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + branch="automation/version-bump" + version="$(python -c 'import tomllib; print(tomllib.load(open("vibi-dpu/Cargo.toml", "rb"))["package"]["version"])')" + git config user.name "nia-sg-bot" + git config user.email "nia-sg-bot@users.noreply.github.com" + git fetch origin "$branch" || true + previous="$(git rev-parse "origin/${branch}" 2>/dev/null || true)" + git switch -C "$branch" + git add vibi-dpu/Cargo.toml vibi-dpu/Cargo.lock + git commit -m "chore: propose vibi-dpu ${version}" + if [[ -n "$previous" ]]; then + git push --force-with-lease="${branch}:${previous}" origin "$branch" + else + git push origin "$branch" + fi + + run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + body_file="$(mktemp)" + cat >"$body_file" < str: + package = re.search(r"(?ms)^\[package\]\s*(.*?)(?=^\[|\Z)", document) + if package is None: + raise ValueError("Cargo.toml has no [package] table") + version = re.search(r'^version\s*=\s*"([^"]+)"\s*$', package.group(1), re.MULTILINE) + if version is None: + raise ValueError("Cargo.toml [package] has no version") + return version.group(1) + + +def next_patch(version: str) -> str: + match = SEMVER.fullmatch(version) + if match is None: + raise ValueError(f"expected a stable MAJOR.MINOR.PATCH version, got {version!r}") + major, minor, patch = (int(part) for part in match.groups()) + return f"{major}.{minor}.{patch + 1}" + + +def next_untagged_patch(version: str, tags: set[str]) -> str: + candidate = next_patch(version) + while f"v{candidate}" in tags or candidate in tags: + candidate = next_patch(candidate) + return candidate + + +def repository_tags() -> set[str]: + result = subprocess.run( + ["git", "tag", "--list"], cwd=ROOT, check=True, text=True, capture_output=True + ) + return set(result.stdout.splitlines()) + + +def replace_manifest_version(document: str, old: str, new: str) -> str: + package = re.search(r"(?ms)^\[package\]\s*(.*?)(?=^\[|\Z)", document) + assert package is not None + updated, count = re.subn( + rf'(?m)^version\s*=\s*"{re.escape(old)}"\s*$', + f'version = "{new}"', + package.group(0), + count=1, + ) + if count != 1: + raise ValueError("could not update Cargo.toml [package] version") + return document[: package.start()] + updated + document[package.end() :] + + +def replace_lock_version(document: str, old: str, new: str) -> str: + package = re.search( + r'(?ms)^\[\[package\]\]\s*\nname = "vibi-dpu"\s*\nversion = "([^"]+)"(.*?)(?=^\[\[package\]\]|\Z)', + document, + ) + if package is None: + raise ValueError("Cargo.lock has no vibi-dpu package entry") + if package.group(1) != old: + raise ValueError( + f"Cargo.toml version {old} does not match Cargo.lock vibi-dpu version {package.group(1)}" + ) + updated = package.group(0).replace(f'version = "{old}"', f'version = "{new}"', 1) + return document[: package.start()] + updated + document[package.end() :] + + +def main(tags: set[str] | None = None) -> None: + manifest = MANIFEST.read_text() + lockfile = LOCKFILE.read_text() + old = package_version(manifest) + new = next_untagged_patch(old, repository_tags() if tags is None else tags) + + # Validate both documents before writing either one. + updated_manifest = replace_manifest_version(manifest, old, new) + updated_lockfile = replace_lock_version(lockfile, old, new) + MANIFEST.write_text(updated_manifest) + LOCKFILE.write_text(updated_lockfile) + print(f"Updated vibi-dpu from {old} to {new}") + + +if __name__ == "__main__": + main() diff --git a/scripts/verify_release.py b/scripts/verify_release.py new file mode 100644 index 0000000..a32d13c --- /dev/null +++ b/scripts/verify_release.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Validate that a release tag matches Cargo.toml and Cargo.lock.""" + +import argparse +from pathlib import Path +import re +import tomllib + +SEMVER_TAG = re.compile(r"^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") + + +def verify(tag: str, manifest_path: Path, lockfile_path: Path) -> str: + match = SEMVER_TAG.fullmatch(tag) + if match is None: + raise ValueError(f"release tag must be vMAJOR.MINOR.PATCH, got {tag!r}") + tag_version = ".".join(match.groups()) + + with manifest_path.open("rb") as stream: + manifest_version = tomllib.load(stream)["package"]["version"] + with lockfile_path.open("rb") as stream: + lockfile = tomllib.load(stream) + + locked_versions = [ + package["version"] + for package in lockfile.get("package", []) + if package.get("name") == "vibi-dpu" + ] + if locked_versions != [manifest_version]: + raise ValueError( + "Cargo.lock must contain exactly one vibi-dpu entry matching Cargo.toml; " + f"manifest={manifest_version!r}, lock={locked_versions!r}" + ) + if tag_version != manifest_version: + raise ValueError( + f"tag version {tag_version!r} does not match Cargo.toml {manifest_version!r}" + ) + return manifest_version + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--tag", required=True) + parser.add_argument( + "--manifest", type=Path, default=Path("vibi-dpu/Cargo.toml") + ) + parser.add_argument( + "--lockfile", type=Path, default=Path("vibi-dpu/Cargo.lock") + ) + args = parser.parse_args() + print(verify(args.tag, args.manifest, args.lockfile)) + + +if __name__ == "__main__": + main() diff --git a/vibi-dpu/Cargo.toml b/vibi-dpu/Cargo.toml index 0a1cd05..ac0ce78 100644 --- a/vibi-dpu/Cargo.toml +++ b/vibi-dpu/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" authors = ["Tapish Rathore "] license = "GPL-3.0-or-later" description = "Vibinex Data Processing Unit for analysing source code" -readme = "README.md" +readme = "../README.md" homepage = "https://vibinex.com" repository = "https://github.com/vibinex/vibi-dpu" keywords = ["pullrequest", "privacy", "git"]