diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml new file mode 100644 index 0000000000..088405ca61 --- /dev/null +++ b/.github/workflows/publish-package.yml @@ -0,0 +1,492 @@ +# 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@ +# permissions: +# contents: write +# id-token: write +# 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: "Control-plane commit that dispatched this publication workflow. Not production-branch authority." + 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 + # 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 + 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..6383f2c7ee --- /dev/null +++ b/.github/workflows/release-tag.yml @@ -0,0 +1,678 @@ +# 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 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 +# 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 + 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 + closed until fast-mlsirm publishes a calibrated decision receipt and + contextual-orchestrator publishes its immutable client/schema. + required: false + type: boolean + default: false + 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). 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" + 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" +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 protected production branch + env: + 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)" + if [ "$checked_out_commit" != "$RELEASE_COMMIT" ]; then + echo "checked-out release source does not match release_commit" >&2 + exit 1 + 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 + 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: + 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 }} + # 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 + if [ -z "$REQUESTED_VERSION" ]; then + echo "release_version is required when decide_version_with_noema is false" >&2 + exit 1 + fi + # 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 + 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 + 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 + 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 + import os + from pathlib import Path + + 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"] + # 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=( + --evidence /tmp/release-evidence.json + --previous-version "$previous_version" + --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 + # 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" \ + -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..a2912277b7 --- /dev/null +++ b/docs/adr/0032-release-pipeline-reusable-workflows.md @@ -0,0 +1,79 @@ +# 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. +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`) 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 new file mode 100644 index 0000000000..49fa160e5c --- /dev/null +++ b/docs/adr/0033-noema-semver-bump.md @@ -0,0 +1,63 @@ +# ADR-0033: Noema decides semantic-version bumps for central releases + +- Status: Proposed +- 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 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 + +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), + loads a recorded Noema verdict via + `NOEMA_SEMVER_RECORDED_RESPONSE_PATH` for + `{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 + (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) → + breaking + - deprecated-alias-only → minor (not breaking) +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. 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. + +## Consequences + +- 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 + 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 new file mode 100644 index 0000000000..532d605e7c --- /dev/null +++ b/docs/doctoring/release-pipeline-reusable-workflows.md @@ -0,0 +1,237 @@ +# 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 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`) | +| 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`) + +## 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) + +| 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`. 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). | + +### 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. 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. | + +### Example thin callers + +**release-tag.yml** (caller): + +```yaml +name: Release Tag +on: + workflow_dispatch: + inputs: + release_version: + required: false + 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 }} + # 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 +``` + +**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@ + permissions: + contents: write + id-token: write + 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. 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). + 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. 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 +`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 (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/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/scripts/ci/noema_semver_bump.py b/scripts/ci/noema_semver_bump.py new file mode 100644 index 0000000000..194654aeb4 --- /dev/null +++ b/scripts/ci/noema_semver_bump.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Fail-closed semantic-version gate for the central release pipeline. + +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. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any, Mapping + +BUMP_VALUES = frozenset({"major", "minor", "patch"}) +CORE_SEMVER_RE = re.compile( + r"^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$" +) +class SemverBumpError(RuntimeError): + """Fail-closed release-bump failure that must stop the cut.""" + + +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 decide_release_version( + evidence: Mapping[str, Any], + *, + previous_version: str | None = None, + requested_version: str | None = None, +) -> dict[str, Any]: + """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" + ) + + +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( + "--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: + decide_release_version( + evidence, + previous_version=args.previous_version or None, + requested_version=args.requested_version or None, + ) + except SemverBumpError as exc: + print(f"::error::Noema semver gate failed: {exc}", file=sys.stderr) + return 1 + + raise AssertionError("fail-closed decision unexpectedly returned") + + +if __name__ == "__main__": + raise SystemExit(main()) 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/scripts/ci/strix_evidence_binding.py b/scripts/ci/strix_evidence_binding.py index 7319040df2..c0cdd7579f 100644 --- a/scripts/ci/strix_evidence_binding.py +++ b/scripts/ci/strix_evidence_binding.py @@ -754,3 +754,4 @@ def main(argv: Sequence[str] | None = None) -> int: if __name__ == "__main__": # pragma: no cover - exercised via 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..907bebb91f --- /dev/null +++ b/tests/fixtures/noema_semver/evidence_breaking.json @@ -0,0 +1,17 @@ +{ + "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": [], + "api_surface_inspected": true +} diff --git a/tests/fixtures/noema_semver/evidence_minor.json b/tests/fixtures/noema_semver/evidence_minor.json new file mode 100644 index 0000000000..2c57aecc0f --- /dev/null +++ b/tests/fixtures/noema_semver/evidence_minor.json @@ -0,0 +1,19 @@ +{ + "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" + ], + "api_surface_inspected": true +} 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..4c132a2553 --- /dev/null +++ b/tests/test_noema_semver_bump.py @@ -0,0 +1,218 @@ +"""Tests for Noema-decided semver bump (ADR-0033).""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +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_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, + 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" + assert semver.apply_bump("0.11.2", "minor") == "0.12.0" + assert semver.apply_bump("0.11.2", "patch") == "0.11.3" + + +def test_main_fails_without_writing_release_outputs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The direct CLI cannot publish provenance or GitHub release outputs.""" + 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 == 1 + assert not output.exists() + assert not notes.exists() + assert not gh_out.exists() + + +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_parse_core_semver_rejects_noncanonical_version() -> None: + """Noncanonical core versions fail closed.""" + with pytest.raises(semver.SemverBumpError): + semver.parse_core_semver("01.0.0") + + +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_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 == 1 + + +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", + ] + ) + == 1 + ) 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 new file mode 100644 index 0000000000..84a5a49623 --- /dev/null +++ b/tests/test_release_pipeline_reusable_workflow_contract.py @@ -0,0 +1,312 @@ +"""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") +_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" +_MATURIN_PIN = "e83996d129638aa358a18fbd1dfb82f0b0fb5d3b" +_UPLOAD_ARTIFACT_PIN = "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" +_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.""" + 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 _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 _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( + line for line in workflow.splitlines() if not line.lstrip().startswith("#") + ) + + +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: + """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:", + "evidence_path:", + "production_branch:", + "publish_workflow:", + "run_changelog_fragment_check:", + "pyproject_path:", + "changelog_path:", + ): + assert name in workflow + assert 'default: "publish-pypi.yml"' in workflow + assert 'default: "release-evidence.json"' in workflow + assert "decide_version_with_noema:" in workflow + 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() + markers = [ + "release dispatch must target", + "release_commit must be a canonical 40-character lowercase SHA-1", + "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", + "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", + "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 + + +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_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() + 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; 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/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 + workflow inputs. + """ + 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 + for pin_field in ( + "central_workflows_ref", + "decide_version_with_noema", + "release_commit", + "production_branch", + "evidence_path", + "min_confidence", + "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 "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() + 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 + 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: + """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 "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: + """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