-
Notifications
You must be signed in to change notification settings - Fork 0
feat(release): pure release-manifest assembler (#953 increment 1) #1057
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| """Release-evidence assembly for the first commercial release (issue #953). | ||
|
|
||
| This package holds the pure, side-effect-free assemblers that turn facts a | ||
| caller has already gathered (from git, CI, the lockfiles, the PR queue) into | ||
| the immutable release-evidence artifacts #953 requires. Nothing here runs | ||
| git, reaches the network, or touches the filesystem — the caller supplies | ||
| every fact, and each function only validates and normalizes it into a | ||
| stable, JSON-serializable shape. | ||
| """ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| """Assemble the immutable release-evidence manifest (issue #953). | ||
|
|
||
| A commercial release is only credible when inclusion, dependency pinning, | ||
| migration compatibility, exact-head provenance, and known limitations can be | ||
| stated from **one immutable manifest**. This module builds that manifest | ||
| from facts the caller has already gathered — it runs no git, no network, no | ||
| filesystem access. | ||
|
|
||
| The manifest is honest by construction: ``is_ga_candidate`` is ``True`` | ||
| only when ``known_limitations`` is empty. Listing a limitation is the | ||
| supported way to ship a beta / non-GA artifact without the manifest | ||
| claiming otherwise. | ||
|
|
||
| References (APA 7th): | ||
|
|
||
| National Institute of Standards and Technology. (2022). *Secure software | ||
| development framework (SSDF) version 1.1* (NIST Special Publication | ||
| 800-218). https://doi.org/10.6028/NIST.SP.800-218 | ||
|
|
||
| SLSA Community. (2025). *Supply-chain levels for software artifacts | ||
| specification, version 1.2*. https://slsa.dev/spec/v1.2/ | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import re | ||
| from datetime import datetime | ||
| from typing import Any | ||
|
|
||
| #: Manifest contract version. Bump on any breaking change to the output shape. | ||
| MANIFEST_VERSION = "1" | ||
|
|
||
| _COMMIT_RE = re.compile(r"^[0-9a-f]{7,40}$") | ||
| _VERSION_RE = re.compile(r"^\d+\.\d+\.\d+") | ||
| _DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$") | ||
|
Comment on lines
+34
to
+35
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: printf '%s\n' '--- repository guidance ---'
find /tmp/coderabbit-repo-knowledge/contextualwisdomlab-pg-erd-cloud-d2563f2a -maxdepth 2 -type f -name '*.md' -print
printf '%s\n' '--- target file ---'
cat -n backend/app/release/manifest.pyRepository: ContextualWisdomLab/pg-erd-cloud Length of output: 8069 🏁 Script executed: printf '%s\n' '--- manifest callers and focused tests ---'
rg -n --glob '*.py' 'build_release_manifest|backend_version|frontend_version|VERSION_RE' backend tests 2>/dev/null | head -120
printf '%s\n' '--- Python regex behavior for the cited inputs ---'
python3 - <<'PY'
import re
pattern = re.compile(r"^\d+\.\d+\.\d+")
for value in ("1.2.3garbage", "1.2.3 ", "1.2.3.4", "1.2.3-alpha", "1.2.3+build"):
print(value.encode(), bool(pattern.match(value)))
PYRepository: ContextualWisdomLab/pg-erd-cloud Length of output: 2374 버전 검증을 완전한 SemVer 규칙으로 제한하세요.
🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| def _require_str(value: object, field: str) -> str: | ||
| """Return ``value`` as a non-empty stripped string or raise ``ValueError``.""" | ||
| if not isinstance(value, str) or not value.strip(): | ||
| raise ValueError(f"{field} must be a non-empty string") | ||
| return value | ||
|
|
||
|
|
||
| def build_release_manifest( | ||
| *, | ||
| source_commit: str, | ||
| backend_version: str, | ||
| frontend_version: str, | ||
| migration_revisions: list[str], | ||
| dependency_lock_digests: dict[str, str], | ||
| included_prs: list[int], | ||
| known_limitations: list[str], | ||
| generated_at: str, | ||
| ) -> dict[str, Any]: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win 공개 반환 스키마를
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| """Validate the supplied release facts and return the immutable manifest. | ||
|
|
||
| Args: | ||
| source_commit: The exact commit the release is cut from. Lowercased, | ||
| then must match ``^[0-9a-f]{7,40}$``. | ||
| backend_version: Backend package version; must match | ||
| ``^\\d+\\.\\d+\\.\\d+`` (a trailing pre-release suffix is allowed). | ||
| frontend_version: Frontend package version; same rule. | ||
| migration_revisions: Alembic revision ids included in the release. | ||
| Every item must be a non-empty string; the output is sorted and | ||
| de-duplicated. | ||
| dependency_lock_digests: ``{lockfile_name: "sha256:<64 hex>"}``. Keys | ||
| must be non-empty strings; every value must match | ||
| ``^sha256:[0-9a-f]{64}$``. The output dict has sorted keys. | ||
| included_prs: PR numbers merged into the release. Every item must be | ||
| an ``int`` greater than 0; the output is sorted and de-duplicated. | ||
| known_limitations: Human-readable statements of what is *not* GA in | ||
| this release. Every item must be a non-empty string; order is | ||
| preserved. A non-empty list forces ``is_ga_candidate`` to | ||
| ``False``. | ||
| generated_at: When this manifest was assembled. Must be parseable by | ||
| :meth:`datetime.datetime.fromisoformat` and timezone-aware. | ||
|
|
||
| Returns: | ||
| A JSON-serializable dict with ``manifest_version``, the validated | ||
| ``source_commit`` (lowercased), ``backend_version``, | ||
| ``frontend_version``, the normalized ``migration_revisions`` / | ||
| ``dependency_lock_digests`` / ``included_prs``, ``known_limitations``, | ||
| ``generated_at``, and ``is_ga_candidate`` (``len(known_limitations) | ||
| == 0``). Deterministic for a given input. | ||
|
|
||
| Raises: | ||
| ValueError: Naming the first field that fails validation. | ||
| """ | ||
| commit = _require_str(source_commit, "source_commit").lower() | ||
| if not _COMMIT_RE.match(commit): | ||
| raise ValueError("source_commit must be 7-40 lowercase hex characters") | ||
|
Comment on lines
+91
to
+92
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Abbreviated hashes undermine exact provenance Seven-character hashes pass Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| backend = _require_str(backend_version, "backend_version") | ||
| if not _VERSION_RE.match(backend): | ||
| raise ValueError("backend_version must look like N.N.N") | ||
|
Comment on lines
+95
to
+96
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Malformed versions enter release evidence
Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. |
||
| frontend = _require_str(frontend_version, "frontend_version") | ||
| if not _VERSION_RE.match(frontend): | ||
| raise ValueError("frontend_version must look like N.N.N") | ||
|
|
||
| revisions: set[str] = set() | ||
| for revision in migration_revisions: | ||
| revisions.add(_require_str(revision, "migration_revisions[]")) | ||
|
|
||
| digests: dict[str, str] = {} | ||
| for name, digest in dependency_lock_digests.items(): | ||
| key = _require_str(name, "dependency_lock_digests key") | ||
| if not isinstance(digest, str) or not _DIGEST_RE.match(digest): | ||
| raise ValueError( | ||
| f"dependency_lock_digests[{key!r}] must match 'sha256:<64 hex>'" | ||
| ) | ||
| digests[key] = digest | ||
|
|
||
| prs: set[int] = set() | ||
| for pr in included_prs: | ||
| if not isinstance(pr, int) or isinstance(pr, bool) or pr <= 0: | ||
| raise ValueError("included_prs items must be positive integers") | ||
| prs.add(pr) | ||
|
|
||
| limitations: list[str] = [ | ||
| _require_str(item, "known_limitations[]") for item in known_limitations | ||
| ] | ||
|
|
||
| stamp = _require_str(generated_at, "generated_at") | ||
| try: | ||
| parsed = datetime.fromisoformat(stamp) | ||
| except ValueError as exc: | ||
| raise ValueError("generated_at must be an ISO-8601 timestamp") from exc | ||
| if parsed.tzinfo is None: | ||
| raise ValueError("generated_at must be timezone-aware") | ||
|
|
||
| return { | ||
| "manifest_version": MANIFEST_VERSION, | ||
| "source_commit": commit, | ||
| "backend_version": backend, | ||
| "frontend_version": frontend, | ||
| "migration_revisions": sorted(revisions), | ||
| "dependency_lock_digests": {k: digests[k] for k in sorted(digests)}, | ||
| "included_prs": sorted(prs), | ||
| "known_limitations": limitations, | ||
| "generated_at": stamp, | ||
| "is_ga_candidate": len(limitations) == 0, | ||
| } | ||
|
Comment on lines
+132
to
+143
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| """Tests for :mod:`app.release.manifest`. | ||
|
|
||
| The assembler must validate every field, normalize collections, be honest | ||
| about GA candidacy, and produce a deterministic JSON-serializable manifest. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
|
|
||
| import pytest | ||
|
|
||
| from app.release.manifest import MANIFEST_VERSION, build_release_manifest | ||
|
|
||
| _GOOD = dict( | ||
| source_commit="8dc746920c12988f082e914879d95e13c9693535", | ||
| backend_version="0.1.0", | ||
| frontend_version="0.1.0", | ||
| migration_revisions=["0002_add_lineage", "0001_init"], | ||
| dependency_lock_digests={ | ||
| "requirements.lock": "sha256:" + "a" * 64, | ||
| "package-lock.json": "sha256:" + "b" * 64, | ||
| }, | ||
| included_prs=[1024, 942, 1024], | ||
| known_limitations=[], | ||
| generated_at="2026-09-02T10:00:00+00:00", | ||
| ) | ||
|
|
||
|
|
||
| def _manifest(**overrides: object) -> dict: | ||
| return build_release_manifest(**{**_GOOD, **overrides}) # type: ignore[arg-type] | ||
|
|
||
|
|
||
| def test_happy_path_is_a_ga_candidate() -> None: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win 추가한 pytest 테스트 함수에 docstring을 추가하세요.
Also applies to: 41-41, 51-51, 56-56, 61-61, 71-71, 76-76, 81-81, 86-86, 91-91, 96-96, 101-101 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| manifest = _manifest() | ||
| assert manifest["manifest_version"] == MANIFEST_VERSION | ||
| assert manifest["is_ga_candidate"] is True | ||
| assert manifest["source_commit"] == _GOOD["source_commit"] | ||
|
|
||
|
|
||
| def test_a_known_limitation_disqualifies_ga() -> None: | ||
| manifest = _manifest( | ||
| known_limitations=["Persistent migration apply is disabled (non-GA)."] | ||
| ) | ||
| assert manifest["is_ga_candidate"] is False | ||
| assert manifest["known_limitations"] == [ | ||
| "Persistent migration apply is disabled (non-GA)." | ||
| ] | ||
|
|
||
|
|
||
| def test_migration_revisions_are_sorted_and_deduped() -> None: | ||
| manifest = _manifest(migration_revisions=["b", "a", "a"]) | ||
| assert manifest["migration_revisions"] == ["a", "b"] | ||
|
|
||
|
|
||
| def test_included_prs_are_sorted_and_deduped() -> None: | ||
| manifest = _manifest(included_prs=[3, 1, 1]) | ||
| assert manifest["included_prs"] == [1, 3] | ||
|
|
||
|
|
||
| def test_dependency_lock_digests_keys_are_sorted() -> None: | ||
| manifest = _manifest( | ||
| dependency_lock_digests={ | ||
| "z.lock": "sha256:" + "c" * 64, | ||
| "a.lock": "sha256:" + "d" * 64, | ||
| } | ||
| ) | ||
| assert list(manifest["dependency_lock_digests"]) == ["a.lock", "z.lock"] | ||
|
|
||
|
|
||
| def test_bad_source_commit_raises_naming_the_field() -> None: | ||
| with pytest.raises(ValueError, match="source_commit"): | ||
| _manifest(source_commit="xyz") | ||
|
|
||
|
|
||
| def test_bad_backend_version_raises() -> None: | ||
| with pytest.raises(ValueError, match="backend_version"): | ||
| _manifest(backend_version="1.0") | ||
|
|
||
|
|
||
| def test_digest_without_sha256_prefix_raises() -> None: | ||
| with pytest.raises(ValueError, match="dependency_lock_digests"): | ||
| _manifest(dependency_lock_digests={"requirements.lock": "deadbeef"}) | ||
|
|
||
|
|
||
| def test_non_positive_pr_number_raises() -> None: | ||
| with pytest.raises(ValueError, match="included_prs"): | ||
| _manifest(included_prs=[0]) | ||
|
|
||
|
|
||
| def test_naive_generated_at_raises() -> None: | ||
| with pytest.raises(ValueError, match="generated_at"): | ||
| _manifest(generated_at="2026-09-02T10:00:00") | ||
|
|
||
|
|
||
| def test_manifest_round_trips_through_json() -> None: | ||
| manifest = _manifest() | ||
| assert json.loads(json.dumps(manifest)) == manifest | ||
|
|
||
|
|
||
| def test_build_is_deterministic() -> None: | ||
| assert _manifest() == _manifest() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| # Release-evidence manifest | ||
|
|
||
| Status: **in progress** — first increment (pure manifest assembler) landed. | ||
| Tracks issue | ||
| [#953](https://github.com/ContextualWisdomLab/pg-erd-cloud/issues/953) | ||
| ("[Release Epic] Ship the first commercial release with exact-head, | ||
| migration, operability, and supply-chain evidence"). | ||
|
|
||
| ## Why | ||
|
|
||
| Issue #953 requires that release inclusion, dependency pinning, migration | ||
| compatibility, exact-head provenance, and known limitations all be stated | ||
| from **one immutable manifest** rather than reconstructed from scattered | ||
| CI logs and PR comments. A large open-PR count is not itself a defect, but | ||
| a release is not credible when those facts cannot be pinned. | ||
|
|
||
| ## Decision — pure manifest assembler (this increment) | ||
|
|
||
| `app/release/manifest.py` | ||
| `build_release_manifest(*, source_commit, backend_version, frontend_version, | ||
| migration_revisions, dependency_lock_digests, included_prs, | ||
| known_limitations, generated_at) -> dict` validates the facts a caller has | ||
| already gathered and returns a stable, JSON-serializable manifest. It runs | ||
| **no git, no network, no filesystem access** — the caller (a release | ||
| workflow) supplies every fact. | ||
|
|
||
| ### Field contract | ||
|
|
||
| | Field | Rule | Output | | ||
| | --- | --- | --- | | ||
| | `source_commit` | non-empty; lowercased; `^[0-9a-f]{7,40}$` | lowercased commit | | ||
| | `backend_version` / `frontend_version` | `^\d+\.\d+\.\d+` (pre-release suffix allowed) | as given | | ||
| | `migration_revisions` | every item a non-empty str | `sorted(set(...))` | | ||
| | `dependency_lock_digests` | keys non-empty str; values `^sha256:[0-9a-f]{64}$` | dict with sorted keys | | ||
| | `included_prs` | every item an `int` > 0 (`bool` rejected) | `sorted(set(...))` | | ||
| | `known_limitations` | every item a non-empty str | order preserved | | ||
| | `generated_at` | `datetime.fromisoformat` parses **and** is tz-aware | as given | | ||
|
|
||
| `ValueError` is raised naming the **first** field that fails. | ||
|
|
||
| ### Honesty rule | ||
|
|
||
| `is_ga_candidate = len(known_limitations) == 0`. Listing any limitation is | ||
| the supported way to ship a beta / non-GA artifact without the manifest | ||
| claiming GA. A release workflow that wants a GA claim must first drive | ||
| `known_limitations` to empty. | ||
|
|
||
| ## Deferred (later increments on #953) | ||
|
|
||
| - **SBOM generation** — an SPDX or CycloneDX document per shipped artifact, | ||
| referenced from the manifest by digest. | ||
| - **Signed build provenance / attestation** — SLSA v1.2-compatible, tying | ||
| the manifest to the build that produced it. | ||
| - **The operability baseline** — SLI/SLO, dashboards, alerts, runbooks | ||
| (links to the #951 capacity profile). | ||
| - **Migration rehearsal automation** — clean install on the supported | ||
| PostgreSQL matrix + upgrade from the oldest supported `0.1.x`. | ||
| - **Per-dependency release-decision table** — a `release_blocker` / | ||
| `post_ga_committed` / `experimental` / `not_planned` decision + rationale | ||
| for each of #946–#952 and the other tracked PRs. | ||
| - **The full open-PR classification of record** — every open PR captured at | ||
| its exact head and classified (see the #953 section of | ||
| `docs/product-technical-gap-baseline.md`). | ||
|
|
||
| ## References (APA 7th) | ||
|
|
||
| National Institute of Standards and Technology. (2022). *Secure software | ||
| development framework (SSDF) version 1.1* (NIST Special Publication | ||
| 800-218). https://doi.org/10.6028/NIST.SP.800-218 | ||
|
|
||
| SLSA Community. (2025). *Supply-chain levels for software artifacts | ||
| specification, version 1.2*. https://slsa.dev/spec/v1.2/ | ||
|
|
||
| Linux Foundation. (2024). *System Package Data Exchange (SPDX) | ||
| specification, version 3.0*. https://spdx.dev/specifications/ | ||
|
|
||
| OWASP Foundation. (2024). *CycloneDX specification, version 1.6*. | ||
| https://cyclonedx.org/specification/overview/ | ||
|
Comment on lines
+65
to
+78
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: ContextualWisdomLab/pg-erd-cloud
Length of output: 18194
🏁 Script executed:
Repository: ContextualWisdomLab/pg-erd-cloud
Length of output: 6594
source_commit과dependency_lock_digests를 전체 문자열로 검증하세요.source_commit이 7자리 축약 hash를 허용하면 exact-head provenance를 보장하지 못합니다.source_commit은 40자리 hash만 허용해야 합니다. 또한 두 정규식의match()와$조합은 후행 개행이 포함된 값을 통과시킬 수 있습니다.fullmatch()를 사용하고, 축약 hash와 후행 개행의 경계값 회귀 테스트를 추가하세요.🤖 Prompt for AI Agents