mirror of
https://github.com/servo/servo
synced 2026-04-25 17:15:48 +02:00
release test: Add publish script for crates.io (#44005)
This is a PR to test the changes from #43972. Publishing happens on a protected branch, so we need to merge the changes to the protected branch (crates-io-release-testing) first, in order to test that the script here works. ------------- Add a no dependencies python script (besides cargo metadata), to work around current limitations of `cargo publish --workspace` (which can't resume publishing after an error). We could also use 3rd party solutions like cargo workspaces or cargo release, but that would require auditing their source code, and hence writing a small self-contained script for our use seems preferable. Hopefully `cargo publish --workspace` will become more useful in the future, allowing us to eliminate the script again. Testing: This will be tested after merging to the feature branch. --------- Signed-off-by: Jonathan Schwender <schwenderjonathan@gmail.com>
This commit is contained in:
committed by
GitHub
parent
9de833596c
commit
c40180d000
9
.github/workflows/release.yml
vendored
9
.github/workflows/release.yml
vendored
@@ -72,7 +72,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Publish as latest (success)
|
||||
if: ${{ !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}
|
||||
if: ${{ !contains(needs.*.result, 'failure') && !(contains(needs.*.result, 'cancelled') || cancelled()) }}
|
||||
run: |
|
||||
gh api \
|
||||
--method PATCH \
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
/repos/${RELEASE_REPO}/releases/${RELEASE_ID} \
|
||||
-F draft=false
|
||||
- name: Publish as latest (failure)
|
||||
if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
|
||||
if: ${{ contains(needs.*.result, 'failure') || (contains(needs.*.result, 'cancelled') || cancelled()) }}
|
||||
run: |
|
||||
gh api \
|
||||
--method PATCH \
|
||||
@@ -118,11 +118,14 @@ jobs:
|
||||
- name: Publish to crates.io
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
|
||||
SERVO_CRATES_IO_SLEEP_AFTER_PUBLISH_SECONDS: "30"
|
||||
SERVO_CRATES_IO_VERIFY_PUBLISHED_TIMEOUT_SECONDS: "300"
|
||||
SERVO_CRATES_IO_VERIFY_PUBLISHED_INTERVAL_SECONDS: "10"
|
||||
# Verification requires building, which is incredibly slow and also increases our attack surface.
|
||||
# If we decide for an extra verification, we should add a seperate job before this one, which
|
||||
# does a `dry-run` publish without any elevated permissions.
|
||||
run: |
|
||||
cargo publish --workspace --no-verify
|
||||
python3 etc/ci/publish_crates_io.py --no-verify
|
||||
|
||||
build-win:
|
||||
# This job is only useful when run on upstream servo.
|
||||
|
||||
@@ -398,4 +398,4 @@ egui-winit = { git = "https://github.com/emilk/egui.git", rev = "5d8f393335e0517
|
||||
#
|
||||
# [patch."https://github.com/servo/<repository>"]
|
||||
# <crate> = { path = "/path/to/local/checkout" }
|
||||
#
|
||||
#
|
||||
|
||||
@@ -231,8 +231,6 @@ skip = [
|
||||
# github.com organizations to allow git sources for
|
||||
[sources.allow-org]
|
||||
github = [
|
||||
"servo",
|
||||
|
||||
# Temporarily needed by servoshell: see root Cargo.toml
|
||||
"emilk",
|
||||
]
|
||||
|
||||
226
etc/ci/publish_crates_io.py
Normal file
226
etc/ci/publish_crates_io.py
Normal file
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Copyright 2026 The Servo Project Developers. See the COPYRIGHT
|
||||
# file at the top-level directory of this distribution.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
|
||||
# http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
|
||||
# <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
|
||||
# option. This file may not be copied, modified, or distributed
|
||||
# except according to those terms.
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
from urllib import error, parse, request
|
||||
|
||||
# Allow crates.io team to easily identify us incase this script misbehaves.
|
||||
USER_AGENT = "servo-publish-crates-io/1 (https://github.com/servo/servo)"
|
||||
|
||||
WORKSPACE_ROOT = Path(__file__).resolve().parents[2]
|
||||
SLEEP_AFTER_PUBLISH_SECONDS = int(os.environ.get("SERVO_CRATES_IO_SLEEP_AFTER_PUBLISH_SECONDS", "30"))
|
||||
VERIFY_PUBLISHED_TIMEOUT_SECONDS = int(os.environ.get("SERVO_CRATES_IO_VERIFY_PUBLISHED_TIMEOUT_SECONDS", "300"))
|
||||
VERIFY_PUBLISHED_INTERVAL_SECONDS = int(os.environ.get("SERVO_CRATES_IO_VERIFY_PUBLISHED_INTERVAL_SECONDS", "10"))
|
||||
API_TIMEOUT_SECONDS = int(os.environ.get("SERVO_CRATES_IO_API_TIMEOUT_SECONDS", "30"))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkspacePackage:
|
||||
name: str
|
||||
version: str
|
||||
manifest_path: str
|
||||
dependencies: tuple[str, ...]
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Publish workspace crates to crates.io in dependency order, skipping "
|
||||
"versions that already exist and waiting for index propagation after each publish."
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-verify",
|
||||
action="store_true",
|
||||
help="Pass --no-verify to cargo publish.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Print the resolved publish order without querying crates.io or publishing.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_metadata() -> dict:
|
||||
return json.loads(
|
||||
subprocess.check_output(
|
||||
["cargo", "metadata", "--no-deps", "--format-version", "1"],
|
||||
cwd=WORKSPACE_ROOT,
|
||||
text=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def publishes_to_crates_io(package: dict) -> bool:
|
||||
publish = package.get("publish")
|
||||
# See <https://doc.rust-lang.org/cargo/commands/cargo-metadata.html>
|
||||
# > List of registries to which this package may be published.
|
||||
# > Publishing is unrestricted if null, and forbidden if an empty array.
|
||||
return publish is None or "crates-io" in publish
|
||||
|
||||
|
||||
def collect_packages(metadata: dict) -> dict[str, WorkspacePackage]:
|
||||
local_packages = {package["name"]: package for package in metadata["packages"] if package.get("source") is None}
|
||||
publishable_names = {name for name, package in local_packages.items() if publishes_to_crates_io(package)}
|
||||
|
||||
packages: dict[str, WorkspacePackage] = {}
|
||||
for name in sorted(publishable_names):
|
||||
package = local_packages[name]
|
||||
dependencies = set()
|
||||
blocked_dependencies = set()
|
||||
for dependency in package["dependencies"]:
|
||||
dependency_name = dependency["name"]
|
||||
if dependency.get("kind") == "dev":
|
||||
continue
|
||||
if dependency.get("path") is None or dependency_name not in local_packages:
|
||||
continue
|
||||
if dependency_name in publishable_names:
|
||||
dependencies.add(dependency_name)
|
||||
else:
|
||||
blocked_dependencies.add(dependency_name)
|
||||
|
||||
if blocked_dependencies:
|
||||
blocked = ", ".join(sorted(blocked_dependencies))
|
||||
raise ValueError(f"{name} depends on local crate(s) that do not publish to crates.io: {blocked}")
|
||||
|
||||
packages[name] = WorkspacePackage(
|
||||
name=name,
|
||||
version=package["version"],
|
||||
manifest_path=package["manifest_path"],
|
||||
dependencies=tuple(sorted(dependencies)),
|
||||
)
|
||||
|
||||
return packages
|
||||
|
||||
|
||||
def topological_publish_order(packages: dict[str, WorkspacePackage]) -> list[WorkspacePackage]:
|
||||
"""
|
||||
If there were a command like `cargo publish --workspace --print`, which just prints a
|
||||
publishable order of crates, then we would use that. Since that doesn't exist we
|
||||
implement this ourselves by walking the dependency tree.
|
||||
In principle we have a list of WorkspacePackage objects, where each entry has a list of
|
||||
in-workspace dependencies. We start from the edges (no dependencies), and work our way
|
||||
to the top, in each iteration appending to the publish-list, and removing the entries
|
||||
from the dependency lists of the remaining crates.
|
||||
"""
|
||||
remaining = {name: set(package.dependencies) for name, package in packages.items()}
|
||||
ordered_names: list[str] = []
|
||||
|
||||
while remaining:
|
||||
ready = sorted(name for name, dependencies in remaining.items() if not dependencies)
|
||||
assert ready is not None and len(ready) > 0, "Unable to resolve publish order"
|
||||
|
||||
ordered_names.extend(ready)
|
||||
for name in ready:
|
||||
remaining.pop(name)
|
||||
for dependencies in remaining.values():
|
||||
dependencies.difference_update(ready)
|
||||
|
||||
return [packages[name] for name in ordered_names]
|
||||
|
||||
|
||||
def crates_io_version_exists(crate_name: str, version: str) -> bool:
|
||||
crate_path = parse.quote(crate_name, safe="")
|
||||
version_path = parse.quote(version, safe="")
|
||||
api_url = f"https://crates.io/api/v1/crates/{crate_path}/{version_path}"
|
||||
req = request.Request(api_url, headers={"User-Agent": USER_AGENT})
|
||||
try:
|
||||
with request.urlopen(req, timeout=API_TIMEOUT_SECONDS):
|
||||
return True
|
||||
except error.HTTPError as http_error:
|
||||
if http_error.code == 404:
|
||||
return False
|
||||
raise RuntimeError(
|
||||
f"crates.io returned HTTP {http_error.code} while checking {crate_name} {version}"
|
||||
) from http_error
|
||||
except error.URLError as url_error:
|
||||
raise RuntimeError(f"failed to query crates.io for {crate_name} {version}: {url_error.reason}") from url_error
|
||||
|
||||
|
||||
def wait_until_published(package: WorkspacePackage) -> None:
|
||||
deadline = time.monotonic() + VERIFY_PUBLISHED_TIMEOUT_SECONDS
|
||||
while True:
|
||||
try:
|
||||
if crates_io_version_exists(package.name, package.version):
|
||||
print(f"verified {package.name} {package.version} on crates.io")
|
||||
return
|
||||
except RuntimeError as runtime_error:
|
||||
print(runtime_error, file=sys.stderr)
|
||||
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise RuntimeError(f"timed out waiting for {package.name} {package.version} to appear on crates.io")
|
||||
|
||||
time.sleep(min(float(VERIFY_PUBLISHED_INTERVAL_SECONDS), remaining))
|
||||
|
||||
|
||||
def publish_package(
|
||||
args: argparse.Namespace,
|
||||
package: WorkspacePackage,
|
||||
) -> None:
|
||||
command = ["cargo", "publish", "--manifest-path", package.manifest_path]
|
||||
if args.no_verify:
|
||||
command.append("--no-verify")
|
||||
|
||||
print(f"publishing {package.name} {package.version}")
|
||||
subprocess.run(command, cwd=WORKSPACE_ROOT, check=True)
|
||||
|
||||
|
||||
def publish_packages(args: argparse.Namespace, packages: Iterable[WorkspacePackage]) -> None:
|
||||
for package in packages:
|
||||
if crates_io_version_exists(package.name, package.version):
|
||||
print(f"skipping {package.name} {package.version}; already on crates.io")
|
||||
continue
|
||||
|
||||
publish_package(args, package)
|
||||
duration_seconds = SLEEP_AFTER_PUBLISH_SECONDS
|
||||
print(f"published {package.name} {package.version}. Waiting for {duration_seconds}s")
|
||||
# To distribute load on crates.io, we sleep for a bit after each publish.
|
||||
time.sleep(duration_seconds)
|
||||
# And in case crates.io is under heavy load and publishing takes longer than usual,
|
||||
# try and wait until the new version appears on crates.io.
|
||||
wait_until_published(package)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
|
||||
metadata = load_metadata()
|
||||
packages = collect_packages(metadata)
|
||||
ordered_packages = topological_publish_order(packages)
|
||||
|
||||
if not ordered_packages:
|
||||
print("no local crates publish to crates.io")
|
||||
return 0
|
||||
|
||||
print("publish order:")
|
||||
for package in ordered_packages:
|
||||
dependency_list = ", ".join(package.dependencies) if package.dependencies else "none"
|
||||
print(f" {package.name} {package.version} (deps: {dependency_list})")
|
||||
|
||||
if args.dry_run:
|
||||
return 0
|
||||
|
||||
publish_packages(args, ordered_packages)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user