Commit bb5718c2 authored by Konstantinos Togias's avatar Konstantinos Togias
Browse files

ci: verify installed SDK distributions and contract release gates

parent 12ed6729
Loading
Loading
Loading
Loading
Loading
+3 −0
Changes for .gitignore: 3 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -8,3 +8,6 @@ __pycache__/
.tar.gz
/dist
src/openop_tfsdk.egg-info/

.cache/
.coverage
+143 −52
Changes for .gitlab-ci.yml: 143 added lines, 52 removed lines.
Original line number Diff line number Diff line
workflow:
  rules:
  - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  - if: $CI_COMMIT_TAG =~ /^v2\.0\.[0-9]+(rc[1-9][0-9]*)?$/
  - if: $CI_PIPELINE_SOURCE == "push" && ($CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "develop")
  - if: $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
    when: never
  - if: $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH
  - if: $CI_PIPELINE_SOURCE == "schedule" || $CI_PIPELINE_SOURCE == "web"
  - when: never
stages:
- validate
- build
- package
- consumer
- qualify
- publish

default:
  image: python:3.12-slim

  image: python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1
  interruptible: true
  timeout: 15 minutes
  before_script:
  - mkdir -p "$TMPDIR" "$PIP_CACHE_DIR"
  - python --version
  - python -m pip install --require-hashes -r ci/requirements.lock
  cache:
    key:
      files:
      - ci/requirements.lock
      prefix: pip-$PYTHON_VERSION
    paths:
    - .cache/pip/
variables:
  PACKAGE_NAME: openop-tfsdk

validate-mr:
  PYTHON_VERSION: '3.12'
  PIP_CACHE_DIR: $CI_PROJECT_DIR/.cache/pip
  TMPDIR: $CI_PROJECT_DIR/.cache/ci/tmp
  PYTHONDONTWRITEBYTECODE: '1'
  PIP_DISABLE_PIP_VERSION_CHECK: '1'
  GIT_DEPTH: '0'
lint:
  stage: validate
  before_script:
    - echo "Running merge request validation..."
    - pip install -r requirements.txt
    - pip install -e .
    - pip install isort black flake8 pytest
  script:
    - echo "Running linters..."
    - isort src tests --check --profile black --filter-files
    - black src tests --check --line-length=100
    - flake8 src tests
    - echo "Running tests..."
    - pytest -v tests/common/test_invoke_edgecloud_clients.py
    - pytest -v tests/common/test_invoke_network_clients.py
    - pytest -v tests/contract/
  only:
    - merge_requests

  - python ci/verify.py lint
secret-scan:
  stage: validate
  script:
  - python ci/security.py secrets
  artifacts:
    when: always
    paths:
    - .cache/ci/secrets.json
    expire_in: 14 days
build-package:
  stage: build
  before_script:
    - pip install build twine
    - pip install -r requirements.txt
    - pip install -e .
  script:
    - echo "Building Python package..."
    - python -m build
    - echo "Package built successfully"
    - ls -la dist/
  - python ci/verify.py build
  artifacts:
    paths:
      - dist/
    expire_in: 1 day
  only:
    - main
    - develop
    - tags

publish-gitlab:
  stage: publish
  dependencies:
    - build-package
  before_script:
    - pip install twine
    - .cache/ci/dist/
    - .cache/ci/manifest.json
    - .cache/ci/build.env
    reports:
      dotenv: .cache/ci/build.env
    expire_in: 90 days
wheel-tests:
  stage: package
  needs:
  - job: build-package
    artifacts: true
  script:
  - python ci/verify.py tests
  artifacts:
    when: always
    paths:
    - .cache/ci/junit.xml
    - .cache/ci/coverage.xml
    - .cache/ci/coverage.json
    - .cache/ci/environment.json
    reports:
      junit: .cache/ci/junit.xml
      coverage_report:
        coverage_format: cobertura
        path: .cache/ci/coverage.xml
    expire_in: 90 days
  coverage: /TOTAL.*\s([0-9]+(?:\.[0-9]+)?%)$/
sdist-check:
  stage: package
  needs:
  - job: build-package
    artifacts: true
  script:
  - python ci/verify.py sdist
chart-check:
  stage: package
  needs:
  - job: build-package
    artifacts: true
  script:
  - python ci/verify.py charts
  artifacts:
    when: always
    paths:
    - .cache/ci/chart-*.yaml
    expire_in: 14 days
public-contract-types:
  stage: package
  needs:
  - job: build-package
    artifacts: true
  script:
  - python ci/verify.py typing
dependency-audit:
  stage: package
  script:
    - echo "Validating package format..."
    - twine check dist/*
    - echo "Publishing to Package Registry..."
    - TWINE_PASSWORD="$CI_JOB_TOKEN" TWINE_USERNAME="gitlab-ci-token" python -m twine upload --repository-url ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/pypi dist/*
    - echo "Package published successfully"
  only:
    - main
    - develop
    - tags
  - python ci/security.py dependencies
  rules:
  - if: $CI_COMMIT_TAG || $CI_PIPELINE_SOURCE == "schedule"
  - when: manual
    allow_failure: true
  artifacts:
    when: always
    paths:
    - .cache/ci/vulnerabilities.json
    - .cache/ci/security-summary.json
    - .cache/ci/sbom.json
    expire_in: 90 days
python-compatibility:
  stage: package
  image: $PYTHON_IMAGE
  parallel:
    matrix:
    - PYTHON_VERSION:
      - '3.10'
      PYTHON_IMAGE:
      - python:3.10-slim@sha256:6ff506466f8b1e981719b468cc0023b8ef000b983cdec0eafe119d01f379e31d
    - PYTHON_VERSION:
      - '3.11'
      PYTHON_IMAGE:
      - python:3.11-slim@sha256:e88e9763f943ec1834f992a4b51e0f24500486803e8bc534e5767af9ea65f6ce
    - PYTHON_VERSION:
      - '3.13'
      PYTHON_IMAGE:
      - python:3.13-slim@sha256:70729b46c69b4f1e97c4822c1af3df53a1476cf5ddc6c087c0c10bc3a5678c2f
  needs:
  - job: build-package
    artifacts: true
  rules:
  - if: $CI_COMMIT_TAG || $CI_PIPELINE_SOURCE == "schedule"
  script:
  - python ci/verify.py tests
  artifacts:
    when: always
    reports:
      junit: .cache/ci/junit.xml
    paths:
    - .cache/ci/environment.json
    expire_in: 90 days
+2 −2
Changes for .pre-commit-config.yaml: 2 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -13,7 +13,7 @@ repos:
        name: isort (python)
        args: ["--profile", "black", "--filter-files"]
-   repo: https://github.com/psf/black
    rev: 25.1.0
    rev: 24.8.0
    hooks:
    -   id: black
        args:
@@ -21,7 +21,7 @@ repos:
            - "--target-version=py310"
            - "--line-length=100"
-   repo: https://github.com/pycqa/flake8
    rev: 7.2.0
    rev: 7.1.1
    hooks:
    -   id: flake8
        additional_dependencies: []

ci/README.md

0 → 100644
+28 −0
Changes for ci/README.md: 28 added lines, 0 removed lines.
Original line number Diff line number Diff line
# SDK verification and release gates

The default pipeline validates Python 3.12 using a hashed dependency/tool lock.
Run `python ci/verify.py SUITE` for lint, build, tests, sdist, charts and typing.
Build first; subsequent suites consume that wheel, not an editable checkout.
Tests select contract, common and offline location cases explicitly. Live NEF
cases are excluded and require separate acceptance evidence.

JUnit, focused contract branch coverage (81% minimum), environment inventories,
distribution hashes and packaged chart renders are retained as CI artifacts.
Helm 3.18.6 is downloaded with its official archive checksum. Python 3.10,
3.11 and 3.13 run in scheduled and release pipelines using digest-pinned images.
Update tools.in, requirements.lock, runtime.lock and toolchain.json together.
Locks were compiled with `uv pip compile pyproject.toml ci/tools.in --universal
--generate-hashes` (omit ci/tools.in for runtime.lock). Review dependency updates.

`python ci/security.py secrets` rejects new findings. The reviewed fingerprint
file contains hashes and reasons for synthetic schema examples, generated field
maps and the Helm checksum; only this hash inventory is excluded from scanning.
Changing a value or moving it to another file requires a new review. Access-token
fixtures use an explicit example value. Never place real credentials in fixtures.
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.

ci/release.py

0 → 100644
+107 −0
Changes for ci/release.py: 107 added lines, 0 removed lines.
Original line number Diff line number Diff line
"""Check release identity and qualification before publishing verified artifacts."""

import argparse
import json
import os
import re
import subprocess
import sys
import zipfile
from email.parser import BytesParser
from pathlib import Path

from packaging.version import Version
from verify import DIST, OUT, ROOT, sha256, write_json


def check_tag(tag, version, protected):
    if not re.fullmatch(r"v2\.0\.\d+(?:rc[1-9][0-9]*)?", tag):
        raise ValueError("Use an immutable v2.0.X or v2.0.XrcN release tag")
    if Version(tag[1:]) != Version(version):
        raise ValueError("Release tag and package version differ")
    if protected != "true":
        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")
        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"]:
        if sha256(DIST / item["name"]) != item["sha256"]:
            raise ValueError("Artifact changed after verification")
    wheel = next(DIST.glob("*.whl"))
    with zipfile.ZipFile(wheel) as archive:
        name = next(n for n in archive.namelist() if n.endswith(".dist-info/METADATA"))
        metadata = BytesParser().parsebytes(archive.read(name))
    if metadata["Name"] != "openop-tfsdk":
        raise ValueError("Unexpected distribution name")
    version = metadata["Version"]
    check_tag(os.getenv("CI_COMMIT_TAG", ""), version, os.getenv("CI_COMMIT_REF_PROTECTED"))
    if manifest["source_sha"] != os.environ["CI_COMMIT_SHA"]:
        raise ValueError("Build and tag source revisions differ")
    branches = ["origin/develop", "origin/main", "origin/refactor/tfsdk-architecture-alignment"]
    if not any(
        subprocess.run(
            ["git", "merge-base", "--is-ancestor", "HEAD", ref], capture_output=True
        ).returncode
        == 0
        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",
        {
            "tag": os.environ["CI_COMMIT_TAG"],
            "version": version,
            "source_sha": manifest["source_sha"],
            "wheel_sha256": sha256(wheel),
            "qualification": "candidate" if Version(version).is_prerelease else "final",
        },
    )


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("action", choices=["check", "publish"])
    args = parser.parse_args()
    os.chdir(ROOT)
    verify_release()
    if args.action == "publish":
        environment = dict(
            os.environ, TWINE_USERNAME="gitlab-ci-token", TWINE_PASSWORD=os.environ["CI_JOB_TOKEN"]
        )
        url = f"{os.environ['CI_API_V4_URL']}/projects/{os.environ['CI_PROJECT_ID']}/packages/pypi"
        subprocess.run(
            [
                sys.executable,
                "-m",
                "twine",
                "upload",
                "--repository-url",
                url,
                *[str(p) for p in DIST.iterdir()],
            ],
            env=environment,
            check=True,
        )


if __name__ == "__main__":
    main()
Loading