diff --git a/.github/workflows/check-gate-ci.yml b/.github/workflows/check-gate-ci.yml index f07210378..f25835a31 100644 --- a/.github/workflows/check-gate-ci.yml +++ b/.github/workflows/check-gate-ci.yml @@ -75,9 +75,7 @@ jobs: - 'Makefile' - '.github/workflows/generated-code-ci.yml' release_tooling: - - 'release/compatibility/**' - 'script/release/**' - - '.github/workflows/compatibility-manifest.yml' - '.github/workflows/release-changed-components.yml' - '.github/workflows/release-tooling-ci.yml' - 'CONTRIBUTING.md' diff --git a/.github/workflows/compatibility-manifest.yml b/.github/workflows/compatibility-manifest.yml deleted file mode 100644 index 84290524f..000000000 --- a/.github/workflows/compatibility-manifest.yml +++ /dev/null @@ -1,105 +0,0 @@ -name: Publish compatibility manifest - -on: - workflow_call: - inputs: - version: - description: Shared Dex semantic version - required: true - type: string - workflow_dispatch: - inputs: - version: - description: Released Dex semantic version - required: true - type: string - -permissions: - contents: write - -concurrency: - group: compatibility-manifest-${{ inputs.version }} - cancel-in-progress: false - -jobs: - publish: - name: Verify and publish manifest - if: github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - outputs: - manifest_sha256: ${{ steps.manifest.outputs.sha256 }} - manifest_url: ${{ steps.manifest.outputs.url }} - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - uses: docker/setup-buildx-action@v4 - - name: Generate verified compatibility manifest - id: manifest - env: - GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ inputs.version }} - run: | - output="dist/dex-compatibility-v${RELEASE_VERSION}.json" - python3 script/release/compatibility_manifest.py \ - "${RELEASE_VERSION}" \ - --verify-releases \ - --output "${output}" | tee manifest-metadata.txt - echo "sha256=$(sha256sum "${output}" | cut -d ' ' -f 1)" >> "${GITHUB_OUTPUT}" - echo "url=https://github.com/${GITHUB_REPOSITORY}/releases/download/server/v${RELEASE_VERSION}/dex-compatibility-v${RELEASE_VERSION}.json" >> "${GITHUB_OUTPUT}" - cat manifest-metadata.txt >> "${GITHUB_STEP_SUMMARY}" - - name: Upload immutable release contract - env: - GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ inputs.version }} - run: | - asset="dex-compatibility-v${RELEASE_VERSION}.json" - release="server/v${RELEASE_VERSION}" - if gh release view "${release}" --json assets --jq '.assets[].name' | - grep --fixed-strings --line-regexp --quiet "${asset}"; then - existing="${RUNNER_TEMP}/existing-dex-compatibility" - mkdir -p "${existing}" - gh release download "${release}" --pattern "${asset}" --dir "${existing}" - cmp --silent "dist/${asset}" "${existing}/${asset}" || { - echo "Release asset ${asset} already exists with different contents" >&2 - exit 1 - } - echo "Release asset ${asset} already contains the expected manifest" - else - gh release upload "${release}" "dist/${asset}" - fi - - request-superagent-upgrade: - name: Request SuperAgent upgrade - needs: publish - runs-on: ubuntu-latest - steps: - - name: Create repository-scoped release automation token - id: app-token - uses: actions/create-github-app-token@v2 - with: - app-id: ${{ vars.RELEASE_AUTOMATION_APP_ID }} - private-key: ${{ secrets.RELEASE_AUTOMATION_PRIVATE_KEY }} - owner: superdurable - repositories: superagent - - name: Dispatch audited SuperAgent upgrade - env: - DEX_MANIFEST_SHA256: ${{ needs.publish.outputs.manifest_sha256 }} - DEX_MANIFEST_URL: ${{ needs.publish.outputs.manifest_url }} - DEX_VERSION: ${{ inputs.version }} - GH_TOKEN: ${{ steps.app-token.outputs.token }} - run: | - gh api --method POST repos/superdurable/superagent/dispatches \ - -f event_type=dex-release-published \ - -f "client_payload[version]=${DEX_VERSION}" \ - -f "client_payload[manifest_url]=${DEX_MANIFEST_URL}" \ - -f "client_payload[manifest_sha256]=${DEX_MANIFEST_SHA256}" - - reject-non-main: - name: Reject non-main publication - if: github.ref != 'refs/heads/main' - runs-on: ubuntu-latest - steps: - - run: | - echo "Compatibility manifests can be published only from main" >&2 - exit 1 diff --git a/.github/workflows/release-changed-components.yml b/.github/workflows/release-changed-components.yml index 1c35ca09a..7ca3cc154 100644 --- a/.github/workflows/release-changed-components.yml +++ b/.github/workflows/release-changed-components.yml @@ -220,33 +220,6 @@ jobs: publish: true secrets: inherit - publish-compatibility: - name: Publish compatibility manifest - needs: - - plan - - create-releases - - publish-java - - publish-python - - publish-rust - - publish-typescript - - publish-server - - publish-cli - if: > - always() && - needs.plan.result == 'success' && - needs.create-releases.result == 'success' && - needs.plan.outputs.server == 'true' && - (needs.plan.outputs.java != 'true' || needs.publish-java.result == 'success') && - (needs.plan.outputs.python != 'true' || needs.publish-python.result == 'success') && - (needs.plan.outputs.rust != 'true' || needs.publish-rust.result == 'success') && - (needs.plan.outputs.typescript != 'true' || needs.publish-typescript.result == 'success') && - needs.publish-server.result == 'success' && - (needs.plan.outputs.cli != 'true' || needs.publish-cli.result == 'success') - uses: ./.github/workflows/compatibility-manifest.yml - with: - version: ${{ needs.plan.outputs.version }} - secrets: inherit - complete: name: Verify publications if: always() && needs.plan.result != 'skipped' @@ -259,7 +232,6 @@ jobs: - publish-typescript - publish-server - publish-cli - - publish-compatibility runs-on: ubuntu-latest steps: - name: Report results @@ -280,7 +252,6 @@ jobs: SERVER_RESULT: ${{ needs.publish-server.result }} CLI_SELECTED: ${{ needs.plan.outputs.cli }} CLI_RESULT: ${{ needs.publish-cli.result }} - COMPATIBILITY_RESULT: ${{ needs.publish-compatibility.result }} run: | { echo "### Publication results" @@ -302,7 +273,6 @@ jobs: [[ "${selected}" == "true" ]] || outcome="skipped" echo "| ${name} | ${outcome} |" done - echo "| Compatibility manifest | ${COMPATIBILITY_RESULT} |" } >> "${GITHUB_STEP_SUMMARY}" if [[ "${PLAN_RESULT}" != "success" || "${RELEASE_RESULT}" != "success" ]]; then @@ -319,6 +289,3 @@ jobs: exit 1 fi done - if [[ "${SERVER_SELECTED}" == "true" && "${COMPATIBILITY_RESULT}" != "success" ]]; then - exit 1 - fi diff --git a/.github/workflows/release-tooling-ci.yml b/.github/workflows/release-tooling-ci.yml index 5e0ac3cad..b223868a6 100644 --- a/.github/workflows/release-tooling-ci.yml +++ b/.github/workflows/release-tooling-ci.yml @@ -4,17 +4,13 @@ on: push: branches: [main] paths: - - "release/compatibility/**" - "script/release/**" - - ".github/workflows/compatibility-manifest.yml" - ".github/workflows/release-changed-components.yml" - ".github/workflows/release-tooling-ci.yml" - "CONTRIBUTING.md" pull_request: paths: - - "release/compatibility/**" - "script/release/**" - - ".github/workflows/compatibility-manifest.yml" - ".github/workflows/release-changed-components.yml" - ".github/workflows/release-tooling-ci.yml" - "CONTRIBUTING.md" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0d8a10bd6..42ab73ed8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -166,29 +166,12 @@ Each component has its own version and tag prefix. Create a GitHub Release for t For coordinated releases, run **Release changed components** from the **main** branch and enter one semantic version. The workflow applies that version to every selected component. It compares each component with its own latest reachable release tag, uses that tag as the start of the component's generated release notes, preflights every selected target tag, creates the GitHub Releases, and directly invokes each publisher. The run succeeds only after every selected registry, Docker, CLI asset, and Homebrew publication succeeds. Go SDK publication is complete when its module tag and GitHub Release exist. -Every coordinated release that selects Server also publishes a compatibility -manifest. Add `release/compatibility/.json` before dispatching the -release. The declaration records the reviewed protocol intervals, open-Flow -compatibility, persistence compatibility, and rollout order. Release tooling -verifies those intervals against each component's tagged source, reads the published CLI checksums and -Server image digest, and uploads -`dex-compatibility-v.json` to the Server GitHub Release. - -For a partial release, include `componentVersions` with all seven component keys: -`server`, `cli`, `sdkGo`, `sdkJava`, `sdkPython`, `sdkRust`, and `sdkTypeScript`. -Changed components use the requested version; unchanged components retain their -published versions. The planner checks this declaration before creating tags. -The manifest's `sourceCommit` identifies Server; each component's tag identifies -its own source. Every declared client protocol must overlap the Server interval. -Omitting `componentVersions` declares that every component uses the release version. -An SDK-only or CLI-only release does not rewrite an existing Server manifest. - -Use **Publish compatibility manifest** to backfill or reverify an already -published Server release, including a partial release. The workflow adds the asset -when absent and requires byte-identical contents when it already exists; -it never recreates component tags or republishes packages. Downstream systems -must verify the downloaded manifest digest before changing a Server or SDK -version. +Coordinated releases do not create a cross-component compatibility manifest. +Each selected component publishes its native immutable artifacts. CLI publishes +`checksums.txt`, Server publishes its versioned image, and SDK registries retain +their normal package checksums. Applications update explicit component versions +in ordinary pull requests and use compilation and integration tests as the +compatibility gate. Components without relevant changes are skipped. A run with no relevant changes succeeds without creating tags. Documentation and workflow changes alone do not select a product release. A missing component baseline is treated as its first release. diff --git a/Makefile b/Makefile index 80c960792..5630aa8a7 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ GENERATED_CODE_PATHS := \ .PHONY: help ci-runner-check copyright copyright-check generated-code generated-code-check githooks docs-prose-check docs-prose-fix release-tooling-test -release-tooling-test: ## Verify component selection and immutable release manifests +release-tooling-test: ## Verify changed-component selection and release preflight PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s script/release -p '*_test.py' help: ## Show targets diff --git a/release/compatibility/0.10.0.json b/release/compatibility/0.10.0.json deleted file mode 100644 index 5d6a10f96..000000000 --- a/release/compatibility/0.10.0.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "schemaVersion": 1, - "release": "0.10.0", - "rolloutOrder": "server-first", - "runningFlowsCompatibility": "compatible", - "persistenceCompatibility": "compatible", - "protocol": { - "server": { - "minimum": 1, - "maximum": 1 - }, - "clients": { - "cli": { - "minimum": 1, - "maximum": 1 - }, - "sdkGo": { - "minimum": 1, - "maximum": 1 - }, - "sdkJava": { - "minimum": 1, - "maximum": 1 - }, - "sdkPython": { - "minimum": 1, - "maximum": 1 - }, - "sdkRust": { - "minimum": 1, - "maximum": 1 - }, - "sdkTypeScript": { - "minimum": 1, - "maximum": 1 - } - } - }, - "componentVersions": { - "server": "0.10.0", - "cli": "0.10.0", - "sdkGo": "0.10.0", - "sdkJava": "0.9.0", - "sdkPython": "0.9.0", - "sdkRust": "0.9.0", - "sdkTypeScript": "0.9.0" - } -} diff --git a/release/compatibility/0.9.0.json b/release/compatibility/0.9.0.json deleted file mode 100644 index 1d4c3bb07..000000000 --- a/release/compatibility/0.9.0.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "schemaVersion": 1, - "release": "0.9.0", - "rolloutOrder": "server-first", - "runningFlowsCompatibility": "compatible", - "persistenceCompatibility": "compatible", - "protocol": { - "server": { - "minimum": 1, - "maximum": 1 - }, - "clients": { - "cli": { - "minimum": 1, - "maximum": 1 - }, - "sdkGo": { - "minimum": 1, - "maximum": 1 - }, - "sdkJava": { - "minimum": 1, - "maximum": 1 - }, - "sdkPython": { - "minimum": 1, - "maximum": 1 - }, - "sdkRust": { - "minimum": 1, - "maximum": 1 - }, - "sdkTypeScript": { - "minimum": 1, - "maximum": 1 - } - } - } -} diff --git a/script/release/changed_components.py b/script/release/changed_components.py index f8683569c..19559a6e7 100644 --- a/script/release/changed_components.py +++ b/script/release/changed_components.py @@ -16,9 +16,6 @@ from dataclasses import dataclass from pathlib import Path -from compatibility_manifest import ManifestError, component_versions, load_declaration - - VERSION_PATTERN = re.compile( r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)" r"(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$" @@ -179,20 +176,6 @@ def write_outputs(output_path: Path, version: str) -> int: print(f"- {collision}", file=sys.stderr) return 1 - if any(component.key == "server" and selected for component, _, selected, _ in selections): - try: - declaration = load_declaration(Path("release/compatibility") / f"{version}.json", version) - versions = component_versions(declaration) - names = {"go": "sdkGo", "rust": "sdkRust", "java": "sdkJava", "python": "sdkPython", "typescript": "sdkTypeScript"} - for component, baseline, selected, target in selections: - key = names.get(component.key, component.key) - declared_tag = f"{component.tag_prefix}{versions[key]}" - if declared_tag != (target if selected else baseline): - raise ManifestError(f"declared {key} release does not match the release plan") - except ManifestError as error: - print(f"Compatibility preflight failed: {error}", file=sys.stderr) - return 1 - output_path.write_text("\n".join(output_lines) + "\n", encoding="utf-8") return 0 diff --git a/script/release/changed_components_integ_test.py b/script/release/changed_components_integ_test.py index f046f4b05..5fd375dfc 100644 --- a/script/release/changed_components_integ_test.py +++ b/script/release/changed_components_integ_test.py @@ -9,7 +9,6 @@ from __future__ import annotations import subprocess -import json import tempfile import unittest from pathlib import Path @@ -125,7 +124,6 @@ def test_shared_native_change_triggers_dependent_sdks(self) -> None: def test_server_change_triggers_server_and_cli(self) -> None: self.change("server/main.go") - self.write_declaration("server", "cli") result, values = self.plan() self.assertEqual(result.returncode, 0, result.stderr) self.assert_selected(values, "server", "cli") @@ -157,37 +155,10 @@ def test_mixed_changes_select_multiple_releases(self) -> None: self.change("sdk-go/client.go") self.change("sdk-typescript/package.json") self.change("protos/api.proto") - self.write_declaration("go", "typescript", "server", "cli") result, values = self.plan() self.assertEqual(result.returncode, 0, result.stderr) self.assert_selected(values, "go", "typescript", "server", "cli") - def write_declaration(self, *selected: str) -> None: - source = SCRIPT.parents[2] / "release/compatibility/0.9.0.json" - declaration = json.loads(source.read_text(encoding="utf-8")) - declaration["release"] = "1.2.3" - names = {"go": "sdkGo", "rust": "sdkRust", "java": "sdkJava", "python": "sdkPython", "typescript": "sdkTypeScript"} - declaration["componentVersions"] = { - names.get(key, key): "1.2.3" if key in selected else "0.1.0" - for key in COMPONENT_KEYS - } - path = self.repository / "release/compatibility/1.2.3.json" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(declaration), encoding="utf-8") - - def test_server_release_requires_declaration_before_creating_tags(self) -> None: - self.change("server/main.go") - result, _ = self.plan() - self.assertNotEqual(result.returncode, 0) - self.assertIn("cannot read compatibility declaration", result.stderr) - - def test_partial_release_rejects_declaring_unpublished_sdk_version(self) -> None: - self.change("server/main.go") - self.write_declaration("server", "cli", "go") - result, _ = self.plan() - self.assertNotEqual(result.returncode, 0) - self.assertIn("declared sdkGo release does not match", result.stderr) - def test_missing_baseline_selects_first_release(self) -> None: self.git("tag", "-d", "sdk-go/v0.1.0") result, values = self.plan() diff --git a/script/release/compatibility_manifest.py b/script/release/compatibility_manifest.py deleted file mode 100644 index f7d1aded9..000000000 --- a/script/release/compatibility_manifest.py +++ /dev/null @@ -1,385 +0,0 @@ -# Copyright (c) 2026 Super Durable, Inc. -# -# Licensed under the Sustainable Use License 1.0. -# You may not use this file except in compliance with the License. -# See the LICENSE file in the repository root. -# -# SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 - -from __future__ import annotations - -import argparse -import hashlib -import json -import re -import subprocess -import sys -import tempfile -from dataclasses import dataclass -from pathlib import Path -from typing import Any - - -VERSION_PATTERN = re.compile( - r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)" - r"(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$" -) -COMMIT_PATTERN = re.compile(r"^[0-9a-f]{40}$") -DIGEST_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$") -CHECKSUM_PATTERN = re.compile(r"^[0-9a-f]{64}$") - - -@dataclass(frozen=True) -class Component: - key: str - tag_prefix: str - - def tag(self, version: str) -> str: - return f"{self.tag_prefix}{version}" - - -COMPONENTS = ( - Component("server", "server/v"), - Component("cli", "cli-v"), - Component("sdkGo", "sdk-go/v"), - Component("sdkJava", "sdk-java/v"), - Component("sdkPython", "sdk-python/v"), - Component("sdkRust", "sdk-rust/v"), - Component("sdkTypeScript", "sdk-typescript/v"), -) - -PROTOCOL_SOURCES = { - "server": ( - "server/service/version.go", - r"MinimumSupportedProtocolVersion\s+uint32\s*=\s*(\d+)", - r"CurrentProtocolVersion\s+uint32\s*=\s*(\d+)", - ), - "cli": ( - "cli/internal/command/version.go", - r"minimumSupportedServerProtocolVersion\s+uint32\s*=\s*(\d+)", - r"maximumSupportedServerProtocolVersion\s+uint32\s*=\s*(\d+)", - ), - "sdkGo": ( - "sdk-go/dex/server_protocol.go", - r"minimumSupportedServerProtocolVersion\s+uint32\s*=\s*(\d+)", - r"maximumSupportedServerProtocolVersion\s+uint32\s*=\s*(\d+)", - ), - "sdkJava": ( - "sdk-java/src/main/java/io/superdurable/dex/ServerProtocolCompatibility.java", - r"MINIMUM_SUPPORTED_SERVER_PROTOCOL_VERSION\s*=\s*(\d+)", - r"MAXIMUM_SUPPORTED_SERVER_PROTOCOL_VERSION\s*=\s*(\d+)", - ), - "sdkPython": ( - "sdk-python/dex/_server_protocol.py", - r"MINIMUM_SUPPORTED_SERVER_PROTOCOL_VERSION\s*=\s*(\d+)", - r"MAXIMUM_SUPPORTED_SERVER_PROTOCOL_VERSION\s*=\s*(\d+)", - ), - "sdkRust": ( - "sdk-rust/crates/dex-sdk/src/server_protocol.rs", - r"MINIMUM_SUPPORTED_SERVER_PROTOCOL_VERSION:\s*u32\s*=\s*(\d+)", - r"MAXIMUM_SUPPORTED_SERVER_PROTOCOL_VERSION:\s*u32\s*=\s*(\d+)", - ), - "sdkTypeScript": ( - "sdk-typescript/src/server-protocol.ts", - r"minimumSupportedServerProtocolVersion:\s*number\s*=\s*(\d+)", - r"maximumSupportedServerProtocolVersion:\s*number\s*=\s*(\d+)", - ), -} - -EXPECTED_CHECKSUM_NAMES = ( - "dexcli_v{version}_darwin_amd64.tar.gz", - "dexcli_v{version}_darwin_arm64.tar.gz", - "dexcli_v{version}_linux_amd64.tar.gz", - "dexcli_v{version}_linux_arm64.tar.gz", -) - - -class ManifestError(RuntimeError): - pass - - -def run(*command: str) -> str: - try: - result = subprocess.run( - command, - check=True, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - except FileNotFoundError as error: - raise ManifestError(f"required command is unavailable: {command[0]}") from error - except subprocess.CalledProcessError as error: - detail = error.stderr.strip() or error.stdout.strip() - raise ManifestError(f"command failed: {' '.join(command)}: {detail}") from error - return result.stdout - - -def git(*arguments: str) -> str: - return run("git", *arguments).strip() - - -def load_declaration(path: Path, version: str) -> dict[str, Any]: - try: - declaration = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as error: - raise ManifestError(f"cannot read compatibility declaration: {path}: {error}") from error - expected_keys = { - "schemaVersion", - "release", - "rolloutOrder", - "runningFlowsCompatibility", - "persistenceCompatibility", - "protocol", - } - if not isinstance(declaration, dict) or set(declaration) - {"componentVersions"} != expected_keys: - raise ManifestError("compatibility declaration has unexpected fields") - if declaration["schemaVersion"] != 1 or declaration["release"] != version: - raise ManifestError("compatibility declaration version does not match the requested release") - if declaration["rolloutOrder"] not in {"server-first"}: - raise ManifestError("rolloutOrder must be server-first") - if declaration["runningFlowsCompatibility"] not in {"compatible", "cancel-required"}: - raise ManifestError("runningFlowsCompatibility is invalid") - if declaration["persistenceCompatibility"] not in { - "compatible", - "migration-required", - "reset-required", - }: - raise ManifestError("persistenceCompatibility is invalid") - validate_protocol_declaration(declaration["protocol"]) - component_versions(declaration) - return declaration - - -def component_versions(declaration: dict[str, Any]) -> dict[str, str]: - versions = declaration.get("componentVersions", { - component.key: declaration["release"] for component in COMPONENTS - }) - if not isinstance(versions, dict) or set(versions) != {component.key for component in COMPONENTS}: - raise ManifestError("componentVersions must name every component") - if any(not isinstance(version, str) or not VERSION_PATTERN.fullmatch(version) for version in versions.values()): - raise ManifestError("componentVersions contains an invalid version") - if versions["server"] != declaration["release"]: - raise ManifestError("Server component version must match the release") - return versions - - -def validate_protocol_declaration(protocol: Any) -> None: - if not isinstance(protocol, dict) or set(protocol) != {"server", "clients"}: - raise ManifestError("protocol declaration has unexpected fields") - clients = protocol["clients"] - expected_clients = {component.key for component in COMPONENTS if component.key != "server"} - if not isinstance(clients, dict) or set(clients) != expected_clients: - raise ManifestError("protocol clients do not match released clients") - for name, interval in (("server", protocol["server"]), *clients.items()): - if not isinstance(interval, dict) or set(interval) != {"minimum", "maximum"}: - raise ManifestError(f"protocol interval is invalid: {name}") - minimum = interval["minimum"] - maximum = interval["maximum"] - if ( - not isinstance(minimum, int) - or isinstance(minimum, bool) - or not isinstance(maximum, int) - or isinstance(maximum, bool) - or minimum <= 0 - or maximum < minimum - ): - raise ManifestError(f"protocol interval is invalid: {name}") - - -def resolve_release_commits(versions: dict[str, str], verify_releases: bool) -> tuple[dict[str, str], dict[str, str]]: - tags = {component.key: component.tag(versions[component.key]) for component in COMPONENTS} - commits: dict[str, str] = {} - for key, tag in tags.items(): - commit = git("rev-parse", f"refs/tags/{tag}^{{commit}}") - if not COMMIT_PATTERN.fullmatch(commit): - raise ManifestError(f"release tag did not resolve to a commit: {tag}") - commits[key] = commit - if verify_releases: - metadata = json.loads( - run( - "gh", - "release", - "view", - tag, - "--json", - "tagName,targetCommitish", - ) - ) - if metadata.get("tagName") != tag or metadata.get("targetCommitish") != commit: - raise ManifestError(f"GitHub release target does not match tag commit: {tag}") - return commits, tags - - -def read_protocol_interval(commit: str, key: str) -> dict[str, int]: - path, minimum_pattern, maximum_pattern = PROTOCOL_SOURCES[key] - source = git("show", f"{commit}:{path}") - minimum_match = re.search(minimum_pattern, source) - maximum_match = re.search(maximum_pattern, source) - if minimum_match is None or maximum_match is None: - raise ManifestError(f"cannot read protocol interval from {path}") - return { - "minimum": int(minimum_match.group(1)), - "maximum": int(maximum_match.group(1)), - } - - -def verify_protocols(commits: dict[str, str], declared: dict[str, Any]) -> None: - intervals = {"server": declared["server"], **declared["clients"]} - for key, expected in intervals.items(): - actual = read_protocol_interval(commits[key], key) - if actual != expected: - raise ManifestError( - f"protocol declaration does not match {key} source: expected {expected}, got {actual}" - ) - server = declared["server"] - if max(actual["minimum"], server["minimum"]) > min(actual["maximum"], server["maximum"]): - raise ManifestError(f"{key} protocol does not overlap Server protocol") - - -def parse_cli_checksums(path: Path, version: str) -> dict[str, str]: - checksums: dict[str, str] = {} - try: - lines = path.read_text(encoding="utf-8").splitlines() - except OSError as error: - raise ManifestError(f"cannot read CLI checksums: {path}: {error}") from error - for line in lines: - parts = line.split() - if len(parts) != 2: - raise ManifestError(f"invalid CLI checksum line: {line}") - checksum, name = parts - name = name.removeprefix("*").removeprefix("./") - if not CHECKSUM_PATTERN.fullmatch(checksum) or name in checksums: - raise ManifestError(f"invalid CLI checksum entry: {line}") - checksums[name] = checksum - expected_names = {name.format(version=version) for name in EXPECTED_CHECKSUM_NAMES} - if set(checksums) != expected_names: - raise ManifestError("CLI checksums do not contain exactly four supported archives") - return dict(sorted(checksums.items())) - - -def download_cli_checksums(tag: str) -> tuple[tempfile.TemporaryDirectory[str], Path]: - directory = tempfile.TemporaryDirectory(prefix="dex-compatibility-") - run( - "gh", - "release", - "download", - tag, - "--pattern", - "checksums.txt", - "--dir", - directory.name, - ) - return directory, Path(directory.name) / "checksums.txt" - - -def verify_cli_release_assets(tag: str, checksums: dict[str, str]) -> None: - metadata = json.loads(run("gh", "release", "view", tag, "--json", "assets")) - assets = { - asset.get("name"): asset.get("digest") - for asset in metadata.get("assets", []) - if isinstance(asset, dict) - } - for name, checksum in checksums.items(): - if assets.get(name) != f"sha256:{checksum}": - raise ManifestError(f"CLI checksum does not match release asset digest: {name}") - - -def resolve_server_digest(version: str) -> str: - image = f"docker.io/superdurable/dex-server:v{version}" - output = run("docker", "buildx", "imagetools", "inspect", image) - match = re.search(r"^Digest:\s+(sha256:[0-9a-f]{64})$", output, re.MULTILINE) - if match is None: - raise ManifestError(f"cannot resolve immutable image digest: {image}") - return match.group(1) - - -def require_digest(value: str) -> str: - if not DIGEST_PATTERN.fullmatch(value): - raise ManifestError("Server image digest must be sha256:<64 lowercase hex characters>") - return value - - -def build_manifest( - version: str, - declaration_path: Path, - server_digest: str, - cli_checksums_path: Path, - verify_releases: bool, -) -> dict[str, Any]: - if not VERSION_PATTERN.fullmatch(version): - raise ManifestError(f"invalid semantic version: {version}") - declaration = load_declaration(declaration_path, version) - versions = component_versions(declaration) - commits, tags = resolve_release_commits(versions, verify_releases) - verify_protocols(commits, declaration["protocol"]) - checksums = parse_cli_checksums(cli_checksums_path, versions["cli"]) - if verify_releases: - verify_cli_release_assets(tags["cli"], checksums) - components: dict[str, Any] = {} - for component in COMPONENTS: - value: dict[str, Any] = {"version": versions[component.key], "tag": tags[component.key]} - if component.key == "server": - value["image"] = ( - "docker.io/superdurable/dex-server@" + require_digest(server_digest) - ) - elif component.key == "cli": - value["checksums"] = checksums - components[component.key] = value - return { - **{key: value for key, value in declaration.items() if key != "componentVersions"}, - "sourceCommit": commits["server"], - "components": components, - } - - -def write_manifest(path: Path, manifest: dict[str, Any]) -> str: - encoded = (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode("utf-8") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(encoded) - return hashlib.sha256(encoded).hexdigest() - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("version") - parser.add_argument("--declaration", type=Path) - parser.add_argument("--output", required=True, type=Path) - parser.add_argument("--server-image-digest") - parser.add_argument("--cli-checksums", type=Path) - parser.add_argument("--verify-releases", action="store_true") - arguments = parser.parse_args() - declaration_path = arguments.declaration or ( - Path("release") / "compatibility" / f"{arguments.version}.json" - ) - temporary_directory: tempfile.TemporaryDirectory[str] | None = None - try: - declaration = load_declaration(declaration_path, arguments.version) - versions = component_versions(declaration) - server_digest = arguments.server_image_digest or resolve_server_digest(arguments.version) - checksums_path = arguments.cli_checksums - if checksums_path is None: - temporary_directory, checksums_path = download_cli_checksums( - f"cli-v{versions['cli']}" - ) - manifest = build_manifest( - arguments.version, - declaration_path, - server_digest, - checksums_path, - arguments.verify_releases, - ) - digest = write_manifest(arguments.output, manifest) - print(f"manifest={arguments.output}") - print(f"sha256={digest}") - return 0 - except ManifestError as error: - print(f"compatibility manifest failed: {error}", file=sys.stderr) - return 1 - finally: - if temporary_directory is not None: - temporary_directory.cleanup() - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/script/release/compatibility_manifest_integ_test.py b/script/release/compatibility_manifest_integ_test.py deleted file mode 100644 index 5b5735467..000000000 --- a/script/release/compatibility_manifest_integ_test.py +++ /dev/null @@ -1,155 +0,0 @@ -# Copyright (c) 2026 Super Durable, Inc. -# -# Licensed under the Sustainable Use License 1.0. -# You may not use this file except in compliance with the License. -# See the LICENSE file in the repository root. -# -# SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 - -from __future__ import annotations - -import importlib.util -import json -import sys -import tempfile -import unittest -from pathlib import Path -from typing import Any - - -SCRIPT = Path(__file__).with_name("compatibility_manifest.py") -SPEC = importlib.util.spec_from_file_location("compatibility_manifest", SCRIPT) -assert SPEC and SPEC.loader -MODULE = importlib.util.module_from_spec(SPEC) -sys.modules[SPEC.name] = MODULE -SPEC.loader.exec_module(MODULE) - -REPOSITORY = SCRIPT.parents[2] -VERSION = "0.9.0" -SOURCE_COMMIT = "e93b803a829735292af8c81a0cc1c98b12aee7f7" -SERVER_DIGEST = "sha256:fdaeafe20ed7685f4a1eb8a5acedf2fb48feade2e5ca99b1c3a71a705893e1f0" -CHECKSUMS = { - "dexcli_v0.9.0_darwin_amd64.tar.gz": "071f530422e869554b2e2a2dc10ce5d917e1093a38a5af4e1438192e9c532408", - "dexcli_v0.9.0_darwin_arm64.tar.gz": "4ee2df39d0218169b5fe0fc581e9cac2c1f40e24a011ac5c5ba441eccdfd1f51", - "dexcli_v0.9.0_linux_amd64.tar.gz": "0df459cdde367191e7c962b819a1491073b614da93f5459129f38f90970a7016", - "dexcli_v0.9.0_linux_arm64.tar.gz": "68f5771cde6ae4a1cfb8c78efb35881765273d4727d6353de41d6b4252476d67", -} - - -class CompatibilityManifestIntegrationTest(unittest.TestCase): - def write_checksums(self, directory: Path, checksums: dict[str, str] | None = None) -> Path: - path = directory / "checksums.txt" - values = checksums or CHECKSUMS - path.write_text( - "".join(f"{checksum} ./{name}\n" for name, checksum in values.items()), - encoding="utf-8", - ) - return path - - def declaration(self) -> dict[str, Any]: - return json.loads( - (REPOSITORY / "release" / "compatibility" / f"{VERSION}.json").read_text( - encoding="utf-8" - ) - ) - - def test_builds_manifest_from_released_component_tags(self) -> None: - with tempfile.TemporaryDirectory() as directory_name: - directory = Path(directory_name) - manifest = MODULE.build_manifest( - VERSION, - REPOSITORY / "release" / "compatibility" / f"{VERSION}.json", - SERVER_DIGEST, - self.write_checksums(directory), - False, - ) - digest = MODULE.write_manifest(directory / "manifest.json", manifest) - self.assertEqual(digest, "dc09203a2d785008d4449e23f70bd3598107e82d5f7934d86f49f1c534310906") - self.assertEqual(manifest["sourceCommit"], SOURCE_COMMIT) - self.assertEqual( - manifest["components"]["server"]["image"], - "docker.io/superdurable/dex-server@" + SERVER_DIGEST, - ) - self.assertEqual(manifest["components"]["cli"]["checksums"], CHECKSUMS) - self.assertEqual(manifest["protocol"]["server"], {"minimum": 1, "maximum": 1}) - self.assertEqual(set(manifest["components"]), {component.key for component in MODULE.COMPONENTS}) - - def test_rejects_protocol_declaration_drift(self) -> None: - declaration = self.declaration() - declaration["protocol"]["clients"]["sdkGo"]["maximum"] = 2 - with tempfile.TemporaryDirectory() as directory_name: - directory = Path(directory_name) - declaration_path = directory / "declaration.json" - declaration_path.write_text(json.dumps(declaration), encoding="utf-8") - with self.assertRaisesRegex(MODULE.ManifestError, "does not match sdkGo source"): - MODULE.build_manifest( - VERSION, - declaration_path, - SERVER_DIGEST, - self.write_checksums(directory), - False, - ) - - def test_partial_release_uses_each_published_component_tag(self) -> None: - with tempfile.TemporaryDirectory() as directory_name: - directory = Path(directory_name) - checksums = {name.replace("0.9.0", "0.10.0"): value for name, value in CHECKSUMS.items()} - manifest = MODULE.build_manifest( - "0.10.0", - REPOSITORY / "release/compatibility/0.10.0.json", - SERVER_DIGEST, - self.write_checksums(directory, checksums), - False, - ) - self.assertEqual(manifest["sourceCommit"], "90dbc4ef121d575e4f79505cde0889cf49583fc2") - self.assertEqual(manifest["components"]["sdkGo"]["version"], "0.10.0") - self.assertEqual(manifest["components"]["sdkJava"]["tag"], "sdk-java/v0.9.0") - self.assertEqual(manifest["components"]["cli"]["checksums"], checksums) - - def test_partial_release_checks_retained_sdk_protocol_source(self) -> None: - declaration = json.loads((REPOSITORY / "release/compatibility/0.10.0.json").read_text()) - declaration["protocol"]["clients"]["sdkJava"]["maximum"] = 2 - with tempfile.TemporaryDirectory() as directory_name: - directory = Path(directory_name) - declaration_path = directory / "declaration.json" - declaration_path.write_text(json.dumps(declaration), encoding="utf-8") - with self.assertRaisesRegex(MODULE.ManifestError, "does not match sdkJava source"): - MODULE.build_manifest("0.10.0", declaration_path, SERVER_DIGEST, self.write_checksums(directory), False) - - def test_workflow_requires_manifest_for_partial_server_release(self) -> None: - workflow = (REPOSITORY / ".github/workflows/release-changed-components.yml").read_text() - condition = workflow.split(" publish-compatibility:", 1)[1].split(" uses:", 1)[0] - self.assertIn("needs.plan.outputs.server == 'true'", condition) - self.assertNotIn("needs.plan.outputs.java == 'true'", condition) - self.assertIn("needs.plan.outputs.java != 'true' || needs.publish-java.result == 'success'", condition) - self.assertIn('if [[ "${SERVER_SELECTED}" == "true" && "${COMPATIBILITY_RESULT}" != "success" ]]', workflow) - - def test_rejects_missing_cli_archive(self) -> None: - checksums = dict(CHECKSUMS) - checksums.pop("dexcli_v0.9.0_linux_arm64.tar.gz") - with tempfile.TemporaryDirectory() as directory_name: - directory = Path(directory_name) - with self.assertRaisesRegex(MODULE.ManifestError, "exactly four"): - MODULE.build_manifest( - VERSION, - REPOSITORY / "release" / "compatibility" / f"{VERSION}.json", - SERVER_DIGEST, - self.write_checksums(directory, checksums), - False, - ) - - def test_rejects_mutable_server_image(self) -> None: - with tempfile.TemporaryDirectory() as directory_name: - directory = Path(directory_name) - with self.assertRaisesRegex(MODULE.ManifestError, "Server image digest"): - MODULE.build_manifest( - VERSION, - REPOSITORY / "release" / "compatibility" / f"{VERSION}.json", - "v0.9.0", - self.write_checksums(directory), - False, - ) - - -if __name__ == "__main__": - unittest.main()