diff --git a/.github/workflows/release-crates.yml b/.github/workflows/release-crates.yml new file mode 100644 index 0000000..4bbbaf7 --- /dev/null +++ b/.github/workflows/release-crates.yml @@ -0,0 +1,120 @@ +name: Release Rust crates + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + dry_run: + description: "Package and verify the crates without publishing" + required: true + type: boolean + default: true + +permissions: + contents: read + +concurrency: + group: release-crates-${{ github.ref }} + cancel-in-progress: false + +jobs: + package: + name: validate and package crates + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Validate release metadata + env: + RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || '' }} + run: python3 tools/check_crate_release.py + + - name: Set up Rust + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + toolchain: stable + + # Packaging both crates in one command verifies the facade against the + # just-packaged core, so a release that changes both is checked before + # anything reaches crates.io. + - name: Package and verify crates + run: cargo package --locked -p rustwright-core -p rustwright + + - name: Upload crate packages + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: crates-package + path: target/package/*.crate + if-no-files-found: error + + publish: + name: publish to crates.io + needs: package + if: >- + ${{ + github.repository == 'Skyvern-AI/rustwright' && + github.ref_type == 'tag' && + ( + github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && !inputs.dry_run) + ) + }} + runs-on: ubuntu-latest + environment: + name: crates-io + url: https://crates.io/crates/rustwright + # Trusted Publishing (OIDC): exchange the workflow's GitHub identity for a + # short-lived crates.io token — no long-lived CARGO_REGISTRY_TOKEN secret. + permissions: + id-token: write + contents: read + + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # The rustwright-core verify build compiles pyo3, which needs a Python + # interpreter. + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Set up Rust + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + toolchain: stable + + - name: crates.io login (Trusted Publishing) + id: crates_io_auth + uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5 + + # The core publishes first because the facade depends on it. A crate + # version that already exists is skipped, so a rerun after a partial + # failure publishes only what is missing. A failed lookup falls through + # to cargo publish, which refuses a version that already exists. + - name: Publish crates + shell: bash + env: + CARGO_REGISTRY_TOKEN: ${{ steps.crates_io_auth.outputs.token }} + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + for crate in rustwright-core rustwright; do + if curl -fs --output /dev/null \ + --user-agent "rustwright-release (https://github.com/Skyvern-AI/rustwright)" \ + "https://crates.io/api/v1/crates/${crate}/${version}"; then + echo "${crate} ${version} is already on crates.io; skipping." + else + cargo publish --locked -p "$crate" + fi + done diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f6fcfe..0114134 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable user-facing changes to Rustwright are documented in this file. ## [Unreleased] +### Added + +- Each release tag now publishes the `rustwright-core` and `rustwright` Rust + crates to crates.io. The `rustwright-core` package now contains only the + engine sources. + ### Breaking - Removed `disable_playwright_compat()`. Compatibility aliases are now a one-way diff --git a/Cargo.toml b/Cargo.toml index e25dd29..65528ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,9 @@ homepage = "https://github.com/Skyvern-AI/rustwright" readme = "README.md" keywords = ["browser", "automation", "cdp", "playwright", "chromium"] categories = ["web-programming"] +# The crates.io package carries only the engine sources, not the bindings, +# tests, or workflows of the whole repository. +include = ["/src/**/*.rs", "/README.md", "/LICENSE"] [workspace] members = ["node", "capi", "rust-native", "agent"] diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 4293d7e..62646c9 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -8,7 +8,8 @@ Use `/version-upgrade prepare` to create and validate a release PR without publishing. Use `/version-upgrade full release` only when the agent should merge the prepared release, run final dry runs on the merged commit, tag it, and publish to both PyPI and npm. The skill is defined in -`.claude/skills/version-upgrade/SKILL.md`. +`.claude/skills/version-upgrade/SKILL.md`. Approve the `nuget`, `rubygems`, +`maven-central`, and `crates-io` deployments yourself. ## One-time setup @@ -24,20 +25,27 @@ commit, tag it, and publish to both PyPI and npm. The skill is defined in - [ ] In GitHub, create an `npm` environment, add a required reviewer, and add an environment secret named `NPM_TOKEN`. - [ ] Supply `NPM_TOKEN`: create an npm granular access token with **Packages and scopes: Read and write**, **All Packages** for the first unscoped publish, and **Bypass 2FA** for non-interactive publishing. Set an expiration and calendar a rotation. After the first release, replace it with a token restricted to `rustwright` if npm permits that scope. - [ ] Confirm the npm account behind `NPM_TOKEN` may create the unscoped public package `rustwright`. Unscoped packages are owned by npm user accounts, not organizations. +- [ ] In GitHub, create a `crates-io` environment and add a required reviewer. +- [ ] In crates.io, open **Settings → Trusted Publishing** for both `rustwright-core` and `rustwright`, add a GitHub publisher, and enter exactly: + - Repository owner: `Skyvern-AI` + - Repository name: `rustwright` + - Workflow filename: `release-crates.yml` + - Environment: `crates-io` +- [ ] Do not create a crates.io API token secret. `.github/workflows/release-crates.yml` uses the `crates-io` GitHub environment and OIDC Trusted Publishing. - [ ] `examples/quickstart.py` is the public smoke test used by the registry-verification step below; confirm it still runs cleanly before tagging. -`rustwright-core` and `rustwright` are already published on crates.io, but no workflow publishes them: there is no crates.io job and no `CARGO_REGISTRY_TOKEN`, so a `v*` tag leaves both crates untouched and they are updated by hand. Publishing the core commits the team to Rust API compatibility, documentation, security advisories, and an additional release channel, so decide deliberately whether to keep that channel current, add a dedicated crates.io workflow, or yank it — but do not let it drift silently behind the tagged releases. - ## Prepare a release -- [ ] Choose one version in SemVer form, for example `0.2.0`. A single `v*` tag drives the PyPI, npm, NuGet, RubyGems, and Maven Central workflows, and each one compares its own packages against that tag, so every version field in the tree has to hold that exact string. -- [ ] Set that exact string in every source-of-truth field. All five tagged +- [ ] Choose one version in SemVer form, for example `0.2.0`. A single `v*` tag drives the PyPI, npm, NuGet, RubyGems, Maven Central, and crates.io workflows, and each one compares its own packages against that tag, so every version field in the tree has to hold that exact string. +- [ ] Set that exact string in every source-of-truth field. All six tagged workflows validate their own packages, so a field missed here fails the release at tag time, after the tag is already pushed: - `pyproject.toml` → `[project].version` - `Cargo.toml` → `[package].version` for `rustwright-core` - `capi/Cargo.toml` → `[package].version` for `rustwright-capi` - - `rust-native/Cargo.toml` → `[package].version` for `rustwright` + - `rust-native/Cargo.toml` → **two** sites: `[package].version` for + `rustwright` and the `version` requirement on its `rustwright_core` + dependency - `node/Cargo.toml` → `[package].version` for `rustwright-node` - `node/package.json` → `version` - `csharp/Rustwright/Rustwright.csproj` → `` @@ -89,6 +97,7 @@ commit, tag it, and publish to both PyPI and npm. The skill is defined in ```bash cargo check --locked cargo test --locked + python3 tools/check_crate_release.py cargo metadata --manifest-path cli/Cargo.toml --locked --format-version 1 > /dev/null cargo metadata --manifest-path mcp/Cargo.toml --locked --format-version 1 > /dev/null (cd node && npm ci --ignore-scripts && npm run build && npm run smoke) @@ -102,18 +111,20 @@ only in its temporary assembled package. ## Dry run - [ ] Merge the version bump and release setup before tagging. -- [ ] Dry-run **all five** workflows against the release commit, not just PyPI and +- [ ] Dry-run **all six** workflows against the release commit, not just PyPI and npm. One tag starts all of them, so a workflow you did not dry-run is a workflow that first runs for real. For each of **Release Python package**, **Release Node.js package**, **Release .NET package**, **Release Ruby - gem**, and **Release Maven package**, open **Actions → *workflow* → Run - workflow**, select the release commit, leave `dry_run` checked, and run it. + gem**, **Release Maven package**, and **Release Rust crates**, open + **Actions → *workflow* → Run workflow**, select the release commit, leave + `dry_run` checked, and run it. - [ ] Confirm each run's `validate release metadata` job passed. That job is what compares the tree against the tag, so a green metadata job is the signal that the version fields are consistent. - [ ] Download and inspect the build artifacts: `pypi-wheel-*`, `pypi-sdist`, - `npm-package`, the NuGet `.nupkg`, the platform gems, and the Maven bundle. - A dispatch with `dry_run: true` never reaches any publish job. + `npm-package`, the NuGet `.nupkg`, the platform gems, the Maven bundle, + and `crates-package`. A dispatch with `dry_run: true` never reaches any + publish job. ## Publish @@ -125,9 +136,9 @@ only in its temporary assembled package. git push origin "v${VERSION}" ``` -- [ ] Approve all five GitHub environment deployments: `pypi`, `npm`, `nuget`, - `rubygems`, and `maven-central`. The one tag starts every workflow; - publishing is also guarded to `Skyvern-AI/rustwright`. +- [ ] Approve all six GitHub environment deployments: `pypi`, `npm`, `nuget`, + `rubygems`, `maven-central`, and `crates-io`. The one tag starts every + workflow; publishing is also guarded to `Skyvern-AI/rustwright`. - [ ] Maven Central publishes are **permanent** — a released coordinate cannot be deleted, only superseded. PyPI, npm, NuGet, RubyGems, and crates.io allow yanking, which hides a version from resolution without removing it. Treat @@ -165,8 +176,14 @@ only in its temporary assembled package. - [ ] Update the prose that describes a binding as unpublished now that it is published — `java/README.md` still frames the Maven coordinates as planned and the artifact as unavailable. -- [ ] `rustwright-core` and `rustwright` on crates.io are **not** published by - any workflow. If this release is meant to reach crates.io, publish both by - hand from the tagged commit, core first, and confirm the versions match - the tag. If it is not, record that decision so the gap is deliberate. +- [ ] Confirm crates.io lists `${VERSION}` for `rustwright-core` and + `rustwright`, then build a new project against the release. Do not + publish either crate by hand from a development checkout; a manual + package can include files that never reached the public repository. + + ```bash + test_dir="$(mktemp -d)" + (cd "$test_dir" && cargo new --quiet verify && cd verify && cargo add "rustwright@${VERSION}" && cargo check) + ``` + - [ ] Record both registry URLs and workflow run URLs on the release tracking issue. diff --git a/rust-native/Cargo.toml b/rust-native/Cargo.toml index f73d38d..6bb6ad8 100644 --- a/rust-native/Cargo.toml +++ b/rust-native/Cargo.toml @@ -2,9 +2,15 @@ name = "rustwright" version = "0.3.0" edition = "2021" -publish = false +description = "Idiomatic native Rust API for the Rustwright Chromium CDP engine (a Rust rewrite of Playwright)." +license = "MIT" +repository = "https://github.com/Skyvern-AI/rustwright" +homepage = "https://github.com/Skyvern-AI/rustwright" +readme = "README.md" +keywords = ["browser", "automation", "cdp", "chromium", "playwright"] +categories = ["web-programming"] [dependencies] -rustwright_core = { package = "rustwright-core", path = "..", default-features = false } +rustwright_core = { package = "rustwright-core", path = "..", version = "0.3.0", default-features = false } serde = { version = "1.0.194", features = ["derive"] } serde_json = "1.0.127" diff --git a/rust-native/README.md b/rust-native/README.md index 9225d49..8c58f54 100644 --- a/rust-native/README.md +++ b/rust-native/README.md @@ -8,15 +8,14 @@ This crate is a thin, ergonomic wrapper over `rustwright-core`. It runs the engi in-process; there is no separate binding library to load. ```rust -use rustwright::{chromium, LaunchOptions}; +use rustwright::{chromium, ActionOptions, GotoOptions, LaunchOptions}; -fn main() -> Result<(), Box> { +fn main() -> rustwright::Result<()> { let browser = chromium().launch(LaunchOptions::default())?; let page = browser.new_page()?; - page.goto("https://example.com", None)?; - println!("{}", page.title(None)?); - browser.close()?; - Ok(()) + page.goto("https://example.com", GotoOptions::default())?; + println!("{}", page.title(ActionOptions::default())?); + browser.close() } ``` diff --git a/tools/check_crate_release.py b/tools/check_crate_release.py new file mode 100644 index 0000000..63b52f1 --- /dev/null +++ b/tools/check_crate_release.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Check that rustwright-core and rustwright can publish to crates.io. + +The test workflow runs this on every pull request, and the release-crates +workflow runs it before it packages the crates. A manifest change that blocks +the crates.io release therefore fails before a release tag exists. Set +RELEASE_TAG (for example v0.4.0) to also compare the crate version with a tag. +""" + +from __future__ import annotations + +import os +import sys +import tomllib +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +CORE_MANIFEST = "Cargo.toml" +FACADE_MANIFEST = "rust-native/Cargo.toml" + + +def main() -> int: + manifests = { + path: tomllib.loads((ROOT / path).read_text(encoding="utf-8")) + for path in (CORE_MANIFEST, FACADE_MANIFEST) + } + errors = [] + for path, manifest in manifests.items(): + package = manifest["package"] + if package.get("publish", True) is not True: + errors.append(f"{path} must not restrict [package].publish") + # crates.io rejects an upload without these fields. + for field in ("description", "license"): + if not package.get(field): + errors.append(f"{path} is missing [package].{field}") + + version = manifests[CORE_MANIFEST]["package"]["version"] + facade = manifests[FACADE_MANIFEST] + facade_versions = { + f"{FACADE_MANIFEST} [package].version": facade["package"]["version"], + f"{FACADE_MANIFEST} rustwright_core requirement": ( + facade.get("dependencies", {}).get("rustwright_core", {}).get("version") + ), + } + for label, found in facade_versions.items(): + if found != version: + errors.append(f"{label} is {found!r}, but {CORE_MANIFEST} is {version!r}") + + tag = os.environ.get("RELEASE_TAG", "") + if tag and tag.removeprefix("v") != version: + errors.append(f"tag {tag!r} does not match crate version {version!r}") + + for error in errors: + print(f"error: {error}", file=sys.stderr) + if errors: + return 1 + print(f"crates.io release metadata is valid for version {version}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())