diff --git a/.github/workflows/validation.yml b/.github/workflows/validation.yml new file mode 100644 index 0000000..a96d387 --- /dev/null +++ b/.github/workflows/validation.yml @@ -0,0 +1,51 @@ +name: Pull request validation + +on: + pull_request: + types: [opened, synchronize, reopened, edited, ready_for_review] + push: + branches: [main] + +permissions: + contents: read + +jobs: + tests-package: + name: Tests and package (Python ${{ matrix.python }}) + runs-on: ubuntu-24.04 + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + python: ['3.11', '3.13'] + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: ${{ matrix.python }} + - name: Build wheel + run: | + python -m pip install build + python -m build --wheel + - name: Test installed wheel and interfaces + run: python scripts/check_package.py --repository . --wheel-directory dist + + release-check: + name: Release check + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: '3.13' + - name: Install validator + run: python -m pip install . + - name: Discover releases and validate additions + run: python -m container_platform.ci --repository . --github-event diff --git a/README.md b/README.md index 8daa816..e92e86c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Container Platform defines the contract for an **Online Shop** release composed The project does not build application code or operate a Kubernetes cluster. Its target workflow turns images published to Docker Hub into a versioned release bundle pinned by digests for review, validation and promotion to the SRE Platform. -> **Status:** Release contract v1 is defined; a digest-pinned baseline candidate and profile inventory are available for review. Offline and opt-in registry metadata checks are implemented; PR gates and integrations remain planned. +> **Status:** Release contract v1 is defined; a digest-pinned baseline candidate and profile inventory are available for review. Offline and opt-in registry metadata checks are implemented; Pull request checks are available; required-check rules are owner-configured. Compose and SRE integrations remain planned. ## Why this platform exists @@ -39,7 +39,7 @@ The two paths into the SRE Platform have different roles: - Docker Hub is the data path: Kubernetes nodes pull the approved images from `dmitryzhuravlev/online-shop-*`. - Container Platform is the release-control path: it identifies the exact image digests that are approved together as one Online Shop release. -The diagram describes target relationships. The offline validator and opt-in Docker Hub metadata checks are implemented. GitHub Actions gates, Compose integration and SRE handoff remain planned. +The diagram describes target relationships. The offline validator and opt-in Docker Hub metadata checks are implemented. GitHub Actions validation checks are available; required-check configuration remains an owner action. Compose integration and SRE handoff remain planned. ## Responsibilities @@ -112,3 +112,7 @@ The committed baseline passes structure and inventory checks but returns exit co ## Optional Docker Hub verification Add `--registry` to inspect immutable manifest/config bytes and declared platforms after local prerequisites pass. Legacy failures remain visible and keep the baseline unsuccessful. No layers are downloaded, and metadata success grants no approval or deployment authorization. See [transport restrictions, output v2 and limits](docs/registry-verification.md). + +## Pull request checks + +[GitHub Actions validation](docs/pull-request-validation.md) runs tests/package checks on Python 3.11 and 3.13 and a stable release check. New candidates receive opt-in registry validation; merged release files are immutable. No candidate changes produces an explicit successful result. The historical legacy baseline remains a negative regression fixture. Workflow availability does not configure required merge checks or grant release approval. diff --git a/docs/pull-request-validation.md b/docs/pull-request-validation.md new file mode 100644 index 0000000..41823ed --- /dev/null +++ b/docs/pull-request-validation.md @@ -0,0 +1,62 @@ +# Pull request validation + +The `Pull request validation` workflow runs on every `pull_request` opened, synchronized, reopened, edited or marked ready for review, and on pushes to `main`. There are no path filters: documentation-only changes still produce explicit check results. + +## Stable checks and scope + +| Check name | Scope | +|---|---| +| `Tests and package (Python 3.11)` | Minimum supported Python; offline/mocked tests and installed-wheel smoke checks | +| `Tests and package (Python 3.13)` | Newer supported Python; same checks | +| `Release check` | Git discovery/immutability and opt-in registry validation of new candidates | + +Both matrix entries use GitHub-hosted Ubuntu 24.04 with a 15-minute job timeout. The release job has a 20-minute timeout. This small Python matrix exercises the supported minimum and a newer interpreter without duplicating operating-system jobs. All checkout/setup Actions are pinned to full official commit SHAs with major-version comments. Checkout fetches full history (`fetch-depth: 0`) and does not persist credentials. + +The wheel is built, installed into a new temporary venv, and tested there. The helper executes the module and console commands outside the checkout, compares the packaged schema with the canonical JSON, and asserts the committed baseline's **expected negative** offline result: seven passing prerequisite stages, 12 `legacy_ineligible` diagnostics, exit 1. Automated tests use mocked registry exchanges; this suite makes no real registry requests. The baseline remains ineligible and is never granted an exception to make CI pass. + +## Candidate paths and cumulative comparison + +Candidate locations are direct-child `releases/*.json` files except `*.observations.json`. `examples/`, nested release directories and observation records are excluded. Selection is based on Git filenames, not the ability to parse JSON or read `purpose`: malformed new manifests remain selected for the existing CLI to reject. A synthetic example moved into the candidate location is selected and rejected by its candidate-purpose check. + +For pull requests, event `pull_request.base.sha` and `head.sha` are validated as full commit objects; their merge-base defines the cumulative change set. This includes all commits in the PR, rather than only the most recent commit. Releases added to the current base after divergence are also considered when checking path/ID collisions; they are not treated as deletions by an older feature branch. Local result-tree identity checking combines the immutable base release catalog with head additions. It does not attempt to resolve unrelated merge conflicts; GitHub mergeability/review remains separate. + +Push events use exact `before` and `after` trees, including multi-commit updates. Missing commits, unavailable merge-bases, all-zero initial/deleted revisions and unsupported events fail explicitly. There is no checkout/fetch or `HEAD~1` fallback inside orchestration. Required inventory objects must already be present locally; the release CLI reports missing objects rather than substituting working-tree content. Full checkout history supplies normal reachable baseline/inventory history, but cannot guarantee arbitrary unpublished references exist. + +Diff names and trees use NUL-delimited Git output. Filenames remain data, never shell fragments. Non-UTF-8 Git filenames fail explicitly. Candidate content is read from regular blobs in the selected head revision and passed to the CLI through a temporary fixed-name file. Symlinks/gitlinks are not new candidate files. Summaries identify only counts/results; detailed CLI diagnostics in step logs use safe candidate/component indices, without copying arbitrary release IDs or paths into shell commands or summaries. + +## Immutability and identity rules + +- Added candidates are validated with the installed `python -m container_platform ... --registry --format json` interface. +- Modification, deletion or rename of a candidate already present in the comparison base fails discovery/immutability before registry access. Moving it into observations, examples or another directory is also forbidden. +- Moving an excluded file into a candidate location is a new candidate and receives normal validation. Changing an observation record alone is not candidate discovery. +- Every new candidate must have a new `releaseId` relative to other candidates in the resulting release catalog. Historical candidates are read for identity only, not rechecked for registry eligibility. If an existing identity cannot be read, uniqueness cannot be established and an addition fails. Malformed new identities are left for CLI diagnostics, not silently excluded. +- With no candidate changes, `Release check` returns **exit 0 / `no_candidate_changes`**, with an explicit small summary. The historical baseline is not inspected against the live registry on unrelated PRs. + +These storage rules apply to every previously merged candidate manifest, including historical and not-yet-approved candidates. Immutability does not establish release approval. Corrections require a new file and a new release ID. A candidate newly introduced in the current PR can be edited or renamed repeatedly before merge: only the cumulative final change relative to base is evaluated. A rename of a file already in base remains forbidden. + +All new candidates must satisfy both offline and registry rules. CLI exits **1 and 2 fail the release check**, including legacy ineligibility, missing inventory objects, registry outages, rate limits, timeouts and execution failures. There is no `continue-on-error` or blanket allow-failure. Independent traceability/registry diagnostics are retained in the JSON step log. The orchestration result preserves candidate exit codes and gives execution errors precedence when multiple candidates fail. A blocked registry must be investigated/retried after recovery, not treated as eligibility success. Normal registry request/retry/response bounds remain as [documented](registry-verification.md). + +## Local execution + +After package installation, use explicit full commit SHAs (replace the example labels with actual SHAs): + +```text +python -m container_platform.ci --repository . --base BASE_SHA --head HEAD_SHA --pull-request +python -m container_platform.ci --repository . --base BEFORE_SHA --head AFTER_SHA +python -m unittest discover -s tests -v +python -m pip install build +python -m build --wheel +python scripts/check_package.py --repository . --wheel-directory dist +``` + +Use a fresh `dist` directory containing exactly one project wheel. The package helper makes its own clean temporary venv. New valid candidates cause real registry requests in orchestration; unit tests inject responses or use malformed inputs rejected before networking. `--github-event` is the workflow form and reads `GITHUB_EVENT_NAME` and `GITHUB_EVENT_PATH` directly, without interpolating event strings into shell commands. + +Orchestration JSON version 1 includes the overall result/exit, discovery result, new-candidate count, stable diagnostic codes and ordered candidate results containing the existing CLI reports. No candidate change returns 0; immutability/identity or CLI validation failure returns 1; comparison/usage/execution failure returns 2. The summary is limited to tests/package outcome or candidate count, discovery result and validation scope. It contains no large raw reports or local-machine/TLS-investigation details. + +## Owner-configured merge protection + +This change supplies workflow checks; it does **not** configure required checks or protect merges. The owner should configure a `main` ruleset/branch protection requiring pull-request review and all three stable checks above. Require the PR branch to be current with the base before merging, or introduce an explicitly designed merge-queue workflow later; `merge_group` is not supported here. Review ownership should cover workflows, validator/orchestration, schema, inventory, package helper and tests. Configure owner review requirements separately; no CODEOWNERS identities or rulesets are created automatically by this task. + +Only `pull_request` executes PR code, never `pull_request_target`. Jobs use GitHub-hosted unprivileged runners and `contents: read`; there are no registry/cloud credentials, new secrets, write-token/comment jobs, OIDC permissions or privileged workflow handoffs. The workflow tests code supplied by the PR author, including workflow/validator changes: it is **not independent tamper-proof enforcement**. Approval and required owner review/rules remain essential. Fork workflow approval or Actions organization policies may require owner action before jobs run. + +Checks establish regression behavior and selected candidate eligibility under implemented policy, not application compatibility, container-release approval or SRE deployment authorization. Compose integration, image publication, promotion and legacy exceptions remain outside this change. diff --git a/docs/release-contract.md b/docs/release-contract.md index 6518687..28666f3 100644 --- a/docs/release-contract.md +++ b/docs/release-contract.md @@ -55,7 +55,7 @@ The pull reference is `repository@digest`. Tags are not accepted selectors. For 5. **SRE promotion (planned):** SRE separately authorizes suitability for a specific environment through a reviewed GitOps change. It must record the full immutable Container Platform commit SHA and manifest path, and consume exactly that image set. A moving branch/tag is insufficient. Checking rendered SRE values against the manifest, making that check mandatory and protecting merges remain future implementation; bypasses are not blocked today. 6. **Deployment and runtime:** Argo CD applies environment configuration; Kubernetes pulls from Docker Hub. SRE evaluates rollout health, observability, data/state constraints and operational acceptance. -Evidence must be rerun or explicitly re-evaluated after candidate changes; results for another revision do not automatically apply. The offline rules are executable locally; mandatory PR gates and authorization policy remain future implementation. This contract does not claim protected merges or deployment enforcement. +Evidence must be rerun or explicitly re-evaluated after candidate changes; results for another revision do not automatically apply. The offline rules are executable locally; [PR validation workflows](pull-request-validation.md) are available; owner-required check configuration and authorization policy remain separate actions. This contract does not claim protected merges or deployment enforcement. ## Rollback @@ -77,4 +77,6 @@ Draft202012Validator.check_schema(schema) Draft202012Validator(schema).validate(example) ``` -The snippet above checks structure only. Use the [offline CLI](offline-validation.md) for immutable-inventory and traceability evaluation; [Opt-in registry metadata verification](registry-verification.md) is implemented; CI gates remain planned. +The snippet above checks structure only. Use the [offline CLI](offline-validation.md) for immutable-inventory and traceability evaluation; [Opt-in registry metadata verification](registry-verification.md) is implemented; [PR checks](pull-request-validation.md) are available; required merge protection remains an owner action. + +Merged manifest files remain immutable even before release approval; corrections use a new file and release ID. The [PR checks](pull-request-validation.md) evaluate cumulative additions and do not grant eligibility to historical legacy fixtures. diff --git a/docs/roadmap.md b/docs/roadmap.md index 2c11dd7..6722f5e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -33,7 +33,7 @@ The platform must remain focused on image-release composition and validation. It - define the promotion and rollback semantics at a repository level; - define MVP non-goals and trust boundaries. -Artifacts: [architecture](architecture.md), [canonical contract](release-contract.md), [v1 schema](../schemas/release-v1.schema.json), and [non-deployable example](../examples/release-v1.example.json). Offline validation and opt-in registry metadata verification are implemented; PR gates and integrations remain planned. +Artifacts: [architecture](architecture.md), [canonical contract](release-contract.md), [v1 schema](../schemas/release-v1.schema.json), and [non-deployable example](../examples/release-v1.example.json). Offline validation and opt-in registry metadata verification are implemented; PR validation workflows are available; owner-required checks and integrations remain separate work. ### Target outcome @@ -87,6 +87,8 @@ Invalid, incomplete, mutable, or unavailable releases cannot be presented as val **Goal:** Make release validation repeatable and prove that the published images compose into an application. +[Pull request checks](pull-request-validation.md) provide tests/package and new-candidate validation. Required-check settings are owner-configured; Compose integration remains unimplemented, so this milestone is not complete. + ### Scope - add GitHub Actions checks for release-manifest pull requests; diff --git a/scripts/check_package.py b/scripts/check_package.py new file mode 100644 index 0000000..61881cb --- /dev/null +++ b/scripts/check_package.py @@ -0,0 +1,76 @@ +"""Build output smoke test in a clean venv, independent of checkout cwd.""" + +import argparse +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import venv + + +def run(args, cwd, env, expected=0): + result = subprocess.run(args, cwd=cwd, env=env, capture_output=True, text=True, check=False, timeout=600) + if result.returncode != expected: + print(result.stdout) + print(result.stderr, file=sys.stderr) + raise RuntimeError("Package/test command did not produce the expected exit code.") + return result.stdout + + +def check(repository, wheel_directory): + repo = Path(repository).resolve() + wheels = list(Path(wheel_directory).resolve().glob("container_platform-*.whl")) + if len(wheels) != 1: + raise RuntimeError("Exactly one Container Platform wheel is required.") + env = {k: v for k, v in os.environ.items() if k not in ("PYTHONPATH", "PYTHONHOME")} + with tempfile.TemporaryDirectory(prefix="package-check-") as temporary: + outside = Path(temporary) + environment = outside / "venv" + venv.EnvBuilder(with_pip=True).create(environment) + binaries = environment / ("Scripts" if os.name == "nt" else "bin") + python = str(binaries / ("python.exe" if os.name == "nt" else "python")) + console = str(binaries / ("container-release.exe" if os.name == "nt" else "container-release")) + run([python, "-m", "pip", "install", str(wheels[0])], outside, env) + # Tests import the installed wheel, not an editable source package. + run([python, "-m", "unittest", "discover", "-s", str(repo / "tests"), "-v"], repo, env) + resource = run([python, "-c", "from importlib.resources import files; print(files('container_platform.schemas').joinpath('release-v1.schema.json').read_text(encoding='utf-8'), end='')"], outside, env) + if resource != (repo / "schemas/release-v1.schema.json").read_text(encoding="utf-8"): + raise RuntimeError("Packaged schema differs from the canonical source.") + reports = [] + for command in ([python, "-m", "container_platform"], [console]): + output = run([*command, str(repo / "releases/baseline-2026-10-18.json"), "--repository", str(repo), "--format", "json"], outside, env, expected=1) + report = json.loads(output) + if (report["outputSchemaVersion"] != 1 or report["exitCode"] != 1 + or any(stage["status"] != "pass" for stage in report["checks"][:-1]) + or [d["code"] for d in report["checks"][-1]["diagnostics"]] != ["legacy_ineligible"] * 12): + raise RuntimeError("Baseline regression semantics changed.") + reports.append(report) + if reports[0] != reports[1]: + raise RuntimeError("Installed module and console results differ.") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repository", required=True) + parser.add_argument("--wheel-directory", required=True) + args = parser.parse_args() + passed = False + try: + check(args.repository, args.wheel_directory) + passed = True + print("Automated tests and installed-wheel checks passed; baseline remains legacy-ineligible.") + finally: + if os.environ.get("GITHUB_STEP_SUMMARY"): + Path(os.environ["GITHUB_STEP_SUMMARY"]).write_text( + "### Tests and package\n\n" + + ("Pass" if passed else "Fail") + + ": automated offline tests; clean installed wheel; module/console and schema outside checkout.\n" + "Baseline: expected negative regression, not release eligibility.\n", + encoding="utf-8", + ) + + +if __name__ == "__main__": + main() diff --git a/src/container_platform/ci.py b/src/container_platform/ci.py new file mode 100644 index 0000000..20bc8cd --- /dev/null +++ b/src/container_platform/ci.py @@ -0,0 +1,226 @@ +"""Git-based candidate discovery and release-check orchestration.""" + +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile + +from .cli import Parser +from .validation import Failure, git_read, strict_json + + +def candidate_path(path): + return (path.startswith("releases/") and path.count("/") == 1 + and path.endswith(".json") and not path.endswith(".observations.json")) + + +def read_git(repo, *args): + result = git_read(repo, *args) + if result.returncode: + raise Failure("comparison_objects_unavailable", "Required local Git objects are unavailable; no fetch or fallback was attempted.", execution=True) + return result.stdout + + +def commit(repo, revision): + if not isinstance(revision, str) or not re.fullmatch(r"[0-9a-f]{40}", revision): + raise Failure("comparison_revision_invalid", "Comparison requires full commit SHAs; initial/deleted push revisions are unsupported.", execution=True) + if read_git(repo, "cat-file", "-t", revision) != b"commit\n": + raise Failure("comparison_revision_invalid", "Comparison revision must name a commit.", execution=True) + + +def tree(repo, revision): + entries = {} + for row in read_git(repo, "ls-tree", "-r", "-z", "--full-tree", revision).split(b"\0"): + if not row: + continue + meta, raw_path = row.split(b"\t", 1) + try: + path = raw_path.decode("utf-8", errors="strict") + except UnicodeDecodeError: + raise Failure("comparison_filename_invalid", "Git filenames must be valid UTF-8.") from None + if candidate_path(path): + mode, kind, oid = meta.split() + entries[path] = (mode, kind, oid.decode("ascii")) + return entries + + +def changes(repo, base, head): + fields = read_git(repo, "diff", "--no-ext-diff", "--no-textconv", "--name-status", "-z", "--find-renames", base, head, "--").split(b"\0") + result = [] + index = 0 + while index < len(fields) and fields[index]: + status = fields[index].decode("ascii") + count = 2 if status[0] in ("R", "C") else 1 + try: + paths = [p.decode("utf-8", errors="strict") for p in fields[index + 1:index + count + 1]] + except UnicodeDecodeError: + raise Failure("comparison_filename_invalid", "Git filenames must be valid UTF-8.") from None + result.append((status[0], paths)) + index += count + 1 + return result + + +def discovery(repo, base, head, pull_request=False): + commit(repo, base) + commit(repo, head) + comparison = read_git(repo, "merge-base", base, head).decode("ascii").strip() if pull_request else base + old, current, base_tree = tree(repo, comparison), tree(repo, head), tree(repo, base) + new = set() + errors = [] + for status, paths in changes(repo, comparison, head): + source, target = paths[0], paths[-1] + if status == "A" or (status in ("R", "C") and not candidate_path(source)): + if candidate_path(target): + new.add(target) + if candidate_path(source) and status in ("M", "T", "D", "R"): + errors.append("merged_release_immutable") + if status == "R" and candidate_path(source) and candidate_path(target): + # A rename is forbidden, not an eligibility test for the renamed artifact. + new.discard(target) + for path in sorted(new): + if path in base_tree: + errors.append("release_path_already_merged") + if current[path][0] not in (b"100644", b"100755") or current[path][1] != b"blob": + errors.append("candidate_not_regular_file") + # PR merge policy includes releases already added to base since divergence. + resultant = {**base_tree, **current} if pull_request else current + return sorted(new), resultant, sorted(set(errors)) + + +def identities(repo, new, entries): + ids = {} + errors = [] + for path in sorted(entries): + mode, kind, oid = entries[path] + if mode not in (b"100644", b"100755") or kind != b"blob": + errors.append("release_identity_unavailable") + continue + try: + data = strict_json(read_git(repo, "cat-file", "blob", oid)) + identity = data.get("releaseId") if isinstance(data, dict) else None + if not isinstance(identity, str) or not identity: + raise Failure("release_identity_unavailable", "Cannot establish release identity.") + except Failure as failure: + if failure.execution: + raise + if path not in new: + errors.append("release_identity_unavailable") + # New malformed candidates remain selected and fail through the CLI. + continue + ids.setdefault(identity, []).append(path) + if any(len(paths) > 1 and any(p in new for p in paths) for paths in ids.values()): + errors.append("release_id_duplicate") + return sorted(set(errors)) + + +def run_candidate(repo, data): + with tempfile.TemporaryDirectory(prefix="release-check-") as temporary: + path = Path(temporary) / "candidate.json" + path.write_bytes(data) + try: + completed = subprocess.run( + [sys.executable, "-m", "container_platform", str(path), "--repository", str(repo), "--registry", "--format", "json"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, shell=False, timeout=240, + ) + except (OSError, subprocess.TimeoutExpired): + raise Failure("candidate_execution_failed", "Release CLI could not complete.", execution=True) from None + try: + report = strict_json(completed.stdout) + except Failure: + raise Failure("candidate_output_invalid", "Release CLI did not produce a valid result document.", execution=True) from None + if (completed.returncode not in (0, 1, 2) or not isinstance(report, dict) + or report.get("exitCode") != completed.returncode or report.get("outputSchemaVersion") != 2): + raise Failure("candidate_output_invalid", "Release CLI exit/result contract is inconsistent.", execution=True) + return completed.returncode, report + + +def evaluate(repo, base, head, *, pull_request=False, runner=run_candidate): + result = {"ciOutputVersion": 1, "result": "execution_error", "exitCode": 2, + "newCandidateCount": 0, "discovery": "not_evaluated", "diagnostics": [], "candidates": []} + try: + new, entries, errors = discovery(repo, base, head, pull_request) + result["newCandidateCount"] = len(new) + if new and not errors: + errors.extend(identities(repo, new, entries)) + result["discovery"] = "fail" if errors else "pass" + if errors: + result.update(result="validation_failed", exitCode=1, diagnostics=errors) + return result + if not new: + result.update(result="no_candidate_changes", exitCode=0) + return result + overall = 0 + for index, path in enumerate(new): + try: + data = read_git(repo, "cat-file", "blob", entries[path][2]) + code, report = runner(repo, data) + if code not in (0, 1, 2): + raise Failure("candidate_execution_failed", "Release CLI returned an unsupported exit code.", execution=True) + result["candidates"].append({"candidateIndex": index, "exitCode": code, "report": report}) + except Failure as failure: + code = 2 if failure.execution else 1 + result["candidates"].append({"candidateIndex": index, "exitCode": code, "diagnostic": failure.code}) + overall = max(overall, code) + result.update(exitCode=overall, result="execution_error" if overall == 2 else "validation_failed" if overall else "release_checks_passed") + except Failure as failure: + result.update(result="execution_error" if failure.execution else "validation_failed", + exitCode=2 if failure.execution else 1, diagnostics=[failure.code]) + return result + + +def comparison_from_event(name, payload): + try: + if name == "pull_request": + return payload["pull_request"]["base"]["sha"], payload["pull_request"]["head"]["sha"], True + if name == "push": + return payload["before"], payload["after"], False + except (KeyError, TypeError): + raise Failure("event_invalid", "Event comparison fields are missing.", execution=True) from None + raise Failure("event_unsupported", "Only pull_request and push events are supported.", execution=True) + + +def write_summary(result, destination): + Path(destination).write_text( + "### Release check\n\n" + f"New candidates: {result['newCandidateCount']}\n\n" + f"Discovery/immutability: {result['discovery']}. Result: {result['result']}.\n\n" + "Scope: local policy and opt-in registry metadata; no approval or deployment authorization.\n", + encoding="utf-8", + ) + + +def main(argv=None): + parser = Parser(description="Compare immutable release files and validate new candidates.", allow_abbrev=False) + parser.add_argument("--repository", required=True) + parser.add_argument("--base") + parser.add_argument("--head") + parser.add_argument("--pull-request", action="store_true", help="Use merge-base cumulative comparison") + parser.add_argument("--github-event", action="store_true", help="Read GITHUB_EVENT_NAME/PATH") + try: + args = parser.parse_args(argv) + if args.github_event: + if args.base or args.head or args.pull_request: + raise Failure("usage_error", "Choose event mode or explicit revisions.", execution=True) + payload = strict_json(Path(os.environ["GITHUB_EVENT_PATH"]).read_bytes()) + base, head, is_pr = comparison_from_event(os.environ.get("GITHUB_EVENT_NAME"), payload) + else: + base, head, is_pr = args.base, args.head, args.pull_request + result = evaluate(args.repository, base, head, pull_request=is_pr) + except (Failure, OSError, KeyError) as failure: + result = {"ciOutputVersion": 1, "result": "execution_error", "exitCode": 2, + "newCandidateCount": 0, "discovery": "not_evaluated", "diagnostics": [failure.code if isinstance(failure, Failure) else "event_unavailable"], "candidates": []} + if os.environ.get("GITHUB_STEP_SUMMARY"): + try: + write_summary(result, os.environ["GITHUB_STEP_SUMMARY"]) + except OSError: + result.update(result="execution_error", exitCode=2) + result["diagnostics"].append("summary_unavailable") + print(json.dumps(result, indent=2)) + return result["exitCode"] + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_ci.py b/tests/test_ci.py new file mode 100644 index 0000000..edb4784 --- /dev/null +++ b/tests/test_ci.py @@ -0,0 +1,179 @@ +"""Release orchestration tests use local Git objects and no real registry.""" + +import copy +import io +import json +import os +from pathlib import Path +from contextlib import redirect_stdout +import unittest +from unittest.mock import patch + +import test_validation as fixtures +from container_platform import ci + + +class OrchestrationTests(unittest.TestCase): + def setUp(self): + self.fixture = fixtures.ValidationTests() + self.fixture.setUp() + self.addCleanup(self.fixture.doCleanups) + self.repo = self.fixture.repo + self.base = self.fixture.revision + self.counter = 0 + + def record(self, path, data): + p = self.repo / path + p.parent.mkdir(parents=True, exist_ok=True) + p.write_bytes(data if isinstance(data, bytes) else json.dumps(data).encode()) + + def commit(self): + self.fixture.git("add", "-A") + self.fixture.git("commit", "-qm", "test: change release tree", "--allow-empty") + return self.fixture.git("rev-parse", "HEAD").decode().strip() + + def candidate(self, identity="test-release"): + c = copy.deepcopy(self.fixture.candidate) + c["releaseId"] = identity + return c + + def evaluate(self, head, runner=None, base=None, pull_request=False): + return ci.evaluate(self.repo, base or self.base, head, pull_request=pull_request, + runner=runner or (lambda repo, data: (0, {"exitCode": 0}))) + + def test_cumulative_additions_and_edits_within_same_pr(self): + self.record("releases/one.json", self.candidate("one")) + self.commit() + self.record("releases/one.json", self.candidate("one-edited")) + self.record("releases/two.json", self.candidate("two")) + result = self.evaluate(self.commit(), pull_request=True) + self.assertEqual(result["exitCode"], 0) + self.assertEqual(result["newCandidateCount"], 2) + + def test_existing_modification_deletion_rename_and_excluded_moves(self): + for action in ["modify", "delete", "rename", "observation", "example"]: + with self.subTest(action=action): + self.fixture.git("reset", "--hard", self.base) + self.record("releases/old.json", self.candidate()) + merged = self.commit() + old = self.repo / "releases/old.json" + if action == "modify": + self.record("releases/old.json", self.candidate("different")) + elif action == "delete": + old.unlink() + else: + target = {"rename": "releases/new.json", "observation": "releases/old.observations.json", "example": "examples/old.json"}[action] + (self.repo / target).parent.mkdir(parents=True, exist_ok=True) + old.rename(self.repo / target) + result = self.evaluate(self.commit(), base=merged) + self.assertEqual(result["exitCode"], 1) + self.assertIn("merged_release_immutable", result["diagnostics"]) + self.assertEqual(result["candidates"], []) + + def test_excluded_paths_and_no_changes(self): + for path in ["README.md", "examples/sample.json", "releases/baseline.observations.json", "releases/nested/example.json"]: + self.record(path, b'{not candidate JSON') + result = self.evaluate(self.commit()) + self.assertEqual(result["result"], "no_candidate_changes") + self.assertEqual(result["exitCode"], 0) + + def test_move_excluded_to_candidate_is_new_and_malformed_is_not_hidden(self): + self.record("releases/old.observations.json", b'{not JSON') + base = self.commit() + (self.repo / "releases/old.observations.json").rename(self.repo / "releases/new.json") + called = [] + def runner(repo, data): + called.append(data) + return 1, {"exitCode": 1} + result = self.evaluate(self.commit(), base=base, runner=runner) + self.assertEqual(result["newCandidateCount"], 1) + self.assertEqual(called, [b'{not JSON']) + self.assertEqual(result["exitCode"], 1) + + def test_duplicate_ids_against_historical_and_new_tree(self): + self.record("releases/old.json", self.candidate("same")) + base = self.commit() + self.record("releases/new.json", self.candidate("same")) + result = self.evaluate(self.commit(), base=base) + self.assertIn("release_id_duplicate", result["diagnostics"]) + self.fixture.git("reset", "--hard", self.base) + self.record("releases/a.json", self.candidate("same")) + self.record("releases/b.json", self.candidate("same")) + self.assertIn("release_id_duplicate", self.evaluate(self.commit())["diagnostics"]) + + def test_base_advance_is_not_a_pr_deletion_and_ids_still_checked(self): + self.record("releases/feature.json", self.candidate("same")) + feature = self.commit() + self.fixture.git("checkout", "--detach", self.base) + self.record("releases/main.json", self.candidate("same")) + main = self.commit() + result = self.evaluate(feature, base=main, pull_request=True) + self.assertNotIn("merged_release_immutable", result["diagnostics"]) + self.assertIn("release_id_duplicate", result["diagnostics"]) + + def test_no_registry_for_policy_failure_and_nonzero_codes_preserved(self): + self.record("releases/a.json", self.candidate("a")) + self.record("releases/b.json", self.candidate("b")) + head = self.commit() + for code in (1, 2): + result = self.evaluate(head, runner=lambda repo, data: (code, {"exitCode": code})) + self.assertEqual(result["exitCode"], code) + self.assertEqual(len(result["candidates"]), 2) + self.record("releases/old.json", self.candidate("a")) + head = self.commit() + def forbidden(repo, data): + raise AssertionError("Must not validate duplicate IDs") + result = self.evaluate(head, runner=forbidden) + self.assertEqual(result["exitCode"], 1) + self.assertEqual(result["candidates"], []) + + def test_missing_comparison_objects_and_invalid_events(self): + for base, head in [("f" * 40, self.base), (self.base, "f" * 40), ("0" * 40, self.base), ("main", self.base)]: + result = ci.evaluate(self.repo, base, head) + self.assertEqual(result["exitCode"], 2) + self.assertEqual(result["discovery"], "not_evaluated") + for name, payload in [("pull_request_target", {}), ("workflow_dispatch", {}), ("push", {}), ("pull_request", [])]: + with self.assertRaises(ci.Failure): + ci.comparison_from_event(name, payload) + + def test_precise_event_comparison_selection(self): + self.assertEqual(ci.comparison_from_event("push", {"before": "a" * 40, "after": "b" * 40}), ("a" * 40, "b" * 40, False)) + self.assertEqual(ci.comparison_from_event("pull_request", {"pull_request": {"base": {"sha": "a" * 40}, "head": {"sha": "b" * 40}}}), ("a" * 40, "b" * 40, True)) + + def test_safe_filenames_and_snapshot_not_worktree(self): + path = 'releases/space ; $(no-command)' + ('' if os.name == 'nt' else '\n') + 'name.json' + self.record(path, self.candidate()) + head = self.commit() + self.record(path, b'{bad working tree') + received = [] + def runner(repo, data): + received.append(json.loads(data)["releaseId"]) + return 0, {"exitCode": 0} + result = self.evaluate(head, runner=runner) + self.assertEqual(result["exitCode"], 0) + self.assertEqual(received, ["test-release"]) + self.assertNotIn("no-command", json.dumps(result)) + + def test_real_cli_nonzero_and_diagnostics_without_network(self): + self.record("releases/malformed.json", b'{') + result = ci.evaluate(self.repo, self.base, self.commit()) + self.assertEqual(result["exitCode"], 1) + report = result["candidates"][0]["report"] + self.assertEqual(report["checks"][0]["status"], "fail") + self.assertEqual(report["checks"][-1]["status"], "skipped") + + def test_github_summary_no_changes_is_explicit_and_small(self): + event = Path(self.fixture.temp.name) / "event.json" + summary = Path(self.fixture.temp.name) / "summary.md" + event.write_text(json.dumps({"before": self.base, "after": self.base})) + with patch.dict(os.environ, {"GITHUB_EVENT_NAME": "push", "GITHUB_EVENT_PATH": str(event), "GITHUB_STEP_SUMMARY": str(summary)}): + out = io.StringIO() + with redirect_stdout(out): + self.assertEqual(ci.main(["--repository", str(self.repo), "--github-event"]), 0) + self.assertEqual(json.loads(out.getvalue())["result"], "no_candidate_changes") + self.assertIn("no_candidate_changes", summary.read_text()) + self.assertLess(len(summary.read_text()), 400) + + +if __name__ == "__main__": + unittest.main()