blob: 95bfb7d5f8b97ee94bc8aab7f549e8f12154c783 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import argparse
import json
import os
import shutil
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
from plan import PROJECT_DIR, plan
from publish import parse_retry_after, should_retry
from trusted_publishing import REGISTRY_URL, USER_AGENT
REPOSITORY = "https://github.com/apache/reqsign"
# Published versions keep their original metadata after a repository rename.
LEGACY_REPOSITORY = "https://github.com/apache/opendal-reqsign"
PLACEHOLDER_VERSION = "0.0.0"
PLACEHOLDER_DESCRIPTION = "Namespace reservation for a crate planned by Apache Reqsign."
LEGACY_PLACEHOLDER_DESCRIPTION = (
"Namespace reservation for a crate planned by Apache OpenDAL reqsign."
)
PUBLISHER = {
"repository_owner": "apache",
"repository_name": "reqsign",
"workflow_filename": "release.yml",
"environment": "release",
}
class CratesIoClient:
def __init__(self, token: str | None = None):
self.token = token
def request(self, method, path, body=None, *, authenticated=False):
headers = {"User-Agent": USER_AGENT, "Content-Type": "application/json"}
if authenticated:
assert self.token, "a crates.io bootstrap token is required"
headers["Authorization"] = self.token
request = urllib.request.Request(
f"{REGISTRY_URL}/api/v1/{path}",
data=json.dumps(body).encode() if body is not None else None,
headers=headers,
method=method,
)
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)
def get_crate(self, name):
try:
return self.request("GET", f"crates/{name}")["crate"]
except urllib.error.HTTPError as error:
if error.code != 404:
raise
error.close()
return None
def list_github_configs(self, name):
return self.request(
"GET",
f"trusted_publishing/github_configs?crate={name}&per_page=100",
authenticated=True,
)["github_configs"]
def create_github_config(self, name):
return self.request(
"POST",
"trusted_publishing/github_configs",
{"github_config": {"crate": name, **PUBLISHER}},
authenticated=True,
)["github_config"]
def set_trustpub_only(self, name):
return self.request(
"PATCH",
f"crates/{name}",
{"crate": {"trustpub_only": True}},
authenticated=True,
)["crate"]
def validate_crate(name, metadata):
assert metadata["id"] == name, f"crate name mismatch for {name}"
repository = (metadata["repository"] or "").rstrip("/").removesuffix(".git").lower()
assert repository in {REPOSITORY, LEGACY_REPOSITORY}, (
f"{name} has an unexpected repository: {repository}"
)
if metadata["max_version"] == PLACEHOLDER_VERSION:
assert metadata["description"] in {
PLACEHOLDER_DESCRIPTION,
LEGACY_PLACEHOLDER_DESCRIPTION,
}, f"{name} has an unexpected placeholder"
def validate_publisher(name, configs):
expected = {"crate": name, **PUBLISHER}
assert len(configs) == 1 and all(
configs[0][key] == value for key, value in expected.items()
), f"{name} has unexpected Trusted Publishers: {configs}"
def audit(names, client, *, ready=False):
"""Check every crate before any write; return names eligible for bootstrap."""
candidates = []
for name in names:
metadata = client.get_crate(name)
if metadata is None:
assert not ready, f"{name} does not exist on crates.io"
candidates.append(name)
continue
validate_crate(name, metadata)
configs = client.list_github_configs(name)
if not ready and metadata["max_version"] == PLACEHOLDER_VERSION:
if configs:
validate_publisher(name, configs)
candidates.append(name)
else:
validate_publisher(name, configs)
assert metadata["trustpub_only"] is True, (
f"{name} does not require Trusted Publishing"
)
return candidates
def _placeholder_manifest(name: str) -> str:
return f"""# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
[package]
name = {json.dumps(name)}
version = {json.dumps(PLACEHOLDER_VERSION)}
edition = "2024"
rust-version = "1.85.0"
description = {json.dumps(PLACEHOLDER_DESCRIPTION)}
homepage = "https://docs.rs/reqsign"
repository = {json.dumps(REPOSITORY)}
license = "Apache-2.0"
readme = "README.md"
include = ["src/lib.rs", "README.md", "LICENSE", "NOTICE"]
"""
def _placeholder_readme(name: str) -> str:
return f"""# {name}
This crate belongs to [Apache Reqsign]({REPOSITORY}).
Version {PLACEHOLDER_VERSION} reserves the crates.io package name for Apache Reqsign.
It is not an ASF software release, contains no implementation, and must not be
used as a dependency.
"""
PLACEHOLDER_LIB = """// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
#![doc = include_str!("../README.md")]
"""
def write_placeholder_package(name: str, package_dir: Path) -> None:
(package_dir / "src").mkdir()
(package_dir / "Cargo.toml").write_text(
_placeholder_manifest(name), encoding="utf-8"
)
(package_dir / "README.md").write_text(_placeholder_readme(name), encoding="utf-8")
(package_dir / "src/lib.rs").write_text(PLACEHOLDER_LIB, encoding="utf-8")
for filename in ("LICENSE", "NOTICE"):
shutil.copyfile(PROJECT_DIR / filename, package_dir / filename)
def publish_placeholder(name, token):
with tempfile.TemporaryDirectory(prefix=f"{name}-bootstrap-") as tmpdir:
package_dir = Path(tmpdir)
write_placeholder_package(name, package_dir)
command = ["cargo", "publish", "--manifest-path", "Cargo.toml"]
while True:
process = subprocess.run(
command,
cwd=package_dir,
check=False,
env={**os.environ, "CARGO_REGISTRY_TOKEN": token},
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
print(process.stdout, end="", flush=True)
if process.returncode == 0:
return
if not should_retry(process.stdout):
process.check_returncode()
delay = parse_retry_after(process.stdout)
print(f"crates.io rate limited {name}; sleeping {delay}s", flush=True)
time.sleep(delay)
def wait_for_crate(client, name, *, ready=False):
# Public metadata may lag behind a successful publish or settings update.
deadline = time.monotonic() + 120
while True:
metadata = client.get_crate(name)
if metadata is not None and (not ready or metadata["trustpub_only"] is True):
validate_crate(name, metadata)
return metadata
assert time.monotonic() < deadline, f"timed out waiting for {name} on crates.io"
time.sleep(2)
def reconcile(name, client):
metadata = client.get_crate(name)
if metadata is None:
publish_placeholder(name, client.token)
metadata = wait_for_crate(client, name)
validate_crate(name, metadata)
assert metadata["max_version"] == PLACEHOLDER_VERSION, (
f"{name} is an established crate; bootstrap must not modify it"
)
configs = client.list_github_configs(name)
if not configs:
configs = [client.create_github_config(name)]
validate_publisher(name, configs)
if metadata["trustpub_only"] is not True:
updated = client.set_trustpub_only(name)
assert updated["trustpub_only"] is True, f"failed to restrict {name}"
wait_for_crate(client, name, ready=True)
print(f"{name}: bootstrapped", flush=True)
def apply(names, client):
candidates = audit(names, client)
print(f"Authenticated audit passed; bootstrap candidates: {candidates}", flush=True)
for name in candidates:
reconcile(name, client)
audit(names, client, ready=True)
print(f"Verified all {len(names)} crates", flush=True)
def main():
parser = argparse.ArgumentParser(description="Bootstrap reqsign crates.io names.")
parser.add_argument("command", choices=("discover", "apply", "verify"))
command = parser.parse_args().command
names = [package.name for package in plan()]
if command == "apply":
apply(names, CratesIoClient(os.environ["CARGO_REGISTRY_BOOTSTRAP_TOKEN"]))
return
client = CratesIoClient()
for name in names:
metadata = client.get_crate(name)
if metadata is not None:
validate_crate(name, metadata)
if command == "verify":
assert metadata is not None, f"{name} does not exist on crates.io"
assert metadata["trustpub_only"] is True, (
f"{name} does not require Trusted Publishing"
)
version = metadata["max_version"] if metadata else "missing"
print(f"{name}: {version}")
if __name__ == "__main__":
main()