Commit 67e4dc07 authored by Konstantinos Togias's avatar Konstantinos Togias
Browse files

Merge branch 'ci/simple-release-evidence' into 'develop'

Simplify release approval and collect CI reports

See merge request !211
parents ab436f31 d45bfef6
Loading
Loading
Loading
Loading
Loading
+1 −0
Changes for .gitlab-ci.yml: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
+14 −13
Changes for ci/README.md: 14 added lines, 13 removed lines.
Original line number Diff line number Diff line
@@ -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
@@ -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

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.
+5 −27
Changes for ci/release.py: 5 added lines, 27 removed lines.
Original line number Diff line number Diff line
@@ -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


@@ -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"]:
@@ -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",
        {
@@ -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():

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