From e6f21c8ab260287b5abbb428c7308e4453c82620 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:39:57 +0900 Subject: [PATCH] feat(release): pure release-manifest assembler (#953 increment 1) app/release/ -- new package for the pure, side-effect-free assemblers that turn already-gathered facts into #953's immutable release-evidence artifacts. 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 and normalizes the supplied facts into one stable, JSON-serializable manifest. No git, no network, no filesystem. - ValueError names the first bad field. - migration_revisions / included_prs sorted + de-duped; digests forced to ^sha256:[0-9a-f]{64}$; commit lowercased to ^[0-9a-f]{7,40}$; versions ^N.N.N; generated_at must be tz-aware ISO-8601; bool rejected as a PR id. - is_ga_candidate = len(known_limitations) == 0 -- honest by construction: listing any limitation ships a beta artifact without a GA claim. - 12 tests; mypy app clean; interrogate 100%. New docs/doctoring/release-manifest.md (field contract + honesty rule + deferred SBOM / signed provenance / operability baseline; cites NIST SP 800-218 and SLSA v1.2). Stacked on main. Blocked from merge by ContextualWisdomLab/.github#1531. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013SeQS8tSee5QVeyGpJ9SaY --- CHANGELOG.md | 1 + backend/app/release/__init__.py | 9 ++ backend/app/release/manifest.py | 143 +++++++++++++++++++++++++ backend/tests/test_release_manifest.py | 102 ++++++++++++++++++ docs/doctoring/release-manifest.md | 78 ++++++++++++++ 5 files changed, 333 insertions(+) create mode 100644 backend/app/release/__init__.py create mode 100644 backend/app/release/manifest.py create mode 100644 backend/tests/test_release_manifest.py create mode 100644 docs/doctoring/release-manifest.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 35613431a..86bdd2b13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +- [BE] ๐Ÿ“ฆ **๋ฆด๋ฆฌ์Šค ์ฆ๊ฑฐ ๋งค๋‹ˆํŽ˜์ŠคํŠธ ์กฐ๋ฆฝ๊ธฐ (#953 1์ฐจ ์ฆ๋ถ„)**: `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`๋ฅผ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. ํ˜ธ์ถœ์ž๊ฐ€ ์ด๋ฏธ ์ˆ˜์ง‘ํ•œ ๋ฆด๋ฆฌ์Šค ์‚ฌ์‹ค(commitยท๋ฒ„์ „ยทAlembic revisionยทlock ํŒŒ์ผ sha256 ๋‹ค์ด์ œ์ŠคํŠธยทํฌํ•จ PRยท์•Œ๋ ค์ง„ ํ•œ๊ณ„ยท์ƒ์„ฑ ์‹œ๊ฐ)์„ ๊ฒ€์ฆยท์ •๊ทœํ™”ํ•ด ๋ถˆ๋ณ€ยทJSON ์ง๋ ฌํ™” ๊ฐ€๋Šฅํ•œ ๋‹จ์ผ ๋งค๋‹ˆํŽ˜์ŠคํŠธ๋กœ ๋งŒ๋“ญ๋‹ˆ๋‹ค. gitยท๋„คํŠธ์›ŒํฌยทํŒŒ์ผ์‹œ์Šคํ…œ ์ ‘๊ทผ ์—†์Œ. ๊ฒ€์ฆ ์‹คํŒจ ์‹œ ์ฒซ ๋ฌธ์ œ ํ•„๋“œ๋ช…์„ ๋‹ด์€ `ValueError`. `migration_revisions`/`included_prs`๋Š” ์ •๋ ฌยท์ค‘๋ณต ์ œ๊ฑฐ, digest๋Š” `^sha256:[0-9a-f]{64}$` ๊ฐ•์ œ. `is_ga_candidate = len(known_limitations) == 0`(ํ•œ๊ณ„๊ฐ€ ํ•˜๋‚˜๋ผ๋„ ์žˆ์œผ๋ฉด GA ํ›„๋ณด ์•„๋‹˜ โ€” ์ •์ง์„ฑ ๊ทœ์น™). ํ…Œ์ŠคํŠธ 12์ข…. `docs/doctoring/release-manifest.md`์— ํ•„๋“œ ๊ณ„์•ฝยทํ›„์† ์ฆ๋ถ„(SBOMยท์„œ๋ช… provenanceยทoperability baselineยท๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ๋ฆฌํ—ˆ์„ค) ๊ธฐ๋ก, NIST SP 800-218ยทSLSA v1.2 ์ธ์šฉ. - [BE] ๐Ÿ”’ **Cryptography 50+ ๋ณด์•ˆ ๊ฒฝ๊ณ„ ๊ฐฑ์‹ **: `pyproject.toml`๊ณผ ๋‘ hash-locked ์š”๊ตฌ์‚ฌํ•ญ ํŒŒ์ผ์„ ๋™์ผํ•œ Cryptography 50+ ํ•ด์„์œผ๋กœ ์ •ํ•ฉํ™”ํ•˜์—ฌ PKCS#7 ์˜ค๋ฅ˜ยทํƒ€์ด๋ฐ ๊ตฌ๋ถ„์œผ๋กœ ์ธํ•œ CVE-2026-69247 ์™„ํ™”๋ฅผ ์‹ค์ œ ์„ค์น˜ยท๊ฒ€์ฆ ๊ฒฝ๋กœ์— ๋ฐ˜์˜ํ–ˆ์Šต๋‹ˆ๋‹ค. - [FE] โšก **๊ฒ€์ƒ‰ ๋…ธ๋“œ ์ฐธ์กฐ ์•ˆ์ •ํ™” ๋ฐ ์ˆœ์ฐจ ์Šค๋ƒ…์ƒท ํด๋ง**: ๊ฐ™์€ ์ •๊ทœํ™” ๊ฒ€์ƒ‰์–ด์™€ ์›๋ณธ ํ…Œ์ด๋ธ” ๋ฐ์ดํ„ฐ์—๋Š” ์žฅ์‹๋œ `node.data` ์ฐธ์กฐ๋ฅผ ์žฌ์‚ฌ์šฉํ•˜์—ฌ ๋“œ๋ž˜๊ทธ ์ค‘ ๋ถˆํ•„์š”ํ•œ ํ•˜์œ„ ๋ Œ๋”๋ง๊ณผ ํ• ๋‹น์„ ์ค„์ž…๋‹ˆ๋‹ค. ์Šค๋ƒ…์ƒท ํด๋ง์€ ์ด์ „ ์š”์ฒญ์ด ๋๋‚œ ๋’ค์—๋งŒ ๋‹ค์Œ ์š”์ฒญ์„ ์˜ˆ์•ฝํ•˜๋ฉฐ, ์„ ํƒ ๋ณ€๊ฒฝยท์–ธ๋งˆ์šดํŠธ ํ›„ ๋„์ฐฉํ•œ ์˜ค๋ž˜๋œ ์„ฑ๊ณต ๋˜๋Š” ์‹คํŒจ ์‘๋‹ต์„ ๋ฌด์‹œํ•ฉ๋‹ˆ๋‹ค. - [BE] ๐Ÿ”’ **๊ณต์œ  export ์ „ ๊ฒฝ๋กœ redaction**: ๊ณต๊ฐœ share์˜ SQL / index-design / reversing-spec export์—์„œ ์ฝ”๋ฉ˜ํŠธยท`example_value`๋ฅผ ์ œ๊ฑฐํ•ฉ๋‹ˆ๋‹ค. ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๋กœ ๋ˆ„์ถœ์„ ์ฐจ๋‹จํ•ฉ๋‹ˆ๋‹ค. diff --git a/backend/app/release/__init__.py b/backend/app/release/__init__.py new file mode 100644 index 000000000..d3804e164 --- /dev/null +++ b/backend/app/release/__init__.py @@ -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. +""" diff --git a/backend/app/release/manifest.py b/backend/app/release/manifest.py new file mode 100644 index 000000000..3e181cf8b --- /dev/null +++ b/backend/app/release/manifest.py @@ -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}$") + + +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]: + """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") + + backend = _require_str(backend_version, "backend_version") + if not _VERSION_RE.match(backend): + raise ValueError("backend_version must look like N.N.N") + 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, + } diff --git a/backend/tests/test_release_manifest.py b/backend/tests/test_release_manifest.py new file mode 100644 index 000000000..1033ae3ac --- /dev/null +++ b/backend/tests/test_release_manifest.py @@ -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: + 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() diff --git a/docs/doctoring/release-manifest.md b/docs/doctoring/release-manifest.md new file mode 100644 index 000000000..83b7fa170 --- /dev/null +++ b/docs/doctoring/release-manifest.md @@ -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/