Loading .gitlab-ci.yml +1 −0 Changes for .gitlab-ci.yml: 1 added line, 0 removed lines. Original line number Diff line number Diff line Loading @@ -182,6 +182,7 @@ publish-package: when: always paths: - .cache/ci/release-check.json - .cache/ci/release-qualification.json - .cache/ci/manifest.json - .cache/ci/sbom.json - .cache/ci/security-summary.json Loading ci/README.md +14 −13 Changes for ci/README.md: 14 added lines, 13 removed lines. Original line number Diff line number Diff line Loading @@ -23,15 +23,17 @@ Dependency audit produces vulnerability JSON and CycloneDX SBOM; tag and nightly runs must pass. Audit service failures cannot produce a clean result. Source, sdist and wheel are checked before publication. Branch pushes cannot publish packages. Release promotion must reuse the built and tested bytes; never rebuild a package under an existing version. Final release qualification must identify the exact SDK wheel, SRM revision and OEG-driven acceptance run. The candidate version is 2.0.0rc4. A maintainer creates a protected matching release tag only when the intended candidate is ready. Publication remains a blocking manual job, uses CI_JOB_TOKEN, checks tag/version/source and artifact checksums, and retains a permanent release receipt. No branch pipeline uploads. Final 2.0.0 additionally needs exact-wheel SRM and live E2E qualification. publish packages. Release promotion reuses the built and tested bytes; never rebuild a package under an existing version. The candidate version is 2.0.0rc5. Create a protected matching release tag when the intended candidate is ready. After required CI jobs pass, a maintainer plays publish-package: this single action approves publication. No qualification file, extra pipeline IDs or exception variables are required. CI_JOB_TOKEN uploads the verified artifacts; tag/version/source and checksum guards remain mandatory. An automatic informational release-qualification.json preserves actual SDK and linked downstream test reports. Missing reports remain visible; applicable E2E results are reviewed in the release MR. See [the release procedure](RELEASE.md). GitLab Runner downloads artifacts before before_script. Its initial TMPDIR is the existing project directory; individual verification commands then create and use Loading @@ -39,10 +41,9 @@ existing project directory; individual verification commands then create and use Python support is bounded to >=3.10,<3.14, matching the classifiers and tested matrix; the current pinned pydantic-core cannot build with Python 3.14. Release qualification files are maintainer-reviewed receipts, not signed attestations or a claimed SLSA level. Each SRM/E2E receipt must carry the same SDK SHA/checksum and a pipeline URL in its owning ETSI project. Keep final publication blocked until the release scope and the corresponding live evidence are agreed. reports record available evidence, not signed attestations or a claimed SLSA level. The release owner judges whether the tested scope is sufficient for the release; the manual publisher records that decision in GitLab. Consumer qualification follows a protected SRM candidate branch. GitLab fixes the downstream checkout when creating that pipeline, and its receipt records Loading ci/RELEASE.md 0 → 100644 +32 −0 Changes for ci/RELEASE.md: 32 added lines, 0 removed lines. Original line number Diff line number Diff line # Release an SDK package 1. Review and merge the intended source/version through the release MR. 2. Create its matching protected tag, for example v2.0.0rc5 or v2.0.0. CI builds the wheel and source distribution once, tests those bytes and runs the linked SRM consumer. The release matrix and dependency audit also run. 3. Review the green pipeline and applicable OEG-driven acceptance results, then play **publish-package**. That one click approves and uploads the tested artifacts to the project's GitLab Python package registry. No qualification file, pipeline-ID input, extra approval form or exception flag is required. The authorized manual job is the publication decision. Merging the release MR integrates source; branch pipelines continue to avoid package uploads. The publisher automatically saves release-qualification.json with artifact identity, the GitLab approval job URL, actual SDK JUnit summary and linked downstream pipeline/test results. It uses the current public project APIs without a new token. Missing or inaccessible reports are recorded as unavailable and cannot add a new publication gate. External E2E runs remain visible in their own pipelines and are reviewed in the release MR; this collector cannot certify application, NEF or device acceptance from SDK/consumer results. The existing required jobs and protected tag/version/source/checksum checks remain enforced. Report collection changes no test outcome. If evidence is incomplete, the release owner decides whether to wait or proceed and can explain the decision in the existing MR. No separate exception workflow is needed. If a required job fails, fix/retry it or use an explicitly approved targeted CI change through the normal review process. For upload failures, retry the same job with the original artifacts. Inspect the registry first if a partial upload occurred; never replace published bytes. A source, version or rebuilt-artifact change needs a fresh version/tag and checks. ci/release.py +5 −27 Changes for ci/release.py: 5 added lines, 27 removed lines. Original line number Diff line number Diff line Loading @@ -8,9 +8,9 @@ import subprocess import sys import zipfile from email.parser import BytesParser from pathlib import Path from packaging.version import Version from reports import collect_reports from verify import DIST, OUT, ROOT, sha256, write_json Loading @@ -23,26 +23,6 @@ def check_tag(tag, version, protected): raise ValueError("Publication requires a protected release tag") def check_evidence(evidence, manifest, wheel_hash): if evidence.get("sdk_sha") != manifest["source_sha"]: raise ValueError("Qualification belongs to another SDK source revision") if evidence.get("wheel_sha256") != wheel_hash: raise ValueError("Qualification belongs to another wheel") for component in ("srm", "e2e"): item = evidence.get(component, {}) if item.get("status") != "passed" or not item.get("pipeline_url"): raise ValueError(f"Successful {component} qualification is required") expected_project = "service-resource-manager" if component == "srm" else "ansible" group = "code" if component == "srm" else "deploy" prefix = f"https://labs.etsi.org/rep/oop/{group}/{expected_project}/-/pipelines/" if not re.fullmatch(re.escape(prefix) + r"[0-9]+", item["pipeline_url"]): raise ValueError(f"Unexpected {component} qualification pipeline") if item.get("sdk_sha") != manifest["source_sha"] or item.get("wheel_sha256") != wheel_hash: raise ValueError(f"The {component} receipt belongs to another SDK artifact") if not re.fullmatch(r"[0-9a-f]{40}", item.get("source_sha", "")): raise ValueError(f"Missing exact {component} source revision") def verify_release(): manifest = json.loads((OUT / "manifest.json").read_text()) for item in manifest["artifacts"]: Loading @@ -69,11 +49,6 @@ def verify_release(): for ref in branches ): raise ValueError("Tagged commit is outside the reviewed integration lines") if not Version(version).is_prerelease: path = os.getenv("RELEASE_QUALIFICATION_FILE") if not path: raise ValueError("Final publication requires the reviewed SRM/E2E qualification file") check_evidence(json.loads(Path(path).read_text()), manifest, sha256(wheel)) write_json( OUT / "release-check.json", { Loading @@ -81,9 +56,12 @@ def verify_release(): "version": version, "source_sha": manifest["source_sha"], "wheel_sha256": sha256(wheel), "qualification": "candidate" if Version(version).is_prerelease else "final", "release_type": "candidate" if Version(version).is_prerelease else "final", "approval_job_url": os.getenv("CI_JOB_URL"), }, ) receipt = json.loads((OUT / "release-check.json").read_text()) write_json(OUT / "release-qualification.json", {**receipt, **collect_reports()}) def main(): Loading ci/reports.py 0 → 100644 +59 −0 Changes for ci/reports.py: 59 added lines, 0 removed lines. Original line number Diff line number Diff line """Collect informational GitLab test reports without another release gate.""" import json import os from urllib.request import urlopen def collect_reports(): report = { "pipeline_url": os.getenv("CI_PIPELINE_URL"), "sdk_tests": None, "downstreams": [], "unavailable": [], "external_e2e": "Review applicable acceptance results in the release MR.", } base = os.getenv("CI_API_V4_URL", "").rstrip("/") project = os.getenv("CI_PROJECT_ID", "") pipeline = os.getenv("CI_PIPELINE_ID", "") if not base or not project or not pipeline: report["unavailable"].append("Missing GitLab pipeline context") return report def read(path): try: # The current projects expose these reports publicly. No new token. with urlopen(f"{base}/{path}", timeout=5) as response: return json.load(response) except (OSError, ValueError) as error: report["unavailable"].append(f"{path}: {type(error).__name__}") print(f"CI report unavailable: {path} ({type(error).__name__})") return None prefix = f"projects/{project}/pipelines/{pipeline}" report["sdk_tests"] = read(prefix + "/test_report_summary") page = 1 while True: bridges = read(prefix + f"/bridges?per_page=100&page={page}") if not isinstance(bridges, list): if bridges is not None: report["unavailable"].append("Unexpected downstream pipeline response") break for bridge in bridges: try: downstream = bridge.get("downstream_pipeline") item = { "job": bridge["name"], "status": bridge["status"], "pipeline": downstream, } if downstream: path = f"projects/{downstream['project_id']}/pipelines/{downstream['id']}" item["tests"] = read(path + "/test_report_summary") report["downstreams"].append(item) except (AttributeError, KeyError, TypeError): report["unavailable"].append("Unexpected downstream pipeline entry") if len(bridges) < 100: break page += 1 return report Loading
.gitlab-ci.yml +1 −0 Changes for .gitlab-ci.yml: 1 added line, 0 removed lines. Original line number Diff line number Diff line Loading @@ -182,6 +182,7 @@ publish-package: when: always paths: - .cache/ci/release-check.json - .cache/ci/release-qualification.json - .cache/ci/manifest.json - .cache/ci/sbom.json - .cache/ci/security-summary.json Loading
ci/README.md +14 −13 Changes for ci/README.md: 14 added lines, 13 removed lines. Original line number Diff line number Diff line Loading @@ -23,15 +23,17 @@ Dependency audit produces vulnerability JSON and CycloneDX SBOM; tag and nightly runs must pass. Audit service failures cannot produce a clean result. Source, sdist and wheel are checked before publication. Branch pushes cannot publish packages. Release promotion must reuse the built and tested bytes; never rebuild a package under an existing version. Final release qualification must identify the exact SDK wheel, SRM revision and OEG-driven acceptance run. The candidate version is 2.0.0rc4. A maintainer creates a protected matching release tag only when the intended candidate is ready. Publication remains a blocking manual job, uses CI_JOB_TOKEN, checks tag/version/source and artifact checksums, and retains a permanent release receipt. No branch pipeline uploads. Final 2.0.0 additionally needs exact-wheel SRM and live E2E qualification. publish packages. Release promotion reuses the built and tested bytes; never rebuild a package under an existing version. The candidate version is 2.0.0rc5. Create a protected matching release tag when the intended candidate is ready. After required CI jobs pass, a maintainer plays publish-package: this single action approves publication. No qualification file, extra pipeline IDs or exception variables are required. CI_JOB_TOKEN uploads the verified artifacts; tag/version/source and checksum guards remain mandatory. An automatic informational release-qualification.json preserves actual SDK and linked downstream test reports. Missing reports remain visible; applicable E2E results are reviewed in the release MR. See [the release procedure](RELEASE.md). GitLab Runner downloads artifacts before before_script. Its initial TMPDIR is the existing project directory; individual verification commands then create and use Loading @@ -39,10 +41,9 @@ existing project directory; individual verification commands then create and use Python support is bounded to >=3.10,<3.14, matching the classifiers and tested matrix; the current pinned pydantic-core cannot build with Python 3.14. Release qualification files are maintainer-reviewed receipts, not signed attestations or a claimed SLSA level. Each SRM/E2E receipt must carry the same SDK SHA/checksum and a pipeline URL in its owning ETSI project. Keep final publication blocked until the release scope and the corresponding live evidence are agreed. reports record available evidence, not signed attestations or a claimed SLSA level. The release owner judges whether the tested scope is sufficient for the release; the manual publisher records that decision in GitLab. Consumer qualification follows a protected SRM candidate branch. GitLab fixes the downstream checkout when creating that pipeline, and its receipt records Loading
ci/RELEASE.md 0 → 100644 +32 −0 Changes for ci/RELEASE.md: 32 added lines, 0 removed lines. Original line number Diff line number Diff line # Release an SDK package 1. Review and merge the intended source/version through the release MR. 2. Create its matching protected tag, for example v2.0.0rc5 or v2.0.0. CI builds the wheel and source distribution once, tests those bytes and runs the linked SRM consumer. The release matrix and dependency audit also run. 3. Review the green pipeline and applicable OEG-driven acceptance results, then play **publish-package**. That one click approves and uploads the tested artifacts to the project's GitLab Python package registry. No qualification file, pipeline-ID input, extra approval form or exception flag is required. The authorized manual job is the publication decision. Merging the release MR integrates source; branch pipelines continue to avoid package uploads. The publisher automatically saves release-qualification.json with artifact identity, the GitLab approval job URL, actual SDK JUnit summary and linked downstream pipeline/test results. It uses the current public project APIs without a new token. Missing or inaccessible reports are recorded as unavailable and cannot add a new publication gate. External E2E runs remain visible in their own pipelines and are reviewed in the release MR; this collector cannot certify application, NEF or device acceptance from SDK/consumer results. The existing required jobs and protected tag/version/source/checksum checks remain enforced. Report collection changes no test outcome. If evidence is incomplete, the release owner decides whether to wait or proceed and can explain the decision in the existing MR. No separate exception workflow is needed. If a required job fails, fix/retry it or use an explicitly approved targeted CI change through the normal review process. For upload failures, retry the same job with the original artifacts. Inspect the registry first if a partial upload occurred; never replace published bytes. A source, version or rebuilt-artifact change needs a fresh version/tag and checks.
ci/release.py +5 −27 Changes for ci/release.py: 5 added lines, 27 removed lines. Original line number Diff line number Diff line Loading @@ -8,9 +8,9 @@ import subprocess import sys import zipfile from email.parser import BytesParser from pathlib import Path from packaging.version import Version from reports import collect_reports from verify import DIST, OUT, ROOT, sha256, write_json Loading @@ -23,26 +23,6 @@ def check_tag(tag, version, protected): raise ValueError("Publication requires a protected release tag") def check_evidence(evidence, manifest, wheel_hash): if evidence.get("sdk_sha") != manifest["source_sha"]: raise ValueError("Qualification belongs to another SDK source revision") if evidence.get("wheel_sha256") != wheel_hash: raise ValueError("Qualification belongs to another wheel") for component in ("srm", "e2e"): item = evidence.get(component, {}) if item.get("status") != "passed" or not item.get("pipeline_url"): raise ValueError(f"Successful {component} qualification is required") expected_project = "service-resource-manager" if component == "srm" else "ansible" group = "code" if component == "srm" else "deploy" prefix = f"https://labs.etsi.org/rep/oop/{group}/{expected_project}/-/pipelines/" if not re.fullmatch(re.escape(prefix) + r"[0-9]+", item["pipeline_url"]): raise ValueError(f"Unexpected {component} qualification pipeline") if item.get("sdk_sha") != manifest["source_sha"] or item.get("wheel_sha256") != wheel_hash: raise ValueError(f"The {component} receipt belongs to another SDK artifact") if not re.fullmatch(r"[0-9a-f]{40}", item.get("source_sha", "")): raise ValueError(f"Missing exact {component} source revision") def verify_release(): manifest = json.loads((OUT / "manifest.json").read_text()) for item in manifest["artifacts"]: Loading @@ -69,11 +49,6 @@ def verify_release(): for ref in branches ): raise ValueError("Tagged commit is outside the reviewed integration lines") if not Version(version).is_prerelease: path = os.getenv("RELEASE_QUALIFICATION_FILE") if not path: raise ValueError("Final publication requires the reviewed SRM/E2E qualification file") check_evidence(json.loads(Path(path).read_text()), manifest, sha256(wheel)) write_json( OUT / "release-check.json", { Loading @@ -81,9 +56,12 @@ def verify_release(): "version": version, "source_sha": manifest["source_sha"], "wheel_sha256": sha256(wheel), "qualification": "candidate" if Version(version).is_prerelease else "final", "release_type": "candidate" if Version(version).is_prerelease else "final", "approval_job_url": os.getenv("CI_JOB_URL"), }, ) receipt = json.loads((OUT / "release-check.json").read_text()) write_json(OUT / "release-qualification.json", {**receipt, **collect_reports()}) def main(): Loading
ci/reports.py 0 → 100644 +59 −0 Changes for ci/reports.py: 59 added lines, 0 removed lines. Original line number Diff line number Diff line """Collect informational GitLab test reports without another release gate.""" import json import os from urllib.request import urlopen def collect_reports(): report = { "pipeline_url": os.getenv("CI_PIPELINE_URL"), "sdk_tests": None, "downstreams": [], "unavailable": [], "external_e2e": "Review applicable acceptance results in the release MR.", } base = os.getenv("CI_API_V4_URL", "").rstrip("/") project = os.getenv("CI_PROJECT_ID", "") pipeline = os.getenv("CI_PIPELINE_ID", "") if not base or not project or not pipeline: report["unavailable"].append("Missing GitLab pipeline context") return report def read(path): try: # The current projects expose these reports publicly. No new token. with urlopen(f"{base}/{path}", timeout=5) as response: return json.load(response) except (OSError, ValueError) as error: report["unavailable"].append(f"{path}: {type(error).__name__}") print(f"CI report unavailable: {path} ({type(error).__name__})") return None prefix = f"projects/{project}/pipelines/{pipeline}" report["sdk_tests"] = read(prefix + "/test_report_summary") page = 1 while True: bridges = read(prefix + f"/bridges?per_page=100&page={page}") if not isinstance(bridges, list): if bridges is not None: report["unavailable"].append("Unexpected downstream pipeline response") break for bridge in bridges: try: downstream = bridge.get("downstream_pipeline") item = { "job": bridge["name"], "status": bridge["status"], "pipeline": downstream, } if downstream: path = f"projects/{downstream['project_id']}/pipelines/{downstream['id']}" item["tests"] = read(path + "/test_report_summary") report["downstreams"].append(item) except (AttributeError, KeyError, TypeError): report["unavailable"].append("Unexpected downstream pipeline entry") if len(bridges) < 100: break page += 1 return report