Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
0ac170c
feat(ci): centralize release-tag/publish-package with Noema semver gate
seonghobae Sep 17, 2026
832eb19
docs(ci): durable sibling-caller pin contract for release pipelines
seonghobae Sep 18, 2026
bbe4c33
test(ci): pin Noema evidence_path and min_confidence adoption surface
seonghobae Sep 18, 2026
aed846f
merge(security): carry #2269 urllib GitHub API opener into #2260
seonghobae Sep 19, 2026
8e85ccc
merge(deps): carry #2278 anyio 4.14.2 into #2260
seonghobae Sep 19, 2026
e71f9c2
fix(release): fail-close Noema semver live LLM client until CO pin
seonghobae Sep 19, 2026
2987a3e
fix(release): close remaining #2260 review findings in one cut
seonghobae Sep 19, 2026
ae81d0a
test(release): reject uncalibrated semver authority
seonghobae Sep 19, 2026
db5a482
fix(release): fail closed without calibrated semver authority
seonghobae Sep 19, 2026
8ed9f72
test(release): reject uncalibrated direct semver decisions
seonghobae Sep 19, 2026
7a1be3a
fix(release): fail closed before calibrated semver receipt
seonghobae Sep 19, 2026
3580fb1
test(release): align suite with fail-closed semver gate
seonghobae Sep 19, 2026
6617e30
merge(main): preserve release gate and protected API authority
seonghobae Sep 19, 2026
590aac5
test(release): remove uncalibrated production verdict surface
seonghobae Sep 19, 2026
dccacc7
fix(release): delete uncalibrated verdict runtime
seonghobae Sep 19, 2026
5db1584
fix(release): admit tags only from a protected production branch
seonghobae Sep 28, 2026
718226f
merge(main): keep protected production release authority
seonghobae Sep 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
492 changes: 492 additions & 0 deletions .github/workflows/publish-package.yml

Large diffs are not rendered by default.

678 changes: 678 additions & 0 deletions .github/workflows/release-tag.yml

Large diffs are not rendered by default.

79 changes: 79 additions & 0 deletions docs/adr/0032-release-pipeline-reusable-workflows.md
Original file line number Diff line number Diff line change
@@ -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@<sha>`
/
`uses: ContextualWisdomLab/.github/.github/workflows/publish-package.yml@<sha>`
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).
63 changes: 63 additions & 0 deletions docs/adr/0033-noema-semver-bump.md
Original file line number Diff line number Diff line change
@@ -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.
237 changes: 237 additions & 0 deletions docs/doctoring/release-pipeline-reusable-workflows.md
Original file line number Diff line number Diff line change
@@ -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 `<sha>` 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@<sha>` |
| package publish | `uses: ContextualWisdomLab/.github/.github/workflows/publish-package.yml@<sha>` |

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 `<sha>` 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@<sha>
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: <sha>
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@<sha>
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@<sha>` 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: …@<sha>`, `central_workflows_ref`, Noema bump
gate).
14 changes: 14 additions & 0 deletions requirements-publish-build-ci-hashes.txt
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions requirements-publish-build-ci.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
build==1.2.2
Loading
Loading