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
36 changes: 36 additions & 0 deletions .github/scripts/test_version.py
Original file line number Diff line number Diff line change
@@ -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()
77 changes: 77 additions & 0 deletions .github/scripts/version.py
Original file line number Diff line number Diff line change
@@ -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()
7 changes: 5 additions & 2 deletions .github/workflows/rust.yml → .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ name: Rust checks
on:
pull_request:
paths:
- '.github/scripts/**'
- 'src/**'
- 'tests/**'
- 'benches/**'
Expand All @@ -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/**'
Expand All @@ -29,7 +31,7 @@ on:
- 'docs/**'
- '.cargo/**'
- 'rust-toolchain*'
- '.github/workflows/rust.yml'
- '.github/workflows/*.yml'
workflow_dispatch:
inputs:
miri:
Expand Down Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions .github/workflows/publish-crates.yml
Original file line number Diff line number Diff line change
@@ -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
74 changes: 74 additions & 0 deletions .github/workflows/update-version.yml
Original file line number Diff line number Diff line change
@@ -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"
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
46 changes: 44 additions & 2 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<package version>` 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.
Loading