diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec603080..e4335ff3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,20 @@ jobs: package: rustauth,rustauth-core,rustauth-oauth,rustauth-plugins,rustauth-sqlx release-type: major + release-order: + name: Release order + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Test release-order checker + run: python3 -m unittest discover -s scripts/tests -p 'test_check_release_order.py' + + - name: Check release order + run: python3 scripts/check_release_order.py + test-crate: name: Test ${{ matrix.package }} runs-on: ubuntu-latest @@ -79,6 +93,8 @@ jobs: nextest_args: --all-features - package: rustauth-core nextest_args: --all-features + - package: rustauth-fred + nextest_args: --all-features - package: rustauth-oauth nextest_args: --all-features - package: rustauth-social-providers @@ -141,6 +157,7 @@ jobs: needs: - fmt - clippy + - release-order - semver - test-crate if: always() diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 23752615..76cc304a 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -90,7 +90,7 @@ jobs: --test-threads 1 -E 'not test(schema_and_provider_store_work_when_configured)' doc_tests: true - - package: rustauth-fred + - package: rustauth-integration-tests services: redis valkey nextest_args: --all-features - package: rustauth-redis diff --git a/Cargo.lock b/Cargo.lock index 73125aaa..0a140a5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4465,15 +4465,9 @@ name = "rustauth-fred" version = "0.3.1" dependencies = [ "fred", - "http 1.4.2", - "rustauth", "rustauth-core", - "rustauth-plugins", - "rustauth-redis", - "serde_json", "time", "tokio", - "url", ] [[package]] @@ -4490,6 +4484,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "rustauth-integration-tests" +version = "0.3.1" +dependencies = [ + "http 1.4.2", + "rustauth", + "rustauth-core", + "rustauth-fred", + "rustauth-plugins", + "rustauth-redis", + "serde_json", + "time", + "tokio", + "url", +] + [[package]] name = "rustauth-oauth" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index cec866d0..794ce1d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/rustauth-diesel", "crates/rustauth-fred", "crates/rustauth-i18n", + "crates/rustauth-integration-tests", "crates/rustauth-oauth", "crates/rustauth-oauth-provider", "crates/rustauth-oidc", diff --git a/RELEASE.md b/RELEASE.md index 213cb413..21c3bae8 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -112,31 +112,36 @@ The current workspace packages must be published in this order: 12. `rustauth-deadpool-postgres` — depends on `rustauth-core` and `rustauth-tokio-postgres`. 13. `rustauth-redis` — depends on `rustauth-core`. -14. `rustauth-plugins` — depends on `rustauth-core`, `rustauth-oauth`, and - `rustauth-social-providers`; publish verification also uses - `rustauth-redis` and `rustauth-sqlx`. -15. `rustauth-passkey` — depends on `rustauth-core`; publish verification also +14. `rustauth-passkey` — depends on `rustauth-core`; publish verification also uses `rustauth-sqlx`. +15. `rustauth-plugins` — depends on `rustauth-core`, `rustauth-oauth`, and + `rustauth-social-providers`; publish verification also uses + `rustauth-passkey`, `rustauth-redis`, and `rustauth-sqlx`. 16. `rustauth-sso` — depends on `rustauth-core`, `rustauth-oauth`, - `rustauth-oidc`, and `rustauth-saml`; publish verification also uses - `rustauth-sqlx`. -17. `rustauth-scim` — depends on `rustauth-core`; publish verification also - uses `rustauth-deadpool-postgres`, `rustauth-plugins`, `rustauth-sqlx`, and - `rustauth-tokio-postgres`. + `rustauth-oidc`, `rustauth-plugins`, and optionally `rustauth-saml`; + publish verification also uses `rustauth-saml` and `rustauth-sqlx`. +17. `rustauth-scim` — depends on `rustauth-core` and `rustauth-plugins`; + publish verification also uses `rustauth-deadpool-postgres`, + `rustauth-sqlx`, and `rustauth-tokio-postgres`. 18. `rustauth-oauth-provider` — depends on `rustauth-core` and `rustauth-plugins`. -19. `rustauth` — depends on `rustauth-core`, - `rustauth-deadpool-postgres`, `rustauth-diesel`, `rustauth-i18n`, - `rustauth-oidc`, `rustauth-passkey`, `rustauth-plugins`, `rustauth-saml`, +19. `rustauth-fred` — depends on `rustauth-core`. Its cross-crate and live + coverage lives in the unpublished `rustauth-integration-tests` crate. +20. `rustauth` — depends on `rustauth-core` and optionally on + `rustauth-deadpool-postgres`, `rustauth-diesel`, `rustauth-fred`, + `rustauth-i18n`, `rustauth-oauth-provider`, `rustauth-oidc`, + `rustauth-passkey`, `rustauth-plugins`, `rustauth-redis`, `rustauth-saml`, `rustauth-scim`, `rustauth-sqlx`, `rustauth-sso`, `rustauth-stripe`, - `rustauth-telemetry`, and `rustauth-tokio-postgres`. -20. `rustauth-fred` — depends on `rustauth-core`, and its publish - verification uses a dev-dependency on `rustauth`. + `rustauth-telemetry`, and `rustauth-tokio-postgres`; publish verification + also uses `rustauth-sqlx`. 21. `rustauth-axum` — depends on `rustauth`. 22. `rustauth-actix-web` — depends on `rustauth`. -23. `rustauth-cli` — depends on `rustauth`, `rustauth-core`, - `rustauth-plugins`, `rustauth-sqlx`, and optionally `rustauth-diesel` - (via the `diesel` feature). +23. `rustauth-cli` — depends on `rustauth-core` and optionally on + `rustauth-deadpool-postgres`, `rustauth-diesel`, + `rustauth-oauth-provider`, `rustauth-passkey`, `rustauth-plugins`, + `rustauth-scim`, `rustauth-sqlx`, `rustauth-sso`, `rustauth-stripe`, + `rustauth-telemetry`, and `rustauth-tokio-postgres`; it does not depend on + the `rustauth` facade. ## Crate names @@ -152,6 +157,7 @@ workspace currently includes: - `rustauth-diesel` - `rustauth-fred` - `rustauth-i18n` +- `rustauth-integration-tests` — unpublished cross-crate and live test harness - `rustauth-oidc` - `rustauth-oauth` - `rustauth-oauth-provider` diff --git a/crates/rustauth-fred/Cargo.toml b/crates/rustauth-fred/Cargo.toml index 9c3db9f7..85b3bc57 100644 --- a/crates/rustauth-fred/Cargo.toml +++ b/crates/rustauth-fred/Cargo.toml @@ -20,15 +20,9 @@ rustauth-core.workspace = true tokio.workspace = true [dev-dependencies] -http.workspace = true -rustauth.workspace = true rustauth-core = { workspace = true, features = ["test-utils"] } -rustauth-plugins.workspace = true -rustauth-redis.workspace = true -serde_json.workspace = true time.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } -url.workspace = true [features] default = [] diff --git a/crates/rustauth-integration-tests/Cargo.toml b/crates/rustauth-integration-tests/Cargo.toml new file mode 100644 index 00000000..9e1b5fb1 --- /dev/null +++ b/crates/rustauth-integration-tests/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "rustauth-integration-tests" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[lib] +path = "src/lib.rs" + +[dev-dependencies] +http.workspace = true +rustauth.workspace = true +rustauth-core = { workspace = true, features = ["test-utils"] } +rustauth-fred.workspace = true +rustauth-plugins.workspace = true +rustauth-redis.workspace = true +serde_json.workspace = true +time.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +url.workspace = true + +[lints] +workspace = true diff --git a/crates/rustauth-integration-tests/src/lib.rs b/crates/rustauth-integration-tests/src/lib.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/crates/rustauth-integration-tests/src/lib.rs @@ -0,0 +1 @@ + diff --git a/crates/rustauth-fred/tests/api_key_secondary_storage.rs b/crates/rustauth-integration-tests/tests/api_key_secondary_storage.rs similarity index 100% rename from crates/rustauth-fred/tests/api_key_secondary_storage.rs rename to crates/rustauth-integration-tests/tests/api_key_secondary_storage.rs diff --git a/crates/rustauth-fred/tests/fred_rate_limit.rs b/crates/rustauth-integration-tests/tests/fred_rate_limit.rs similarity index 100% rename from crates/rustauth-fred/tests/fred_rate_limit.rs rename to crates/rustauth-integration-tests/tests/fred_rate_limit.rs diff --git a/release-plz.toml b/release-plz.toml index 092949df..444eb019 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -94,6 +94,11 @@ version_group = "rustauth-workspace" name = "rustauth-fred" version_group = "rustauth-workspace" +[[package]] +name = "rustauth-integration-tests" +publish = false +release = false + [[package]] name = "rustauth-i18n" version_group = "rustauth-workspace" diff --git a/scripts/check_release_order.py b/scripts/check_release_order.py new file mode 100755 index 00000000..19f790a8 --- /dev/null +++ b/scripts/check_release_order.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +"""Validate the documented publish order against Cargo workspace metadata.""" + +from __future__ import annotations + +import collections +import json +from pathlib import Path +import re +import subprocess +import sys +from typing import Any + + +REPO_ROOT = Path(__file__).resolve().parent.parent +RELEASE_FILE = REPO_ROOT / "RELEASE.md" +PUBLISH_ORDER_HEADING = "## Publish order" +PUBLISH_ORDER_ENTRY = re.compile(r"^(\d+)\.\s+`([^`]+)`(?:\s|$)") + + +def load_metadata(repo_root: Path) -> dict[str, Any]: + result = subprocess.run( + ["cargo", "metadata", "--format-version", "1", "--no-deps", "--locked"], + cwd=repo_root, + check=True, + capture_output=True, + text=True, + ) + return json.loads(result.stdout) + + +def is_publishable(package: dict[str, Any]) -> bool: + allowed_registries = package.get("publish") + return allowed_registries is None or bool(allowed_registries) + + +def is_packaged_dependency(dependency: dict[str, Any]) -> bool: + kind = dependency.get("kind") + if kind in (None, "normal", "build"): + return True + if kind == "dev": + return dependency.get("req") not in (None, "*") + return False + + +def is_same_workspace_dependency( + dependency: dict[str, Any], workspace_package: dict[str, Any] +) -> bool: + dependency_path = dependency.get("path") + if dependency_path is None: + return False + package_path = Path(workspace_package["manifest_path"]).parent + return Path(dependency_path).resolve() == package_path.resolve() + + +def build_publish_graph(metadata: dict[str, Any]) -> dict[str, set[str]]: + workspace_member_ids = set(metadata["workspace_members"]) + workspace_packages = { + package["name"]: package + for package in metadata["packages"] + if package["id"] in workspace_member_ids + } + published_packages = { + name: package + for name, package in workspace_packages.items() + if is_publishable(package) + } + + graph = {name: set() for name in published_packages} + for package_name, package in published_packages.items(): + for dependency in package["dependencies"]: + dependency_name = dependency["name"] + dependency_package = published_packages.get(dependency_name) + if ( + dependency_package is not None + and is_same_workspace_dependency(dependency, dependency_package) + and is_packaged_dependency(dependency) + ): + graph[package_name].add(dependency_name) + return graph + + +def parse_publish_order_text(contents: str) -> list[tuple[int, str]]: + lines = contents.splitlines() + try: + heading_index = next( + index + for index, line in enumerate(lines) + if line.strip() == PUBLISH_ORDER_HEADING + ) + except StopIteration as error: + raise ValueError(f"missing `{PUBLISH_ORDER_HEADING}` section") from error + + entries = [] + for line in lines[heading_index + 1 :]: + if line.startswith("## "): + break + if match := PUBLISH_ORDER_ENTRY.match(line): + entries.append((int(match.group(1)), match.group(2))) + + if not entries: + raise ValueError("publish order section has no numbered package entries") + return entries + + +def find_cycle(graph: dict[str, set[str]]) -> list[str] | None: + states: dict[str, int] = {} + stack: list[str] = [] + stack_positions: dict[str, int] = {} + + def visit(package: str) -> list[str] | None: + states[package] = 1 + stack_positions[package] = len(stack) + stack.append(package) + + for dependency in sorted(graph[package]): + if states.get(dependency, 0) == 0: + if cycle := visit(dependency): + return cycle + elif states[dependency] == 1: + start = stack_positions[dependency] + return [*stack[start:], dependency] + + stack.pop() + stack_positions.pop(package) + states[package] = 2 + return None + + for package in sorted(graph): + if states.get(package, 0) == 0: + if cycle := visit(package): + return cycle + return None + + +def validate_release_order( + graph: dict[str, set[str]], entries: list[tuple[int, str]] +) -> list[str]: + errors = [] + numbers = [number for number, _ in entries] + package_names = [package for _, package in entries] + + expected_numbers = list(range(1, len(entries) + 1)) + if numbers != expected_numbers: + errors.append( + "publish order numbering must be consecutive from 1 " + f"(found {numbers})" + ) + + duplicate_packages = sorted( + package + for package, count in collections.Counter(package_names).items() + if count > 1 + ) + if duplicate_packages: + errors.append( + "duplicate packages in publish order: " + ", ".join(duplicate_packages) + ) + + documented = set(package_names) + published = set(graph) + missing = sorted(published - documented) + extra = sorted(documented - published) + if missing: + errors.append("published packages missing from RELEASE.md: " + ", ".join(missing)) + if extra: + errors.append( + "unpublished or unknown packages in publish order: " + ", ".join(extra) + ) + + if cycle := find_cycle(graph): + errors.append("packaged workspace dependency cycle: " + " -> ".join(cycle)) + + if not duplicate_packages and documented == published: + positions = {package: index for index, package in enumerate(package_names)} + for dependent in sorted(graph): + for dependency in sorted(graph[dependent]): + if positions[dependency] >= positions[dependent]: + errors.append( + f"`{dependency}` must appear before dependent `{dependent}`" + ) + return errors + + +def main() -> int: + try: + metadata = load_metadata(REPO_ROOT) + graph = build_publish_graph(metadata) + entries = parse_publish_order_text(RELEASE_FILE.read_text(encoding="utf-8")) + except (OSError, ValueError, json.JSONDecodeError, subprocess.CalledProcessError) as error: + print(f"release-order preflight failed: {error}", file=sys.stderr) + return 1 + + errors = validate_release_order(graph, entries) + if errors: + print("release-order preflight failed:", file=sys.stderr) + for error in errors: + print(f" - {error}", file=sys.stderr) + return 1 + + edge_count = sum(len(dependencies) for dependencies in graph.values()) + print( + "release-order preflight passed: " + f"{len(graph)} publishable workspace packages, " + f"{edge_count} packaged workspace dependency edges." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tests/test_check_release_order.py b/scripts/tests/test_check_release_order.py new file mode 100644 index 00000000..fc70a225 --- /dev/null +++ b/scripts/tests/test_check_release_order.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import sys +from pathlib import Path +import unittest + + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from scripts import check_release_order + + +def dependency( + name: str, kind: str | None, requirement: str, path: str | None +) -> dict[str, str | None]: + return {"name": name, "kind": kind, "req": requirement, "path": path} + + +def package( + name: str, + dependencies: list[dict[str, str | None]], + publish: list[str] | None = None, +) -> dict[str, object]: + return { + "id": name, + "name": name, + "dependencies": dependencies, + "manifest_path": f"/workspace/{name}/Cargo.toml", + "publish": publish, + } + + +class ReleaseOrderTests(unittest.TestCase): + def test_graph_includes_versioned_dev_dependencies_only(self) -> None: + packages = [ + package("base", []), + package("app", [dependency("base", None, "^1", "/workspace/base")]), + package( + "verifier", [dependency("base", "dev", "^1", "/workspace/base")] + ), + package( + "path-only-tests", + [dependency("base", "dev", "*", "/workspace/base")], + ), + package( + "unpublished", + [dependency("app", None, "^1", "/workspace/app")], + publish=[], + ), + ] + metadata = { + "workspace_members": [package["id"] for package in packages], + "packages": packages, + } + + self.assertEqual( + check_release_order.build_publish_graph(metadata), + { + "base": set(), + "app": {"base"}, + "verifier": {"base"}, + "path-only-tests": set(), + }, + ) + + def test_graph_ignores_registry_dependency_with_workspace_package_name(self) -> None: + packages = [ + package("base", []), + package("local-user", [dependency("base", None, "^1", "/workspace/base")]), + package("registry-user", [dependency("base", None, "^0.9", None)]), + ] + metadata = { + "workspace_members": [package["id"] for package in packages], + "packages": packages, + } + + self.assertEqual( + check_release_order.build_publish_graph(metadata), + { + "base": set(), + "local-user": {"base"}, + "registry-user": set(), + }, + ) + + def test_find_cycle_returns_closed_dependency_path(self) -> None: + graph = { + "first": {"second"}, + "second": {"third"}, + "third": {"first"}, + } + + self.assertEqual( + check_release_order.find_cycle(graph), + ["first", "second", "third", "first"], + ) + + def test_validation_reports_order_drift(self) -> None: + entries = check_release_order.parse_publish_order_text( + """ +## Publish order + +1. `app` — depends on base. +2. `base` — no dependencies. + +## Next section +""" + ) + + self.assertEqual( + check_release_order.validate_release_order( + {"base": set(), "app": {"base"}}, entries + ), + ["`base` must appear before dependent `app`"], + ) + + def test_validation_reports_duplicate_missing_and_extra_packages(self) -> None: + self.assertEqual( + check_release_order.validate_release_order( + {"base": set(), "app": {"base"}}, + [(1, "base"), (2, "base"), (3, "unknown")], + ), + [ + "duplicate packages in publish order: base", + "published packages missing from RELEASE.md: app", + "unpublished or unknown packages in publish order: unknown", + ], + ) + + +if __name__ == "__main__": + unittest.main()