From 8bcff3d9f9778e601bd2177619023a4b2fab5ba8 Mon Sep 17 00:00:00 2001 From: Yehor Smoliakov Date: Thu, 8 Oct 2026 18:27:43 +0300 Subject: [PATCH] ci: add version PRs and release-triggered crates.io publishing --- .github/scripts/test_version.py | 36 ++++++++++++ .github/scripts/version.py | 77 ++++++++++++++++++++++++++ .github/workflows/{rust.yml => ci.yml} | 7 ++- .github/workflows/publish-crates.yml | 51 +++++++++++++++++ .github/workflows/update-version.yml | 74 +++++++++++++++++++++++++ README.md | 5 +- docs/development.md | 46 ++++++++++++++- 7 files changed, 290 insertions(+), 6 deletions(-) create mode 100644 .github/scripts/test_version.py create mode 100644 .github/scripts/version.py rename .github/workflows/{rust.yml => ci.yml} (94%) create mode 100644 .github/workflows/publish-crates.yml create mode 100644 .github/workflows/update-version.yml diff --git a/.github/scripts/test_version.py b/.github/scripts/test_version.py new file mode 100644 index 0000000..7ce9a67 --- /dev/null +++ b/.github/scripts/test_version.py @@ -0,0 +1,36 @@ +import unittest +from version import components, prepare, validate_tag + +MANIFEST = '[package]\nname = "rustedbytes-tl"\nversion = "0.2.0" # retained\n\n[dependencies]\nother = { version = "0.2.0" }\n' + + +class VersionTests(unittest.TestCase): + def test_bumps_preserve_dependencies_and_comments(self): + for bump, expected in (("patch", "0.2.1"), ("minor", "0.3.0"), ("major", "1.0.0")): + with self.subTest(bump=bump): + text, version = prepare(MANIFEST, bump, "") + self.assertEqual(version, expected) + self.assertEqual(text, MANIFEST.replace('version = "0.2.0" #', f'version = "{expected}" #')) + + def test_explicit_version_overrides_component(self): + self.assertEqual(prepare(MANIFEST, "patch", "1.2.3")[1], "1.2.3") + + def test_rejects_noncanonical_versions(self): + for value in ("01.2.3", "v1.2.3", "1.2", "1.2.3-rc.1", "1.2.3+build", "1.2.3\n", "$(id)"): + with self.subTest(value=value), self.assertRaises(ValueError): + components(value) + + def test_rejects_reused_or_decreased_versions(self): + for value in ("0.2.0", "0.1.99"): + with self.subTest(value=value), self.assertRaises(ValueError): + prepare(MANIFEST, "patch", value) + + def test_release_tag_must_match_exactly(self): + self.assertEqual(validate_tag(MANIFEST, "v0.2.0"), "0.2.0") + for tag in ("0.2.0", "v0.3.0", "v0.2.0-rc.1"): + with self.subTest(tag=tag), self.assertRaises(ValueError): + validate_tag(MANIFEST, tag) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/version.py b/.github/scripts/version.py new file mode 100644 index 0000000..a638912 --- /dev/null +++ b/.github/scripts/version.py @@ -0,0 +1,77 @@ +"""Validate stable release versions and update the root package without dependencies.""" +import argparse +import os +from pathlib import Path +import re +import tomllib + +SEMVER = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\Z") + + +def components(value): + match = SEMVER.fullmatch(value) + if not match: + raise ValueError("Expected MAJOR.MINOR.PATCH without v, prerelease or build metadata") + return tuple(int(part) for part in match.groups()) + + +def prepare(text, bump, explicit): + old = tomllib.loads(text)["package"]["version"] + major, minor, patch = components(old) + if explicit: + new = explicit + elif bump == "major": + new = f"{major + 1}.0.0" + elif bump == "minor": + new = f"{major}.{minor + 1}.0" + elif bump == "patch": + new = f"{major}.{minor}.{patch + 1}" + else: + raise ValueError("Unknown version component") + if components(new) <= components(old): + raise ValueError("New version must be greater than the current version") + # Change only the root [package] version; preserve formatting and dependency versions. + section = re.search(r"(?ms)^\[package\][ \t]*\n.*?(?=^\[|\Z)", text) + if section is None: + raise ValueError("Missing [package] section") + updated, count = re.subn( + r'(?m)^(version\s*=\s*)"[^"]+"', + lambda match: f'{match[1]}"{new}"', section[0], + ) + if count != 1: + raise ValueError("Expected one quoted package version") + return text[:section.start()] + updated + text[section.end():], new + + +def validate_tag(text, tag): + version = tomllib.loads(text)["package"]["version"] + components(version) + if tag != f"v{version}": + raise ValueError(f"Release tag {tag!r} must equal v{version}") + return version + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--bump", choices=("patch", "minor", "major"), default="patch") + parser.add_argument("--version", default="") + parser.add_argument("--check-tag") + args = parser.parse_args() + manifest = Path("Cargo.toml") + text = manifest.read_text() + try: + if args.check_tag is not None: + version = validate_tag(text, args.check_tag) + else: + updated, version = prepare(text, args.bump, args.version) + manifest.write_text(updated) + except (ValueError, KeyError) as error: + parser.error(str(error)) + if output := os.environ.get("GITHUB_OUTPUT"): + with open(output, "a") as stream: + stream.write(f"version={version}\n") + print(version) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/rust.yml b/.github/workflows/ci.yml similarity index 94% rename from .github/workflows/rust.yml rename to .github/workflows/ci.yml index d5e1280..ae41986 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,7 @@ name: Rust checks on: pull_request: paths: + - '.github/scripts/**' - 'src/**' - 'tests/**' - 'benches/**' @@ -14,10 +15,11 @@ on: - 'docs/**' - '.cargo/**' - 'rust-toolchain*' - - '.github/workflows/rust.yml' + - '.github/workflows/*.yml' push: branches: [master] paths: + - '.github/scripts/**' - 'src/**' - 'tests/**' - 'benches/**' @@ -29,7 +31,7 @@ on: - 'docs/**' - '.cargo/**' - 'rust-toolchain*' - - '.github/workflows/rust.yml' + - '.github/workflows/*.yml' workflow_dispatch: inputs: miri: @@ -60,6 +62,7 @@ jobs: with: toolchain: '1.99.0' components: rustfmt, clippy + - run: python3 -m unittest discover -s .github/scripts -p "test_*.py" - run: cargo fmt --check - name: Tests and strict Clippy for each stable feature configuration shell: bash diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml new file mode 100644 index 0000000..54ef0ba --- /dev/null +++ b/.github/workflows/publish-crates.yml @@ -0,0 +1,51 @@ +name: Publish crates.io + +on: + release: + types: [published] + +permissions: + contents: read + +concurrency: + group: crates-io-${{ github.event.release.tag_name }} + cancel-in-progress: false + +jobs: + publish: + if: github.event.release.prerelease == false + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: crates-io + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.release.tag_name }} + fetch-depth: 0 + persist-credentials: false + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: '1.99.0' + - name: Validate release tag and source + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + python3 .github/scripts/version.py --check-tag "$RELEASE_TAG" + git merge-base --is-ancestor HEAD origin/master + - name: Verify release package and stable feature configurations + run: | + cargo test --no-default-features + cargo test --features std + cargo test --features entities + cargo publish --dry-run --no-default-features + cargo publish --dry-run --features entities + - name: Authenticate to crates.io + id: auth + uses: rust-lang/crates-io-auth-action@v1 + - name: Publish verified version + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} + run: cargo publish --no-default-features diff --git a/.github/workflows/update-version.yml b/.github/workflows/update-version.yml new file mode 100644 index 0000000..f5de27b --- /dev/null +++ b/.github/workflows/update-version.yml @@ -0,0 +1,74 @@ +name: Update version + +on: + workflow_dispatch: + inputs: + bump: + description: SemVer component (ignored when version is supplied) + type: choice + options: [patch, minor, major] + default: patch + version: + description: Optional explicit MAJOR.MINOR.PATCH version, without v + type: string + required: false + +permissions: + contents: write + pull-requests: write + actions: write + +concurrency: + group: update-version + cancel-in-progress: false + +jobs: + update-version: + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + with: + ref: master + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: '1.99.0' + - name: Prepare version + id: version + env: + VERSION_BUMP: ${{ inputs.bump }} + EXPLICIT_VERSION: ${{ inputs.version }} + run: python3 .github/scripts/version.py --bump "$VERSION_BUMP" --version "$EXPLICIT_VERSION" + - name: Verify version and package + run: | + cargo metadata --no-deps --format-version 1 > /dev/null + cargo package --allow-dirty --no-default-features + git diff --check + - name: Create version PR and dispatch CI + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + NEW_VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + branch="chore/version-$NEW_VERSION" + git ls-remote --exit-code origin "refs/heads/$branch" "refs/tags/v$NEW_VERSION" > /tmp/version-refs || status=$? + if test -s /tmp/version-refs; then + echo "Version branch or tag already exists: $NEW_VERSION" >&2 + exit 1 + fi + if test "${status:-0}" -ne 2 && test "${status:-0}" -ne 0; then + echo "Unable to check remote refs" >&2 + exit 1 + fi + git switch -c "$branch" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Cargo.toml + git commit -m "chore: bump version to $NEW_VERSION" + git push origin "$branch" + gh pr create --base master --head "$branch" \ + --title "chore: bump version to $NEW_VERSION" \ + --body "Updates Cargo.toml; the library does not track Cargo.lock. Package verification passed. The Rust checks workflow is explicitly dispatched for this branch. Merge this PR before tagging and publishing the GitHub Release." + gh workflow run ci.yml --ref "$branch" diff --git a/README.md b/README.md index 57343af..2180182 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,9 @@ A lightweight HTML parser written in Rust. It borrows input bytes and offers allocation-free bounded parsing by default, or heap-backed DOMs and CSS queries with `std`. The Rust crate is imported as **`tl`**. -This README describes the current Git source. The manifest still says `0.2.0`, -but this branch contains breaking changes intended for the next `0.3.x` release. +This README describes the current Git source. The source contains breaking changes since the published `0.2.0` API; +the first release containing them must be `0.3.x` or later. +See `Cargo.toml` for the version selected for the next release. The published [0.2.0 API reference](https://docs.rs/rustedbytes-tl/0.2.0/tl/) does not describe all APIs below. Generate current API docs with `cargo doc --features entities --open`. diff --git a/docs/development.md b/docs/development.md index 965f147..0d19290 100644 --- a/docs/development.md +++ b/docs/development.md @@ -82,5 +82,47 @@ resolution, features and flags; avoid concurrent builds during timing runs. | Tree differs from a browser | Review HTML5 recovery limits in the compatibility notes | | Capacity error | Increase the relevant bounded budget or use heap-backed `std` parsing | -Docs-only paths trigger CI too. This workflow checks code and docs; it does not -publish a crate, bump the version or create a release. +Docs-only paths trigger CI too. `ci.yml` checks code and docs. Version changes and publishing use the separate +workflows described below. + + +## Version changes and crates.io publishing + +1. Run **Update version** (`update-version.yml`) from `master`. Choose `patch`, + `minor` or `major`, or enter an explicit `MAJOR.MINOR.PATCH`. The workflow + rejects malformed, unchanged or decreased versions and existing version + branches/tags. For the breaking changes since 0.2.0, choose `minor` (0.3.0) + or a later compatible release version, rather than a 0.2.x patch. +2. Review the generated `chore/version-*` PR and its explicitly dispatched + `ci.yml` run, then merge it. Only `Cargo.toml` is committed: this library + does not track `Cargo.lock`. Enable **Allow GitHub Actions to create and + approve pull requests** in repository Actions settings. Branch protection + may require additional checks on the PR itself; a manually dispatched run + does not necessarily satisfy every required-check policy. +3. Create a tag named exactly `v` on the merged commit and + publish a GitHub Release for it. Tag creation alone does not publish the + crate. Publish the Release manually or with credentials that trigger + Actions; Release events generated with `GITHUB_TOKEN` do not trigger this + workflow. +4. **Publish crates.io** (`publish-crates.yml`) runs for a published stable + Release. It checks the tag against `Cargo.toml`, requires its commit to be + reachable from `master`, runs default/std/entities tests and verifies the + packaged crate with default and entities features before uploading. + Prereleases are skipped. Retrying cannot overwrite an existing crates.io + version; Cargo reports an error if that version is already published. + +Configure [crates.io Trusted Publishing](https://crates.io/docs/trusted-publishing) +for the existing `rustedbytes-tl` crate before the first automated publish: + +| Setting | Value | +| --- | --- | +| GitHub owner | `RustedBytes` | +| Repository | `rustedbytes-tl` | +| Workflow filename | `publish-crates.yml` | +| GitHub environment | `crates-io` | + +Create the matching `crates-io` environment in repository settings. The publish +job requests a short-lived token through `rust-lang/crates-io-auth-action`; +no long-lived `CARGO_REGISTRY_TOKEN` secret is required. The action revokes the +token when the job finishes. Restrict the environment to release tags if desired. +These workflows prepare future releases; adding them does not publish a version.