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
120 changes: 120 additions & 0 deletions .github/workflows/release-crates.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
53 changes: 35 additions & 18 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ Use `/version-upgrade <version> prepare` to create and validate a release PR
without publishing. Use `/version-upgrade <version> 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

Expand All @@ -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` → `<Version>`
Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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.
10 changes: 8 additions & 2 deletions rust-native/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
11 changes: 5 additions & 6 deletions rust-native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn std::error::Error>> {
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()
}
```

Expand Down
63 changes: 63 additions & 0 deletions tools/check_crate_release.py
Original file line number Diff line number Diff line change
@@ -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())
Loading