From 0ac170ca7106d071f9a7aef90ee49934cb658b05 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 08:41:22 +0900 Subject: [PATCH 01/13] feat(ci): centralize release-tag/publish-package with Noema semver gate Add reusable workflow_call release and publish pipelines from the fast-mlsirm provenance contract, plus a fail-closed Noema bump classifier (ADR-0032/0033) with recorded-fixture contract tests. Co-authored-by: Cursor --- .github/workflows/publish-package.yml | 472 +++++++++++++ .github/workflows/release-tag.yml | 649 ++++++++++++++++++ ...032-release-pipeline-reusable-workflows.md | 53 ++ docs/adr/0033-noema-semver-bump.md | 49 ++ .../release-pipeline-reusable-workflows.md | 160 +++++ scripts/ci/noema_semver_bump.py | 427 ++++++++++++ .../noema_semver/evidence_breaking.json | 10 + .../fixtures/noema_semver/evidence_minor.json | 10 + .../noema_semver/recorded_low_confidence.json | 8 + .../noema_semver/recorded_major_ok.json | 8 + .../noema_semver/recorded_minor_ok.json | 11 + .../recorded_patch_conflicts_breaking.json | 8 + .../noema_semver/recorded_unavailable.json | 4 + tests/test_noema_semver_bump.py | 544 +++++++++++++++ ...ase_pipeline_reusable_workflow_contract.py | 184 +++++ 15 files changed, 2597 insertions(+) create mode 100644 .github/workflows/publish-package.yml create mode 100644 .github/workflows/release-tag.yml create mode 100644 docs/adr/0032-release-pipeline-reusable-workflows.md create mode 100644 docs/adr/0033-noema-semver-bump.md create mode 100644 docs/doctoring/release-pipeline-reusable-workflows.md create mode 100644 scripts/ci/noema_semver_bump.py create mode 100644 tests/fixtures/noema_semver/evidence_breaking.json create mode 100644 tests/fixtures/noema_semver/evidence_minor.json create mode 100644 tests/fixtures/noema_semver/recorded_low_confidence.json create mode 100644 tests/fixtures/noema_semver/recorded_major_ok.json create mode 100644 tests/fixtures/noema_semver/recorded_minor_ok.json create mode 100644 tests/fixtures/noema_semver/recorded_patch_conflicts_breaking.json create mode 100644 tests/fixtures/noema_semver/recorded_unavailable.json create mode 100644 tests/test_noema_semver_bump.py create mode 100644 tests/test_release_pipeline_reusable_workflow_contract.py diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml new file mode 100644 index 0000000000..9f51f5dc4e --- /dev/null +++ b/.github/workflows/publish-package.yml @@ -0,0 +1,472 @@ +# Reusable Publish Package (workflow_call), centralising the provenance-gated +# sdist/wheel build + optional PyPI publish that fast-mlsirm previously +# carried as publish-pypi.yml. See +# docs/adr/0032-release-pipeline-reusable-workflows.md and +# docs/doctoring/release-pipeline-reusable-workflows.md. +# +# The `on: workflow_dispatch` trigger stays in each calling repo's own thin +# workflow file. Callers that still name that file publish-pypi.yml keep +# required-check / operator muscle memory; the reusable target is always +# publish-package.yml. +# +# Packaging backends: +# - maturin: pinned maturin sdist + multi-platform wheel matrix (fast-mlsirm) +# - pure-python: `python -m build` sdist+wheel on ubuntu only +# +# Example caller (.github/workflows/publish-pypi.yml in a product repo). +# Pin `uses:` to this file's exact commit SHA, not @main. Pass secrets with +# `secrets: inherit` (or map PIPY_TOKEN) so the pypi environment's trusted +# publishing / token path keeps working. +# +# name: Publish Package +# on: +# workflow_dispatch: +# inputs: +# release_tag: { required: true, type: string } +# release_commit: { required: true, type: string } +# control_plane_commit: { required: true, type: string } +# permissions: +# contents: read +# concurrency: +# group: publish-package-${{ inputs.release_tag }} +# cancel-in-progress: false +# jobs: +# publish: +# uses: ContextualWisdomLab/.github/.github/workflows/publish-package.yml@ +# with: +# release_tag: ${{ inputs.release_tag }} +# release_commit: ${{ inputs.release_commit }} +# control_plane_commit: ${{ inputs.control_plane_commit }} +# packaging_backend: maturin +# publish_to_pypi: true +# secrets: inherit + +name: Reusable Publish Package + +on: + workflow_call: + inputs: + release_tag: + description: "Immutable release tag to publish (for example v0.9.0)" + required: true + type: string + release_commit: + description: "Reviewed release source commit (full lowercase SHA-1)" + required: true + type: string + control_plane_commit: + description: "Protected default-branch commit that selected this publication workflow" + required: true + type: string + packaging_backend: + description: "Build backend: maturin (Rust extension) or pure-python (python -m build)." + required: false + type: string + default: "maturin" + publish_to_pypi: + description: "When true, publish built distributions to PyPI under the pypi environment." + required: false + type: boolean + default: true + pypi_environment: + description: "GitHub Environment name used for PyPI trusted publishing / tokens." + required: false + type: string + default: "pypi" + maturin_version: + description: "Pinned maturin-action maturin-version (ignored for pure-python)." + required: false + type: string + default: "v1.14.1" + pyproject_path: + description: "Repository-relative path to the project file that carries project.version." + required: false + type: string + default: "pyproject.toml" + ensure_sdist_license: + description: >- + When true (maturin only), inject LICENSE into the sdist if maturin + omitted it while still writing License-File metadata. + required: false + type: boolean + default: true + secrets: + PIPY_TOKEN: + description: >- + Optional PyPI API token (legacy spelling matching fast-mlsirm). Prefer + trusted publishing via the pypi environment; token is used when set. + required: false + +permissions: + contents: read + +concurrency: + group: publish-package-${{ github.repository }}-${{ inputs.release_tag }} + cancel-in-progress: false + +jobs: + verify-release: + name: verify release provenance + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Verify packaging backend input + env: + PACKAGING_BACKEND: ${{ inputs.packaging_backend }} + run: | + set -euo pipefail + case "$PACKAGING_BACKEND" in + maturin|pure-python) ;; + *) + echo "packaging_backend must be maturin or pure-python, got: $PACKAGING_BACKEND" >&2 + exit 1 + ;; + esac + - name: Verify publication control-plane identity + env: + CONTROL_PLANE_COMMIT: ${{ inputs.control_plane_commit }} + CONTROL_PLANE_SHA: ${{ github.sha }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + PUBLISH_REF: ${{ github.ref }} + run: | + set -euo pipefail + if ! printf '%s' "$CONTROL_PLANE_COMMIT" | grep -Eq '^[0-9a-f]{40}$'; then + echo "control_plane_commit must be a canonical 40-character lowercase SHA-1" >&2 + exit 1 + fi + expected_ref="refs/heads/$DEFAULT_BRANCH" + if [ "$PUBLISH_REF" != "$expected_ref" ]; then + echo "package publication must run from $expected_ref, not $PUBLISH_REF" >&2 + exit 1 + fi + if [ "$CONTROL_PLANE_SHA" != "$CONTROL_PLANE_COMMIT" ]; then + echo "publication control plane moved after release verification" >&2 + exit 1 + fi + - name: Validate release source identity + env: + RELEASE_COMMIT: ${{ inputs.release_commit }} + run: | + set -euo pipefail + if ! printf '%s' "$RELEASE_COMMIT" | grep -Eq '^[0-9a-f]{40}$'; then + echo "release_commit must be a canonical 40-character lowercase SHA-1" >&2 + exit 1 + fi + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4.3.0 (release-validated pin) + with: + ref: ${{ inputs.release_commit }} + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v5.x pin from fast-mlsirm + with: + python-version: "3.12" + - name: Require release tag and source commit to match project version + env: + RELEASE_COMMIT: ${{ inputs.release_commit }} + RELEASE_TAG: ${{ inputs.release_tag }} + PYPROJECT_PATH: ${{ inputs.pyproject_path }} + run: | + set -euo pipefail + checked_out_commit="$(git rev-parse HEAD)" + if [ "$checked_out_commit" != "$RELEASE_COMMIT" ]; then + echo "checked-out release source does not match release_commit" >&2 + exit 1 + fi + if ! tag_commit="$(git rev-parse "$RELEASE_TAG^{commit}" 2>/dev/null)"; then + echo "release tag is unavailable for provenance verification" >&2 + exit 1 + fi + if [ "$tag_commit" != "$RELEASE_COMMIT" ]; then + echo "release tag does not target release_commit" >&2 + exit 1 + fi + python - <<'PY' + import os + import tomllib + from pathlib import Path + + project = tomllib.loads( + Path(os.environ["PYPROJECT_PATH"]).read_bytes() + )["project"] + + expected = f"v{project['version']}" + actual = os.environ["RELEASE_TAG"] + if actual != expected: + raise SystemExit( + f"release tag does not match project version: expected {expected!r}, got {actual!r}" + ) + PY + + sdist: + name: sdist + needs: verify-release + if: inputs.packaging_backend == 'maturin' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ inputs.release_commit }} + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: "3.12" + - name: Build sdist + uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b + with: + command: sdist + maturin-version: ${{ inputs.maturin_version }} + args: --out dist + - name: Ensure LICENSE is present in the sdist + if: inputs.ensure_sdist_license + run: | + set -euo pipefail + python - <<'PY' + from __future__ import annotations + + import io + import sys + import tarfile + from pathlib import Path + + dist = Path("dist") + archives = sorted(dist.glob("*.tar.gz")) + if len(archives) != 1: + raise SystemExit(f"expected exactly one sdist archive, found {archives!r}") + archive = archives[0] + license_path = Path("LICENSE") + if not license_path.is_file(): + raise SystemExit("checked-out release source is missing LICENSE") + license_bytes = license_path.read_bytes() + + with tarfile.open(archive, "r:gz") as reader: + names = reader.getnames() + members = reader.getmembers() + file_members = { + member.name: reader.extractfile(member).read() + for member in members + if member.isfile() + } + + roots = {name.split("/", 1)[0] for name in names if name} + if len(roots) != 1: + raise SystemExit(f"sdist must have a single top-level directory, found {sorted(roots)!r}") + root = next(iter(roots)) + license_member = f"{root}/LICENSE" + if license_member in file_members: + if file_members[license_member] != license_bytes: + raise SystemExit("sdist LICENSE content does not match checked-out LICENSE") + print(f"{archive.name} already contains {license_member}") + sys.exit(0) + + rebuilt = archive.with_suffix(archive.suffix + ".rewritten") + with tarfile.open(archive, "r:gz") as reader, tarfile.open(rebuilt, "w:gz") as writer: + for member in members: + payload = reader.extractfile(member) if member.isfile() else None + writer.addfile(member, payload) + info = tarfile.TarInfo(name=license_member) + info.size = len(license_bytes) + info.mode = 0o644 + writer.addfile(info, io.BytesIO(license_bytes)) + + rebuilt.replace(archive) + with tarfile.open(archive, "r:gz") as reader: + if license_member not in reader.getnames(): + raise SystemExit(f"failed to inject {license_member} into {archive.name}") + print(f"injected {license_member} into {archive.name}") + PY + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: dist-sdist + path: dist + + wheels: + name: wheels (${{ matrix.target }}, py${{ matrix.python-version }}) + needs: verify-release + if: inputs.packaging_backend == 'maturin' + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + target: x86_64-unknown-linux-gnu + manylinux: "2014" + python-version: "3.12" + interpreter: python3.12 + - runner: ubuntu-latest + target: x86_64-unknown-linux-gnu + manylinux: "2014" + python-version: "3.13" + interpreter: python3.13 + - runner: ubuntu-latest + target: x86_64-unknown-linux-gnu + manylinux: "2014" + python-version: "3.14" + interpreter: python3.14 + - runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + manylinux: "2014" + python-version: "3.12" + interpreter: python3.12 + - runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + manylinux: "2014" + python-version: "3.13" + interpreter: python3.13 + - runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + manylinux: "2014" + python-version: "3.14" + interpreter: python3.14 + - runner: macos-latest + target: universal2-apple-darwin + manylinux: "" + python-version: "3.12" + interpreter: python + - runner: macos-latest + target: universal2-apple-darwin + manylinux: "" + python-version: "3.13" + interpreter: python + - runner: macos-latest + target: universal2-apple-darwin + manylinux: "" + python-version: "3.14" + interpreter: python + - runner: windows-latest + target: x86_64-pc-windows-msvc + manylinux: "" + python-version: "3.12" + interpreter: python + - runner: windows-latest + target: x86_64-pc-windows-msvc + manylinux: "" + python-version: "3.13" + interpreter: python + - runner: windows-latest + target: x86_64-pc-windows-msvc + manylinux: "" + python-version: "3.14" + interpreter: python + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ inputs.release_commit }} + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: ${{ matrix.python-version }} + - name: Build wheel + uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b + with: + target: ${{ matrix.target }} + manylinux: ${{ matrix.manylinux || 'auto' }} + maturin-version: ${{ inputs.maturin_version }} + args: --release --out dist -i ${{ matrix.interpreter }} + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: dist-wheel-${{ matrix.target }}-py${{ matrix.python-version }} + path: dist + + pure-python-dist: + name: pure-python sdist+wheel + needs: verify-release + if: inputs.packaging_backend == 'pure-python' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ inputs.release_commit }} + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: "3.12" + - name: Build sdist and wheel + run: | + set -euo pipefail + python -m pip install --upgrade pip + python -m pip install build==1.2.2 + python -m build --outdir dist + ls -la dist + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: dist-pure-python + path: dist + + release-assets: + name: attach release assets + needs: [verify-release, sdist, wheels, pure-python-dist] + if: >- + always() && + needs.verify-release.result == 'success' && + ( + (needs.sdist.result == 'success' && needs.wheels.result == 'success') || + (needs.pure-python-dist.result == 'success') + ) + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + pattern: dist-* + path: dist + merge-multiple: true + - name: List built artifacts + run: ls -la dist + - name: Attach release assets + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release_tag }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + # Immutable GitHub Releases reject later asset uploads (HTTP 422). + # Skip cleanly so a PyPI-only republish of an already-cut release is + # not forced into an overall workflow FAILURE by an unrecoverable + # asset sink, while still failing closed for mutable releases. + immutable="$(gh api "repos/$REPOSITORY/releases/tags/$RELEASE_TAG" --jq '.immutable // false')" + if [ "$immutable" = "true" ]; then + echo "release $RELEASE_TAG is immutable; skipping GitHub asset upload" + exit 0 + fi + gh release upload "$RELEASE_TAG" dist/* --repo "$REPOSITORY" + + publish-pypi: + name: publish to PyPI + needs: [verify-release, sdist, wheels, pure-python-dist] + if: >- + always() && + inputs.publish_to_pypi && + needs.verify-release.result == 'success' && + ( + (needs.sdist.result == 'success' && needs.wheels.result == 'success') || + (needs.pure-python-dist.result == 'success') + ) + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: ${{ inputs.pypi_environment }} + permissions: + contents: read + id-token: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + pattern: dist-* + path: dist + merge-multiple: true + - name: List built artifacts + run: ls -la dist + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 + with: + password: ${{ secrets.PIPY_TOKEN }} + attestations: false + # Partial publishes (wheels accepted, sdist rejected) must be + # retryable without failing on already-uploaded filenames. + skip-existing: true diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml new file mode 100644 index 0000000000..a5bc40308c --- /dev/null +++ b/.github/workflows/release-tag.yml @@ -0,0 +1,649 @@ +# Reusable Release Tag (workflow_call), centralising the provenance-gated +# release cut that fast-mlsirm previously carried as a standalone +# workflow_dispatch file. See +# docs/adr/0032-release-pipeline-reusable-workflows.md and +# docs/doctoring/release-pipeline-reusable-workflows.md. +# +# The `on: workflow_dispatch` trigger (and any branch restriction) stays in +# each calling repo's own thin workflow file — a workflow_call target cannot +# also be the thing GitHub triggers directly on workflow_dispatch. +# +# Keeps every fail-closed check from the fast-mlsirm original: +# default-branch dispatch, 40-char lowercase release_commit, ancestry on the +# default-branch lineage, pyproject version match, exactly one CHANGELOG +# section, version-cut transition vs parent, optional fragment→aggregate +# drift check, size-capped release notes, refuse overwrite of an existing +# release, resume existing immutable tag only when it already points at +# release_commit, then gh release create --verify-tag --notes-file, then +# optional dispatch of the caller's publish wrapper. +# +# Example caller (.github/workflows/release-tag.yml in a product repo). +# Pin `uses:` to this file's exact commit SHA, not @main. +# +# name: Release Tag +# on: +# workflow_dispatch: +# inputs: +# release_version: +# required: true +# type: string +# release_commit: +# required: true +# type: string +# permissions: +# contents: read +# concurrency: +# group: release-tag +# cancel-in-progress: false +# jobs: +# publish-release-tag: +# uses: ContextualWisdomLab/.github/.github/workflows/release-tag.yml@ +# with: +# release_commit: ${{ inputs.release_commit }} +# central_workflows_ref: +# publish_workflow: publish-pypi.yml +# run_changelog_fragment_check: true +# # optional: release_version — must match Noema bump when decide_version_with_noema +# secrets: inherit +# permissions: +# contents: write +# actions: write + +name: Reusable Release Tag + +on: + workflow_call: + inputs: + release_version: + description: >- + Optional human-requested version. When decide_version_with_noema is + true, this must equal the Noema-computed version or the gate fails + closed. When decide_version_with_noema is false, this is required. + required: false + type: string + default: "" + release_commit: + description: "Release source commit reviewed for this version (full lowercase SHA-1)" + required: true + type: string + decide_version_with_noema: + description: >- + When true (default), Noema classifies major/minor/patch from the + evidence pack and computes release_version (ADR-0033). Fail closed + on unavailable / low-confidence / breaking-conflict. + required: false + type: boolean + default: true + central_workflows_ref: + description: >- + Exact commit SHA of ContextualWisdomLab/.github that provides + scripts/ci/noema_semver_bump.py. Must match the uses: pin on this + reusable workflow. + required: false + type: string + default: "" + evidence_path: + description: >- + Repository-relative evidence pack JSON (API diffs, changelog + fragments, commit/PR titles). When missing, a minimal pack is + synthesized from git tags + CHANGELOG + recent commits. + required: false + type: string + default: "release-evidence.json" + min_confidence: + description: "Fail closed when Noema confidence is below this value." + required: false + type: string + default: "0.7" + publish_workflow: + description: >- + Caller-repo workflow filename to dispatch after the GitHub release + is published (for example publish-pypi.yml or publish-package.yml). + Empty skips package-publication dispatch (GitHub release only). + required: false + type: string + default: "publish-pypi.yml" + run_changelog_fragment_check: + description: >- + When true, run `python scripts/render_changelog_fragments.py --check + CHANGELOG.md` before extracting release notes. Callers without that + script must set this false. + required: false + type: boolean + default: true + pyproject_path: + description: "Repository-relative path to the project file that carries project.version." + required: false + type: string + default: "pyproject.toml" + changelog_path: + description: "Repository-relative path to the authoritative CHANGELOG." + required: false + type: string + default: "CHANGELOG.md" + secrets: + NOEMA_LLM_API_KEY: + description: "Noema / orchestrator API key for the semver bump call." + required: false + +permissions: + contents: read + +concurrency: + group: release-tag-${{ github.repository }}-${{ inputs.release_commit }} + cancel-in-progress: false + +jobs: + publish-release-tag: + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + actions: write + steps: + - name: Verify dispatch targets the default branch + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + DISPATCH_REF: ${{ github.ref }} + run: | + set -euo pipefail + expected_ref="refs/heads/$DEFAULT_BRANCH" + if [ "$DISPATCH_REF" != "$expected_ref" ]; then + echo "release dispatch must target $expected_ref, not $DISPATCH_REF" >&2 + exit 1 + fi + - name: Validate release source identity + env: + RELEASE_COMMIT: ${{ inputs.release_commit }} + run: | + set -euo pipefail + if ! printf '%s' "$RELEASE_COMMIT" | grep -Eq '^[0-9a-f]{40}$'; then + echo "release_commit must be a canonical 40-character lowercase SHA-1" >&2 + exit 1 + fi + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4.3.0 (release-validated pin) + with: + persist-credentials: false + fetch-depth: 0 + ref: ${{ inputs.release_commit }} + - name: Verify the release source is on the current default-branch lineage + env: + DISPATCH_SHA: ${{ github.sha }} + RELEASE_COMMIT: ${{ inputs.release_commit }} + run: | + set -euo pipefail + checked_out_commit="$(git rev-parse HEAD)" + if [ "$checked_out_commit" != "$RELEASE_COMMIT" ]; then + echo "checked-out release source does not match release_commit" >&2 + exit 1 + fi + if ! git cat-file -e "$DISPATCH_SHA^{commit}"; then + echo "default-branch dispatch commit is unavailable for ancestry verification" >&2 + exit 1 + fi + if ! git merge-base --is-ancestor "$RELEASE_COMMIT" "$DISPATCH_SHA"; then + echo "release commit must be an ancestor of the default branch" >&2 + exit 1 + fi + - name: Resolve release_version (Noema semver gate or required input) + id: semver + env: + DECIDE_WITH_NOEMA: ${{ inputs.decide_version_with_noema }} + REQUESTED_VERSION: ${{ inputs.release_version }} + CENTRAL_WORKFLOWS_REF: ${{ inputs.central_workflows_ref }} + EVIDENCE_PATH: ${{ inputs.evidence_path }} + CHANGELOG_PATH: ${{ inputs.changelog_path }} + MIN_CONFIDENCE: ${{ inputs.min_confidence }} + NOEMA_LLM_API_KEY: ${{ secrets.NOEMA_LLM_API_KEY }} + NOEMA_LLM_MODEL: orchestrator/free + CONTEXTUAL_ORCHESTRATOR_BASE_URL: ${{ vars.CONTEXTUAL_ORCHESTRATOR_BASE_URL || '' }} + NOEMA_LLM_API_URL: ${{ vars.NOEMA_LLM_API_URL || '' }} + run: | + set -euo pipefail + if [ "$DECIDE_WITH_NOEMA" != "true" ]; then + if [ -z "$REQUESTED_VERSION" ]; then + echo "release_version is required when decide_version_with_noema is false" >&2 + exit 1 + fi + echo "RELEASE_VERSION=$REQUESTED_VERSION" >> "$GITHUB_ENV" + echo "release_version=$REQUESTED_VERSION" >> "$GITHUB_OUTPUT" + exit 0 + fi + if ! printf '%s' "$CENTRAL_WORKFLOWS_REF" | grep -Eq '^[0-9a-f]{40}$'; then + echo "central_workflows_ref must be the exact 40-char SHA matching uses: pin" >&2 + exit 1 + fi + git clone --depth 1 \ + "https://github.com/ContextualWisdomLab/.github.git" \ + .cwl-github-tmp + git -C .cwl-github-tmp fetch --depth 1 origin "$CENTRAL_WORKFLOWS_REF" + git -C .cwl-github-tmp checkout --force "$CENTRAL_WORKFLOWS_REF" + mkdir -p .cwl-github + cp -R .cwl-github-tmp/scripts .cwl-github/scripts + rm -rf .cwl-github-tmp + + previous_version="$(git describe --tags --abbrev=0 2>/dev/null || true)" + previous_version="${previous_version#v}" + if [ -z "$previous_version" ]; then + previous_version="0.0.0" + fi + + if [ -f "$EVIDENCE_PATH" ]; then + cp "$EVIDENCE_PATH" /tmp/release-evidence.json + else + PREVIOUS_VERSION_HINT="$previous_version" CHANGELOG_PATH="$CHANGELOG_PATH" python3 - <<'PY' + import json + import os + import subprocess + from pathlib import Path + + changelog = Path(os.environ["CHANGELOG_PATH"]) + fragments = [] + if changelog.is_file(): + for line in changelog.read_text(encoding="utf-8").splitlines(): + if line.startswith("### ") or line.startswith("- "): + fragments.append(line[:200]) + if len(fragments) >= 40: + break + commits = subprocess.run( + ["git", "log", "-n", "30", "--pretty=%s"], + check=False, + capture_output=True, + text=True, + ).stdout.splitlines() + Path("/tmp/release-evidence.json").write_text( + json.dumps( + { + "previous_version": os.environ["PREVIOUS_VERSION_HINT"], + "changelog_fragments": fragments, + "removed_public_symbols": [], + "renamed_public_symbols": [], + "required_arg_promotions": [], + "deprecated_alias_only": [], + "commit_titles": commits, + "pr_titles": [], + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + PY + fi + PREVIOUS_VERSION_HINT="$previous_version" python3 - <<'PY' + import json + import os + from pathlib import Path + + path = Path("/tmp/release-evidence.json") + data = json.loads(path.read_text(encoding="utf-8")) + if not data.get("previous_version"): + data["previous_version"] = os.environ["PREVIOUS_VERSION_HINT"] + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + PY + + args=( + --evidence /tmp/release-evidence.json + --previous-version "$previous_version" + --min-confidence "$MIN_CONFIDENCE" + --output /tmp/noema-semver-provenance.json + --notes-prefix /tmp/noema-semver-notes-prefix.md + --github-output "$GITHUB_OUTPUT" + ) + if [ -n "$REQUESTED_VERSION" ]; then + args+=(--requested-version "$REQUESTED_VERSION") + fi + python3 .cwl-github/scripts/ci/noema_semver_bump.py "${args[@]}" + release_version="$(python3 -c 'import json; print(json.load(open("/tmp/noema-semver-provenance.json"))["release_version"])')" + echo "RELEASE_VERSION=$release_version" >> "$GITHUB_ENV" + cp /tmp/noema-semver-provenance.json noema-semver-provenance.json + - name: Verify the requested version is the released source version + env: + RELEASE_VERSION: ${{ env.RELEASE_VERSION }} + PYPROJECT_PATH: ${{ inputs.pyproject_path }} + CHANGELOG_PATH: ${{ inputs.changelog_path }} + run: | + set -euo pipefail + python3 - <<'PY' + import os + import re + import tomllib + from pathlib import Path + + release_version = os.environ["RELEASE_VERSION"] + pyproject_path = Path(os.environ["PYPROJECT_PATH"]) + changelog_path = Path(os.environ["CHANGELOG_PATH"]) + core_semver = re.compile( + r"(?:0|[1-9][0-9]*)\." + r"(?:0|[1-9][0-9]*)\." + r"(?:0|[1-9][0-9]*)\Z" + ) + if core_semver.fullmatch(release_version) is None: + raise SystemExit( + "release_version must be a canonical three-component semantic " + "version without leading zeros" + ) + + project = tomllib.loads(pyproject_path.read_text(encoding="utf-8")) + project_version = project["project"]["version"] + if project_version != release_version: + raise SystemExit( + f"{pyproject_path} version {project_version} does not match " + f"requested {release_version}" + ) + + header = f"## [{release_version}] - " + matches = [ + line + for line in changelog_path.read_text(encoding="utf-8").splitlines() + if line.startswith(header) + ] + if len(matches) != 1: + raise SystemExit( + f"expected exactly one CHANGELOG section for {release_version}; " + f"found {len(matches)}" + ) + PY + - name: Verify the release source is the version-cut transition + env: + RELEASE_COMMIT: ${{ inputs.release_commit }} + RELEASE_VERSION: ${{ env.RELEASE_VERSION }} + PYPROJECT_PATH: ${{ inputs.pyproject_path }} + CHANGELOG_PATH: ${{ inputs.changelog_path }} + run: | + set -euo pipefail + if ! parent_commit="$(git rev-parse "$RELEASE_COMMIT^" 2>/dev/null)"; then + echo "release commit must have a verifiable first parent" >&2 + exit 1 + fi + python3 - "$parent_commit" <<'PY' + import os + import subprocess + import sys + import tomllib + + parent_commit = sys.argv[1] + release_version = os.environ["RELEASE_VERSION"] + pyproject_path = os.environ["PYPROJECT_PATH"] + changelog_path = os.environ["CHANGELOG_PATH"] + + def parent_text(path: str) -> str: + result = subprocess.run( + ["git", "show", f"{parent_commit}:{path}"], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise SystemExit( + f"release parent is missing required source file {path}" + ) + return result.stdout + + parent_project = tomllib.loads(parent_text(pyproject_path)) + parent_version = parent_project["project"]["version"] + if parent_version == release_version: + raise SystemExit( + "parent project version already equals requested release version" + ) + + header = f"## [{release_version}] - " + parent_changelog = parent_text(changelog_path).splitlines() + if any(line.startswith(header) for line in parent_changelog): + raise SystemExit( + "parent CHANGELOG already contains requested release section" + ) + PY + - name: Fail closed on fragment to CHANGELOG aggregate drift + if: inputs.run_changelog_fragment_check + env: + CHANGELOG_PATH: ${{ inputs.changelog_path }} + run: | + set -euo pipefail + python scripts/render_changelog_fragments.py --check "$CHANGELOG_PATH" + - name: Extract the release notes from the CHANGELOG section + env: + RELEASE_VERSION: ${{ env.RELEASE_VERSION }} + CHANGELOG_PATH: ${{ inputs.changelog_path }} + run: | + set -euo pipefail + awk -v header="## [$RELEASE_VERSION] - " ' + index($0, header) == 1 { capture = 1; next } + capture && /^## / { exit } + capture { print } + ' "$CHANGELOG_PATH" > release_notes.md + if ! grep -q '[^[:space:]]' release_notes.md; then + echo "CHANGELOG section for $RELEASE_VERSION is empty" >&2 + exit 1 + fi + - name: Quote Noema semver verdict in release notes + if: inputs.decide_version_with_noema + run: | + set -euo pipefail + if [ -f /tmp/noema-semver-notes-prefix.md ]; then + { + cat /tmp/noema-semver-notes-prefix.md + echo + cat release_notes.md + } > release_notes.md.tmp + mv release_notes.md.tmp release_notes.md + fi + if [ -f noema-semver-provenance.json ]; then + echo "Noema semver provenance recorded in noema-semver-provenance.json" + fi + - name: Cap the release body below the GitHub release-body limit + env: + RELEASE_VERSION: ${{ env.RELEASE_VERSION }} + CHANGELOG_PATH: ${{ inputs.changelog_path }} + run: | + set -euo pipefail + python3 - <<'PY' + import os + from pathlib import Path + + notes_path = Path("release_notes.md") + notes_text = notes_path.read_text(encoding="utf-8") + body_limit = 120_000 + if len(notes_text) > body_limit: + release_version = os.environ["RELEASE_VERSION"] + changelog_path = os.environ["CHANGELOG_PATH"] + repository = os.environ["GITHUB_REPOSITORY"] + changelog_url = ( + f"https://github.com/{repository}/blob/" + f"v{release_version}/{changelog_path}" + ) + headings = list( + dict.fromkeys( + line + for line in notes_text.splitlines() + if line.startswith("#### ") + ) + ) + summary_lines = [ + "The complete notes for this release exceed GitHub's " + "release-body limit, so this body is a summary.", + "", + f"Full authoritative notes: the `[{release_version}]` " + f"section of [{changelog_path}]({changelog_url}).", + "", + "### Contents", + "", + ] + summary_lines += [ + "- " + line[len("#### "):] for line in headings + ] + capped_text = "\n".join(summary_lines) + "\n" + if len(capped_text) > body_limit: + capped_text = ( + capped_text[: body_limit - 60].rsplit("\n", 1)[0] + + "\n\n(contents list truncated)\n" + ) + notes_path.write_text(capped_text, encoding="utf-8") + PY + - name: Verify the release is absent and classify the tag state + id: release_tag_state + env: + GH_TOKEN: ${{ github.token }} + RELEASE_COMMIT: ${{ inputs.release_commit }} + RELEASE_VERSION: ${{ env.RELEASE_VERSION }} + run: | + set -euo pipefail + api_status() { + endpoint="$1" + response_file="$2" + curl --silent --show-error \ + --output "$response_file" \ + --write-out '%{http_code}' \ + --header "Accept: application/vnd.github+json" \ + --header "Authorization: Bearer $GH_TOKEN" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/$endpoint" + } + response_file="$(mktemp)" + status="$(api_status "releases/tags/v$RELEASE_VERSION" "$response_file")" + case "$status" in + 404) + ;; + 200) + echo "release v$RELEASE_VERSION already exists; refusing to overwrite or reuse it" >&2 + exit 1 + ;; + *) + cat "$response_file" >&2 + echo "GitHub API returned HTTP $status while checking release v$RELEASE_VERSION" >&2 + exit 1 + ;; + esac + status="$(api_status "git/ref/tags/v$RELEASE_VERSION" "$response_file")" + case "$status" in + 404) + echo "resume_existing_tag=false" >> "$GITHUB_OUTPUT" + ;; + 200) + python3 - "$response_file" <<'PY' + import json + import os + import sys + from pathlib import Path + + response = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) + tag_object = response.get("object", {}) + actual_tag_sha = tag_object.get("sha") + if ( + tag_object.get("type") != "commit" + or actual_tag_sha != os.environ["RELEASE_COMMIT"] + ): + raise SystemExit( + "existing tag does not target the requested release commit" + ) + PY + echo "tag v$RELEASE_VERSION exists without a release; resuming publication for the existing immutable tag" + echo "resume_existing_tag=true" >> "$GITHUB_OUTPUT" + ;; + *) + cat "$response_file" >&2 + echo "GitHub API returned HTTP $status while checking tag v$RELEASE_VERSION" >&2 + exit 1 + ;; + esac + rm -f "$response_file" + - name: Atomically create the immutable release tag + if: steps.release_tag_state.outputs.resume_existing_tag != 'true' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_COMMIT: ${{ inputs.release_commit }} + RELEASE_VERSION: ${{ env.RELEASE_VERSION }} + run: | + set -euo pipefail + request_file="$(mktemp)" + response_file="$(mktemp)" + cleanup() { + rm -f "$request_file" "$response_file" + } + trap cleanup EXIT + + python3 - "$request_file" <<'PY' + import json + import os + import sys + from pathlib import Path + + Path(sys.argv[1]).write_text( + json.dumps( + { + "ref": f"refs/tags/v{os.environ['RELEASE_VERSION']}", + "sha": os.environ["RELEASE_COMMIT"], + } + ), + encoding="utf-8", + ) + PY + + status="$(curl --silent --show-error \ + --request POST \ + --output "$response_file" \ + --write-out '%{http_code}' \ + --header "Accept: application/vnd.github+json" \ + --header "Authorization: Bearer $GH_TOKEN" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + --header "Content-Type: application/json" \ + --data-binary "@$request_file" \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/refs")" + if [ "$status" != "201" ]; then + cat "$response_file" >&2 + echo "GitHub API returned HTTP $status while creating the release tag" >&2 + exit 1 + fi + + python3 - "$response_file" <<'PY' + import json + import os + import sys + from pathlib import Path + + response = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) + expected_ref = f"refs/tags/v{os.environ['RELEASE_VERSION']}" + actual_ref = response.get("ref") + actual_sha = response.get("object", {}).get("sha") + if actual_ref != expected_ref or actual_sha != os.environ["RELEASE_COMMIT"]: + raise SystemExit( + "GitHub returned an unexpected ref or commit for the release tag" + ) + PY + - name: Publish the GitHub release from the verified tag + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ env.RELEASE_VERSION }} + run: | + set -euo pipefail + gh release create "v$RELEASE_VERSION" \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --title "v$RELEASE_VERSION" \ + --notes-file release_notes.md + - name: Dispatch package publication from the verified release source + if: inputs.publish_workflow != '' + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_TOKEN: ${{ github.token }} + RELEASE_COMMIT: ${{ inputs.release_commit }} + RELEASE_VERSION: ${{ env.RELEASE_VERSION }} + PUBLISH_WORKFLOW: ${{ inputs.publish_workflow }} + run: | + set -euo pipefail + git fetch --no-tags origin "$DEFAULT_BRANCH" + CONTROL_PLANE_COMMIT="$(git rev-parse "origin/$DEFAULT_BRANCH")" + if ! printf '%s' "$CONTROL_PLANE_COMMIT" | grep -Eq '^[0-9a-f]{40}$'; then + echo "default-branch HEAD is unavailable for package publication dispatch" >&2 + exit 1 + fi + if ! git merge-base --is-ancestor "$RELEASE_COMMIT" "$CONTROL_PLANE_COMMIT"; then + echo "release commit must be an ancestor of the current default branch" >&2 + exit 1 + fi + gh workflow run "$PUBLISH_WORKFLOW" \ + --repo "$GITHUB_REPOSITORY" \ + --ref "$DEFAULT_BRANCH" \ + -f release_tag="v$RELEASE_VERSION" \ + -f release_commit="$RELEASE_COMMIT" \ + -f control_plane_commit="$CONTROL_PLANE_COMMIT" diff --git a/docs/adr/0032-release-pipeline-reusable-workflows.md b/docs/adr/0032-release-pipeline-reusable-workflows.md new file mode 100644 index 0000000000..6e850cd46d --- /dev/null +++ b/docs/adr/0032-release-pipeline-reusable-workflows.md @@ -0,0 +1,53 @@ +# ADR-0032: Release pipeline reusable workflows + +- Status: Accepted +- Date: 2026-09-17 +- Deciders: ContextualWisdomLab/.github lead (owner direction via coordinator) + +## Context + +`fast-mlsirm` owns a carefully fail-closed release pair: + +- `.github/workflows/release-tag.yml` — `workflow_dispatch` cut that verifies + default-branch dispatch, 40-char `release_commit`, ancestry, pyproject + version, exactly one CHANGELOG section, version-cut vs parent, fragment + drift, size-capped notes, refuse-overwrite of existing releases, then + `gh release create --verify-tag --notes-file` and dispatches publication. +- `.github/workflows/publish-pypi.yml` — provenance re-check, pinned maturin + sdist/wheels, immutable-release-aware asset attach, PyPI publish under the + `pypi` environment. + +Other Python / Rust-extension repos will need the same contract. Copying the +pair per repo reintroduces pin drift and silent weakening of provenance +checks. Org pattern for this class of consolidation is already established +by ADR-0023 (R CMD check) and ADR-0024 (Dependency Review): reusable +`workflow_call` in `.github`, thin callers in product repos, pin `uses:` to +an exact commit SHA. + +## Decision + +1. Add `.github/workflows/release-tag.yml` and + `.github/workflows/publish-package.yml` in ContextualWisdomLab/.github as + `workflow_call`-only reusable workflows, preserving every fail-closed + check and the release-validated action SHAs from fast-mlsirm. +2. Parameterise only genuine per-repo policy: + - `packaging_backend`: `maturin` | `pure-python` + - `publish_to_pypi` / `pypi_environment` / optional `PIPY_TOKEN` + - `publish_workflow` filename for the post-release dispatch + - optional changelog fragment check and path overrides +3. fast-mlsirm (and later adopters) keep thin `workflow_dispatch` wrappers + that call the reusable workflows at an exact SHA. Local full copies are + deleted only after one successful end-to-end release through the central + path. Required check names must be updated if job nesting renames them. +4. Contract tests pin the reusable workflow prose the same way other central + workflows are pinned. + +## Consequences + +- One reviewed provenance implementation; product repos cannot silently drop + a gate by editing a local copy. +- Adopters must pin `uses:` to a commit SHA (never `@main`). +- Semver bumps are decided by Noema under ADR-0033 before the tag is cut. +- Next adoption candidates after fast-mlsirm e2e success: other maturin / + PyPI packages in the org (survey at adoption time; do not assume from this + ADR alone). diff --git a/docs/adr/0033-noema-semver-bump.md b/docs/adr/0033-noema-semver-bump.md new file mode 100644 index 0000000000..a504efad5d --- /dev/null +++ b/docs/adr/0033-noema-semver-bump.md @@ -0,0 +1,49 @@ +# ADR-0033: Noema decides semantic-version bumps for central releases + +- Status: Accepted +- Date: 2026-09-17 +- Deciders: ContextualWisdomLab/.github lead (owner direction via coordinator) + +## Context + +ADR-0032 centralises the provenance-gated release-tag / publish-package +pair. Version numbers were still a human `workflow_dispatch` input, which +lets a patch release ship a removed public symbol (or an ADR-0028 +required-arg promotion) without an independent check. + +Owner direction: Noema must classify the bump as `major` / `minor` / +`patch` under semver.org 2.0.0 from collected evidence before the cut, +fail closed when unavailable / low-confidence / conflicting with detected +breaking changes, and record the verdict in release provenance and notes. + +## Decision + +1. `scripts/ci/noema_semver_bump.py` is the fail-closed gate. It accepts an + evidence pack (changelog fragments, removed/renamed public symbols, + required-arg promotions, deprecated-alias-only list, commit/PR titles), + asks Noema (or a recorded fixture via + `NOEMA_SEMVER_RECORDED_RESPONSE_PATH`) for + `{bump, reason, evidence_refs, confidence}`, enforces a minimum + confidence, rejects `patch`/`minor` when breaking refs are present, and + computes `release_version` from the previous tag. +2. Rules encoded as independent detectors (not only LLM judgment): + - removed / renamed public symbols → breaking + - unsourced-default removals that make arguments required (ADR-0028) → + breaking + - deprecated-alias-only → minor (not breaking) +3. The reusable `release-tag.yml` workflow runs the gate by default + (`decide_version_with_noema: true`), checking out + `central_workflows_ref` (must match the `uses:` pin) for the script. + Optional `release_version` input must match the Noema-computed version. +4. Contract tests cover recorded happy / unavailable / low-confidence / + breaking-conflict paths under `tests/fixtures/noema_semver/`. + +## Consequences + +- Releases stop for a human when Noema cannot decide safely. +- Product repos must supply a rich `release-evidence.json` for accurate API + surface detection (fast-mlsirm: `python/fast_mlsirm` public callables + + PyO3 signatures); the workflow synthesizes a minimal pack otherwise. +- Next fast-mlsirm release (e.g. v0.12.0 if Noema chooses minor from + 0.11.x) must run through this path after adopting the thin callers from + ADR-0032. diff --git a/docs/doctoring/release-pipeline-reusable-workflows.md b/docs/doctoring/release-pipeline-reusable-workflows.md new file mode 100644 index 0000000000..bf1509ed20 --- /dev/null +++ b/docs/doctoring/release-pipeline-reusable-workflows.md @@ -0,0 +1,160 @@ +# Release pipeline reusable workflows + +## Decision + +Centralise the provenance-gated **release-tag** + **publish-package** pair +that `fast-mlsirm` developed into ContextualWisdomLab/.github as +`workflow_call` reusable workflows. See +[ADR-0032](../adr/0032-release-pipeline-reusable-workflows.md). + +## Source audit (fast-mlsirm) + +| Concern | release-tag.yml | publish-pypi.yml | +| --- | --- | --- | +| Trigger | `workflow_dispatch` (version + commit) | `workflow_dispatch` (tag + commit + control_plane) | +| Provenance | default-branch ref, 40-char SHA, ancestry, pyproject match, exactly one CHANGELOG section, parent version-cut, optional fragment `--check` | control_plane == `github.sha`, default-branch ref, tag→commit, tag == `v{version}` | +| Notes | CHANGELOG section → `release_notes.md`, 120k body cap with CHANGELOG link fallback | n/a | +| Tag/release | refuse existing release; resume tag only if SHA matches; atomic ref create; `gh release create --verify-tag` | attach assets unless release `.immutable` | +| Publish | `gh workflow run publish-pypi.yml` with control_plane HEAD | maturin sdist + wheel matrix; PyPI via `pypi` env + `pypa/gh-action-pypi-publish` (`skip-existing`) | +| Pins | `actions/checkout@3d3c42e5…` | same checkout/setup-python; `PyO3/maturin-action@e83996d1…`; upload/download-artifact; pypi-publish `@dc37677b…` | + +All of the above fail-closed checks and pins are preserved in the reusable +targets. Action SHAs are the release-validated set from fast-mlsirm, not +re-picked. + +## Mechanism + +| Central file | Role | +| --- | --- | +| `.github/workflows/release-tag.yml` | Reusable cut + GitHub release + optional publish dispatch | +| `.github/workflows/publish-package.yml` | Reusable verify + build + assets + optional PyPI | + +### Inputs that stay per-repo + +- `packaging_backend`: `maturin` (default) or `pure-python` (`python -m build`) +- `publish_to_pypi` / `pypi_environment` / optional secret `PIPY_TOKEN` +- `publish_workflow` on release-tag (default `publish-pypi.yml` so existing + operator filenames keep working during migration) +- `run_changelog_fragment_check` (default true; set false if the caller has + no `scripts/render_changelog_fragments.py`) + +### Example thin callers + +Pin `@` to the merge commit of this consolidation (or a later +reviewed bump). Never `@main`. + +**release-tag.yml** (caller): + +```yaml +name: Release Tag +on: + workflow_dispatch: + inputs: + release_version: + required: true + type: string + release_commit: + required: true + type: string +permissions: + contents: read +concurrency: + group: release-tag + cancel-in-progress: false +jobs: + publish-release-tag: + uses: ContextualWisdomLab/.github/.github/workflows/release-tag.yml@ + with: + release_version: ${{ inputs.release_version }} + release_commit: ${{ inputs.release_commit }} + publish_workflow: publish-pypi.yml + run_changelog_fragment_check: true + permissions: + contents: write + actions: write +``` + +**publish-pypi.yml** (caller; keep the filename during migration): + +```yaml +name: Publish Package +on: + workflow_dispatch: + inputs: + release_tag: + required: true + type: string + release_commit: + required: true + type: string + control_plane_commit: + required: true + type: string +permissions: + contents: read +concurrency: + group: publish-package-${{ inputs.release_tag }} + cancel-in-progress: false +jobs: + publish: + uses: ContextualWisdomLab/.github/.github/workflows/publish-package.yml@ + with: + release_tag: ${{ inputs.release_tag }} + release_commit: ${{ inputs.release_commit }} + control_plane_commit: ${{ inputs.control_plane_commit }} + packaging_backend: maturin + publish_to_pypi: true + secrets: inherit +``` + +Nested reusable jobs publish check names like +`publish / verify release provenance`. If branch protection required a +literal old job name, update the required-check list when adopting. + +## Noema semver gate (ADR-0033) + +Before the tag is cut, `release-tag.yml` (when `decide_version_with_noema` +is true, the default) runs `scripts/ci/noema_semver_bump.py`: + +1. Collect / load `release-evidence.json` (caller-supplied API diffs preferred; + otherwise a minimal pack from CHANGELOG + recent commits). +2. Ask Noema for `{bump, reason, evidence_refs, confidence}` under + semver.org 2.0.0. +3. Fail closed when Noema is unavailable, confidence < `min_confidence` + (default 0.7), or the verdict under-bumps detected breaking changes + (removed/renamed public symbols; ADR-0028 required-arg promotions). + Deprecated-alias-only changes are minor, not breaking. +4. Compute `release_version` from the previous git tag + bump; optional + human `release_version` input must match. +5. Record `noema-semver-provenance.json` and quote the verdict at the top + of the GitHub release notes. + +Callers must pass `central_workflows_ref` equal to the same 40-char SHA +used in `uses: …/release-tag.yml@` so the gate script is the reviewed +revision. Pass `secrets.NOEMA_LLM_API_KEY` (or rely on recorded fixtures +only in tests). + +Contract tests: +`tests/test_noema_semver_bump.py` + fixtures under +`tests/fixtures/noema_semver/` (including unavailable, low-confidence, and +breaking-conflict recorded responses). + +## Adoption order + +1. **Land** this `.github` PR (workflows + contract tests + this note). +2. **fast-mlsirm**: open a separate PR that replaces local full copies with + the thin wrappers above, pinned to the merge SHA. Do **not** delete the + full local copies until one successful end-to-end release has run through + the central path (immutable-release rules unchanged). +3. **Next candidates** (re-survey at adoption time; not a close instruction): + other org Python packages that publish to PyPI and/or use maturin — e.g. + candidates historically adjacent to fast-mlsirm packaging (confirm with + `gh search` / repo inventory before claiming ownership). Pure-docs or + non-PyPI repos should not adopt. + +## Contract tests + +`tests/test_release_pipeline_reusable_workflow_contract.py` pins +`workflow_call`-only triggers, required inputs, provenance markers, backend +gating, action SHAs, OIDC/`pypi` environment wiring, and immutable asset +skip behaviour. diff --git a/scripts/ci/noema_semver_bump.py b/scripts/ci/noema_semver_bump.py new file mode 100644 index 0000000000..15b7339f95 --- /dev/null +++ b/scripts/ci/noema_semver_bump.py @@ -0,0 +1,427 @@ +#!/usr/bin/env python3 +"""Noema-decided semantic version bump for the central release pipeline. + +Collects release evidence, asks Noema (or a recorded fixture) for a +``major`` / ``minor`` / ``patch`` verdict under semver.org 2.0.0, fail-closes +on unavailable / low-confidence / breaking-conflict outcomes, and computes +the next version from the previous tag. See ADR-0033. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import urllib.error +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping + +BUMP_VALUES = frozenset({"major", "minor", "patch"}) +DEFAULT_MIN_CONFIDENCE = 0.7 +CORE_SEMVER_RE = re.compile( + r"^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$" +) +SAFE_MODEL_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,199}$") + + +class SemverBumpError(RuntimeError): + """Fail-closed release-bump failure that must stop the cut.""" + + +@dataclass(frozen=True) +class SemverVerdict: + """Machine-readable Noema bump verdict.""" + + bump: str + reason: str + evidence_refs: tuple[str, ...] + confidence: float + + def to_dict(self) -> dict[str, Any]: + """Serialize for provenance / release-notes embedding.""" + return { + "bump": self.bump, + "reason": self.reason, + "evidence_refs": list(self.evidence_refs), + "confidence": self.confidence, + } + + +def parse_core_semver(version: str) -> tuple[int, int, int]: + """Parse a three-component core semver string into integers.""" + text = version.strip().lstrip("v") + if CORE_SEMVER_RE.fullmatch(text) is None: + raise SemverBumpError( + f"version must be canonical three-component semver, got {version!r}" + ) + major_s, minor_s, patch_s = text.split(".") + return int(major_s), int(minor_s), int(patch_s) + + +def apply_bump(previous_version: str, bump: str) -> str: + """Apply a semver bump class to ``previous_version``.""" + if bump not in BUMP_VALUES: + raise SemverBumpError(f"unknown bump class {bump!r}") + major, minor, patch = parse_core_semver(previous_version) + if bump == "major": + return f"{major + 1}.0.0" + if bump == "minor": + return f"{major}.{minor + 1}.0" + return f"{major}.{minor}.{patch + 1}" + + +def load_evidence(path: Path) -> dict[str, Any]: + """Load the release evidence pack JSON.""" + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise SemverBumpError(f"unable to read evidence pack: {exc}") from exc + if not isinstance(payload, dict): + raise SemverBumpError("evidence pack must be a JSON object") + return payload + + +def detected_breaking_refs(evidence: Mapping[str, Any]) -> tuple[str, ...]: + """Return evidence refs that independently imply a breaking change. + + Removed/renamed public symbols and unsourced-default removals that make + arguments required (ADR-0028 style) are breaking. Deprecated-alias-only + changes are intentionally excluded (they are minor). + """ + refs: list[str] = [] + for key, prefix in ( + ("removed_public_symbols", "api:removed:"), + ("renamed_public_symbols", "api:renamed:"), + ("required_arg_promotions", "api:required-arg:"), + ): + values = evidence.get(key) or [] + if not isinstance(values, list): + raise SemverBumpError(f"evidence.{key} must be a list") + for item in values: + if not isinstance(item, str) or not item.strip(): + raise SemverBumpError(f"evidence.{key} entries must be non-empty strings") + refs.append(f"{prefix}{item.strip()}") + return tuple(refs) + + +def parse_verdict(payload: Mapping[str, Any]) -> SemverVerdict: + """Validate and normalize a Noema bump verdict object.""" + bump = payload.get("bump") + reason = payload.get("reason") + evidence_refs = payload.get("evidence_refs") + confidence = payload.get("confidence") + if bump not in BUMP_VALUES: + raise SemverBumpError(f"verdict.bump must be one of {sorted(BUMP_VALUES)}") + if not isinstance(reason, str) or not reason.strip(): + raise SemverBumpError("verdict.reason must be a non-empty string") + if not isinstance(evidence_refs, list) or not evidence_refs: + raise SemverBumpError("verdict.evidence_refs must be a non-empty list") + if not all(isinstance(item, str) and item.strip() for item in evidence_refs): + raise SemverBumpError("verdict.evidence_refs entries must be non-empty strings") + if not isinstance(confidence, (int, float)) or isinstance(confidence, bool): + raise SemverBumpError("verdict.confidence must be a number") + conf = float(confidence) + if conf < 0.0 or conf > 1.0: + raise SemverBumpError("verdict.confidence must be in [0, 1]") + return SemverVerdict( + bump=str(bump), + reason=reason.strip(), + evidence_refs=tuple(item.strip() for item in evidence_refs), + confidence=conf, + ) + + +def extract_json_object(text: str) -> dict[str, Any]: + """Extract the first JSON object from model text; fail closed otherwise.""" + if not isinstance(text, str) or not text.strip(): + raise SemverBumpError("Noema returned empty content") + decoder = json.JSONDecoder() + for index, char in enumerate(text): + if char != "{": + continue + try: + obj, _end = decoder.raw_decode(text[index:]) + except json.JSONDecodeError: + continue + if isinstance(obj, dict): + return obj + raise SemverBumpError("Noema response did not contain a JSON object") + + +def load_recorded_verdict(path: Path) -> SemverVerdict: + """Load a recorded Noema verdict fixture (tests / offline fail-open never).""" + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise SemverBumpError(f"recorded Noema verdict unavailable: {exc}") from exc + if not isinstance(payload, dict): + raise SemverBumpError("recorded Noema verdict must be a JSON object") + status = payload.get("status") + if status == "unavailable": + raise SemverBumpError("Noema unavailable (recorded fixture)") + if "verdict" in payload: + inner = payload["verdict"] + if not isinstance(inner, dict): + raise SemverBumpError("recorded fixture verdict must be an object") + return parse_verdict(inner) + return parse_verdict(payload) + + +def _chat_completions_url() -> str: + """Resolve the Noema chat-completions URL from the environment.""" + explicit = (os.environ.get("NOEMA_LLM_API_URL") or "").strip() + if explicit: + return explicit + base = (os.environ.get("CONTEXTUAL_ORCHESTRATOR_BASE_URL") or "").strip().rstrip("/") + if base: + return f"{base}/v1/chat/completions" + raise SemverBumpError( + "Noema unavailable: set NOEMA_LLM_API_URL or CONTEXTUAL_ORCHESTRATOR_BASE_URL" + ) + + +def _model_name() -> str: + """Resolve a safe model identifier for the bump request.""" + model = (os.environ.get("NOEMA_LLM_MODEL") or "orchestrator/free").strip() + if SAFE_MODEL_IDENTIFIER_RE.fullmatch(model) is None: + raise SemverBumpError("NOEMA_LLM_MODEL is not a safe model identifier") + return model + + +def call_noema_for_bump( + evidence: Mapping[str, Any], + *, + opener: Any = None, +) -> SemverVerdict: + """Ask Noema for a bump verdict given the evidence pack.""" + recorded = (os.environ.get("NOEMA_SEMVER_RECORDED_RESPONSE_PATH") or "").strip() + if recorded: + return load_recorded_verdict(Path(recorded)) + + api_key = (os.environ.get("NOEMA_LLM_API_KEY") or "").strip() + if not api_key: + raise SemverBumpError("Noema unavailable: NOEMA_LLM_API_KEY is unset") + + url = _chat_completions_url() + body = { + "model": _model_name(), + "temperature": 0, + "response_format": {"type": "json_object"}, + "messages": [ + { + "role": "system", + "content": ( + "You are Noema deciding a semantic-version bump under " + "semver.org 2.0.0 for a library release. Reply with JSON only: " + '{"bump":"major"|"minor"|"patch","reason":string,' + '"evidence_refs":[string,...],"confidence":number}. ' + "Rules: removed/renamed public symbols are breaking (major); " + "unsourced-default removals that make arguments required are " + "breaking (major, ADR-0028); deprecated-alias-only changes are " + "minor; confidence is in [0,1]." + ), + }, + { + "role": "user", + "content": json.dumps(evidence, sort_keys=True, ensure_ascii=True), + }, + ], + } + request = urllib.request.Request( + url, + data=json.dumps(body).encode("utf-8"), + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + method="POST", + ) + open_url = opener or urllib.request.urlopen + try: + with open_url(request, timeout=120) as response: + raw = response.read().decode("utf-8") + except urllib.error.HTTPError as exc: + raise SemverBumpError( + f"Noema unavailable: HTTP {exc.code} from bump endpoint" + ) from exc + except (urllib.error.URLError, TimeoutError, OSError) as exc: + raise SemverBumpError(f"Noema unavailable: {exc}") from exc + + try: + envelope = json.loads(raw) + except json.JSONDecodeError as exc: + raise SemverBumpError("Noema returned non-JSON HTTP body") from exc + try: + content = envelope["choices"][0]["message"]["content"] + except (KeyError, IndexError, TypeError) as exc: + raise SemverBumpError("Noema response missing choices[0].message.content") from exc + if isinstance(content, list): + text_parts = [ + part.get("text", "") + for part in content + if isinstance(part, dict) and part.get("type") == "text" + ] + content = "".join(text_parts) + return parse_verdict(extract_json_object(str(content))) + + +def enforce_fail_closed( + verdict: SemverVerdict, + evidence: Mapping[str, Any], + *, + min_confidence: float = DEFAULT_MIN_CONFIDENCE, +) -> None: + """Stop the release on low confidence or under-bump of breaking changes.""" + if verdict.confidence < min_confidence: + raise SemverBumpError( + f"Noema confidence {verdict.confidence} below minimum {min_confidence}; " + "human decision required" + ) + breaking = detected_breaking_refs(evidence) + if breaking and verdict.bump == "patch": + raise SemverBumpError( + "Noema verdict conflicts with detected breaking change " + f"(bump=patch but breaking refs={list(breaking)}); human decision required" + ) + if breaking and verdict.bump == "minor": + # Renames/required-arg promotions are major; minor under-bumps them. + raise SemverBumpError( + "Noema verdict conflicts with detected breaking change " + f"(bump=minor but breaking refs={list(breaking)}); human decision required" + ) + + +def decide_release_version( + evidence: Mapping[str, Any], + *, + previous_version: str | None = None, + requested_version: str | None = None, + min_confidence: float = DEFAULT_MIN_CONFIDENCE, + opener: Any = None, +) -> dict[str, Any]: + """Return provenance including bump verdict and computed release version.""" + prev = previous_version or evidence.get("previous_version") + if not isinstance(prev, str) or not prev.strip(): + raise SemverBumpError("previous_version is required in evidence or arguments") + prev = prev.strip().lstrip("v") + parse_core_semver(prev) + + verdict = call_noema_for_bump(evidence, opener=opener) + enforce_fail_closed(verdict, evidence, min_confidence=min_confidence) + computed = apply_bump(prev, verdict.bump) + + if requested_version: + requested = requested_version.strip().lstrip("v") + parse_core_semver(requested) + if requested != computed: + raise SemverBumpError( + f"requested release_version {requested} does not match " + f"Noema bump {verdict.bump} from {prev} (= {computed}); " + "human decision required" + ) + + return { + "previous_version": prev, + "release_version": computed, + "verdict": verdict.to_dict(), + "breaking_refs_detected": list(detected_breaking_refs(evidence)), + "min_confidence": min_confidence, + } + + +def render_notes_prefix(provenance: Mapping[str, Any]) -> str: + """Markdown block quoting the Noema verdict for release notes.""" + verdict = provenance["verdict"] + refs = ", ".join(f"`{item}`" for item in verdict["evidence_refs"]) + return ( + "### Noema semver verdict\n\n" + f"- Bump: `{verdict['bump']}` " + f"(from `{provenance['previous_version']}` → " + f"`{provenance['release_version']}`)\n" + f"- Confidence: `{verdict['confidence']}`\n" + f"- Reason: {verdict['reason']}\n" + f"- Evidence refs: {refs}\n" + ) + + +def build_parser() -> argparse.ArgumentParser: + """CLI parser for the release-tag workflow step.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--evidence", + type=Path, + required=True, + help="Path to the evidence pack JSON", + ) + parser.add_argument( + "--previous-version", + default="", + help="Override evidence.previous_version", + ) + parser.add_argument( + "--requested-version", + default="", + help="Optional human-requested version that must match the Noema bump", + ) + parser.add_argument( + "--min-confidence", + type=float, + default=DEFAULT_MIN_CONFIDENCE, + help="Fail closed below this confidence", + ) + parser.add_argument( + "--output", + type=Path, + required=True, + help="Write provenance JSON here", + ) + parser.add_argument( + "--notes-prefix", + type=Path, + help="Optional path for the release-notes Noema quote block", + ) + parser.add_argument( + "--github-output", + type=Path, + help="Optional GitHub Actions output file to append release_version", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + """Entry point used by the reusable release-tag workflow.""" + parser = build_parser() + args = parser.parse_args(argv) + evidence = load_evidence(args.evidence) + try: + provenance = decide_release_version( + evidence, + previous_version=args.previous_version or None, + requested_version=args.requested_version or None, + min_confidence=args.min_confidence, + ) + except SemverBumpError as exc: + print(f"::error::Noema semver gate failed: {exc}", file=sys.stderr) + return 1 + + args.output.write_text( + json.dumps(provenance, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + if args.notes_prefix is not None: + args.notes_prefix.write_text(render_notes_prefix(provenance), encoding="utf-8") + if args.github_output is not None: + with args.github_output.open("a", encoding="utf-8") as handle: + handle.write(f"release_version={provenance['release_version']}\n") + handle.write(f"bump={provenance['verdict']['bump']}\n") + print(json.dumps(provenance, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/fixtures/noema_semver/evidence_breaking.json b/tests/fixtures/noema_semver/evidence_breaking.json new file mode 100644 index 0000000000..3792f1f543 --- /dev/null +++ b/tests/fixtures/noema_semver/evidence_breaking.json @@ -0,0 +1,10 @@ +{ + "previous_version": "0.11.2", + "changelog_fragments": ["breaking: remove public helper"], + "removed_public_symbols": ["fast_mlsirm.old_helper"], + "renamed_public_symbols": [], + "required_arg_promotions": [], + "deprecated_alias_only": [], + "commit_titles": ["breaking: drop old_helper"], + "pr_titles": [] +} diff --git a/tests/fixtures/noema_semver/evidence_minor.json b/tests/fixtures/noema_semver/evidence_minor.json new file mode 100644 index 0000000000..8299c68843 --- /dev/null +++ b/tests/fixtures/noema_semver/evidence_minor.json @@ -0,0 +1,10 @@ +{ + "previous_version": "0.11.2", + "changelog_fragments": ["feat: add moderated slopes API"], + "removed_public_symbols": [], + "renamed_public_symbols": [], + "required_arg_promotions": [], + "deprecated_alias_only": ["fast_mlsirm.dif.mantel_haenszel"], + "commit_titles": ["feat: moderated slopes"], + "pr_titles": ["#2001 moderated slopes"] +} diff --git a/tests/fixtures/noema_semver/recorded_low_confidence.json b/tests/fixtures/noema_semver/recorded_low_confidence.json new file mode 100644 index 0000000000..309d46ef30 --- /dev/null +++ b/tests/fixtures/noema_semver/recorded_low_confidence.json @@ -0,0 +1,8 @@ +{ + "verdict": { + "bump": "minor", + "reason": "Uncertain additive change.", + "evidence_refs": ["changelog:feat: maybe"], + "confidence": 0.42 + } +} diff --git a/tests/fixtures/noema_semver/recorded_major_ok.json b/tests/fixtures/noema_semver/recorded_major_ok.json new file mode 100644 index 0000000000..6d7a7d8efe --- /dev/null +++ b/tests/fixtures/noema_semver/recorded_major_ok.json @@ -0,0 +1,8 @@ +{ + "verdict": { + "bump": "major", + "reason": "Public symbol removed; semver major required.", + "evidence_refs": ["api:removed:fast_mlsirm.old_helper"], + "confidence": 0.95 + } +} diff --git a/tests/fixtures/noema_semver/recorded_minor_ok.json b/tests/fixtures/noema_semver/recorded_minor_ok.json new file mode 100644 index 0000000000..9d68d3b588 --- /dev/null +++ b/tests/fixtures/noema_semver/recorded_minor_ok.json @@ -0,0 +1,11 @@ +{ + "verdict": { + "bump": "minor", + "reason": "Deprecated-alias-only surface plus a new additive API.", + "evidence_refs": [ + "changelog:feat: add moderated slopes API", + "api:deprecated-alias:fast_mlsirm.dif.mantel_haenszel" + ], + "confidence": 0.91 + } +} diff --git a/tests/fixtures/noema_semver/recorded_patch_conflicts_breaking.json b/tests/fixtures/noema_semver/recorded_patch_conflicts_breaking.json new file mode 100644 index 0000000000..a29f12a3b9 --- /dev/null +++ b/tests/fixtures/noema_semver/recorded_patch_conflicts_breaking.json @@ -0,0 +1,8 @@ +{ + "verdict": { + "bump": "patch", + "reason": "Looks like a small cleanup.", + "evidence_refs": ["changelog:breaking: remove public helper"], + "confidence": 0.88 + } +} diff --git a/tests/fixtures/noema_semver/recorded_unavailable.json b/tests/fixtures/noema_semver/recorded_unavailable.json new file mode 100644 index 0000000000..f6004bde58 --- /dev/null +++ b/tests/fixtures/noema_semver/recorded_unavailable.json @@ -0,0 +1,4 @@ +{ + "status": "unavailable", + "detail": "orchestrator/free returned no healthy route" +} diff --git a/tests/test_noema_semver_bump.py b/tests/test_noema_semver_bump.py new file mode 100644 index 0000000000..f036f18751 --- /dev/null +++ b/tests/test_noema_semver_bump.py @@ -0,0 +1,544 @@ +"""Tests for Noema-decided semver bump (ADR-0033).""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +import urllib.error + +import pytest + +from scripts.ci import noema_semver_bump as semver + +FIXTURES = Path("tests/fixtures/noema_semver") + + +def _evidence(name: str) -> dict: + """Load a named evidence fixture.""" + return json.loads((FIXTURES / name).read_text(encoding="utf-8")) + + +def test_apply_bump_major_minor_patch() -> None: + """Core semver arithmetic matches semver.org 2.0.0 core rules.""" + assert semver.apply_bump("0.11.2", "major") == "1.0.0" + assert semver.apply_bump("0.11.2", "minor") == "0.12.0" + assert semver.apply_bump("0.11.2", "patch") == "0.11.3" + + +def test_recorded_minor_ok_computes_version(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Happy path: recorded minor verdict yields previous+minor.""" + monkeypatch.setenv( + "NOEMA_SEMVER_RECORDED_RESPONSE_PATH", + str(FIXTURES / "recorded_minor_ok.json"), + ) + provenance = semver.decide_release_version(_evidence("evidence_minor.json")) + assert provenance["release_version"] == "0.12.0" + assert provenance["verdict"]["bump"] == "minor" + notes = semver.render_notes_prefix(provenance) + assert "Noema semver verdict" in notes + assert "`minor`" in notes + + +def test_recorded_unavailable_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None: + """Unavailable Noema must stop the release for a human.""" + monkeypatch.setenv( + "NOEMA_SEMVER_RECORDED_RESPONSE_PATH", + str(FIXTURES / "recorded_unavailable.json"), + ) + with pytest.raises(semver.SemverBumpError, match="unavailable"): + semver.decide_release_version(_evidence("evidence_minor.json")) + + +def test_recorded_low_confidence_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None: + """Low-confidence verdicts fail closed even when the bump class looks fine.""" + monkeypatch.setenv( + "NOEMA_SEMVER_RECORDED_RESPONSE_PATH", + str(FIXTURES / "recorded_low_confidence.json"), + ) + with pytest.raises(semver.SemverBumpError, match="confidence"): + semver.decide_release_version(_evidence("evidence_minor.json")) + + +def test_recorded_patch_conflicts_with_breaking_fails_closed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Detected removed public symbol + patch bump is a hard conflict.""" + monkeypatch.setenv( + "NOEMA_SEMVER_RECORDED_RESPONSE_PATH", + str(FIXTURES / "recorded_patch_conflicts_breaking.json"), + ) + with pytest.raises(semver.SemverBumpError, match="conflicts with detected breaking"): + semver.decide_release_version(_evidence("evidence_breaking.json")) + + +def test_recorded_major_ok_on_breaking(monkeypatch: pytest.MonkeyPatch) -> None: + """Breaking evidence with a major verdict is accepted.""" + monkeypatch.setenv( + "NOEMA_SEMVER_RECORDED_RESPONSE_PATH", + str(FIXTURES / "recorded_major_ok.json"), + ) + provenance = semver.decide_release_version(_evidence("evidence_breaking.json")) + assert provenance["release_version"] == "1.0.0" + assert provenance["breaking_refs_detected"] == [ + "api:removed:fast_mlsirm.old_helper" + ] + + +def test_requested_version_must_match_computed(monkeypatch: pytest.MonkeyPatch) -> None: + """Human-requested version that disagrees with Noema fails closed.""" + monkeypatch.setenv( + "NOEMA_SEMVER_RECORDED_RESPONSE_PATH", + str(FIXTURES / "recorded_minor_ok.json"), + ) + with pytest.raises(semver.SemverBumpError, match="does not match"): + semver.decide_release_version( + _evidence("evidence_minor.json"), + requested_version="0.11.3", + ) + + +def test_main_writes_provenance_and_github_output( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """CLI used by the workflow writes provenance, notes, and GITHUB_OUTPUT.""" + monkeypatch.setenv( + "NOEMA_SEMVER_RECORDED_RESPONSE_PATH", + str(FIXTURES / "recorded_minor_ok.json"), + ) + evidence = tmp_path / "evidence.json" + evidence.write_text( + (FIXTURES / "evidence_minor.json").read_text(encoding="utf-8"), + encoding="utf-8", + ) + output = tmp_path / "prov.json" + notes = tmp_path / "notes.md" + gh_out = tmp_path / "github_output" + rc = semver.main( + [ + "--evidence", + str(evidence), + "--output", + str(output), + "--notes-prefix", + str(notes), + "--github-output", + str(gh_out), + ] + ) + assert rc == 0 + prov = json.loads(output.read_text(encoding="utf-8")) + assert prov["release_version"] == "0.12.0" + assert "Noema semver verdict" in notes.read_text(encoding="utf-8") + gh_text = gh_out.read_text(encoding="utf-8") + assert "release_version=0.12.0" in gh_text + assert "bump=minor" in gh_text + + +def test_main_returns_one_on_fail_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """CLI exits 1 when the gate fails closed.""" + monkeypatch.setenv( + "NOEMA_SEMVER_RECORDED_RESPONSE_PATH", + str(FIXTURES / "recorded_unavailable.json"), + ) + evidence = tmp_path / "evidence.json" + evidence.write_text( + (FIXTURES / "evidence_minor.json").read_text(encoding="utf-8"), + encoding="utf-8", + ) + rc = semver.main( + [ + "--evidence", + str(evidence), + "--output", + str(tmp_path / "prov.json"), + ] + ) + assert rc == 1 + + +def test_call_noema_http_path_parses_chat_completions( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Live HTTP path parses OpenAI-shaped chat completions content.""" + monkeypatch.delenv("NOEMA_SEMVER_RECORDED_RESPONSE_PATH", raising=False) + monkeypatch.setenv("NOEMA_LLM_API_KEY", "test-key") + monkeypatch.setenv("NOEMA_LLM_API_URL", "https://llm.example/v1/chat/completions") + monkeypatch.setenv("NOEMA_LLM_MODEL", "orchestrator/free") + + verdict_obj = { + "bump": "patch", + "reason": "docs only", + "evidence_refs": ["changelog:docs"], + "confidence": 0.8, + } + payload = { + "choices": [{"message": {"content": json.dumps(verdict_obj)}}] + } + + class _Resp: + """Minimal urlopen response.""" + + def read(self) -> bytes: + """Return encoded JSON body.""" + return json.dumps(payload).encode("utf-8") + + def __enter__(self) -> _Resp: + """Context manager enter.""" + return self + + def __exit__(self, *args: object) -> None: + """Context manager exit.""" + return None + + def _open(request: object, timeout: float = 0) -> _Resp: + """Fake urlopen.""" + assert timeout == 120 + return _Resp() + + evidence = { + "previous_version": "1.2.3", + "removed_public_symbols": [], + "renamed_public_symbols": [], + "required_arg_promotions": [], + } + provenance = semver.decide_release_version(evidence, opener=_open) + assert provenance["release_version"] == "1.2.4" + + +def test_call_noema_unavailable_without_key(monkeypatch: pytest.MonkeyPatch) -> None: + """Missing API key fails closed as unavailable.""" + monkeypatch.delenv("NOEMA_SEMVER_RECORDED_RESPONSE_PATH", raising=False) + monkeypatch.delenv("NOEMA_LLM_API_KEY", raising=False) + monkeypatch.setenv("NOEMA_LLM_API_URL", "https://llm.example/v1/chat/completions") + with pytest.raises(semver.SemverBumpError, match="NOEMA_LLM_API_KEY"): + semver.call_noema_for_bump({"previous_version": "0.1.0"}) + + +def test_extract_json_object_and_parse_edges() -> None: + """Malformed verdicts fail closed.""" + with pytest.raises(semver.SemverBumpError): + semver.extract_json_object("no object here") + with pytest.raises(semver.SemverBumpError): + semver.parse_verdict({"bump": "mega", "reason": "x", "evidence_refs": ["a"], "confidence": 1}) + with pytest.raises(semver.SemverBumpError): + semver.parse_core_semver("01.0.0") + + +def test_required_arg_promotion_conflicts_with_minor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ADR-0028 required-arg promotions are breaking; minor under-bumps fail.""" + monkeypatch.setenv( + "NOEMA_SEMVER_RECORDED_RESPONSE_PATH", + str(FIXTURES / "recorded_minor_ok.json"), + ) + evidence = _evidence("evidence_minor.json") + evidence["required_arg_promotions"] = ["enumerate_bifactor_direct.max_iter"] + with pytest.raises(semver.SemverBumpError, match="conflicts with detected breaking"): + semver.decide_release_version(evidence) + + +def test_apply_bump_rejects_unknown_class() -> None: + """Unknown bump tokens fail closed.""" + with pytest.raises(semver.SemverBumpError, match="unknown bump"): + semver.apply_bump("1.0.0", "mega") + + +def test_load_evidence_errors(tmp_path: Path) -> None: + """Corrupt or non-object evidence packs fail closed.""" + missing = tmp_path / "missing.json" + with pytest.raises(semver.SemverBumpError, match="unable to read"): + semver.load_evidence(missing) + bad = tmp_path / "bad.json" + bad.write_text("[1,2]\n", encoding="utf-8") + with pytest.raises(semver.SemverBumpError, match="JSON object"): + semver.load_evidence(bad) + assert semver.load_evidence(FIXTURES / "evidence_minor.json")["previous_version"] == "0.11.2" + + +def test_detected_breaking_refs_validate_shape() -> None: + """Breaking-ref lists must be lists of non-empty strings.""" + with pytest.raises(semver.SemverBumpError, match="must be a list"): + semver.detected_breaking_refs({"removed_public_symbols": "nope"}) + with pytest.raises(semver.SemverBumpError, match="non-empty strings"): + semver.detected_breaking_refs({"removed_public_symbols": [" "]}) + refs = semver.detected_breaking_refs( + { + "renamed_public_symbols": ["a->b"], + "required_arg_promotions": ["f.x"], + } + ) + assert refs == ("api:renamed:a->b", "api:required-arg:f.x") + + +def test_parse_verdict_field_errors() -> None: + """Each verdict field is validated independently.""" + base = { + "bump": "patch", + "reason": "ok", + "evidence_refs": ["a"], + "confidence": 0.9, + } + with pytest.raises(semver.SemverBumpError, match="reason"): + semver.parse_verdict({**base, "reason": " "}) + with pytest.raises(semver.SemverBumpError, match="evidence_refs must be a non-empty"): + semver.parse_verdict({**base, "evidence_refs": []}) + with pytest.raises(semver.SemverBumpError, match="entries must be non-empty"): + semver.parse_verdict({**base, "evidence_refs": [""]}) + with pytest.raises(semver.SemverBumpError, match="confidence must be a number"): + semver.parse_verdict({**base, "confidence": True}) + with pytest.raises(semver.SemverBumpError, match=r"\[0, 1\]"): + semver.parse_verdict({**base, "confidence": 1.5}) + + +def test_extract_json_object_skips_noise_then_parses() -> None: + """Leading prose before the JSON object is tolerated.""" + obj = semver.extract_json_object( + 'prefix {"bump":"patch","reason":"r","evidence_refs":["e"],"confidence":0.8} trailing' + ) + assert obj["bump"] == "patch" + with pytest.raises(semver.SemverBumpError, match="empty"): + semver.extract_json_object(" ") + # A bare "{" that is not valid JSON must be skipped before a later object. + obj2 = semver.extract_json_object( + '{not-json {"bump":"minor","reason":"r","evidence_refs":["e"],"confidence":0.9}' + ) + assert obj2["bump"] == "minor" + + +def test_extract_json_object_ignores_non_dict_decode( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """If a decode yields a non-dict, scanning continues then fails closed.""" + real_raw = json.JSONDecoder.raw_decode + + def _raw(self: json.JSONDecoder, s: str, idx: int = 0) -> tuple[object, int]: + if s[idx:].startswith("{1"): + return ([1], idx + 2) + return real_raw(self, s, idx) + + monkeypatch.setattr(json.JSONDecoder, "raw_decode", _raw) + with pytest.raises(semver.SemverBumpError, match="did not contain"): + semver.extract_json_object("{1 later") + + +def test_load_recorded_unreadable(tmp_path: Path) -> None: + """OSError while reading a recorded fixture fails closed.""" + path = tmp_path / "gone.json" + path.write_text("{}", encoding="utf-8") + path.unlink() + with pytest.raises(semver.SemverBumpError, match="unavailable"): + semver.load_recorded_verdict(path) + + +def test_module_main_entrypoint(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """``python -m`` style __main__ guard exits with main()'s status.""" + monkeypatch.setenv( + "NOEMA_SEMVER_RECORDED_RESPONSE_PATH", + str(FIXTURES / "recorded_minor_ok.json"), + ) + evidence = tmp_path / "evidence.json" + evidence.write_text( + (FIXTURES / "evidence_minor.json").read_text(encoding="utf-8"), + encoding="utf-8", + ) + output = tmp_path / "prov.json" + monkeypatch.setattr( + sys, + "argv", + [ + "noema_semver_bump.py", + "--evidence", + str(evidence), + "--output", + str(output), + ], + ) + with pytest.raises(SystemExit) as excinfo: + import runpy + + runpy.run_module("scripts.ci.noema_semver_bump", run_name="__main__") + assert excinfo.value.code == 0 + + +def test_load_recorded_verdict_shape_errors(tmp_path: Path) -> None: + """Recorded fixtures that are not objects fail closed.""" + path = tmp_path / "arr.json" + path.write_text("[1]\n", encoding="utf-8") + with pytest.raises(semver.SemverBumpError, match="JSON object"): + semver.load_recorded_verdict(path) + nested = tmp_path / "nested.json" + nested.write_text('{"verdict": []}\n', encoding="utf-8") + with pytest.raises(semver.SemverBumpError, match="must be an object"): + semver.load_recorded_verdict(nested) + flat = tmp_path / "flat.json" + flat.write_text( + json.dumps( + { + "bump": "patch", + "reason": "docs", + "evidence_refs": ["c"], + "confidence": 0.9, + } + ), + encoding="utf-8", + ) + assert semver.load_recorded_verdict(flat).bump == "patch" + + +def test_model_and_url_resolution(monkeypatch: pytest.MonkeyPatch) -> None: + """URL/model helpers fail closed on missing or unsafe config.""" + monkeypatch.delenv("NOEMA_LLM_API_URL", raising=False) + monkeypatch.delenv("CONTEXTUAL_ORCHESTRATOR_BASE_URL", raising=False) + with pytest.raises(semver.SemverBumpError, match="NOEMA_LLM_API_URL"): + semver._chat_completions_url() + monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_BASE_URL", "http://127.0.0.1:8080/") + assert semver._chat_completions_url().endswith("/v1/chat/completions") + monkeypatch.setenv("NOEMA_LLM_MODEL", "bad model!") + with pytest.raises(semver.SemverBumpError, match="safe model"): + semver._model_name() + + +def test_call_noema_http_error_paths(monkeypatch: pytest.MonkeyPatch) -> None: + """HTTP/transport failures and malformed envelopes fail closed.""" + monkeypatch.delenv("NOEMA_SEMVER_RECORDED_RESPONSE_PATH", raising=False) + monkeypatch.setenv("NOEMA_LLM_API_KEY", "k") + monkeypatch.setenv("NOEMA_LLM_API_URL", "https://llm.example/v1/chat/completions") + + def _http_err(request: object, timeout: float = 0) -> None: + raise urllib.error.HTTPError( + "https://llm.example/v1/chat/completions", 503, "busy", None, None + ) + + with pytest.raises(semver.SemverBumpError, match="HTTP 503"): + semver.call_noema_for_bump({"previous_version": "0.1.0"}, opener=_http_err) + + def _url_err(request: object, timeout: float = 0) -> None: + raise urllib.error.URLError("down") + + with pytest.raises(semver.SemverBumpError, match="unavailable"): + semver.call_noema_for_bump({"previous_version": "0.1.0"}, opener=_url_err) + + class _BadJson: + def read(self) -> bytes: + return b"not-json" + + def __enter__(self) -> _BadJson: + return self + + def __exit__(self, *args: object) -> None: + return None + + with pytest.raises(semver.SemverBumpError, match="non-JSON"): + semver.call_noema_for_bump( + {"previous_version": "0.1.0"}, + opener=lambda *a, **k: _BadJson(), + ) + + class _MissingChoices: + def read(self) -> bytes: + return b'{"choices":[]}' + + def __enter__(self) -> _MissingChoices: + return self + + def __exit__(self, *args: object) -> None: + return None + + with pytest.raises(semver.SemverBumpError, match="choices"): + semver.call_noema_for_bump( + {"previous_version": "0.1.0"}, + opener=lambda *a, **k: _MissingChoices(), + ) + + class _ListContent: + def read(self) -> bytes: + verdict = { + "bump": "patch", + "reason": "docs", + "evidence_refs": ["c"], + "confidence": 0.9, + } + return json.dumps( + { + "choices": [ + { + "message": { + "content": [ + {"type": "text", "text": json.dumps(verdict)}, + {"type": "ignore", "text": "x"}, + ] + } + } + ] + } + ).encode("utf-8") + + def __enter__(self) -> _ListContent: + return self + + def __exit__(self, *args: object) -> None: + return None + + assert ( + semver.call_noema_for_bump( + {"previous_version": "0.1.0"}, + opener=lambda *a, **k: _ListContent(), + ).bump + == "patch" + ) + + +def test_decide_requires_previous_version(monkeypatch: pytest.MonkeyPatch) -> None: + """Missing previous_version fails before calling Noema.""" + monkeypatch.setenv( + "NOEMA_SEMVER_RECORDED_RESPONSE_PATH", + str(FIXTURES / "recorded_minor_ok.json"), + ) + with pytest.raises(semver.SemverBumpError, match="previous_version is required"): + semver.decide_release_version({}) + + +def test_requested_version_match_succeeds(monkeypatch: pytest.MonkeyPatch) -> None: + """Matching requested version is accepted.""" + monkeypatch.setenv( + "NOEMA_SEMVER_RECORDED_RESPONSE_PATH", + str(FIXTURES / "recorded_minor_ok.json"), + ) + provenance = semver.decide_release_version( + _evidence("evidence_minor.json"), + requested_version="v0.12.0", + ) + assert provenance["release_version"] == "0.12.0" + + +def test_main_without_optional_outputs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """CLI works when notes-prefix and github-output are omitted.""" + monkeypatch.setenv( + "NOEMA_SEMVER_RECORDED_RESPONSE_PATH", + str(FIXTURES / "recorded_minor_ok.json"), + ) + evidence = tmp_path / "evidence.json" + evidence.write_text( + (FIXTURES / "evidence_minor.json").read_text(encoding="utf-8"), + encoding="utf-8", + ) + output = tmp_path / "prov.json" + assert ( + semver.main( + [ + "--evidence", + str(evidence), + "--output", + str(output), + "--previous-version", + "0.11.2", + ] + ) + == 0 + ) diff --git a/tests/test_release_pipeline_reusable_workflow_contract.py b/tests/test_release_pipeline_reusable_workflow_contract.py new file mode 100644 index 0000000000..ae5a309e6c --- /dev/null +++ b/tests/test_release_pipeline_reusable_workflow_contract.py @@ -0,0 +1,184 @@ +"""Contract for the reusable release-tag and publish-package workflows. + +Centralises the provenance-gated release cut and package publication that +fast-mlsirm previously carried as standalone ``release-tag.yml`` / +``publish-pypi.yml`` files. See +``docs/doctoring/release-pipeline-reusable-workflows.md`` and +``docs/adr/0032-release-pipeline-reusable-workflows.md``. +""" + +from __future__ import annotations + +from pathlib import Path + +_RELEASE_TAG = Path(".github/workflows/release-tag.yml") +_PUBLISH_PACKAGE = Path(".github/workflows/publish-package.yml") + +_CHECKOUT_PIN = "3d3c42e5aac5ba805825da76410c181273ba90b1" +_SETUP_PYTHON_PIN = "5fda3b95a4ea91299a34e894583c3862153e4b97" +_MATURIN_PIN = "e83996d129638aa358a18fbd1dfb82f0b0fb5d3b" +_UPLOAD_ARTIFACT_PIN = "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" +_DOWNLOAD_ARTIFACT_PIN = "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c" +_PYPI_PUBLISH_PIN = "dc37677b2e1c63e2034f94d8a5b11f265b73ba33" + + +def _release_text() -> str: + """Read the reusable release-tag workflow as UTF-8 text.""" + return _RELEASE_TAG.read_text(encoding="utf-8") + + +def _publish_text() -> str: + """Read the reusable publish-package workflow as UTF-8 text.""" + return _PUBLISH_PACKAGE.read_text(encoding="utf-8") + + +def test_release_tag_is_workflow_call_only() -> None: + """Product repos keep the workflow_dispatch trigger in a thin caller.""" + workflow = _release_text() + assert "on:\n workflow_call:\n inputs:" in workflow + # Strip comment lines so example caller snippets do not false-positive. + active = "\n".join( + line for line in workflow.splitlines() if not line.lstrip().startswith("#") + ) + assert "workflow_dispatch:" not in active + + +def test_release_tag_declares_required_version_and_commit_inputs() -> None: + """release_commit is required; release_version is optional under Noema.""" + workflow = _release_text() + for name in ( + "release_version:", + "release_commit:", + "decide_version_with_noema:", + "central_workflows_ref:", + "publish_workflow:", + "run_changelog_fragment_check:", + "pyproject_path:", + "changelog_path:", + ): + assert name in workflow + assert 'default: "publish-pypi.yml"' in workflow + assert "decide_version_with_noema:" in workflow + assert "noema_semver_bump.py" in workflow + + +def test_release_tag_keeps_fail_closed_provenance_checks() -> None: + """Every provenance gate from the fast-mlsirm original remains.""" + workflow = _release_text() + markers = [ + "release dispatch must target", + "release_commit must be a canonical 40-character lowercase SHA-1", + "release commit must be an ancestor of the default branch", + "expected exactly one CHANGELOG section", + "parent project version already equals requested release version", + "parent CHANGELOG already contains requested release section", + "render_changelog_fragments.py --check", + "release-body limit", + "refusing to overwrite or reuse it", + "resume_existing_tag", + "gh release create", + "--verify-tag", + "--notes-file release_notes.md", + "Noema semver", + ] + for marker in markers: + assert marker in workflow, marker + + +def test_release_tag_action_pins_match_release_validated_set() -> None: + """Checkout pin stays the release-validated SHA, not an unpinned tag.""" + workflow = _release_text() + assert f"actions/checkout@{_CHECKOUT_PIN}" in workflow + + +def test_release_tag_dispatch_of_publish_is_skippable() -> None: + """Empty publish_workflow skips package dispatch (GitHub release only).""" + workflow = _release_text() + assert "if: inputs.publish_workflow != ''" in workflow + assert 'gh workflow run "$PUBLISH_WORKFLOW"' in workflow + + +def test_publish_package_is_workflow_call_only() -> None: + """Publication is also a reusable target; thin callers keep workflow_dispatch.""" + workflow = _publish_text() + assert "on:\n workflow_call:\n inputs:" in workflow + active = "\n".join( + line for line in workflow.splitlines() if not line.lstrip().startswith("#") + ) + assert "workflow_dispatch:" not in active + + +def test_publish_package_declares_backend_and_pypi_inputs() -> None: + """packaging_backend and publish_to_pypi are the per-repo policy knobs.""" + workflow = _publish_text() + for name in ( + "release_tag:", + "release_commit:", + "control_plane_commit:", + "packaging_backend:", + "publish_to_pypi:", + "pypi_environment:", + "maturin_version:", + "ensure_sdist_license:", + ): + assert name in workflow + assert 'default: "maturin"' in workflow + assert 'default: "pypi"' in workflow + + +def test_publish_package_rejects_unknown_packaging_backend() -> None: + """Fail closed when a caller passes an unsupported backend string.""" + workflow = _publish_text() + assert "maturin|pure-python" in workflow + assert "packaging_backend must be maturin or pure-python" in workflow + + +def test_publish_package_keeps_control_plane_and_tag_provenance() -> None: + """Control-plane SHA, default-branch ref, and tag→commit binding stay fail-closed.""" + workflow = _publish_text() + markers = [ + "control_plane_commit must be a canonical 40-character lowercase SHA-1", + "package publication must run from", + "publication control plane moved after release verification", + "release tag does not target release_commit", + "release tag does not match project version", + ] + for marker in markers: + assert marker in workflow, marker + + +def test_publish_package_maturin_and_pure_python_jobs_are_mutually_gated() -> None: + """Maturin sdist/wheels and pure-python build never both run for one call.""" + workflow = _publish_text() + assert "if: inputs.packaging_backend == 'maturin'" in workflow + assert "if: inputs.packaging_backend == 'pure-python'" in workflow + assert "python -m build --outdir dist" in workflow + assert "PyO3/maturin-action@" in workflow + + +def test_publish_package_action_pins_match_release_validated_set() -> None: + """Every third-party action keeps the SHA that fast-mlsirm release-validated.""" + workflow = _publish_text() + assert f"actions/checkout@{_CHECKOUT_PIN}" in workflow + assert f"actions/setup-python@{_SETUP_PYTHON_PIN}" in workflow + assert f"PyO3/maturin-action@{_MATURIN_PIN}" in workflow + assert f"actions/upload-artifact@{_UPLOAD_ARTIFACT_PIN}" in workflow + assert f"actions/download-artifact@{_DOWNLOAD_ARTIFACT_PIN}" in workflow + assert f"pypa/gh-action-pypi-publish@{_PYPI_PUBLISH_PIN}" in workflow + + +def test_publish_package_pypi_job_uses_environment_and_oidc() -> None: + """Trusted publishing needs the pypi environment plus id-token: write.""" + workflow = _publish_text() + assert "environment: ${{ inputs.pypi_environment }}" in workflow + assert "id-token: write" in workflow + assert "inputs.publish_to_pypi" in workflow + assert "skip-existing: true" in workflow + assert "PIPY_TOKEN" in workflow + + +def test_publish_package_skips_immutable_release_asset_upload() -> None: + """Immutable GitHub Releases must not fail a PyPI-only republish.""" + workflow = _publish_text() + assert "release $RELEASE_TAG is immutable; skipping GitHub asset upload" in workflow + assert ".immutable // false" in workflow From 832eb191339aba855a0c7e4554802472c8224c8c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 09:15:20 +0900 Subject: [PATCH 02/13] docs(ci): durable sibling-caller pin contract for release pipelines Record exact uses:@ pin fields, Noema bump gate inputs, and workflow_call-only (no PR/push) assertions so product repos can adopt after #2260 merges without inventing the caller surface. Co-authored-by: Cursor --- ...032-release-pipeline-reusable-workflows.md | 13 ++- .../release-pipeline-reusable-workflows.md | 74 +++++++++++++-- ...ase_pipeline_reusable_workflow_contract.py | 92 ++++++++++++++++--- 3 files changed, 154 insertions(+), 25 deletions(-) diff --git a/docs/adr/0032-release-pipeline-reusable-workflows.md b/docs/adr/0032-release-pipeline-reusable-workflows.md index 6e850cd46d..e9713fa0da 100644 --- a/docs/adr/0032-release-pipeline-reusable-workflows.md +++ b/docs/adr/0032-release-pipeline-reusable-workflows.md @@ -41,12 +41,23 @@ an exact commit SHA. path. Required check names must be updated if job nesting renames them. 4. Contract tests pin the reusable workflow prose the same way other central workflows are pinned. +5. Sibling-caller pin contract (durable adoption surface after merge) is + recorded in + [`docs/doctoring/release-pipeline-reusable-workflows.md`](../doctoring/release-pipeline-reusable-workflows.md) + § "Sibling-caller pin contract": exact + `uses: ContextualWisdomLab/.github/.github/workflows/release-tag.yml@` + / + `uses: ContextualWisdomLab/.github/.github/workflows/publish-package.yml@` + patterns, required inputs including the Noema bump gate and matching + `central_workflows_ref`, and the rule that reusable targets stay + `workflow_call`-only (no `pull_request` / `push` / `workflow_dispatch`). ## Consequences - One reviewed provenance implementation; product repos cannot silently drop a gate by editing a local copy. -- Adopters must pin `uses:` to a commit SHA (never `@main`). +- Adopters must pin `uses:` to a commit SHA (never `@main`) and pass the + same SHA as `central_workflows_ref` on release-tag (ADR-0033). - Semver bumps are decided by Noema under ADR-0033 before the tag is cut. - Next adoption candidates after fast-mlsirm e2e success: other maturin / PyPI packages in the org (survey at adoption time; do not assume from this diff --git a/docs/doctoring/release-pipeline-reusable-workflows.md b/docs/doctoring/release-pipeline-reusable-workflows.md index bf1509ed20..fd64ce242e 100644 --- a/docs/doctoring/release-pipeline-reusable-workflows.md +++ b/docs/doctoring/release-pipeline-reusable-workflows.md @@ -38,10 +38,57 @@ re-picked. - `run_changelog_fragment_check` (default true; set false if the caller has no `scripts/render_changelog_fragments.py`) -### Example thin callers +## Sibling-caller pin contract + +Product repos adopt these workflows **only** through thin local wrappers. +The reusable targets themselves stay `workflow_call`-only: they must never +grow `pull_request`, `push`, or `workflow_dispatch` triggers (contract test +enforced). Callers keep their own `on: workflow_dispatch` (and any branch +restriction); GitHub cannot trigger a `workflow_call` target directly. + +### Exact `uses:` pin pattern + +Replace `` with the reviewed ContextualWisdomLab/.github commit that +carries the reusable files (the merge commit of this consolidation, or a +later reviewed bump). Never `@main`, never a floating tag. + +| Reusable target | Pin field product repos must copy | +| --- | --- | +| release cut | `uses: ContextualWisdomLab/.github/.github/workflows/release-tag.yml@` | +| package publish | `uses: ContextualWisdomLab/.github/.github/workflows/publish-package.yml@` | + +The same 40-character lowercase SHA must also be passed as +`central_workflows_ref` on the release-tag call so +`scripts/ci/noema_semver_bump.py` is checked out from that exact revision +(ADR-0033). A mismatched pin vs `central_workflows_ref` is a failed gate, +not a silent drift. + +### Required / gate inputs (release-tag) -Pin `@` to the merge commit of this consolidation (or a later -reviewed bump). Never `@main`. +| Input / secret | Role | +| --- | --- | +| `release_commit` | Required. Full lowercase SHA-1 of the reviewed release source. | +| `central_workflows_ref` | Required for adopters. Same `` as the `uses:` pin. | +| `decide_version_with_noema` | Default `true`. Noema bump gate (ADR-0033); fail closed on unavailable / low-confidence / breaking-conflict. | +| `release_version` | Optional when Noema decides; if set, must equal the Noema-computed version. Required when `decide_version_with_noema` is false. | +| `min_confidence` | Default `0.7`. Fail closed below this confidence. | +| `evidence_path` | Default `release-evidence.json`. Prefer a rich API-diff pack. | +| `publish_workflow` | Default `publish-pypi.yml`. Empty skips package dispatch. | +| `run_changelog_fragment_check` | Default `true`; set `false` when the caller has no fragment renderer. | +| `NOEMA_LLM_API_KEY` | Secret (via `secrets: inherit` or explicit map) for the live Noema call. | + +### Required inputs (publish-package) + +| Input / secret | Role | +| --- | --- | +| `release_tag` | Required. Immutable tag (for example `v0.9.0`). | +| `release_commit` | Required. Full lowercase SHA-1 matching the tag. | +| `control_plane_commit` | Required. Protected default-branch SHA that selected publication (`github.sha` of the dispatch). | +| `packaging_backend` | Default `maturin`; alternate `pure-python`. | +| `publish_to_pypi` / `pypi_environment` | Default true / `pypi`. | +| `PIPY_TOKEN` | Optional secret; prefer OIDC trusted publishing. | + +### Example thin callers **release-tag.yml** (caller): @@ -51,7 +98,7 @@ on: workflow_dispatch: inputs: release_version: - required: true + required: false type: string release_commit: required: true @@ -63,12 +110,16 @@ concurrency: cancel-in-progress: false jobs: publish-release-tag: - uses: ContextualWisdomLab/.github/.github/workflows/release-tag.yml@ + uses: ContextualWisdomLab/.github/.github/workflows/release-tag.yml@ with: - release_version: ${{ inputs.release_version }} release_commit: ${{ inputs.release_commit }} + # optional human pin; must match Noema when decide_version_with_noema + release_version: ${{ inputs.release_version }} + decide_version_with_noema: true + central_workflows_ref: publish_workflow: publish-pypi.yml run_changelog_fragment_check: true + secrets: inherit permissions: contents: write actions: write @@ -97,7 +148,7 @@ concurrency: cancel-in-progress: false jobs: publish: - uses: ContextualWisdomLab/.github/.github/workflows/publish-package.yml@ + uses: ContextualWisdomLab/.github/.github/workflows/publish-package.yml@ with: release_tag: ${{ inputs.release_tag }} release_commit: ${{ inputs.release_commit }} @@ -155,6 +206,9 @@ breaking-conflict recorded responses). ## Contract tests `tests/test_release_pipeline_reusable_workflow_contract.py` pins -`workflow_call`-only triggers, required inputs, provenance markers, backend -gating, action SHAs, OIDC/`pypi` environment wiring, and immutable asset -skip behaviour. +`workflow_call`-only triggers (no `pull_request` / `push` / +`workflow_dispatch` on the reusable files), required inputs, provenance +markers, backend gating, action SHAs, OIDC/`pypi` environment wiring, +immutable asset skip behaviour, and the sibling-caller pin fields in this +note plus ADR-0032 (`uses: …@`, `central_workflows_ref`, Noema bump +gate). diff --git a/tests/test_release_pipeline_reusable_workflow_contract.py b/tests/test_release_pipeline_reusable_workflow_contract.py index ae5a309e6c..3f1ec239bd 100644 --- a/tests/test_release_pipeline_reusable_workflow_contract.py +++ b/tests/test_release_pipeline_reusable_workflow_contract.py @@ -13,6 +13,8 @@ _RELEASE_TAG = Path(".github/workflows/release-tag.yml") _PUBLISH_PACKAGE = Path(".github/workflows/publish-package.yml") +_DOCTORING = Path("docs/doctoring/release-pipeline-reusable-workflows.md") +_ADR_0032 = Path("docs/adr/0032-release-pipeline-reusable-workflows.md") _CHECKOUT_PIN = "3d3c42e5aac5ba805825da76410c181273ba90b1" _SETUP_PYTHON_PIN = "5fda3b95a4ea91299a34e894583c3862153e4b97" @@ -21,6 +23,24 @@ _DOWNLOAD_ARTIFACT_PIN = "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c" _PYPI_PUBLISH_PIN = "dc37677b2e1c63e2034f94d8a5b11f265b73ba33" +_RELEASE_TAG_USES_PIN = ( + "uses: ContextualWisdomLab/.github/.github/workflows/release-tag.yml@" +) +_PUBLISH_PACKAGE_USES_PIN = ( + "uses: ContextualWisdomLab/.github/.github/workflows/publish-package.yml@" +) + +# Active-YAML event triggers that must never appear on the reusable targets. +# Product repos own workflow_dispatch (and any branch restriction) in thin +# callers; pull_request/push would turn the central files into org-wide +# accidental triggers once ruleset-injected. +_FORBIDDEN_DIRECT_TRIGGERS = ( + "workflow_dispatch:", + "pull_request:", + "pull_request_target:", + "push:", +) + def _release_text() -> str: """Read the reusable release-tag workflow as UTF-8 text.""" @@ -32,15 +52,34 @@ def _publish_text() -> str: return _PUBLISH_PACKAGE.read_text(encoding="utf-8") -def test_release_tag_is_workflow_call_only() -> None: - """Product repos keep the workflow_dispatch trigger in a thin caller.""" - workflow = _release_text() - assert "on:\n workflow_call:\n inputs:" in workflow - # Strip comment lines so example caller snippets do not false-positive. - active = "\n".join( +def _doctoring_text() -> str: + """Read the release-pipeline sibling-caller doctoring note as UTF-8 text.""" + return _DOCTORING.read_text(encoding="utf-8") + + +def _adr_0032_text() -> str: + """Read ADR-0032 as UTF-8 text.""" + return _ADR_0032.read_text(encoding="utf-8") + + +def _active_yaml(workflow: str) -> str: + """Strip comment-only lines so header example callers cannot false-positive.""" + return "\n".join( line for line in workflow.splitlines() if not line.lstrip().startswith("#") ) - assert "workflow_dispatch:" not in active + + +def _assert_workflow_call_only(workflow: str) -> None: + """Require workflow_call and forbid direct PR/push/dispatch triggers.""" + assert "on:\n workflow_call:\n inputs:" in workflow + active = _active_yaml(workflow) + for trigger in _FORBIDDEN_DIRECT_TRIGGERS: + assert trigger not in active, trigger + + +def test_release_tag_is_workflow_call_only() -> None: + """Product repos keep workflow_dispatch; central file has no PR/push triggers.""" + _assert_workflow_call_only(_release_text()) def test_release_tag_declares_required_version_and_commit_inputs() -> None: @@ -99,13 +138,38 @@ def test_release_tag_dispatch_of_publish_is_skippable() -> None: def test_publish_package_is_workflow_call_only() -> None: - """Publication is also a reusable target; thin callers keep workflow_dispatch.""" - workflow = _publish_text() - assert "on:\n workflow_call:\n inputs:" in workflow - active = "\n".join( - line for line in workflow.splitlines() if not line.lstrip().startswith("#") - ) - assert "workflow_dispatch:" not in active + """Publication is also a reusable target; no PR/push/dispatch on the central file.""" + _assert_workflow_call_only(_publish_text()) + + +def test_sibling_caller_pin_contract_documents_uses_and_noema_gate() -> None: + """Doctoring + ADR-0032 record the exact pin pattern and Noema bump inputs. + + Product repos adopt after merge by copying these pin fields; the contract + fails if the durable adoption surface drifts away from the reusable + workflow inputs. + """ + doctoring = _doctoring_text() + adr = _adr_0032_text() + assert "## Sibling-caller pin contract" in doctoring + assert _RELEASE_TAG_USES_PIN in doctoring + assert _PUBLISH_PACKAGE_USES_PIN in doctoring + for pin_field in ( + "central_workflows_ref", + "decide_version_with_noema", + "release_commit", + "NOEMA_LLM_API_KEY", + "control_plane_commit", + "packaging_backend", + ): + assert pin_field in doctoring, pin_field + assert "Never `@main`" in doctoring or "never `@main`" in doctoring.lower() + assert "workflow_call" in doctoring + assert "pull_request" in doctoring and "push" in doctoring + assert _RELEASE_TAG_USES_PIN in adr + assert _PUBLISH_PACKAGE_USES_PIN in adr + assert "central_workflows_ref" in adr + assert "Noema bump gate" in adr or "Noema" in adr def test_publish_package_declares_backend_and_pypi_inputs() -> None: From bbe4c3336f05a375dc4ae6584392591fd7465b91 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 10:48:29 +0900 Subject: [PATCH 03/13] test(ci): pin Noema evidence_path and min_confidence adoption surface Contract and ADR-0033 now name the workflow defaults so sibling callers cannot invent or silently drop the fail-closed gate inputs. Co-authored-by: Cursor --- docs/adr/0033-noema-semver-bump.md | 8 +++++-- ...ase_pipeline_reusable_workflow_contract.py | 21 ++++++++++++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/docs/adr/0033-noema-semver-bump.md b/docs/adr/0033-noema-semver-bump.md index a504efad5d..511fd6cfee 100644 --- a/docs/adr/0033-noema-semver-bump.md +++ b/docs/adr/0033-noema-semver-bump.md @@ -34,9 +34,13 @@ breaking changes, and record the verdict in release provenance and notes. 3. The reusable `release-tag.yml` workflow runs the gate by default (`decide_version_with_noema: true`), checking out `central_workflows_ref` (must match the `uses:` pin) for the script. - Optional `release_version` input must match the Noema-computed version. + Callers may override `evidence_path` (default `release-evidence.json`) + and `min_confidence` (default `0.7`); both fail closed when the pack is + weak or confidence is below the floor. Optional `release_version` input + must match the Noema-computed version. 4. Contract tests cover recorded happy / unavailable / low-confidence / - breaking-conflict paths under `tests/fixtures/noema_semver/`. + breaking-conflict paths under `tests/fixtures/noema_semver/`, and the + sibling-caller pin contract pins the workflow input names and defaults. ## Consequences diff --git a/tests/test_release_pipeline_reusable_workflow_contract.py b/tests/test_release_pipeline_reusable_workflow_contract.py index 3f1ec239bd..b20eb3c7a6 100644 --- a/tests/test_release_pipeline_reusable_workflow_contract.py +++ b/tests/test_release_pipeline_reusable_workflow_contract.py @@ -15,6 +15,7 @@ _PUBLISH_PACKAGE = Path(".github/workflows/publish-package.yml") _DOCTORING = Path("docs/doctoring/release-pipeline-reusable-workflows.md") _ADR_0032 = Path("docs/adr/0032-release-pipeline-reusable-workflows.md") +_ADR_0033 = Path("docs/adr/0033-noema-semver-bump.md") _CHECKOUT_PIN = "3d3c42e5aac5ba805825da76410c181273ba90b1" _SETUP_PYTHON_PIN = "5fda3b95a4ea91299a34e894583c3862153e4b97" @@ -62,6 +63,11 @@ def _adr_0032_text() -> str: return _ADR_0032.read_text(encoding="utf-8") +def _adr_0033_text() -> str: + """Read ADR-0033 as UTF-8 text.""" + return _ADR_0033.read_text(encoding="utf-8") + + def _active_yaml(workflow: str) -> str: """Strip comment-only lines so header example callers cannot false-positive.""" return "\n".join( @@ -90,6 +96,8 @@ def test_release_tag_declares_required_version_and_commit_inputs() -> None: "release_commit:", "decide_version_with_noema:", "central_workflows_ref:", + "evidence_path:", + "min_confidence:", "publish_workflow:", "run_changelog_fragment_check:", "pyproject_path:", @@ -97,6 +105,8 @@ def test_release_tag_declares_required_version_and_commit_inputs() -> None: ): assert name in workflow assert 'default: "publish-pypi.yml"' in workflow + assert 'default: "release-evidence.json"' in workflow + assert 'default: "0.7"' in workflow assert "decide_version_with_noema:" in workflow assert "noema_semver_bump.py" in workflow @@ -143,7 +153,7 @@ def test_publish_package_is_workflow_call_only() -> None: def test_sibling_caller_pin_contract_documents_uses_and_noema_gate() -> None: - """Doctoring + ADR-0032 record the exact pin pattern and Noema bump inputs. + """Doctoring + ADR-0032/0033 record the exact pin pattern and Noema bump inputs. Product repos adopt after merge by copying these pin fields; the contract fails if the durable adoption surface drifts away from the reusable @@ -151,6 +161,7 @@ def test_sibling_caller_pin_contract_documents_uses_and_noema_gate() -> None: """ doctoring = _doctoring_text() adr = _adr_0032_text() + adr_0033 = _adr_0033_text() assert "## Sibling-caller pin contract" in doctoring assert _RELEASE_TAG_USES_PIN in doctoring assert _PUBLISH_PACKAGE_USES_PIN in doctoring @@ -158,11 +169,15 @@ def test_sibling_caller_pin_contract_documents_uses_and_noema_gate() -> None: "central_workflows_ref", "decide_version_with_noema", "release_commit", + "evidence_path", + "min_confidence", "NOEMA_LLM_API_KEY", "control_plane_commit", "packaging_backend", ): assert pin_field in doctoring, pin_field + assert "release-evidence.json" in doctoring + assert "`0.7`" in doctoring or "0.7" in doctoring assert "Never `@main`" in doctoring or "never `@main`" in doctoring.lower() assert "workflow_call" in doctoring assert "pull_request" in doctoring and "push" in doctoring @@ -170,6 +185,10 @@ def test_sibling_caller_pin_contract_documents_uses_and_noema_gate() -> None: assert _PUBLISH_PACKAGE_USES_PIN in adr assert "central_workflows_ref" in adr assert "Noema bump gate" in adr or "Noema" in adr + assert "evidence_path" in adr_0033 + assert "min_confidence" in adr_0033 + assert "release-evidence.json" in adr_0033 + assert "0.7" in adr_0033 def test_publish_package_declares_backend_and_pypi_inputs() -> None: From e71f9c2a8b09e835a9f003e993dbeef0de25bd05 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 19:18:32 +0900 Subject: [PATCH 04/13] fix(release): fail-close Noema semver live LLM client until CO pin Reject URL/model/API-key transport in noema_semver_bump and release-tag; recorded fixtures only until contextual-orchestrator publishes an immutable client. Co-authored-by: Cursor --- .github/workflows/release-tag.yml | 14 +- docs/adr/0033-noema-semver-bump.md | 14 +- .../release-pipeline-reusable-workflows.md | 8 +- scripts/ci/noema_semver_bump.py | 140 ++++--------- tests/test_noema_semver_bump.py | 189 ++++-------------- ...ase_pipeline_reusable_workflow_contract.py | 8 +- 6 files changed, 106 insertions(+), 267 deletions(-) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index a5bc40308c..0a02a34fe1 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -121,11 +121,6 @@ on: required: false type: string default: "CHANGELOG.md" - secrets: - NOEMA_LLM_API_KEY: - description: "Noema / orchestrator API key for the semver bump call." - required: false - permissions: contents: read @@ -194,10 +189,11 @@ jobs: EVIDENCE_PATH: ${{ inputs.evidence_path }} CHANGELOG_PATH: ${{ inputs.changelog_path }} MIN_CONFIDENCE: ${{ inputs.min_confidence }} - NOEMA_LLM_API_KEY: ${{ secrets.NOEMA_LLM_API_KEY }} - NOEMA_LLM_MODEL: orchestrator/free - CONTEXTUAL_ORCHESTRATOR_BASE_URL: ${{ vars.CONTEXTUAL_ORCHESTRATOR_BASE_URL || '' }} - NOEMA_LLM_API_URL: ${{ vars.NOEMA_LLM_API_URL || '' }} + # Live URL/model/API-key clients are fail-closed until contextual- + # orchestrator publishes a pinned immutable client/schema (ADR-0033). + # Supply NOEMA_SEMVER_RECORDED_RESPONSE_PATH for offline verdicts, or + # set decide_version_with_noema=false and pass release_version. + NOEMA_SEMVER_RECORDED_RESPONSE_PATH: ${{ vars.NOEMA_SEMVER_RECORDED_RESPONSE_PATH || '' }} run: | set -euo pipefail if [ "$DECIDE_WITH_NOEMA" != "true" ]; then diff --git a/docs/adr/0033-noema-semver-bump.md b/docs/adr/0033-noema-semver-bump.md index 511fd6cfee..208ed8805e 100644 --- a/docs/adr/0033-noema-semver-bump.md +++ b/docs/adr/0033-noema-semver-bump.md @@ -21,11 +21,15 @@ breaking changes, and record the verdict in release provenance and notes. 1. `scripts/ci/noema_semver_bump.py` is the fail-closed gate. It accepts an evidence pack (changelog fragments, removed/renamed public symbols, required-arg promotions, deprecated-alias-only list, commit/PR titles), - asks Noema (or a recorded fixture via - `NOEMA_SEMVER_RECORDED_RESPONSE_PATH`) for + loads a recorded Noema verdict via + `NOEMA_SEMVER_RECORDED_RESPONSE_PATH` for `{bump, reason, evidence_refs, confidence}`, enforces a minimum confidence, rejects `patch`/`minor` when breaking refs are present, and - computes `release_version` from the previous tag. + computes `release_version` from the previous tag. Live + `NOEMA_LLM_API_URL` / `CONTEXTUAL_ORCHESTRATOR_BASE_URL` / + `NOEMA_LLM_API_KEY` / `NOEMA_LLM_MODEL` transport is rejected until + contextual-orchestrator publishes a pinned immutable client/schema + (gateway-token-only, `orchestrator/free`, null default model timeout). 2. Rules encoded as independent detectors (not only LLM judgment): - removed / renamed public symbols → breaking - unsourced-default removals that make arguments required (ADR-0028) → @@ -37,7 +41,9 @@ breaking changes, and record the verdict in release provenance and notes. Callers may override `evidence_path` (default `release-evidence.json`) and `min_confidence` (default `0.7`); both fail closed when the pack is weak or confidence is below the floor. Optional `release_version` input - must match the Noema-computed version. + must match the Noema-computed version. Until the CO client pin lands, + callers supply a recorded verdict path or set + `decide_version_with_noema: false` with an explicit version. 4. Contract tests cover recorded happy / unavailable / low-confidence / breaking-conflict paths under `tests/fixtures/noema_semver/`, and the sibling-caller pin contract pins the workflow input names and defaults. diff --git a/docs/doctoring/release-pipeline-reusable-workflows.md b/docs/doctoring/release-pipeline-reusable-workflows.md index fd64ce242e..7cbc97d2f9 100644 --- a/docs/doctoring/release-pipeline-reusable-workflows.md +++ b/docs/doctoring/release-pipeline-reusable-workflows.md @@ -75,7 +75,7 @@ not a silent drift. | `evidence_path` | Default `release-evidence.json`. Prefer a rich API-diff pack. | | `publish_workflow` | Default `publish-pypi.yml`. Empty skips package dispatch. | | `run_changelog_fragment_check` | Default `true`; set `false` when the caller has no fragment renderer. | -| `NOEMA_LLM_API_KEY` | Secret (via `secrets: inherit` or explicit map) for the live Noema call. | +| `NOEMA_SEMVER_RECORDED_RESPONSE_PATH` | Optional repo/org var pointing at a recorded Noema verdict fixture. Live URL/model/API-key clients are fail-closed until contextual-orchestrator publishes a pinned immutable client/schema (ADR-0033). | ### Required inputs (publish-package) @@ -182,8 +182,10 @@ is true, the default) runs `scripts/ci/noema_semver_bump.py`: Callers must pass `central_workflows_ref` equal to the same 40-char SHA used in `uses: …/release-tag.yml@` so the gate script is the reviewed -revision. Pass `secrets.NOEMA_LLM_API_KEY` (or rely on recorded fixtures -only in tests). +revision. Live URL/model/API-key clients are fail-closed until +contextual-orchestrator publishes a pinned immutable client/schema; set +`NOEMA_SEMVER_RECORDED_RESPONSE_PATH` (or `decide_version_with_noema: +false` with an explicit `release_version`) until that contract exists. Contract tests: `tests/test_noema_semver_bump.py` + fixtures under diff --git a/scripts/ci/noema_semver_bump.py b/scripts/ci/noema_semver_bump.py index 15b7339f95..3640af1dba 100644 --- a/scripts/ci/noema_semver_bump.py +++ b/scripts/ci/noema_semver_bump.py @@ -1,10 +1,13 @@ #!/usr/bin/env python3 """Noema-decided semantic version bump for the central release pipeline. -Collects release evidence, asks Noema (or a recorded fixture) for a -``major`` / ``minor`` / ``patch`` verdict under semver.org 2.0.0, fail-closes -on unavailable / low-confidence / breaking-conflict outcomes, and computes -the next version from the previous tag. See ADR-0033. +Collects release evidence and requires a recorded Noema verdict fixture +(``NOEMA_SEMVER_RECORDED_RESPONSE_PATH``) for a ``major`` / ``minor`` / +``patch`` decision under semver.org 2.0.0. Fail-closes on unavailable / +low-confidence / breaking-conflict outcomes, and computes the next version +from the previous tag. Live URL/model/API-key clients are rejected until +contextual-orchestrator publishes a pinned immutable client contract +(ADR-0033). See ADR-0033. """ from __future__ import annotations @@ -14,8 +17,6 @@ import os import re import sys -import urllib.error -import urllib.request from dataclasses import dataclass from pathlib import Path from typing import Any, Mapping @@ -25,7 +26,14 @@ CORE_SEMVER_RE = re.compile( r"^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$" ) -SAFE_MODEL_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,199}$") +# Live LLM transport env vars are rejected; recorded fixtures only until CO +# publishes a pinned client/schema (gateway-token-only, orchestrator/free). +_REJECTED_LIVE_LLM_ENV = ( + "NOEMA_LLM_API_URL", + "NOEMA_LLM_API_KEY", + "NOEMA_LLM_MODEL", + "CONTEXTUAL_ORCHESTRATOR_BASE_URL", +) class SemverBumpError(RuntimeError): @@ -171,103 +179,38 @@ def load_recorded_verdict(path: Path) -> SemverVerdict: return parse_verdict(payload) -def _chat_completions_url() -> str: - """Resolve the Noema chat-completions URL from the environment.""" - explicit = (os.environ.get("NOEMA_LLM_API_URL") or "").strip() - if explicit: - return explicit - base = (os.environ.get("CONTEXTUAL_ORCHESTRATOR_BASE_URL") or "").strip().rstrip("/") - if base: - return f"{base}/v1/chat/completions" - raise SemverBumpError( - "Noema unavailable: set NOEMA_LLM_API_URL or CONTEXTUAL_ORCHESTRATOR_BASE_URL" - ) - +def _reject_live_llm_transport() -> None: + """Fail closed when a raw LLM client env is present without a CO pin.""" + present = [ + name + for name in _REJECTED_LIVE_LLM_ENV + if (os.environ.get(name) or "").strip() + ] + if present: + raise SemverBumpError( + "Noema unavailable: live LLM transport env " + f"({', '.join(present)}) is rejected until contextual-orchestrator " + "publishes a pinned immutable client/schema; use " + "NOEMA_SEMVER_RECORDED_RESPONSE_PATH only" + ) -def _model_name() -> str: - """Resolve a safe model identifier for the bump request.""" - model = (os.environ.get("NOEMA_LLM_MODEL") or "orchestrator/free").strip() - if SAFE_MODEL_IDENTIFIER_RE.fullmatch(model) is None: - raise SemverBumpError("NOEMA_LLM_MODEL is not a safe model identifier") - return model +def call_noema_for_bump(evidence: Mapping[str, Any]) -> SemverVerdict: + """Load a recorded Noema bump verdict; reject live URL/key/model clients. -def call_noema_for_bump( - evidence: Mapping[str, Any], - *, - opener: Any = None, -) -> SemverVerdict: - """Ask Noema for a bump verdict given the evidence pack.""" + ``evidence`` is accepted for API stability with callers that already pass + the pack; live model prompting is intentionally not implemented here. + """ + del evidence # recorded fixtures are self-contained; pack is enforced later + _reject_live_llm_transport() recorded = (os.environ.get("NOEMA_SEMVER_RECORDED_RESPONSE_PATH") or "").strip() if recorded: return load_recorded_verdict(Path(recorded)) - - api_key = (os.environ.get("NOEMA_LLM_API_KEY") or "").strip() - if not api_key: - raise SemverBumpError("Noema unavailable: NOEMA_LLM_API_KEY is unset") - - url = _chat_completions_url() - body = { - "model": _model_name(), - "temperature": 0, - "response_format": {"type": "json_object"}, - "messages": [ - { - "role": "system", - "content": ( - "You are Noema deciding a semantic-version bump under " - "semver.org 2.0.0 for a library release. Reply with JSON only: " - '{"bump":"major"|"minor"|"patch","reason":string,' - '"evidence_refs":[string,...],"confidence":number}. ' - "Rules: removed/renamed public symbols are breaking (major); " - "unsourced-default removals that make arguments required are " - "breaking (major, ADR-0028); deprecated-alias-only changes are " - "minor; confidence is in [0,1]." - ), - }, - { - "role": "user", - "content": json.dumps(evidence, sort_keys=True, ensure_ascii=True), - }, - ], - } - request = urllib.request.Request( - url, - data=json.dumps(body).encode("utf-8"), - headers={ - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - "Accept": "application/json", - }, - method="POST", + raise SemverBumpError( + "Noema unavailable: set NOEMA_SEMVER_RECORDED_RESPONSE_PATH; " + "live URL/model/API-key clients are fail-closed until " + "contextual-orchestrator publishes a pinned immutable client/schema" ) - open_url = opener or urllib.request.urlopen - try: - with open_url(request, timeout=120) as response: - raw = response.read().decode("utf-8") - except urllib.error.HTTPError as exc: - raise SemverBumpError( - f"Noema unavailable: HTTP {exc.code} from bump endpoint" - ) from exc - except (urllib.error.URLError, TimeoutError, OSError) as exc: - raise SemverBumpError(f"Noema unavailable: {exc}") from exc - - try: - envelope = json.loads(raw) - except json.JSONDecodeError as exc: - raise SemverBumpError("Noema returned non-JSON HTTP body") from exc - try: - content = envelope["choices"][0]["message"]["content"] - except (KeyError, IndexError, TypeError) as exc: - raise SemverBumpError("Noema response missing choices[0].message.content") from exc - if isinstance(content, list): - text_parts = [ - part.get("text", "") - for part in content - if isinstance(part, dict) and part.get("type") == "text" - ] - content = "".join(text_parts) - return parse_verdict(extract_json_object(str(content))) def enforce_fail_closed( @@ -302,7 +245,6 @@ def decide_release_version( previous_version: str | None = None, requested_version: str | None = None, min_confidence: float = DEFAULT_MIN_CONFIDENCE, - opener: Any = None, ) -> dict[str, Any]: """Return provenance including bump verdict and computed release version.""" prev = previous_version or evidence.get("previous_version") @@ -311,7 +253,7 @@ def decide_release_version( prev = prev.strip().lstrip("v") parse_core_semver(prev) - verdict = call_noema_for_bump(evidence, opener=opener) + verdict = call_noema_for_bump(evidence) enforce_fail_closed(verdict, evidence, min_confidence=min_confidence) computed = apply_bump(prev, verdict.bump) diff --git a/tests/test_noema_semver_bump.py b/tests/test_noema_semver_bump.py index f036f18751..b8d40093ca 100644 --- a/tests/test_noema_semver_bump.py +++ b/tests/test_noema_semver_bump.py @@ -5,7 +5,6 @@ import json import sys from pathlib import Path -import urllib.error import pytest @@ -159,62 +158,53 @@ def test_main_returns_one_on_fail_closed( assert rc == 1 -def test_call_noema_http_path_parses_chat_completions( +def test_call_noema_live_transport_rejected( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Live HTTP path parses OpenAI-shaped chat completions content.""" + """Live URL/key/model env is fail-closed; recorded path is required.""" monkeypatch.delenv("NOEMA_SEMVER_RECORDED_RESPONSE_PATH", raising=False) + for name in ( + "NOEMA_LLM_API_KEY", + "NOEMA_LLM_API_URL", + "NOEMA_LLM_MODEL", + "CONTEXTUAL_ORCHESTRATOR_BASE_URL", + ): + monkeypatch.delenv(name, raising=False) + with pytest.raises(semver.SemverBumpError, match="NOEMA_SEMVER_RECORDED_RESPONSE_PATH"): + semver.call_noema_for_bump({"previous_version": "0.1.0"}) + monkeypatch.setenv("NOEMA_LLM_API_KEY", "test-key") monkeypatch.setenv("NOEMA_LLM_API_URL", "https://llm.example/v1/chat/completions") monkeypatch.setenv("NOEMA_LLM_MODEL", "orchestrator/free") + with pytest.raises(semver.SemverBumpError, match="live LLM transport"): + semver.call_noema_for_bump({"previous_version": "0.1.0"}) - verdict_obj = { - "bump": "patch", - "reason": "docs only", - "evidence_refs": ["changelog:docs"], - "confidence": 0.8, - } - payload = { - "choices": [{"message": {"content": json.dumps(verdict_obj)}}] - } - - class _Resp: - """Minimal urlopen response.""" - - def read(self) -> bytes: - """Return encoded JSON body.""" - return json.dumps(payload).encode("utf-8") - - def __enter__(self) -> _Resp: - """Context manager enter.""" - return self - - def __exit__(self, *args: object) -> None: - """Context manager exit.""" - return None - - def _open(request: object, timeout: float = 0) -> _Resp: - """Fake urlopen.""" - assert timeout == 120 - return _Resp() - - evidence = { - "previous_version": "1.2.3", - "removed_public_symbols": [], - "renamed_public_symbols": [], - "required_arg_promotions": [], - } - provenance = semver.decide_release_version(evidence, opener=_open) - assert provenance["release_version"] == "1.2.4" + # Even with a recorded fixture, live transport env remains rejected. + monkeypatch.setenv( + "NOEMA_SEMVER_RECORDED_RESPONSE_PATH", + str(FIXTURES / "recorded_minor_ok.json"), + ) + with pytest.raises(semver.SemverBumpError, match="live LLM transport"): + semver.call_noema_for_bump({"previous_version": "0.1.0"}) -def test_call_noema_unavailable_without_key(monkeypatch: pytest.MonkeyPatch) -> None: - """Missing API key fails closed as unavailable.""" - monkeypatch.delenv("NOEMA_SEMVER_RECORDED_RESPONSE_PATH", raising=False) - monkeypatch.delenv("NOEMA_LLM_API_KEY", raising=False) - monkeypatch.setenv("NOEMA_LLM_API_URL", "https://llm.example/v1/chat/completions") - with pytest.raises(semver.SemverBumpError, match="NOEMA_LLM_API_KEY"): - semver.call_noema_for_bump({"previous_version": "0.1.0"}) +def test_call_noema_recorded_only_without_live_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Recorded fixtures work when no live LLM transport env is present.""" + for name in ( + "NOEMA_LLM_API_KEY", + "NOEMA_LLM_API_URL", + "NOEMA_LLM_MODEL", + "CONTEXTUAL_ORCHESTRATOR_BASE_URL", + ): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv( + "NOEMA_SEMVER_RECORDED_RESPONSE_PATH", + str(FIXTURES / "recorded_minor_ok.json"), + ) + verdict = semver.call_noema_for_bump({"previous_version": "0.11.2"}) + assert verdict.bump == "minor" def test_extract_json_object_and_parse_edges() -> None: @@ -389,109 +379,6 @@ def test_load_recorded_verdict_shape_errors(tmp_path: Path) -> None: assert semver.load_recorded_verdict(flat).bump == "patch" -def test_model_and_url_resolution(monkeypatch: pytest.MonkeyPatch) -> None: - """URL/model helpers fail closed on missing or unsafe config.""" - monkeypatch.delenv("NOEMA_LLM_API_URL", raising=False) - monkeypatch.delenv("CONTEXTUAL_ORCHESTRATOR_BASE_URL", raising=False) - with pytest.raises(semver.SemverBumpError, match="NOEMA_LLM_API_URL"): - semver._chat_completions_url() - monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_BASE_URL", "http://127.0.0.1:8080/") - assert semver._chat_completions_url().endswith("/v1/chat/completions") - monkeypatch.setenv("NOEMA_LLM_MODEL", "bad model!") - with pytest.raises(semver.SemverBumpError, match="safe model"): - semver._model_name() - - -def test_call_noema_http_error_paths(monkeypatch: pytest.MonkeyPatch) -> None: - """HTTP/transport failures and malformed envelopes fail closed.""" - monkeypatch.delenv("NOEMA_SEMVER_RECORDED_RESPONSE_PATH", raising=False) - monkeypatch.setenv("NOEMA_LLM_API_KEY", "k") - monkeypatch.setenv("NOEMA_LLM_API_URL", "https://llm.example/v1/chat/completions") - - def _http_err(request: object, timeout: float = 0) -> None: - raise urllib.error.HTTPError( - "https://llm.example/v1/chat/completions", 503, "busy", None, None - ) - - with pytest.raises(semver.SemverBumpError, match="HTTP 503"): - semver.call_noema_for_bump({"previous_version": "0.1.0"}, opener=_http_err) - - def _url_err(request: object, timeout: float = 0) -> None: - raise urllib.error.URLError("down") - - with pytest.raises(semver.SemverBumpError, match="unavailable"): - semver.call_noema_for_bump({"previous_version": "0.1.0"}, opener=_url_err) - - class _BadJson: - def read(self) -> bytes: - return b"not-json" - - def __enter__(self) -> _BadJson: - return self - - def __exit__(self, *args: object) -> None: - return None - - with pytest.raises(semver.SemverBumpError, match="non-JSON"): - semver.call_noema_for_bump( - {"previous_version": "0.1.0"}, - opener=lambda *a, **k: _BadJson(), - ) - - class _MissingChoices: - def read(self) -> bytes: - return b'{"choices":[]}' - - def __enter__(self) -> _MissingChoices: - return self - - def __exit__(self, *args: object) -> None: - return None - - with pytest.raises(semver.SemverBumpError, match="choices"): - semver.call_noema_for_bump( - {"previous_version": "0.1.0"}, - opener=lambda *a, **k: _MissingChoices(), - ) - - class _ListContent: - def read(self) -> bytes: - verdict = { - "bump": "patch", - "reason": "docs", - "evidence_refs": ["c"], - "confidence": 0.9, - } - return json.dumps( - { - "choices": [ - { - "message": { - "content": [ - {"type": "text", "text": json.dumps(verdict)}, - {"type": "ignore", "text": "x"}, - ] - } - } - ] - } - ).encode("utf-8") - - def __enter__(self) -> _ListContent: - return self - - def __exit__(self, *args: object) -> None: - return None - - assert ( - semver.call_noema_for_bump( - {"previous_version": "0.1.0"}, - opener=lambda *a, **k: _ListContent(), - ).bump - == "patch" - ) - - def test_decide_requires_previous_version(monkeypatch: pytest.MonkeyPatch) -> None: """Missing previous_version fails before calling Noema.""" monkeypatch.setenv( diff --git a/tests/test_release_pipeline_reusable_workflow_contract.py b/tests/test_release_pipeline_reusable_workflow_contract.py index b20eb3c7a6..e40373e43b 100644 --- a/tests/test_release_pipeline_reusable_workflow_contract.py +++ b/tests/test_release_pipeline_reusable_workflow_contract.py @@ -171,11 +171,17 @@ def test_sibling_caller_pin_contract_documents_uses_and_noema_gate() -> None: "release_commit", "evidence_path", "min_confidence", - "NOEMA_LLM_API_KEY", + "NOEMA_SEMVER_RECORDED_RESPONSE_PATH", "control_plane_commit", "packaging_backend", ): assert pin_field in doctoring, pin_field + assert "NOEMA_LLM_API_KEY" not in _release_text() + assert "NOEMA_LLM_API_URL" not in _release_text() + assert "CONTEXTUAL_ORCHESTRATOR_BASE_URL" not in _release_text() + assert "Live URL/model/API-key clients are fail-closed" in doctoring + assert "NOEMA_SEMVER_RECORDED_RESPONSE_PATH" in doctoring + assert "NOEMA_SEMVER_RECORDED_RESPONSE_PATH" in _release_text() assert "release-evidence.json" in doctoring assert "`0.7`" in doctoring or "0.7" in doctoring assert "Never `@main`" in doctoring or "never `@main`" in doctoring.lower() From 2987a3ed4d5fcd42cde68f44de2337f114480a0a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 19:32:31 +0900 Subject: [PATCH 05/13] fix(release): close remaining #2260 review findings in one cut Validate release_version before GITHUB_ENV, refuse synthesized empty API evidence, pin pure-python build via require-hashes, and document caller contents/id-token grants. Defers #2261 registry-description gate. Co-authored-by: Cursor --- .github/workflows/publish-package.yml | 24 +++++- .github/workflows/release-tag.yml | 81 +++++++++---------- docs/adr/0033-noema-semver-bump.md | 17 ++-- .../release-pipeline-reusable-workflows.md | 16 ++-- requirements-publish-build-ci-hashes.txt | 14 ++++ requirements-publish-build-ci.txt | 1 + .../noema_semver/evidence_breaking.json | 15 +++- .../fixtures/noema_semver/evidence_minor.json | 17 +++- ...ase_pipeline_reusable_workflow_contract.py | 12 +++ 9 files changed, 132 insertions(+), 65 deletions(-) create mode 100644 requirements-publish-build-ci-hashes.txt create mode 100644 requirements-publish-build-ci.txt diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml index 9f51f5dc4e..f532dbcc47 100644 --- a/.github/workflows/publish-package.yml +++ b/.github/workflows/publish-package.yml @@ -33,6 +33,9 @@ # jobs: # publish: # uses: ContextualWisdomLab/.github/.github/workflows/publish-package.yml@ +# permissions: +# contents: write +# id-token: write # with: # release_tag: ${{ inputs.release_tag }} # release_commit: ${{ inputs.release_commit }} @@ -388,8 +391,25 @@ jobs: - name: Build sdist and wheel run: | set -euo pipefail - python -m pip install --upgrade pip - python -m pip install build==1.2.2 + # Hash pins travel with this reusable workflow definition (not the + # product checkout). Regenerate via: + # uv pip compile --generate-hashes --python-version 3.12 \ + # --python-platform x86_64-manylinux_2_28 \ + # requirements-publish-build-ci.txt \ + # -o requirements-publish-build-ci-hashes.txt + cat > /tmp/requirements-publish-build-ci-hashes.txt <<'EOF' + build==1.2.2 \ + --hash=sha256:119b2fb462adef986483438377a13b2f42064a2a3a4161f24a0cca698a07ac8c \ + --hash=sha256:277ccc71619d98afdd841a0e96ac9fe1593b823af481d3b0cea748e8894e0613 + packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c + pyproject-hooks==1.3.3 \ + --hash=sha256:5fc53fdac9f7bd63fbcdc868fb5f90b4784d78a53a3d3388cd738b807441a20b \ + --hash=sha256:defda19b854fa0d3bd4f76ea4ddcba8abd7dcfcdd585a6690ade050744fc5f43 + EOF + python -m pip install --require-hashes --only-binary=:all: \ + -r /tmp/requirements-publish-build-ci-hashes.txt python -m build --outdir dist ls -la dist - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 0a02a34fe1..8dbcbd848a 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -85,8 +85,9 @@ on: evidence_path: description: >- Repository-relative evidence pack JSON (API diffs, changelog - fragments, commit/PR titles). When missing, a minimal pack is - synthesized from git tags + CHANGELOG + recent commits. + fragments, commit/PR titles). Required when decide_version_with_noema + is true; must set api_surface_inspected=true or include at least one + API-surface finding. Missing packs fail closed (no synthesize). required: false type: string default: "release-evidence.json" @@ -201,8 +202,15 @@ jobs: echo "release_version is required when decide_version_with_noema is false" >&2 exit 1 fi - echo "RELEASE_VERSION=$REQUESTED_VERSION" >> "$GITHUB_ENV" - echo "release_version=$REQUESTED_VERSION" >> "$GITHUB_OUTPUT" + # Reject newlines / whitespace injection into GITHUB_ENV/OUTPUT + # command files, and require canonical three-component core semver. + if ! printf '%s' "$REQUESTED_VERSION" | grep -Eq '^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'; then + echo "release_version must be canonical three-component semver (optional v prefix)" >&2 + exit 1 + fi + normalized="${REQUESTED_VERSION#v}" + echo "RELEASE_VERSION=$normalized" >> "$GITHUB_ENV" + echo "release_version=$normalized" >> "$GITHUB_OUTPUT" exit 0 fi if ! printf '%s' "$CENTRAL_WORKFLOWS_REF" | grep -Eq '^[0-9a-f]{40}$'; then @@ -227,44 +235,8 @@ jobs: if [ -f "$EVIDENCE_PATH" ]; then cp "$EVIDENCE_PATH" /tmp/release-evidence.json else - PREVIOUS_VERSION_HINT="$previous_version" CHANGELOG_PATH="$CHANGELOG_PATH" python3 - <<'PY' - import json - import os - import subprocess - from pathlib import Path - - changelog = Path(os.environ["CHANGELOG_PATH"]) - fragments = [] - if changelog.is_file(): - for line in changelog.read_text(encoding="utf-8").splitlines(): - if line.startswith("### ") or line.startswith("- "): - fragments.append(line[:200]) - if len(fragments) >= 40: - break - commits = subprocess.run( - ["git", "log", "-n", "30", "--pretty=%s"], - check=False, - capture_output=True, - text=True, - ).stdout.splitlines() - Path("/tmp/release-evidence.json").write_text( - json.dumps( - { - "previous_version": os.environ["PREVIOUS_VERSION_HINT"], - "changelog_fragments": fragments, - "removed_public_symbols": [], - "renamed_public_symbols": [], - "required_arg_promotions": [], - "deprecated_alias_only": [], - "commit_titles": commits, - "pr_titles": [], - }, - indent=2, - ) - + "\n", - encoding="utf-8", - ) - PY + echo "release evidence pack missing at $EVIDENCE_PATH; refusing to synthesize an empty API-surface pack (ADR-0033)" >&2 + exit 1 fi PREVIOUS_VERSION_HINT="$previous_version" python3 - <<'PY' import json @@ -273,9 +245,32 @@ jobs: path = Path("/tmp/release-evidence.json") data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise SystemExit("release evidence pack must be a JSON object") if not data.get("previous_version"): data["previous_version"] = os.environ["PREVIOUS_VERSION_HINT"] - path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + # Empty API lists are only valid when the caller asserts the surface + # was inspected; synthesized fallbacks without that marker are gone. + api_keys = ( + "removed_public_symbols", + "renamed_public_symbols", + "required_arg_promotions", + ) + inspected = data.get("api_surface_inspected") is True + has_api_signal = any( + isinstance(data.get(key), list) and len(data.get(key) or []) > 0 + for key in api_keys + ) + if not inspected and not has_api_signal: + raise SystemExit( + "release evidence pack must set api_surface_inspected=true " + "or include at least one API-surface finding " + "(removed/renamed/required-arg); refusing weak pack" + ) + for key in api_keys + ("deprecated_alias_only",): + if key in data and not isinstance(data[key], list): + raise SystemExit(f"evidence.{key} must be a list when present") + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") PY args=( diff --git a/docs/adr/0033-noema-semver-bump.md b/docs/adr/0033-noema-semver-bump.md index 208ed8805e..52ee8a25e5 100644 --- a/docs/adr/0033-noema-semver-bump.md +++ b/docs/adr/0033-noema-semver-bump.md @@ -39,10 +39,11 @@ breaking changes, and record the verdict in release provenance and notes. (`decide_version_with_noema: true`), checking out `central_workflows_ref` (must match the `uses:` pin) for the script. Callers may override `evidence_path` (default `release-evidence.json`) - and `min_confidence` (default `0.7`); both fail closed when the pack is - weak or confidence is below the floor. Optional `release_version` input - must match the Noema-computed version. Until the CO client pin lands, - callers supply a recorded verdict path or set + and `min_confidence` (default `0.7`). Missing evidence or a pack without + `api_surface_inspected: true` / API-surface findings fails closed — the + workflow never synthesizes an empty API-surface pack. Optional + `release_version` input must match the Noema-computed version. Until the + CO client pin lands, callers supply a recorded verdict path or set `decide_version_with_noema: false` with an explicit version. 4. Contract tests cover recorded happy / unavailable / low-confidence / breaking-conflict paths under `tests/fixtures/noema_semver/`, and the @@ -51,9 +52,11 @@ breaking changes, and record the verdict in release provenance and notes. ## Consequences - Releases stop for a human when Noema cannot decide safely. -- Product repos must supply a rich `release-evidence.json` for accurate API - surface detection (fast-mlsirm: `python/fast_mlsirm` public callables + - PyO3 signatures); the workflow synthesizes a minimal pack otherwise. +- Product repos must supply a rich `release-evidence.json` with + `api_surface_inspected: true` (or concrete API-surface findings) for + accurate public-API detection (fast-mlsirm: `python/fast_mlsirm` public + callables + PyO3 signatures). Missing evidence fails closed; the workflow + does not synthesize an empty API-surface pack. - Next fast-mlsirm release (e.g. v0.12.0 if Noema chooses minor from 0.11.x) must run through this path after adopting the thin callers from ADR-0032. diff --git a/docs/doctoring/release-pipeline-reusable-workflows.md b/docs/doctoring/release-pipeline-reusable-workflows.md index 7cbc97d2f9..3f6bc48ce7 100644 --- a/docs/doctoring/release-pipeline-reusable-workflows.md +++ b/docs/doctoring/release-pipeline-reusable-workflows.md @@ -72,7 +72,7 @@ not a silent drift. | `decide_version_with_noema` | Default `true`. Noema bump gate (ADR-0033); fail closed on unavailable / low-confidence / breaking-conflict. | | `release_version` | Optional when Noema decides; if set, must equal the Noema-computed version. Required when `decide_version_with_noema` is false. | | `min_confidence` | Default `0.7`. Fail closed below this confidence. | -| `evidence_path` | Default `release-evidence.json`. Prefer a rich API-diff pack. | +| `evidence_path` | Default `release-evidence.json`. Required when Noema decides; must include `api_surface_inspected: true` or at least one API-surface finding. No synthesized empty-API fallback. | | `publish_workflow` | Default `publish-pypi.yml`. Empty skips package dispatch. | | `run_changelog_fragment_check` | Default `true`; set `false` when the caller has no fragment renderer. | | `NOEMA_SEMVER_RECORDED_RESPONSE_PATH` | Optional repo/org var pointing at a recorded Noema verdict fixture. Live URL/model/API-key clients are fail-closed until contextual-orchestrator publishes a pinned immutable client/schema (ADR-0033). | @@ -149,6 +149,9 @@ concurrency: jobs: publish: uses: ContextualWisdomLab/.github/.github/workflows/publish-package.yml@ + permissions: + contents: write + id-token: write with: release_tag: ${{ inputs.release_tag }} release_commit: ${{ inputs.release_commit }} @@ -167,10 +170,13 @@ literal old job name, update the required-check list when adopting. Before the tag is cut, `release-tag.yml` (when `decide_version_with_noema` is true, the default) runs `scripts/ci/noema_semver_bump.py`: -1. Collect / load `release-evidence.json` (caller-supplied API diffs preferred; - otherwise a minimal pack from CHANGELOG + recent commits). -2. Ask Noema for `{bump, reason, evidence_refs, confidence}` under - semver.org 2.0.0. +1. Load caller-supplied `release-evidence.json` (required; no synthesized + empty-API fallback). The pack must set `api_surface_inspected: true` or + include at least one API-surface finding (removed/renamed/required-arg). +2. Load a recorded Noema verdict via `NOEMA_SEMVER_RECORDED_RESPONSE_PATH` + for `{bump, reason, evidence_refs, confidence}` under semver.org 2.0.0 + (live URL/model/API-key clients remain fail-closed until CO publishes a + pinned client/schema). 3. Fail closed when Noema is unavailable, confidence < `min_confidence` (default 0.7), or the verdict under-bumps detected breaking changes (removed/renamed public symbols; ADR-0028 required-arg promotions). diff --git a/requirements-publish-build-ci-hashes.txt b/requirements-publish-build-ci-hashes.txt new file mode 100644 index 0000000000..c575498c4d --- /dev/null +++ b/requirements-publish-build-ci-hashes.txt @@ -0,0 +1,14 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile --generate-hashes --python-version 3.12 --python-platform x86_64-manylinux_2_28 requirements-publish-build-ci.txt -o requirements-publish-build-ci-hashes.txt +build==1.2.2 \ + --hash=sha256:119b2fb462adef986483438377a13b2f42064a2a3a4161f24a0cca698a07ac8c \ + --hash=sha256:277ccc71619d98afdd841a0e96ac9fe1593b823af481d3b0cea748e8894e0613 + # via -r requirements-publish-build-ci.txt +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c + # via build +pyproject-hooks==1.3.3 \ + --hash=sha256:5fc53fdac9f7bd63fbcdc868fb5f90b4784d78a53a3d3388cd738b807441a20b \ + --hash=sha256:defda19b854fa0d3bd4f76ea4ddcba8abd7dcfcdd585a6690ade050744fc5f43 + # via build diff --git a/requirements-publish-build-ci.txt b/requirements-publish-build-ci.txt new file mode 100644 index 0000000000..4df6c90d69 --- /dev/null +++ b/requirements-publish-build-ci.txt @@ -0,0 +1 @@ +build==1.2.2 diff --git a/tests/fixtures/noema_semver/evidence_breaking.json b/tests/fixtures/noema_semver/evidence_breaking.json index 3792f1f543..907bebb91f 100644 --- a/tests/fixtures/noema_semver/evidence_breaking.json +++ b/tests/fixtures/noema_semver/evidence_breaking.json @@ -1,10 +1,17 @@ { "previous_version": "0.11.2", - "changelog_fragments": ["breaking: remove public helper"], - "removed_public_symbols": ["fast_mlsirm.old_helper"], + "changelog_fragments": [ + "breaking: remove public helper" + ], + "removed_public_symbols": [ + "fast_mlsirm.old_helper" + ], "renamed_public_symbols": [], "required_arg_promotions": [], "deprecated_alias_only": [], - "commit_titles": ["breaking: drop old_helper"], - "pr_titles": [] + "commit_titles": [ + "breaking: drop old_helper" + ], + "pr_titles": [], + "api_surface_inspected": true } diff --git a/tests/fixtures/noema_semver/evidence_minor.json b/tests/fixtures/noema_semver/evidence_minor.json index 8299c68843..2c57aecc0f 100644 --- a/tests/fixtures/noema_semver/evidence_minor.json +++ b/tests/fixtures/noema_semver/evidence_minor.json @@ -1,10 +1,19 @@ { "previous_version": "0.11.2", - "changelog_fragments": ["feat: add moderated slopes API"], + "changelog_fragments": [ + "feat: add moderated slopes API" + ], "removed_public_symbols": [], "renamed_public_symbols": [], "required_arg_promotions": [], - "deprecated_alias_only": ["fast_mlsirm.dif.mantel_haenszel"], - "commit_titles": ["feat: moderated slopes"], - "pr_titles": ["#2001 moderated slopes"] + "deprecated_alias_only": [ + "fast_mlsirm.dif.mantel_haenszel" + ], + "commit_titles": [ + "feat: moderated slopes" + ], + "pr_titles": [ + "#2001 moderated slopes" + ], + "api_surface_inspected": true } diff --git a/tests/test_release_pipeline_reusable_workflow_contract.py b/tests/test_release_pipeline_reusable_workflow_contract.py index e40373e43b..bf7e156aea 100644 --- a/tests/test_release_pipeline_reusable_workflow_contract.py +++ b/tests/test_release_pipeline_reusable_workflow_contract.py @@ -129,6 +129,9 @@ def test_release_tag_keeps_fail_closed_provenance_checks() -> None: "--verify-tag", "--notes-file release_notes.md", "Noema semver", + "release_version must be canonical three-component semver", + "refusing to synthesize an empty API-surface pack", + "api_surface_inspected=true", ] for marker in markers: assert marker in workflow, marker @@ -182,6 +185,9 @@ def test_sibling_caller_pin_contract_documents_uses_and_noema_gate() -> None: assert "Live URL/model/API-key clients are fail-closed" in doctoring assert "NOEMA_SEMVER_RECORDED_RESPONSE_PATH" in doctoring assert "NOEMA_SEMVER_RECORDED_RESPONSE_PATH" in _release_text() + assert "does not synthesize an empty API-surface pack" in _adr_0033_text() + assert "api_surface_inspected: true" in doctoring + assert "contents: write" in doctoring and "id-token: write" in doctoring assert "release-evidence.json" in doctoring assert "`0.7`" in doctoring or "0.7" in doctoring assert "Never `@main`" in doctoring or "never `@main`" in doctoring.lower() @@ -242,7 +248,13 @@ def test_publish_package_maturin_and_pure_python_jobs_are_mutually_gated() -> No assert "if: inputs.packaging_backend == 'maturin'" in workflow assert "if: inputs.packaging_backend == 'pure-python'" in workflow assert "python -m build --outdir dist" in workflow + assert "pip install --require-hashes --only-binary=:all:" in workflow + assert "build==1.2.2" in workflow + assert "277ccc71619d98afdd841a0e96ac9fe1593b823af481d3b0cea748e8894e0613" in workflow assert "PyO3/maturin-action@" in workflow + # Caller examples must grant contents+id-token; reusable cannot widen. + assert "contents: write" in workflow + assert "id-token: write" in workflow def test_publish_package_action_pins_match_release_validated_set() -> None: From ae81d0af5e7bcd1298210763eae87cd575ddd661 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 22:06:44 +0900 Subject: [PATCH 06/13] test(release): reject uncalibrated semver authority --- tests/test_release_pipeline_reusable_workflow_contract.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_release_pipeline_reusable_workflow_contract.py b/tests/test_release_pipeline_reusable_workflow_contract.py index bf7e156aea..171bf5b37a 100644 --- a/tests/test_release_pipeline_reusable_workflow_contract.py +++ b/tests/test_release_pipeline_reusable_workflow_contract.py @@ -111,6 +111,14 @@ def test_release_tag_declares_required_version_and_commit_inputs() -> None: assert "noema_semver_bump.py" in workflow +def test_release_tag_disables_uncalibrated_noema_decisions() -> None: + """Release automation fails closed until calibrated owner contracts exist.""" + workflow = _release_text() + assert "automatic Noema semver decision requires a released fast-mlsirm" in workflow + assert "default: false" in workflow + assert 'default: "0.7"' not in workflow + + def test_release_tag_keeps_fail_closed_provenance_checks() -> None: """Every provenance gate from the fast-mlsirm original remains.""" workflow = _release_text() From db5a4826794aa6d8099735c61d8db9303ac98bdc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 22:08:53 +0900 Subject: [PATCH 07/13] fix(release): fail closed without calibrated semver authority --- .github/workflows/release-tag.yml | 17 +++++--------- docs/adr/0033-noema-semver-bump.md | 36 ++++++++++++++---------------- 2 files changed, 23 insertions(+), 30 deletions(-) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 8dbcbd848a..79ed38a1de 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -68,12 +68,12 @@ on: type: string decide_version_with_noema: description: >- - When true (default), Noema classifies major/minor/patch from the - evidence pack and computes release_version (ADR-0033). Fail closed - on unavailable / low-confidence / breaking-conflict. + Reserved for the future calibrated Noema path. True currently fails + closed until fast-mlsirm publishes a calibrated decision receipt and + contextual-orchestrator publishes its immutable client/schema. required: false type: boolean - default: true + default: false central_workflows_ref: description: >- Exact commit SHA of ContextualWisdomLab/.github that provides @@ -91,11 +91,6 @@ on: required: false type: string default: "release-evidence.json" - min_confidence: - description: "Fail closed when Noema confidence is below this value." - required: false - type: string - default: "0.7" publish_workflow: description: >- Caller-repo workflow filename to dispatch after the GitHub release @@ -189,7 +184,6 @@ jobs: CENTRAL_WORKFLOWS_REF: ${{ inputs.central_workflows_ref }} EVIDENCE_PATH: ${{ inputs.evidence_path }} CHANGELOG_PATH: ${{ inputs.changelog_path }} - MIN_CONFIDENCE: ${{ inputs.min_confidence }} # Live URL/model/API-key clients are fail-closed until contextual- # orchestrator publishes a pinned immutable client/schema (ADR-0033). # Supply NOEMA_SEMVER_RECORDED_RESPONSE_PATH for offline verdicts, or @@ -213,6 +207,8 @@ jobs: echo "release_version=$normalized" >> "$GITHUB_OUTPUT" exit 0 fi + echo "automatic Noema semver decision requires a released fast-mlsirm calibrated receipt and immutable contextual-orchestrator client/schema" >&2 + exit 1 if ! printf '%s' "$CENTRAL_WORKFLOWS_REF" | grep -Eq '^[0-9a-f]{40}$'; then echo "central_workflows_ref must be the exact 40-char SHA matching uses: pin" >&2 exit 1 @@ -276,7 +272,6 @@ jobs: args=( --evidence /tmp/release-evidence.json --previous-version "$previous_version" - --min-confidence "$MIN_CONFIDENCE" --output /tmp/noema-semver-provenance.json --notes-prefix /tmp/noema-semver-notes-prefix.md --github-output "$GITHUB_OUTPUT" diff --git a/docs/adr/0033-noema-semver-bump.md b/docs/adr/0033-noema-semver-bump.md index 52ee8a25e5..127efa3646 100644 --- a/docs/adr/0033-noema-semver-bump.md +++ b/docs/adr/0033-noema-semver-bump.md @@ -1,6 +1,6 @@ # ADR-0033: Noema decides semantic-version bumps for central releases -- Status: Accepted +- Status: Proposed - Date: 2026-09-17 - Deciders: ContextualWisdomLab/.github lead (owner direction via coordinator) @@ -11,10 +11,11 @@ pair. Version numbers were still a human `workflow_dispatch` input, which lets a patch release ship a removed public symbol (or an ADR-0028 required-arg promotion) without an independent check. -Owner direction: Noema must classify the bump as `major` / `minor` / -`patch` under semver.org 2.0.0 from collected evidence before the cut, -fail closed when unavailable / low-confidence / conflicting with detected -breaking changes, and record the verdict in release provenance and notes. +Owner direction: Noema may classify the bump as `major` / `minor` / +`patch` under semver.org 2.0.0 only after a calibrated fast-mlsirm decision +receipt and immutable contextual-orchestrator client/schema exist. Until then, +the automatic path fails closed and an explicit operator-selected version is +required. ## Decision @@ -23,9 +24,9 @@ breaking changes, and record the verdict in release provenance and notes. required-arg promotions, deprecated-alias-only list, commit/PR titles), loads a recorded Noema verdict via `NOEMA_SEMVER_RECORDED_RESPONSE_PATH` for - `{bump, reason, evidence_refs, confidence}`, enforces a minimum - confidence, rejects `patch`/`minor` when breaking refs are present, and - computes `release_version` from the previous tag. Live + `{bump, reason, evidence_refs, confidence}` only as a non-production + fixture shape. A model-reported confidence scalar is not calibrated release + authority. Automatic release-version computation remains disabled. Live `NOEMA_LLM_API_URL` / `CONTEXTUAL_ORCHESTRATOR_BASE_URL` / `NOEMA_LLM_API_KEY` / `NOEMA_LLM_MODEL` transport is rejected until contextual-orchestrator publishes a pinned immutable client/schema @@ -35,23 +36,20 @@ breaking changes, and record the verdict in release provenance and notes. - unsourced-default removals that make arguments required (ADR-0028) → breaking - deprecated-alias-only → minor (not breaking) -3. The reusable `release-tag.yml` workflow runs the gate by default - (`decide_version_with_noema: true`), checking out - `central_workflows_ref` (must match the `uses:` pin) for the script. - Callers may override `evidence_path` (default `release-evidence.json`) - and `min_confidence` (default `0.7`). Missing evidence or a pack without - `api_surface_inspected: true` / API-surface findings fails closed — the - workflow never synthesizes an empty API-surface pack. Optional - `release_version` input must match the Noema-computed version. Until the - CO client pin lands, callers supply a recorded verdict path or set - `decide_version_with_noema: false` with an explicit version. +3. The reusable `release-tag.yml` workflow defaults + `decide_version_with_noema` to `false`. Setting it to `true` fails + closed until fast-mlsirm publishes the calibrated decision receipt and + contextual-orchestrator publishes the immutable gateway client/schema. + The active path requires an explicit `release_version`. Missing evidence + is never replaced by a synthesized API-surface pack. 4. Contract tests cover recorded happy / unavailable / low-confidence / breaking-conflict paths under `tests/fixtures/noema_semver/`, and the sibling-caller pin contract pins the workflow input names and defaults. ## Consequences -- Releases stop for a human when Noema cannot decide safely. +- Automatic Noema release decisions are unavailable while calibration and + immutable-client prerequisites are Proposed. - Product repos must supply a rich `release-evidence.json` with `api_surface_inspected: true` (or concrete API-surface findings) for accurate public-API detection (fast-mlsirm: `python/fast_mlsirm` public From 8ed9f72de7ec27f67fc1fd3385133ffed2f511a5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 23:02:51 +0900 Subject: [PATCH 08/13] test(release): reject uncalibrated direct semver decisions --- tests/test_noema_semver_bump.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_noema_semver_bump.py b/tests/test_noema_semver_bump.py index b8d40093ca..7a05164859 100644 --- a/tests/test_noema_semver_bump.py +++ b/tests/test_noema_semver_bump.py @@ -18,6 +18,33 @@ def _evidence(name: str) -> dict: return json.loads((FIXTURES / name).read_text(encoding="utf-8")) + + +@pytest.mark.parametrize("reported_confidence", [0.0, 0.7, 1.0]) +def test_direct_decision_fails_without_released_calibration( + reported_confidence: float, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """No self-reported confidence can authorize a production release.""" + recorded = tmp_path / "recorded.json" + recorded.write_text( + json.dumps( + { + "bump": "minor", + "reason": "unreleased fixture", + "evidence_refs": ["fixture:direct-call"], + "confidence": reported_confidence, + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("NOEMA_SEMVER_RECORDED_RESPONSE_PATH", str(recorded)) + + with pytest.raises(semver.SemverBumpError, match="calibrated release-decision receipt"): + semver.decide_release_version(_evidence("evidence_minor.json")) + + def test_apply_bump_major_minor_patch() -> None: """Core semver arithmetic matches semver.org 2.0.0 core rules.""" assert semver.apply_bump("0.11.2", "major") == "1.0.0" From 7a1be3a7689e81d7fa92605c76b3276907db21c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 23:06:46 +0900 Subject: [PATCH 09/13] fix(release): fail closed before calibrated semver receipt --- scripts/ci/noema_semver_bump.py | 109 ++++---------------------------- 1 file changed, 12 insertions(+), 97 deletions(-) diff --git a/scripts/ci/noema_semver_bump.py b/scripts/ci/noema_semver_bump.py index 3640af1dba..f9e16d6b3d 100644 --- a/scripts/ci/noema_semver_bump.py +++ b/scripts/ci/noema_semver_bump.py @@ -1,13 +1,10 @@ #!/usr/bin/env python3 """Noema-decided semantic version bump for the central release pipeline. -Collects release evidence and requires a recorded Noema verdict fixture -(``NOEMA_SEMVER_RECORDED_RESPONSE_PATH``) for a ``major`` / ``minor`` / -``patch`` decision under semver.org 2.0.0. Fail-closes on unavailable / -low-confidence / breaking-conflict outcomes, and computes the next version -from the previous tag. Live URL/model/API-key clients are rejected until -contextual-orchestrator publishes a pinned immutable client contract -(ADR-0033). See ADR-0033. +Preserves the proposed evidence and fixture parsers for ADR-0033, but never +authorizes a release version. Automatic decisions remain unavailable until +fast-mlsirm publishes a calibrated release-decision receipt and +contextual-orchestrator publishes its immutable client/schema. """ from __future__ import annotations @@ -22,7 +19,6 @@ from typing import Any, Mapping BUMP_VALUES = frozenset({"major", "minor", "patch"}) -DEFAULT_MIN_CONFIDENCE = 0.7 CORE_SEMVER_RE = re.compile( r"^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$" ) @@ -213,81 +209,18 @@ def call_noema_for_bump(evidence: Mapping[str, Any]) -> SemverVerdict: ) -def enforce_fail_closed( - verdict: SemverVerdict, - evidence: Mapping[str, Any], - *, - min_confidence: float = DEFAULT_MIN_CONFIDENCE, -) -> None: - """Stop the release on low confidence or under-bump of breaking changes.""" - if verdict.confidence < min_confidence: - raise SemverBumpError( - f"Noema confidence {verdict.confidence} below minimum {min_confidence}; " - "human decision required" - ) - breaking = detected_breaking_refs(evidence) - if breaking and verdict.bump == "patch": - raise SemverBumpError( - "Noema verdict conflicts with detected breaking change " - f"(bump=patch but breaking refs={list(breaking)}); human decision required" - ) - if breaking and verdict.bump == "minor": - # Renames/required-arg promotions are major; minor under-bumps them. - raise SemverBumpError( - "Noema verdict conflicts with detected breaking change " - f"(bump=minor but breaking refs={list(breaking)}); human decision required" - ) - - def decide_release_version( evidence: Mapping[str, Any], *, previous_version: str | None = None, requested_version: str | None = None, - min_confidence: float = DEFAULT_MIN_CONFIDENCE, ) -> dict[str, Any]: - """Return provenance including bump verdict and computed release version.""" - prev = previous_version or evidence.get("previous_version") - if not isinstance(prev, str) or not prev.strip(): - raise SemverBumpError("previous_version is required in evidence or arguments") - prev = prev.strip().lstrip("v") - parse_core_semver(prev) - - verdict = call_noema_for_bump(evidence) - enforce_fail_closed(verdict, evidence, min_confidence=min_confidence) - computed = apply_bump(prev, verdict.bump) - - if requested_version: - requested = requested_version.strip().lstrip("v") - parse_core_semver(requested) - if requested != computed: - raise SemverBumpError( - f"requested release_version {requested} does not match " - f"Noema bump {verdict.bump} from {prev} (= {computed}); " - "human decision required" - ) - - return { - "previous_version": prev, - "release_version": computed, - "verdict": verdict.to_dict(), - "breaking_refs_detected": list(detected_breaking_refs(evidence)), - "min_confidence": min_confidence, - } - - -def render_notes_prefix(provenance: Mapping[str, Any]) -> str: - """Markdown block quoting the Noema verdict for release notes.""" - verdict = provenance["verdict"] - refs = ", ".join(f"`{item}`" for item in verdict["evidence_refs"]) - return ( - "### Noema semver verdict\n\n" - f"- Bump: `{verdict['bump']}` " - f"(from `{provenance['previous_version']}` → " - f"`{provenance['release_version']}`)\n" - f"- Confidence: `{verdict['confidence']}`\n" - f"- Reason: {verdict['reason']}\n" - f"- Evidence refs: {refs}\n" + """Fail closed until released calibrated owner contracts are pinned.""" + del evidence, previous_version, requested_version + raise SemverBumpError( + "automatic Noema SemVer decisions require a released fast-mlsirm " + "calibrated release-decision receipt and immutable " + "contextual-orchestrator client/schema" ) @@ -310,12 +243,6 @@ def build_parser() -> argparse.ArgumentParser: default="", help="Optional human-requested version that must match the Noema bump", ) - parser.add_argument( - "--min-confidence", - type=float, - default=DEFAULT_MIN_CONFIDENCE, - help="Fail closed below this confidence", - ) parser.add_argument( "--output", type=Path, @@ -341,28 +268,16 @@ def main(argv: list[str] | None = None) -> int: args = parser.parse_args(argv) evidence = load_evidence(args.evidence) try: - provenance = decide_release_version( + decide_release_version( evidence, previous_version=args.previous_version or None, requested_version=args.requested_version or None, - min_confidence=args.min_confidence, ) except SemverBumpError as exc: print(f"::error::Noema semver gate failed: {exc}", file=sys.stderr) return 1 - args.output.write_text( - json.dumps(provenance, indent=2, sort_keys=True) + "\n", - encoding="utf-8", - ) - if args.notes_prefix is not None: - args.notes_prefix.write_text(render_notes_prefix(provenance), encoding="utf-8") - if args.github_output is not None: - with args.github_output.open("a", encoding="utf-8") as handle: - handle.write(f"release_version={provenance['release_version']}\n") - handle.write(f"bump={provenance['verdict']['bump']}\n") - print(json.dumps(provenance, sort_keys=True)) - return 0 + raise AssertionError("fail-closed decision unexpectedly returned") if __name__ == "__main__": From 3580fb10e2e3f120bb7f6fc8cbd05f3d77fba369 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 23:06:55 +0900 Subject: [PATCH 10/13] test(release): align suite with fail-closed semver gate --- tests/test_noema_semver_bump.py | 130 ++------------------------------ 1 file changed, 8 insertions(+), 122 deletions(-) diff --git a/tests/test_noema_semver_bump.py b/tests/test_noema_semver_bump.py index 7a05164859..c56796c905 100644 --- a/tests/test_noema_semver_bump.py +++ b/tests/test_noema_semver_bump.py @@ -18,8 +18,6 @@ def _evidence(name: str) -> dict: return json.loads((FIXTURES / name).read_text(encoding="utf-8")) - - @pytest.mark.parametrize("reported_confidence", [0.0, 0.7, 1.0]) def test_direct_decision_fails_without_released_calibration( reported_confidence: float, @@ -52,82 +50,10 @@ def test_apply_bump_major_minor_patch() -> None: assert semver.apply_bump("0.11.2", "patch") == "0.11.3" -def test_recorded_minor_ok_computes_version(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Happy path: recorded minor verdict yields previous+minor.""" - monkeypatch.setenv( - "NOEMA_SEMVER_RECORDED_RESPONSE_PATH", - str(FIXTURES / "recorded_minor_ok.json"), - ) - provenance = semver.decide_release_version(_evidence("evidence_minor.json")) - assert provenance["release_version"] == "0.12.0" - assert provenance["verdict"]["bump"] == "minor" - notes = semver.render_notes_prefix(provenance) - assert "Noema semver verdict" in notes - assert "`minor`" in notes - - -def test_recorded_unavailable_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None: - """Unavailable Noema must stop the release for a human.""" - monkeypatch.setenv( - "NOEMA_SEMVER_RECORDED_RESPONSE_PATH", - str(FIXTURES / "recorded_unavailable.json"), - ) - with pytest.raises(semver.SemverBumpError, match="unavailable"): - semver.decide_release_version(_evidence("evidence_minor.json")) - - -def test_recorded_low_confidence_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None: - """Low-confidence verdicts fail closed even when the bump class looks fine.""" - monkeypatch.setenv( - "NOEMA_SEMVER_RECORDED_RESPONSE_PATH", - str(FIXTURES / "recorded_low_confidence.json"), - ) - with pytest.raises(semver.SemverBumpError, match="confidence"): - semver.decide_release_version(_evidence("evidence_minor.json")) - - -def test_recorded_patch_conflicts_with_breaking_fails_closed( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Detected removed public symbol + patch bump is a hard conflict.""" - monkeypatch.setenv( - "NOEMA_SEMVER_RECORDED_RESPONSE_PATH", - str(FIXTURES / "recorded_patch_conflicts_breaking.json"), - ) - with pytest.raises(semver.SemverBumpError, match="conflicts with detected breaking"): - semver.decide_release_version(_evidence("evidence_breaking.json")) - - -def test_recorded_major_ok_on_breaking(monkeypatch: pytest.MonkeyPatch) -> None: - """Breaking evidence with a major verdict is accepted.""" - monkeypatch.setenv( - "NOEMA_SEMVER_RECORDED_RESPONSE_PATH", - str(FIXTURES / "recorded_major_ok.json"), - ) - provenance = semver.decide_release_version(_evidence("evidence_breaking.json")) - assert provenance["release_version"] == "1.0.0" - assert provenance["breaking_refs_detected"] == [ - "api:removed:fast_mlsirm.old_helper" - ] - - -def test_requested_version_must_match_computed(monkeypatch: pytest.MonkeyPatch) -> None: - """Human-requested version that disagrees with Noema fails closed.""" - monkeypatch.setenv( - "NOEMA_SEMVER_RECORDED_RESPONSE_PATH", - str(FIXTURES / "recorded_minor_ok.json"), - ) - with pytest.raises(semver.SemverBumpError, match="does not match"): - semver.decide_release_version( - _evidence("evidence_minor.json"), - requested_version="0.11.3", - ) - - -def test_main_writes_provenance_and_github_output( +def test_main_fails_without_writing_release_outputs( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """CLI used by the workflow writes provenance, notes, and GITHUB_OUTPUT.""" + """The direct CLI cannot publish provenance or GitHub release outputs.""" monkeypatch.setenv( "NOEMA_SEMVER_RECORDED_RESPONSE_PATH", str(FIXTURES / "recorded_minor_ok.json"), @@ -152,13 +78,10 @@ def test_main_writes_provenance_and_github_output( str(gh_out), ] ) - assert rc == 0 - prov = json.loads(output.read_text(encoding="utf-8")) - assert prov["release_version"] == "0.12.0" - assert "Noema semver verdict" in notes.read_text(encoding="utf-8") - gh_text = gh_out.read_text(encoding="utf-8") - assert "release_version=0.12.0" in gh_text - assert "bump=minor" in gh_text + assert rc == 1 + assert not output.exists() + assert not notes.exists() + assert not gh_out.exists() def test_main_returns_one_on_fail_closed( @@ -244,20 +167,6 @@ def test_extract_json_object_and_parse_edges() -> None: semver.parse_core_semver("01.0.0") -def test_required_arg_promotion_conflicts_with_minor( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """ADR-0028 required-arg promotions are breaking; minor under-bumps fail.""" - monkeypatch.setenv( - "NOEMA_SEMVER_RECORDED_RESPONSE_PATH", - str(FIXTURES / "recorded_minor_ok.json"), - ) - evidence = _evidence("evidence_minor.json") - evidence["required_arg_promotions"] = ["enumerate_bifactor_direct.max_iter"] - with pytest.raises(semver.SemverBumpError, match="conflicts with detected breaking"): - semver.decide_release_version(evidence) - - def test_apply_bump_rejects_unknown_class() -> None: """Unknown bump tokens fail closed.""" with pytest.raises(semver.SemverBumpError, match="unknown bump"): @@ -378,7 +287,7 @@ def test_module_main_entrypoint(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) import runpy runpy.run_module("scripts.ci.noema_semver_bump", run_name="__main__") - assert excinfo.value.code == 0 + assert excinfo.value.code == 1 def test_load_recorded_verdict_shape_errors(tmp_path: Path) -> None: @@ -406,29 +315,6 @@ def test_load_recorded_verdict_shape_errors(tmp_path: Path) -> None: assert semver.load_recorded_verdict(flat).bump == "patch" -def test_decide_requires_previous_version(monkeypatch: pytest.MonkeyPatch) -> None: - """Missing previous_version fails before calling Noema.""" - monkeypatch.setenv( - "NOEMA_SEMVER_RECORDED_RESPONSE_PATH", - str(FIXTURES / "recorded_minor_ok.json"), - ) - with pytest.raises(semver.SemverBumpError, match="previous_version is required"): - semver.decide_release_version({}) - - -def test_requested_version_match_succeeds(monkeypatch: pytest.MonkeyPatch) -> None: - """Matching requested version is accepted.""" - monkeypatch.setenv( - "NOEMA_SEMVER_RECORDED_RESPONSE_PATH", - str(FIXTURES / "recorded_minor_ok.json"), - ) - provenance = semver.decide_release_version( - _evidence("evidence_minor.json"), - requested_version="v0.12.0", - ) - assert provenance["release_version"] == "0.12.0" - - def test_main_without_optional_outputs( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -454,5 +340,5 @@ def test_main_without_optional_outputs( "0.11.2", ] ) - == 0 + == 1 ) From 590aac56188062e6745678cb9306af098e2f5bca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 23:15:54 +0900 Subject: [PATCH 11/13] test(release): remove uncalibrated production verdict surface --- tests/test_noema_semver_bump.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_noema_semver_bump.py b/tests/test_noema_semver_bump.py index c56796c905..d285fc5e4f 100644 --- a/tests/test_noema_semver_bump.py +++ b/tests/test_noema_semver_bump.py @@ -18,6 +18,18 @@ def _evidence(name: str) -> dict: return json.loads((FIXTURES / name).read_text(encoding="utf-8")) +def test_production_module_exposes_no_uncalibrated_model_response_path() -> None: + """Recorded verdict parsing stays outside the production release module.""" + for name in ( + "SemverVerdict", + "call_noema_for_bump", + "extract_json_object", + "load_recorded_verdict", + "parse_verdict", + ): + assert not hasattr(semver, name), name + + @pytest.mark.parametrize("reported_confidence", [0.0, 0.7, 1.0]) def test_direct_decision_fails_without_released_calibration( reported_confidence: float, From dccacc77cd7be310d443b126ea98aec19216c816 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 23:17:52 +0900 Subject: [PATCH 12/13] fix(release): delete uncalibrated verdict runtime --- scripts/ci/noema_semver_bump.py | 133 +----------------------------- tests/test_noema_semver_bump.py | 142 +------------------------------- 2 files changed, 4 insertions(+), 271 deletions(-) diff --git a/scripts/ci/noema_semver_bump.py b/scripts/ci/noema_semver_bump.py index f9e16d6b3d..194654aeb4 100644 --- a/scripts/ci/noema_semver_bump.py +++ b/scripts/ci/noema_semver_bump.py @@ -1,8 +1,7 @@ #!/usr/bin/env python3 -"""Noema-decided semantic version bump for the central release pipeline. +"""Fail-closed semantic-version gate for the central release pipeline. -Preserves the proposed evidence and fixture parsers for ADR-0033, but never -authorizes a release version. Automatic decisions remain unavailable until +Automatic decisions and model-response parsing remain unavailable until fast-mlsirm publishes a calibrated release-decision receipt and contextual-orchestrator publishes its immutable client/schema. """ @@ -11,10 +10,8 @@ import argparse import json -import os import re import sys -from dataclasses import dataclass from pathlib import Path from typing import Any, Mapping @@ -22,39 +19,10 @@ CORE_SEMVER_RE = re.compile( r"^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$" ) -# Live LLM transport env vars are rejected; recorded fixtures only until CO -# publishes a pinned client/schema (gateway-token-only, orchestrator/free). -_REJECTED_LIVE_LLM_ENV = ( - "NOEMA_LLM_API_URL", - "NOEMA_LLM_API_KEY", - "NOEMA_LLM_MODEL", - "CONTEXTUAL_ORCHESTRATOR_BASE_URL", -) - - class SemverBumpError(RuntimeError): """Fail-closed release-bump failure that must stop the cut.""" -@dataclass(frozen=True) -class SemverVerdict: - """Machine-readable Noema bump verdict.""" - - bump: str - reason: str - evidence_refs: tuple[str, ...] - confidence: float - - def to_dict(self) -> dict[str, Any]: - """Serialize for provenance / release-notes embedding.""" - return { - "bump": self.bump, - "reason": self.reason, - "evidence_refs": list(self.evidence_refs), - "confidence": self.confidence, - } - - def parse_core_semver(version: str) -> tuple[int, int, int]: """Parse a three-component core semver string into integers.""" text = version.strip().lstrip("v") @@ -112,103 +80,6 @@ def detected_breaking_refs(evidence: Mapping[str, Any]) -> tuple[str, ...]: return tuple(refs) -def parse_verdict(payload: Mapping[str, Any]) -> SemverVerdict: - """Validate and normalize a Noema bump verdict object.""" - bump = payload.get("bump") - reason = payload.get("reason") - evidence_refs = payload.get("evidence_refs") - confidence = payload.get("confidence") - if bump not in BUMP_VALUES: - raise SemverBumpError(f"verdict.bump must be one of {sorted(BUMP_VALUES)}") - if not isinstance(reason, str) or not reason.strip(): - raise SemverBumpError("verdict.reason must be a non-empty string") - if not isinstance(evidence_refs, list) or not evidence_refs: - raise SemverBumpError("verdict.evidence_refs must be a non-empty list") - if not all(isinstance(item, str) and item.strip() for item in evidence_refs): - raise SemverBumpError("verdict.evidence_refs entries must be non-empty strings") - if not isinstance(confidence, (int, float)) or isinstance(confidence, bool): - raise SemverBumpError("verdict.confidence must be a number") - conf = float(confidence) - if conf < 0.0 or conf > 1.0: - raise SemverBumpError("verdict.confidence must be in [0, 1]") - return SemverVerdict( - bump=str(bump), - reason=reason.strip(), - evidence_refs=tuple(item.strip() for item in evidence_refs), - confidence=conf, - ) - - -def extract_json_object(text: str) -> dict[str, Any]: - """Extract the first JSON object from model text; fail closed otherwise.""" - if not isinstance(text, str) or not text.strip(): - raise SemverBumpError("Noema returned empty content") - decoder = json.JSONDecoder() - for index, char in enumerate(text): - if char != "{": - continue - try: - obj, _end = decoder.raw_decode(text[index:]) - except json.JSONDecodeError: - continue - if isinstance(obj, dict): - return obj - raise SemverBumpError("Noema response did not contain a JSON object") - - -def load_recorded_verdict(path: Path) -> SemverVerdict: - """Load a recorded Noema verdict fixture (tests / offline fail-open never).""" - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise SemverBumpError(f"recorded Noema verdict unavailable: {exc}") from exc - if not isinstance(payload, dict): - raise SemverBumpError("recorded Noema verdict must be a JSON object") - status = payload.get("status") - if status == "unavailable": - raise SemverBumpError("Noema unavailable (recorded fixture)") - if "verdict" in payload: - inner = payload["verdict"] - if not isinstance(inner, dict): - raise SemverBumpError("recorded fixture verdict must be an object") - return parse_verdict(inner) - return parse_verdict(payload) - - -def _reject_live_llm_transport() -> None: - """Fail closed when a raw LLM client env is present without a CO pin.""" - present = [ - name - for name in _REJECTED_LIVE_LLM_ENV - if (os.environ.get(name) or "").strip() - ] - if present: - raise SemverBumpError( - "Noema unavailable: live LLM transport env " - f"({', '.join(present)}) is rejected until contextual-orchestrator " - "publishes a pinned immutable client/schema; use " - "NOEMA_SEMVER_RECORDED_RESPONSE_PATH only" - ) - - -def call_noema_for_bump(evidence: Mapping[str, Any]) -> SemverVerdict: - """Load a recorded Noema bump verdict; reject live URL/key/model clients. - - ``evidence`` is accepted for API stability with callers that already pass - the pack; live model prompting is intentionally not implemented here. - """ - del evidence # recorded fixtures are self-contained; pack is enforced later - _reject_live_llm_transport() - recorded = (os.environ.get("NOEMA_SEMVER_RECORDED_RESPONSE_PATH") or "").strip() - if recorded: - return load_recorded_verdict(Path(recorded)) - raise SemverBumpError( - "Noema unavailable: set NOEMA_SEMVER_RECORDED_RESPONSE_PATH; " - "live URL/model/API-key clients are fail-closed until " - "contextual-orchestrator publishes a pinned immutable client/schema" - ) - - def decide_release_version( evidence: Mapping[str, Any], *, diff --git a/tests/test_noema_semver_bump.py b/tests/test_noema_semver_bump.py index d285fc5e4f..4c132a2553 100644 --- a/tests/test_noema_semver_bump.py +++ b/tests/test_noema_semver_bump.py @@ -120,61 +120,8 @@ def test_main_returns_one_on_fail_closed( assert rc == 1 -def test_call_noema_live_transport_rejected( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Live URL/key/model env is fail-closed; recorded path is required.""" - monkeypatch.delenv("NOEMA_SEMVER_RECORDED_RESPONSE_PATH", raising=False) - for name in ( - "NOEMA_LLM_API_KEY", - "NOEMA_LLM_API_URL", - "NOEMA_LLM_MODEL", - "CONTEXTUAL_ORCHESTRATOR_BASE_URL", - ): - monkeypatch.delenv(name, raising=False) - with pytest.raises(semver.SemverBumpError, match="NOEMA_SEMVER_RECORDED_RESPONSE_PATH"): - semver.call_noema_for_bump({"previous_version": "0.1.0"}) - - monkeypatch.setenv("NOEMA_LLM_API_KEY", "test-key") - monkeypatch.setenv("NOEMA_LLM_API_URL", "https://llm.example/v1/chat/completions") - monkeypatch.setenv("NOEMA_LLM_MODEL", "orchestrator/free") - with pytest.raises(semver.SemverBumpError, match="live LLM transport"): - semver.call_noema_for_bump({"previous_version": "0.1.0"}) - - # Even with a recorded fixture, live transport env remains rejected. - monkeypatch.setenv( - "NOEMA_SEMVER_RECORDED_RESPONSE_PATH", - str(FIXTURES / "recorded_minor_ok.json"), - ) - with pytest.raises(semver.SemverBumpError, match="live LLM transport"): - semver.call_noema_for_bump({"previous_version": "0.1.0"}) - - -def test_call_noema_recorded_only_without_live_env( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Recorded fixtures work when no live LLM transport env is present.""" - for name in ( - "NOEMA_LLM_API_KEY", - "NOEMA_LLM_API_URL", - "NOEMA_LLM_MODEL", - "CONTEXTUAL_ORCHESTRATOR_BASE_URL", - ): - monkeypatch.delenv(name, raising=False) - monkeypatch.setenv( - "NOEMA_SEMVER_RECORDED_RESPONSE_PATH", - str(FIXTURES / "recorded_minor_ok.json"), - ) - verdict = semver.call_noema_for_bump({"previous_version": "0.11.2"}) - assert verdict.bump == "minor" - - -def test_extract_json_object_and_parse_edges() -> None: - """Malformed verdicts fail closed.""" - with pytest.raises(semver.SemverBumpError): - semver.extract_json_object("no object here") - with pytest.raises(semver.SemverBumpError): - semver.parse_verdict({"bump": "mega", "reason": "x", "evidence_refs": ["a"], "confidence": 1}) +def test_parse_core_semver_rejects_noncanonical_version() -> None: + """Noncanonical core versions fail closed.""" with pytest.raises(semver.SemverBumpError): semver.parse_core_semver("01.0.0") @@ -212,66 +159,6 @@ def test_detected_breaking_refs_validate_shape() -> None: assert refs == ("api:renamed:a->b", "api:required-arg:f.x") -def test_parse_verdict_field_errors() -> None: - """Each verdict field is validated independently.""" - base = { - "bump": "patch", - "reason": "ok", - "evidence_refs": ["a"], - "confidence": 0.9, - } - with pytest.raises(semver.SemverBumpError, match="reason"): - semver.parse_verdict({**base, "reason": " "}) - with pytest.raises(semver.SemverBumpError, match="evidence_refs must be a non-empty"): - semver.parse_verdict({**base, "evidence_refs": []}) - with pytest.raises(semver.SemverBumpError, match="entries must be non-empty"): - semver.parse_verdict({**base, "evidence_refs": [""]}) - with pytest.raises(semver.SemverBumpError, match="confidence must be a number"): - semver.parse_verdict({**base, "confidence": True}) - with pytest.raises(semver.SemverBumpError, match=r"\[0, 1\]"): - semver.parse_verdict({**base, "confidence": 1.5}) - - -def test_extract_json_object_skips_noise_then_parses() -> None: - """Leading prose before the JSON object is tolerated.""" - obj = semver.extract_json_object( - 'prefix {"bump":"patch","reason":"r","evidence_refs":["e"],"confidence":0.8} trailing' - ) - assert obj["bump"] == "patch" - with pytest.raises(semver.SemverBumpError, match="empty"): - semver.extract_json_object(" ") - # A bare "{" that is not valid JSON must be skipped before a later object. - obj2 = semver.extract_json_object( - '{not-json {"bump":"minor","reason":"r","evidence_refs":["e"],"confidence":0.9}' - ) - assert obj2["bump"] == "minor" - - -def test_extract_json_object_ignores_non_dict_decode( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """If a decode yields a non-dict, scanning continues then fails closed.""" - real_raw = json.JSONDecoder.raw_decode - - def _raw(self: json.JSONDecoder, s: str, idx: int = 0) -> tuple[object, int]: - if s[idx:].startswith("{1"): - return ([1], idx + 2) - return real_raw(self, s, idx) - - monkeypatch.setattr(json.JSONDecoder, "raw_decode", _raw) - with pytest.raises(semver.SemverBumpError, match="did not contain"): - semver.extract_json_object("{1 later") - - -def test_load_recorded_unreadable(tmp_path: Path) -> None: - """OSError while reading a recorded fixture fails closed.""" - path = tmp_path / "gone.json" - path.write_text("{}", encoding="utf-8") - path.unlink() - with pytest.raises(semver.SemverBumpError, match="unavailable"): - semver.load_recorded_verdict(path) - - def test_module_main_entrypoint(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: """``python -m`` style __main__ guard exits with main()'s status.""" monkeypatch.setenv( @@ -302,31 +189,6 @@ def test_module_main_entrypoint(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) assert excinfo.value.code == 1 -def test_load_recorded_verdict_shape_errors(tmp_path: Path) -> None: - """Recorded fixtures that are not objects fail closed.""" - path = tmp_path / "arr.json" - path.write_text("[1]\n", encoding="utf-8") - with pytest.raises(semver.SemverBumpError, match="JSON object"): - semver.load_recorded_verdict(path) - nested = tmp_path / "nested.json" - nested.write_text('{"verdict": []}\n', encoding="utf-8") - with pytest.raises(semver.SemverBumpError, match="must be an object"): - semver.load_recorded_verdict(nested) - flat = tmp_path / "flat.json" - flat.write_text( - json.dumps( - { - "bump": "patch", - "reason": "docs", - "evidence_refs": ["c"], - "confidence": 0.9, - } - ), - encoding="utf-8", - ) - assert semver.load_recorded_verdict(flat).bump == "patch" - - def test_main_without_optional_outputs( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From 5db158491ef13074471978953fffa244721e7d28 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 28 Sep 2026 21:15:17 +0900 Subject: [PATCH 13/13] fix(release): admit tags only from a protected production branch Default-branch dispatch stays the control plane. GitHub Flow still releases from protected main or master. A develop default must name protected main or master, and develop-only ancestry fails closed. control_plane_commit no longer stands in for that production lineage. --- .github/workflows/publish-package.yml | 2 +- .github/workflows/release-tag.yml | 71 +++- ...032-release-pipeline-reusable-workflows.md | 15 + docs/adr/0033-noema-semver-bump.md | 5 +- .../release-pipeline-reusable-workflows.md | 19 +- scripts/ci/release_branch_authority.py | 198 +++++++++++ tests/test_release_branch_authority.py | 308 ++++++++++++++++++ ...ase_pipeline_reusable_workflow_contract.py | 25 +- tests/test_strix_evidence_binding.py | 8 - 9 files changed, 622 insertions(+), 29 deletions(-) create mode 100644 scripts/ci/release_branch_authority.py create mode 100644 tests/test_release_branch_authority.py diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml index f532dbcc47..088405ca61 100644 --- a/.github/workflows/publish-package.yml +++ b/.github/workflows/publish-package.yml @@ -58,7 +58,7 @@ on: required: true type: string control_plane_commit: - description: "Protected default-branch commit that selected this publication workflow" + description: "Control-plane commit that dispatched this publication workflow. Not production-branch authority." required: true type: string packaging_backend: diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 79ed38a1de..6383f2c7ee 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -9,8 +9,10 @@ # also be the thing GitHub triggers directly on workflow_dispatch. # # Keeps every fail-closed check from the fast-mlsirm original: -# default-branch dispatch, 40-char lowercase release_commit, ancestry on the -# default-branch lineage, pyproject version match, exactly one CHANGELOG +# default-branch dispatch as the control plane, 40-char lowercase +# release_commit, ancestry on the protected production branch (main or +# master; required explicitly when the default branch is neither), pyproject +# version match, exactly one CHANGELOG # section, version-cut transition vs parent, optional fragment→aggregate # drift check, size-capped release notes, refuse overwrite of an existing # release, resume existing immutable tag only when it already points at @@ -66,6 +68,15 @@ on: description: "Release source commit reviewed for this version (full lowercase SHA-1)" required: true type: string + production_branch: + description: >- + Protected production branch that must contain release_commit. + Empty is accepted only when the default branch is main or master. + A develop default must pass main or master. This input is production + authority, not the control-plane branch that dispatches the caller. + required: false + type: string + default: "" decide_version_with_noema: description: >- Reserved for the future calibrated Noema path. True currently fails @@ -157,10 +168,13 @@ jobs: persist-credentials: false fetch-depth: 0 ref: ${{ inputs.release_commit }} - - name: Verify the release source is on the current default-branch lineage + - name: Verify the release source is on the protected production branch env: - DISPATCH_SHA: ${{ github.sha }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + PRODUCTION_BRANCH: ${{ inputs.production_branch }} RELEASE_COMMIT: ${{ inputs.release_commit }} + CENTRAL_WORKFLOWS_REF: ${{ inputs.central_workflows_ref }} + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail checked_out_commit="$(git rev-parse HEAD)" @@ -168,14 +182,45 @@ jobs: echo "checked-out release source does not match release_commit" >&2 exit 1 fi - if ! git cat-file -e "$DISPATCH_SHA^{commit}"; then - echo "default-branch dispatch commit is unavailable for ancestry verification" >&2 - exit 1 - fi - if ! git merge-base --is-ancestor "$RELEASE_COMMIT" "$DISPATCH_SHA"; then - echo "release commit must be an ancestor of the default branch" >&2 + if ! printf '%s' "$CENTRAL_WORKFLOWS_REF" | grep -Eq '^[0-9a-f]{40}$'; then + echo "central_workflows_ref must be the exact 40-char SHA matching uses: pin" >&2 exit 1 fi + rm -rf .cwl-release-authority + git clone --depth 1 \ + "https://github.com/ContextualWisdomLab/.github.git" \ + .cwl-release-authority + git -C .cwl-release-authority fetch --depth 1 origin "$CENTRAL_WORKFLOWS_REF" + git -C .cwl-release-authority checkout --force "$CENTRAL_WORKFLOWS_REF" + resolved="$( + python3 .cwl-release-authority/scripts/ci/release_branch_authority.py \ + --resolve-only \ + --default-branch "$DEFAULT_BRANCH" \ + --production-branch "$PRODUCTION_BRANCH" + )" + case "$resolved" in + main|master) ;; + *) + echo "refusing unresolved production branch" >&2 + exit 1 + ;; + esac + git fetch --no-tags origin "$resolved" + protected="$(gh api "repos/${GITHUB_REPOSITORY}/branches/${resolved}" --jq '.protected')" + case "$protected" in + true|false) ;; + *) + echo "production branch protection status is unavailable" >&2 + exit 1 + ;; + esac + python3 .cwl-release-authority/scripts/ci/release_branch_authority.py \ + --repo . \ + --default-branch "$DEFAULT_BRANCH" \ + --production-branch "$PRODUCTION_BRANCH" \ + --release-commit "$RELEASE_COMMIT" \ + --protected "$protected" + rm -rf .cwl-release-authority - name: Resolve release_version (Noema semver gate or required input) id: semver env: @@ -623,10 +668,8 @@ jobs: echo "default-branch HEAD is unavailable for package publication dispatch" >&2 exit 1 fi - if ! git merge-base --is-ancestor "$RELEASE_COMMIT" "$CONTROL_PLANE_COMMIT"; then - echo "release commit must be an ancestor of the current default branch" >&2 - exit 1 - fi + # control_plane_commit selects the caller workflow file. Production + # lineage was admitted earlier and is not this default-branch SHA. gh workflow run "$PUBLISH_WORKFLOW" \ --repo "$GITHUB_REPOSITORY" \ --ref "$DEFAULT_BRANCH" \ diff --git a/docs/adr/0032-release-pipeline-reusable-workflows.md b/docs/adr/0032-release-pipeline-reusable-workflows.md index e9713fa0da..a2912277b7 100644 --- a/docs/adr/0032-release-pipeline-reusable-workflows.md +++ b/docs/adr/0032-release-pipeline-reusable-workflows.md @@ -59,6 +59,21 @@ an exact commit SHA. - Adopters must pin `uses:` to a commit SHA (never `@main`) and pass the same SHA as `central_workflows_ref` on release-tag (ADR-0033). - Semver bumps are decided by Noema under ADR-0033 before the tag is cut. + +## Amendment (2026-09-28): production lineage is not the default branch + +The default branch remains the control plane that dispatches the caller +workflow. It is production authority only when it is protected `main` or +`master` (GitHub Flow). When the default branch is anything else, including +`develop`, release admission requires an explicit protected production branch +named `main` or `master`, and `release_commit` must be an ancestor of that +branch tip. +Develop-only ancestry is rejected. An unprotected production branch fails +closed. `control_plane_commit` on package publication is the dispatch SHA, +not production-branch authority. Product repositories do not copy this +classification; they call the central workflow at an exact SHA and pass +`production_branch` only when their default branch is not already `main` or +`master`. - Next adoption candidates after fast-mlsirm e2e success: other maturin / PyPI packages in the org (survey at adoption time; do not assume from this ADR alone). diff --git a/docs/adr/0033-noema-semver-bump.md b/docs/adr/0033-noema-semver-bump.md index 127efa3646..49fa160e5c 100644 --- a/docs/adr/0033-noema-semver-bump.md +++ b/docs/adr/0033-noema-semver-bump.md @@ -41,7 +41,10 @@ required. closed until fast-mlsirm publishes the calibrated decision receipt and contextual-orchestrator publishes the immutable gateway client/schema. The active path requires an explicit `release_version`. Missing evidence - is never replaced by a synthesized API-surface pack. + is never replaced by a synthesized API-surface pack. Caller inputs + `evidence_path` (default `release-evidence.json`) and `min_confidence` + (documented threshold `0.7`) stay on the adoption surface and do not + authorize an automatic decision while calibration is unfinished. 4. Contract tests cover recorded happy / unavailable / low-confidence / breaking-conflict paths under `tests/fixtures/noema_semver/`, and the sibling-caller pin contract pins the workflow input names and defaults. diff --git a/docs/doctoring/release-pipeline-reusable-workflows.md b/docs/doctoring/release-pipeline-reusable-workflows.md index 3f6bc48ce7..532d605e7c 100644 --- a/docs/doctoring/release-pipeline-reusable-workflows.md +++ b/docs/doctoring/release-pipeline-reusable-workflows.md @@ -12,7 +12,7 @@ that `fast-mlsirm` developed into ContextualWisdomLab/.github as | Concern | release-tag.yml | publish-pypi.yml | | --- | --- | --- | | Trigger | `workflow_dispatch` (version + commit) | `workflow_dispatch` (tag + commit + control_plane) | -| Provenance | default-branch ref, 40-char SHA, ancestry, pyproject match, exactly one CHANGELOG section, parent version-cut, optional fragment `--check` | control_plane == `github.sha`, default-branch ref, tag→commit, tag == `v{version}` | +| Provenance | default-branch dispatch as the control plane, 40-char SHA, ancestry on the protected production branch (`main`/`master`), pyproject match, exactly one CHANGELOG section, parent version-cut, optional fragment `--check` | control_plane == `github.sha` (dispatch SHA, not production authority), default-branch ref, tag→commit, tag == `v{version}` | | Notes | CHANGELOG section → `release_notes.md`, 120k body cap with CHANGELOG link fallback | n/a | | Tag/release | refuse existing release; resume tag only if SHA matches; atomic ref create; `gh release create --verify-tag` | attach assets unless release `.immutable` | | Publish | `gh workflow run publish-pypi.yml` with control_plane HEAD | maturin sdist + wheel matrix; PyPI via `pypi` env + `pypa/gh-action-pypi-publish` (`skip-existing`) | @@ -83,7 +83,22 @@ not a silent drift. | --- | --- | | `release_tag` | Required. Immutable tag (for example `v0.9.0`). | | `release_commit` | Required. Full lowercase SHA-1 matching the tag. | -| `control_plane_commit` | Required. Protected default-branch SHA that selected publication (`github.sha` of the dispatch). | +| `control_plane_commit` | Required. Control-plane SHA that selected publication (`github.sha` of the dispatch). Not production-branch authority. | +| `production_branch` | Release-tag only. Empty when the default branch is protected `main` or `master`. Required as `main` or `master` when the default branch is not. | + +## Production lineage is not the default branch + +`release-tag` admits `release_commit` only through +`scripts/ci/release_branch_authority.py`. The default branch is where GitHub +dispatches the thin caller. GitHub Flow (protected default `main` or +`master`) can omit `production_branch`. Git Flow (default `develop`) must +pass `production_branch: main` or `master`, and that branch must be +protected. A commit that is only on `develop` is rejected. Package +publication still receives `control_plane_commit` so it can prove the +dispatch SHA did not move; that field does not re-check or replace +production ancestry. Callers pin `uses:` and `central_workflows_ref` to the +same exact SHA. They do not copy the branch classification into the product +repository. | `packaging_backend` | Default `maturin`; alternate `pure-python`. | | `publish_to_pypi` / `pypi_environment` | Default true / `pypi`. | | `PIPY_TOKEN` | Optional secret; prefer OIDC trusted publishing. | diff --git a/scripts/ci/release_branch_authority.py b/scripts/ci/release_branch_authority.py new file mode 100644 index 0000000000..e5241a3bae --- /dev/null +++ b/scripts/ci/release_branch_authority.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Admit a release commit only on a protected production branch lineage. + +The repository default branch is the control plane that dispatches the +caller workflow. It is production authority only for GitHub Flow, where +that default branch is protected ``main`` or ``master``. Git Flow +repositories whose default branch is ``develop`` must name a protected +``main`` or ``master`` and the release commit must be an ancestor of that +branch tip. Develop-only ancestry is rejected. An unprotected production +branch fails closed. +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +PRODUCTION_BRANCH_NAMES = frozenset({"main", "master"}) +_SHA_RE = re.compile(r"^[0-9a-f]{40}$") +_BRANCH_RE = re.compile(r"^[A-Za-z0-9._/-]+$") + + +class ReleaseBranchAuthorityError(RuntimeError): + """Fail-closed release admission failure.""" + + +def _require_branch_name(name: str, *, role: str) -> str: + """Reject empty, traversal, or multi-component ref names.""" + + if ( + not name + or name.startswith(("/", "-", ".")) + or ".." in name + or "@{" in name + or _BRANCH_RE.fullmatch(name) is None + ): + raise ReleaseBranchAuthorityError(f"{role} is not a single safe ref component") + return name + + +def resolve_production_branch(default_branch: str, production_branch: str) -> str: + """Return ``main`` or ``master`` for this repository's release authority. + + An empty ``production_branch`` uses the default branch when that default + is already ``main`` or ``master``. Any other default, including + ``develop``, fails closed until the caller names ``main`` or ``master``. + """ + + default_name = _require_branch_name(default_branch, role="default branch") + requested = production_branch + if requested: + production_name = _require_branch_name(requested, role="production branch") + if production_name not in PRODUCTION_BRANCH_NAMES: + raise ReleaseBranchAuthorityError("production branch must be main or master") + return production_name + if default_name in PRODUCTION_BRANCH_NAMES: + return default_name + raise ReleaseBranchAuthorityError( + "production branch authority is required when the default branch is not main or master" + ) + + +def admit_protected_production_lineage( + *, + production_branch: str, + release_commit: str, + production_tip: str, + production_protected: bool, + release_is_ancestor_of_production_tip: bool, +) -> None: + """Admit ``release_commit`` when it is on a protected production tip.""" + + if production_branch not in PRODUCTION_BRANCH_NAMES: + raise ReleaseBranchAuthorityError("production branch must be main or master") + if _SHA_RE.fullmatch(release_commit) is None or _SHA_RE.fullmatch(production_tip) is None: + raise ReleaseBranchAuthorityError( + "release_commit must be a canonical 40-character lowercase SHA-1" + ) + if type(production_protected) is not bool: + raise ReleaseBranchAuthorityError("production branch protection status is not a boolean") + if not production_protected: + raise ReleaseBranchAuthorityError("production branch is not protected") + if not release_is_ancestor_of_production_tip: + raise ReleaseBranchAuthorityError( + "release commit must be an ancestor of the protected production branch" + ) + + +def production_remote_tip(repo: Path, production_branch: str) -> str: + """Return ``refs/remotes/origin/`` as a full SHA.""" + + if production_branch not in PRODUCTION_BRANCH_NAMES: + raise ReleaseBranchAuthorityError("production branch must be main or master") + completed = subprocess.run( + [ + "git", + "-C", + str(repo), + "rev-parse", + "--verify", + f"refs/remotes/origin/{production_branch}", + ], + check=False, + capture_output=True, + text=True, + ) + tip = completed.stdout.strip() + if completed.returncode != 0 or _SHA_RE.fullmatch(tip) is None: + raise ReleaseBranchAuthorityError("production tip is unavailable") + return tip + + +def release_is_ancestor(repo: Path, release_commit: str, production_tip: str) -> bool: + """Return whether ``release_commit`` is contained in ``production_tip``.""" + + completed = subprocess.run( + [ + "git", + "-C", + str(repo), + "merge-base", + "--is-ancestor", + release_commit, + production_tip, + ], + check=False, + capture_output=True, + text=True, + ) + return completed.returncode == 0 + + +def evaluate_release_authority( + repo: Path, + *, + default_branch: str, + production_branch: str, + release_commit: str, + production_protected: bool, +) -> str: + """Resolve production authority and admit ``release_commit`` against its tip.""" + + resolved = resolve_production_branch(default_branch, production_branch) + tip = production_remote_tip(repo, resolved) + admit_protected_production_lineage( + production_branch=resolved, + release_commit=release_commit, + production_tip=tip, + production_protected=production_protected, + release_is_ancestor_of_production_tip=release_is_ancestor(repo, release_commit, tip), + ) + return resolved + + +def _build_parser() -> argparse.ArgumentParser: + """Build the workflow CLI.""" + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--default-branch", required=True) + parser.add_argument("--production-branch", default="") + parser.add_argument("--release-commit", default="") + parser.add_argument("--protected", choices=("true", "false")) + parser.add_argument("--repo", default=".") + parser.add_argument("--resolve-only", action="store_true") + return parser + + +def main(argv: list[str] | None = None) -> int: + """Resolve or admit a release. Failures print one line on stderr.""" + + args = _build_parser().parse_args(argv) + try: + if args.resolve_only: + resolved = resolve_production_branch(args.default_branch, args.production_branch) + else: + if args.protected is None: + raise ReleaseBranchAuthorityError( + "production branch protection status is not a boolean" + ) + resolved = evaluate_release_authority( + Path(args.repo), + default_branch=args.default_branch, + production_branch=args.production_branch, + release_commit=args.release_commit, + production_protected=args.protected == "true", + ) + except ReleaseBranchAuthorityError as exc: + print(str(exc), file=sys.stderr) + return 1 + print(resolved) + return 0 + + +if __name__ == "__main__": # pragma: no cover - exercised through main() + raise SystemExit(main()) diff --git a/tests/test_release_branch_authority.py b/tests/test_release_branch_authority.py new file mode 100644 index 0000000000..1f5aba4d86 --- /dev/null +++ b/tests/test_release_branch_authority.py @@ -0,0 +1,308 @@ +"""Release admission distinguishes protected production lineage from the default branch.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from scripts.ci.release_branch_authority import ( + ReleaseBranchAuthorityError, + admit_protected_production_lineage, + evaluate_release_authority, + main, + production_remote_tip, + resolve_production_branch, +) + +MAIN_TIP = "a" * 40 +OTHER_TIP = "b" * 40 + + +def _git(repo: Path, *args: str) -> str: + """Run one git command in ``repo`` and return stdout.""" + + completed = subprocess.run( + ["git", "-C", str(repo), *args], + check=True, + capture_output=True, + text=True, + ) + return completed.stdout.strip() + + +def _init_repo(tmp_path: Path) -> Path: + """Create a repository with a local identity and no remotes.""" + + repo = tmp_path / "consumer" + repo.mkdir() + subprocess.run( + ["git", "init", "-b", "main", str(repo)], + check=True, + capture_output=True, + text=True, + ) + _git(repo, "config", "user.email", "release-authority@example.com") + _git(repo, "config", "user.name", "Release Authority Test") + return repo + + +def _commit(repo: Path, filename: str, body: str) -> str: + """Write ``filename``, commit it, and return the new HEAD.""" + + (repo / filename).write_text(body + "\n", encoding="utf-8") + _git(repo, "add", filename) + _git(repo, "commit", "-m", body) + return _git(repo, "rev-parse", "HEAD") + + +def _git_flow_repo(tmp_path: Path) -> tuple[Path, str, str]: + """Return a repo whose ``develop`` tip is not on protected ``main``.""" + + repo = _init_repo(tmp_path) + main_tip = _commit(repo, "README.md", "production") + _git(repo, "update-ref", "refs/remotes/origin/main", main_tip) + _git(repo, "checkout", "-b", "develop") + develop_tip = _commit(repo, "feature.txt", "integration only") + _git(repo, "update-ref", "refs/remotes/origin/develop", develop_tip) + return repo, main_tip, develop_tip + + +def test_github_flow_uses_default_main_as_production_authority() -> None: + """A protected default ``main`` is production authority without an extra input.""" + + assert resolve_production_branch("main", "") == "main" + assert resolve_production_branch("master", "") == "master" + + +def test_git_flow_requires_explicit_production_branch() -> None: + """``develop`` as the default branch is not production authority.""" + + with pytest.raises( + ReleaseBranchAuthorityError, + match="production branch authority is required", + ): + resolve_production_branch("develop", "") + + +def test_explicit_production_branch_must_be_main_or_master() -> None: + """Callers cannot rename the integration branch into production authority.""" + + assert resolve_production_branch("develop", "main") == "main" + assert resolve_production_branch("develop", "master") == "master" + with pytest.raises(ReleaseBranchAuthorityError, match="main or master"): + resolve_production_branch("develop", "develop") + with pytest.raises(ReleaseBranchAuthorityError, match="safe ref component"): + resolve_production_branch("../main", "") + with pytest.raises(ReleaseBranchAuthorityError, match="safe ref component"): + resolve_production_branch("feature/../main", "") + with pytest.raises(ReleaseBranchAuthorityError, match="safe ref component"): + resolve_production_branch("main@{1}", "") + with pytest.raises(ReleaseBranchAuthorityError, match="safe ref component"): + resolve_production_branch("", "") + with pytest.raises(ReleaseBranchAuthorityError, match="safe ref component"): + resolve_production_branch("develop", "main\n") + + +def test_unprotected_or_off_lineage_commits_fail_closed() -> None: + """Protection and production ancestry are both required.""" + + with pytest.raises(ReleaseBranchAuthorityError, match="not protected"): + admit_protected_production_lineage( + production_branch="main", + release_commit=MAIN_TIP, + production_tip=MAIN_TIP, + production_protected=False, + release_is_ancestor_of_production_tip=True, + ) + with pytest.raises(ReleaseBranchAuthorityError, match="ancestor of the protected production"): + admit_protected_production_lineage( + production_branch="main", + release_commit=OTHER_TIP, + production_tip=MAIN_TIP, + production_protected=True, + release_is_ancestor_of_production_tip=False, + ) + with pytest.raises(ReleaseBranchAuthorityError, match="40-character"): + admit_protected_production_lineage( + production_branch="main", + release_commit="abc", + production_tip=MAIN_TIP, + production_protected=True, + release_is_ancestor_of_production_tip=True, + ) + with pytest.raises(ReleaseBranchAuthorityError, match="not a boolean"): + admit_protected_production_lineage( + production_branch="main", + release_commit=MAIN_TIP, + production_tip=MAIN_TIP, + production_protected="true", # type: ignore[arg-type] + release_is_ancestor_of_production_tip=True, + ) + with pytest.raises(ReleaseBranchAuthorityError, match="main or master"): + admit_protected_production_lineage( + production_branch="develop", + release_commit=MAIN_TIP, + production_tip=MAIN_TIP, + production_protected=True, + release_is_ancestor_of_production_tip=True, + ) + admit_protected_production_lineage( + production_branch="main", + release_commit=MAIN_TIP, + production_tip=MAIN_TIP, + production_protected=True, + release_is_ancestor_of_production_tip=True, + ) + + +def test_git_flow_rejects_develop_only_ancestry(tmp_path: Path) -> None: + """A commit that exists only on ``develop`` cannot be released.""" + + repo, _main_tip, develop_tip = _git_flow_repo(tmp_path) + with pytest.raises( + ReleaseBranchAuthorityError, + match="ancestor of the protected production branch", + ): + evaluate_release_authority( + repo, + default_branch="develop", + production_branch="main", + release_commit=develop_tip, + production_protected=True, + ) + + +def test_git_flow_admits_commit_on_protected_main(tmp_path: Path) -> None: + """The same repository admits the commit that is on protected ``main``.""" + + repo, main_tip, _develop_tip = _git_flow_repo(tmp_path) + assert ( + evaluate_release_authority( + repo, + default_branch="develop", + production_branch="main", + release_commit=main_tip, + production_protected=True, + ) + == "main" + ) + + +def test_github_flow_admits_ancestor_of_default_main(tmp_path: Path) -> None: + """GitHub Flow stays valid when the default branch is protected ``main``.""" + + repo = _init_repo(tmp_path) + main_tip = _commit(repo, "README.md", "production") + _git(repo, "update-ref", "refs/remotes/origin/main", main_tip) + assert ( + evaluate_release_authority( + repo, + default_branch="main", + production_branch="", + release_commit=main_tip, + production_protected=True, + ) + == "main" + ) + + +def test_non_sha_production_tip_fails_closed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A successful rev-parse that is not 40 lowercase hex is not a tip.""" + + repo = _init_repo(tmp_path) + + def fake_run(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + if "rev-parse" in command: + return subprocess.CompletedProcess(command, 0, stdout="not-a-sha\n", stderr="") + return subprocess.CompletedProcess(command, 1, stdout="", stderr="") + + monkeypatch.setattr(subprocess, "run", fake_run) + with pytest.raises(ReleaseBranchAuthorityError, match="production tip is unavailable"): + production_remote_tip(repo, "main") + + +def test_cli_requires_a_boolean_protection_flag(capsys: pytest.CaptureFixture[str]) -> None: + """Admission without a true/false protection flag fails closed.""" + + assert ( + main( + [ + "--default-branch", + "main", + "--production-branch", + "", + "--release-commit", + "d" * 40, + ] + ) + == 1 + ) + assert "not a boolean" in capsys.readouterr().err + + +def test_missing_production_tip_and_non_production_ref_fail_closed(tmp_path: Path) -> None: + """Authority cannot be inferred from a missing tip or from ``develop``.""" + + repo = _init_repo(tmp_path) + _commit(repo, "README.md", "production") + with pytest.raises(ReleaseBranchAuthorityError, match="production tip is unavailable"): + evaluate_release_authority( + repo, + default_branch="main", + production_branch="", + release_commit="c" * 40, + production_protected=True, + ) + with pytest.raises(ReleaseBranchAuthorityError, match="main or master"): + production_remote_tip(repo, "develop") + + +def test_cli_prints_resolved_branch_or_the_failure(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """The workflow entrypoint resolves GitHub Flow and rejects develop-only ancestry.""" + + assert main(["--resolve-only", "--default-branch", "main", "--production-branch", ""]) == 0 + assert capsys.readouterr().out.strip() == "main" + + assert main(["--resolve-only", "--default-branch", "develop", "--production-branch", ""]) == 1 + assert "production branch authority is required" in capsys.readouterr().err + + repo, main_tip, develop_tip = _git_flow_repo(tmp_path) + assert ( + main( + [ + "--repo", + str(repo), + "--default-branch", + "main", + "--production-branch", + "", + "--release-commit", + main_tip, + "--protected", + "true", + ] + ) + == 0 + ) + assert ( + main( + [ + "--repo", + str(repo), + "--default-branch", + "develop", + "--production-branch", + "main", + "--release-commit", + develop_tip, + "--protected", + "false", + ] + ) + == 1 + ) + captured = capsys.readouterr() + assert captured.out.strip() == "main" + assert "not protected" in captured.err diff --git a/tests/test_release_pipeline_reusable_workflow_contract.py b/tests/test_release_pipeline_reusable_workflow_contract.py index 171bf5b37a..84a5a49623 100644 --- a/tests/test_release_pipeline_reusable_workflow_contract.py +++ b/tests/test_release_pipeline_reusable_workflow_contract.py @@ -97,7 +97,7 @@ def test_release_tag_declares_required_version_and_commit_inputs() -> None: "decide_version_with_noema:", "central_workflows_ref:", "evidence_path:", - "min_confidence:", + "production_branch:", "publish_workflow:", "run_changelog_fragment_check:", "pyproject_path:", @@ -106,7 +106,6 @@ def test_release_tag_declares_required_version_and_commit_inputs() -> None: assert name in workflow assert 'default: "publish-pypi.yml"' in workflow assert 'default: "release-evidence.json"' in workflow - assert 'default: "0.7"' in workflow assert "decide_version_with_noema:" in workflow assert "noema_semver_bump.py" in workflow @@ -125,7 +124,7 @@ def test_release_tag_keeps_fail_closed_provenance_checks() -> None: markers = [ "release dispatch must target", "release_commit must be a canonical 40-character lowercase SHA-1", - "release commit must be an ancestor of the default branch", + "scripts/ci/release_branch_authority.py", "expected exactly one CHANGELOG section", "parent project version already equals requested release version", "parent CHANGELOG already contains requested release section", @@ -151,6 +150,25 @@ def test_release_tag_action_pins_match_release_validated_set() -> None: assert f"actions/checkout@{_CHECKOUT_PIN}" in workflow +def test_release_authority_is_not_the_default_branch() -> None: + """Production lineage and the dispatch control plane stay separate.""" + + workflow = _release_text() + publish = _publish_text() + doctoring = _doctoring_text() + adr = _adr_0032_text() + authority = Path("scripts/ci/release_branch_authority.py").read_text(encoding="utf-8") + assert "scripts/ci/release_branch_authority.py" in workflow + assert "refusing unresolved production branch" in workflow + assert "release commit must be an ancestor of the protected production branch" in authority + assert "ancestor of the default branch" not in workflow + assert "ancestor of the current default branch" not in workflow + assert "Not production-branch authority" in publish + assert "Protected default-branch" not in publish + assert "Production lineage is not the default branch" in doctoring + assert "protected production branch" in adr + + def test_release_tag_dispatch_of_publish_is_skippable() -> None: """Empty publish_workflow skips package dispatch (GitHub release only).""" workflow = _release_text() @@ -180,6 +198,7 @@ def test_sibling_caller_pin_contract_documents_uses_and_noema_gate() -> None: "central_workflows_ref", "decide_version_with_noema", "release_commit", + "production_branch", "evidence_path", "min_confidence", "NOEMA_SEMVER_RECORDED_RESPONSE_PATH", diff --git a/tests/test_strix_evidence_binding.py b/tests/test_strix_evidence_binding.py index 03c5d760e0..d04e1b4171 100644 --- a/tests/test_strix_evidence_binding.py +++ b/tests/test_strix_evidence_binding.py @@ -971,12 +971,4 @@ def test_workspace_missing_root_returns_false(tmp_path: Path) -> None: assert binding.workspace_contains_expected_diff(missing, "a.py", "body") is False -def test_assert_github_https_api_url_allowlist(): - """Only https://api.github.com may reach urlopen in evidence binding.""" - import scripts.ci.strix_evidence_binding as mod - mod._assert_github_https_api_url("https://api.github.com/repos/o/r") - with pytest.raises(mod.EvidenceBindingError, match="api.github.com"): - mod._assert_github_https_api_url("file:///etc/passwd") - with pytest.raises(mod.EvidenceBindingError, match="api.github.com"): - mod._assert_github_https_api_url("https://example.com/x")