Skip to main content

Release pipeline

A release here is a pushed tag and nothing else typed by hand. Everything from that tag to a published artefact, a GitHub Release and a machine-readable witness record happens in .github/workflows/publish.yml, in one order, with one human decision in the middle of it.

This page says what the order is, what each stage refuses, and — the part worth more than the rest — what a consumer runs to check the result without taking any of it on trust.

The convention is the specification's: MUST, SHOULD, MAY.

1. The six jobs, and why they are six​

SOIF Part 1 §50 — the Synapse Open Interoperability Framework, whose Part 1 specification is a private document and whose section numbers this page cites — fixes an order. One job per stage boundary, each needs: the one before it, so the order is enforced by the dependency graph rather than by the order steps happen to be written in:

gate ──► build ──► attest ──► publish ──► release ──► witness
(held)
jobwhat it doeswhat it refuses
gateruff E9,F,E7,W over an explicit per-file legacy baseline (see §6); the suite (condition 1); tag names PACKAGE_VERSION (condition 3); the tag is annotated; schemas and manifests --check; the conformance sweep over every shipped adapter; evidence records; gitleaks; pip-audit --strict; the CodeQL gate — and it exports the two scan verdicts, read off what the two steps printed, for the release job's notesa red suite, a tag that names another version, a lightweight tag, drifted schemas or manifests, a non-conformant adapter, a secret in history, a vulnerable dependency, a CodeQL finding at or above CVSS 7.0 — and a commit CodeQL has not analysed at all
buildgates/wheel_install.py --mutation-check --export-dist (condition 2, and the ONE build); twine check --strict; a clean install from the built wheel into a fresh venv; a package test run from that install; SPDX and CycloneDX SBOMs over that install; one SHA256SUMS; condition 4's derivationsa wheel that fails its own gate, metadata twine rejects, an installed wheel whose adapters are not conformant, a dist/ that does not hold exactly the two expected artefacts, an SBOM with no components or one that does not name synapse-cdm at PACKAGE_VERSION
attestSigstore build provenance over the wheel, the sdist, both SBOMs and the evidence bundle, then gh attestation verify on eacha digest that changed in the hand-off between jobs; an attestation that does not verify
publishthe pypi environment's hold, then an OIDC upload with no credential in the fileeverything, until a person approves
releasegh release create with notes rendered by synapse release-notes and the full asset setnotes that describe another version
witnessbuilds witness-<version>.json from PyPI, the Release API, the deployment's status history and the attestation store, verifies it against the assets on disk and against the index and the Release, attaches it — and has never succeeded on a tag push: it ran on v2.1.2 and failed at its own verification step on a missing instant, and on v2.2.0 (run 35200069387, 2026-09-17) it built the record and failed at the same step on an empty approval comment; §6 says what that run dida record whose digests disagree with the index, with the assets, or with the Release's SHA256SUMS; an approval or an attestation with no instant; an approval whose comment names nothing

workflow_dispatch runs the first three and stops. publish, release and witness each carry if: startsWith(github.ref, 'refs/tags/v'), so the whole pipeline short of the irreversible act is exercisable on any branch. That is what it is for: a mechanism first run during a release is a mechanism debugged during a release.

What the SBOMs describe — added 2026-09-08. The subject is the environment, not the wheel file: the build job installs the gated wheel into a venv that has never seen this repository, and both released documents are taken over that install by syft. So each carries synapse-cdm at the version being released, the whole runtime closure that came with it, and the licence each of those declares. cyclonedx-py then reads the same environment as a cross-check — a second tool asking the interpreter what is importable, where syft catalogues the directory — and its document is printed and compared rather than published. The pipeline asserts on its own output: it fails if either released document has no entries, if any of the three names no synapse-cdm, if the version named is not this tree's PACKAGE_VERSION, or if generation itself failed.

That paragraph exists because the previous arrangement was none of those things and looked like all of them. syft ran over the .whl file, which catalogues one SPDX package — the filename — and zero CycloneDX components; the file path was built from the git ref, which names no wheel that has ever existed, on a branch or on a tag. A dispatch run of round P8's qualification is what found it, which is the argument for workflow_dispatch in the paragraph above.

What the dependency audit leaves out, and why it is exactly one thing — added 2026-09-09. pip-audit --strict in the gate job audits every third-party dependency of the release candidate and excludes exactly one distribution: synapse-cdm itself. The reason is a circle. Both this workflow and ci.yml install the package by the documented path before auditing, so the environment holds the project at the tree's own PACKAGE_VERSION; --strict turns a dependency pip-audit cannot resolve into a failure rather than a skipped line; and the index cannot carry that version until the publish job, which needs: the gate. The first run of this pipeline on a real tag is what showed it — job gate, step 15 of 15, synapse-cdm: Dependency not found on PyPI and could not be audited, with fourteen steps green and nothing published.

The exclusion is a scope, not a suppression, and the distinction is worth stating because the two look alike in a green log. An --ignore-vuln hides a known finding on a package that WAS audited, and this repository requires a dated file under security/exceptions/ for each one. What this exclusion does is leave one distribution out of the audit altogether — so it is written down here, in the workflow comments, and in a test that fails if the exclusion ever widens. Exactly one name may be in it; every transitive dependency stays under --strict; and the release candidate itself is covered by CodeQL, the suite, the conformance gates, dependency review, secret scanning and the release qualification, and by ordinary downstream monitoring once the version exists on the index.

Mechanically it is a filter and not a flag. pip-audit has --skip-editable, which looks like the answer and is not: under --strict a skipped distribution counts as a collection failure, so the flag fails on the very package it was told to skip. Each audit therefore exports pip list --format=freeze, removes the one line matching ^synapse[-_]cdm==, asserts that exactly one line was removed, and audits what is left.

What the CodeQL gate reads, and why it is the commit and not the ref — added 2026-09-10. The gate job's CodeQL stage asks GitHub for this repository's code-scanning analyses and keeps the ones whose commit_sha is the commit under release. It does not filter by the ref the release was triggered on. Until 2026-09-10 it did, and the filter made the step unpassable by any tag: v2.1.1 was tagged on 4409115 and pushed, Release run 34452755466 reached step 16 of 17, asked for the analyses of refs/tags/v2.1.1, and got none — because codeql.yml triggers on pushes and pull requests to main and soif/** and a weekly schedule, and has no trigger under which the ref is a tag. The commit itself had two clean analyses on refs/heads/main, minutes old, and the gate excluded them by ref. Nothing was published and the tag was spent.

The repair is the promise the step's own name has always made: over the analysis this commit already has. It is not a weakening. The gate still requires a real analysis of the exact release commit, still refuses a commit nobody has scanned — no analysis is a failure, not a pass — and still hands the SARIFs to gates/codeql_gate.py, the same module codeql.yml runs against the same security/exceptions/ directory. What it stopped doing is caring which ref produced the analysis, including a pull-request ref for the same SHA, which is the same code scanned by the same query suite. The paging is bounded and the refusal says how many analysis records it examined before concluding that none belongs to the commit, because "none" is only honest with that number beside it; and the refusal now names the fix that exists — let codeql.yml finish on a branch containing the commit — instead of the one that does not.

The pre-push rehearsal — added 2026-09-10. gates/release_ref_rehearsal.py replays, against the real API and the real tree, every release step whose behaviour depends on the ref, with the tag that is about to be pushed: the refs/tags/v guard on everything irreversible, condition 3, the annotated-tag check and its tagger, the CodeQL query and the threshold module over the SARIFs it returns, the five ${GITHUB_REF_NAME#v} version derivations, and whether a Release already carries the tag's name. It is a mandatory act between tagging and pushing, and a red rehearsal is a stop with the tag still local and unspent.

It exists because a tag is the one thing this repository cannot un-spend, and two releases were burned in two days by gates whose first execution was also their last chance — v2.1.0 at the dependency audit, v2.1.1 at the CodeQL gate. Both defects have one shape: a step that depends on the ref, first run on a ref nobody had run it on. Neither was findable by the suite, which reads the workflow's text and not the API's answer to it; by a workflow_dispatch run, whose ref is a branch and therefore the ref that works; or by a re-run, which does not change the input that decides. The module's last check is the one that outlives the round that wrote it: it greps publish.yml for every use of GITHUB_REF, GITHUB_REF_NAME, github.ref and github.ref_name and fails on any use its own covered-uses table does not name — so a future step that depends on the ref without deciding how it is rehearsed fails here, locally, rather than on a pushed tag.

2. The approval, and what it is not​

The pypi environment carries a required reviewer, and the upload waits there. A PyPI upload cannot be undone — a yanked release still owns its filename forever — and it is the only step of a release that is irreversible. Every other step is revocable: a tag moves, a Release deletes, a commit reverts.

It is a confirmation prompt and not a review. prevent_self_review is off, because with one maintainer the only person who can push a tag is the only person who could approve it, and the stronger setting would be a deadlock rather than a second pair of eyes. PUBLICATION.md entry 6 records that, and records the trigger for changing it: a second maintainer.

3. Advancing main​

Campaign work happens on a branch and main is unchanged by it. A release round is the only round that advances main, and it advances it by fast-forward:

git fetch origin
git switch main
git merge --ff-only soif/1.0 # a refusal is a STOP
git tag -a v2.0.0 -m "..." # on main's new tip, after the fast-forward
git push origin main --follow-tags

If the fast-forward is impossible the release STOPS. It is not fixed by merging and not by rebasing. A refusal means main holds a commit the reviewed branch does not — so the thing that was reviewed is not the thing that would be released, which is the condition a release must not paper over. VERSIONING.md §5 and §5.1 are the authority.

4. What a consumer checks, and how​

Nothing on this page has to be believed. Four artefacts are attached to every Release and two commands check them.

The build provenance, which chains the artefact to the commit and the workflow that made it:

gh attestation verify synapse_cdm-2.2.0-py3-none-any.whl \
--repo Decent-Cybersecurity/synapsecommand-public \
--signer-workflow Decent-Cybersecurity/synapsecommand-public/.github/workflows/publish.yml

The witness record, and the command that exits non-zero on any disagreement. What it re-derives depends on the mode, and this paragraph says which (until 2026-09-16 it said "every digest in the release from PyPI and from the Release API", while the verifier read the Release's id and instant and compared no asset digest at all):

python gates/witness_verify.py releases/witness/2.2.0.json # index and Release
python gates/witness_verify.py releases/witness/2.2.0.json --offline # no network
python gates/witness_verify.py releases/witness/2.2.0.json --download # re-hash the served bytes
python gates/witness_verify.py releases/witness/2.2.0.json --offline --assets <dir> # re-hash a download
python gates/witness_verify.py releases/witness/2.1.2.json releases/witness/2.2.0.json releases/witness/3.0.1.json --offline # every record
  • Offline: the record's shape, the agreement between the gate's digests and the index's, the tag naming its version. With --assets <dir> — a gh release download of the Release, or the pipeline's own assets/ — the SBOMs, the evidence bundle and the conformance sweep on disk are re-hashed against the record, and a SHA256SUMS beside them is read line by line.
  • Online (the default): the index's digests for the wheel and the sdist; the Release's id and instant; that the Release carries every file the record digests; and the Release's own SHA256SUMS against the record.
  • --download: a SHA-256 recomputed over the bytes the index serves and over the Release's own copy of every digested asset — a second upload, so agreement is a reading — and the wheel's Sigstore bundle read back from the attestation store against attestation.bundle_sha256.

The check worth understanding is the one that needs no network at all: the record carries the digests the build job produced and the digests the index reports, from two different sources, and the verifier compares them. A release where those disagree uploaded bytes nothing gated.

releases/witness/README.md documents every field. The record is produced by CI after the upload and committed by a person afterwards — a workflow does not commit here, which is why the directory is filled by the witness round rather than by the run. That is the design; until 2026-09-20 no committed record had been produced by it. The witness job executed twice on a tag push before it succeeded: on v2.1.2 it failed at its own verification step on a missing instant, and on v2.2.0 (run 35200069387) the repaired job built the record and failed at the same step because the pypi approval comment was empty. releases/witness/2.1.2.json and releases/witness/2.2.0.json were both built by hand with the repaired builder over their runs' inputs — the second with a review_file the maintainer designated, which PUBLICATION.md entry 20 states — and §6 says what that run did. MIGRATIONS.md's release procedure says to confirm the job succeeded before the witness round commits, and what the approval comment must carry so that it can. On v3.0.1 (run 35514833652, 2026-09-20) it did: the job succeeded end to end, attached witness-3.0.1.json to the Release, and releases/witness/3.0.1.json is that asset byte for byte — PUBLICATION.md entry 21.

5. The release notes​

SOIF §52 requires ten things in a release's notes. Nine are derived from the tree at the tag by synapse release-notes: the five version axes, the adapter status table, the security controls, the adapters' stated limitations, and the conformance summary. The tenth — major changes — is quoted from RELEASE_NOTES.md, which is written by a person.

That split is deliberate and it is not a loophole in MIGRATIONS.md's condition 4. "Derived, not remembered" is a claim about what the writer read, and a generated file does not satisfy it. So the renderer refuses rather than guessing: if RELEASE_NOTES.md describes a different version than the one being released, nothing is rendered and the release fails there. Notes carrying the right number in the heading over prose about the previous release is the §52 failure with no visible symptom, and it is the one this refusal exists for.

6. What this pipeline does not cover​

  • The npm dependency set of this documentation site. pip-audit is Python-only and CodeQL reads source rather than dependencies. Dependabot alerts cover it and no job in this pipeline fails on one — see Supply chain §2.
  • Type checking. There is no mypy configuration in this package and none is run. The lint stage is ruff with E9, F, E7 and W — syntax errors (which ruff reports whatever is selected), E902 unreadable files, the whole of pyflakes (undefined names, unused imports and variables, f-strings with no placeholder), the E7 statement rules and the whitespace warnings — over an explicit legacy baseline: [tool.ruff.lint.per-file-ignores] in packages/cdm/pyproject.toml names each file that carried a finding when the set was widened on 2026-09-20 (audit remediation F09) and the one rule it carried, so every other file and every other rule is held to the full set and the baseline can only shrink. (Until 2026-09-16 the set was E9 alone, which under ruff 0.16.6 selects only E902 and never caught an undefined name; from then until 2026-09-20 it was E9,F821.) The same command runs on every push in ci.yml's lint job, at the version the package's [lint] extra pins.
  • A refused upload. It has never happened, so the recovery path is unexercised. The fix is on pypi.org, per PUBLICATION.md entry 6, and never a token pasted into the workflow.
  • A witness job that has succeeded on a tag push — it has, on its third execution: v3.0.1, run 35514833652, 2026-09-20. The two before it are why this bullet exists. On v2.1.2 it failed at its own verification step because the builder read an instant off a key the approvals endpoint does not carry, and releases/witness/2.1.2.json was built outside the pipeline with the repaired builder, under a ruling PUBLICATION.md entry 19 records. On v2.2.0 — run 35200069387 — the repaired job ran for the first time, with the additions made to it on 2026-09-16: the actions: read and deployments: read grants, the attestation fetch by the wheel's digest, --attestation-bundles, and the verifier's --assets and token. The build step succeeded and wrote the record, instant and bundle digest included; the verify step refused it because the pypi approval had been given with an empty comment, which is the rule of 2026-09-16 working on a human input; the attach step was skipped. So the additions were proven before the job was: releases/witness/2.2.0.json was built outside the pipeline with the same builder over that run's inputs and a --review-file the maintainer designated, and none of the job's inputs can be reached by gates/release_ref_rehearsal.py, because they do not exist before the tag. MIGRATIONS.md's release procedure says to confirm the job succeeded before the witness round commits, says what the approval comment must carry, and tests/test_cdm_witness.py holds this bullet to the directory. On v3.0.1 the comment named the readiness report at the release commit, the build step wrote the record with review_file derived from it, the verify step read VERIFIED with --download --assets assets and a token, and the attach step put witness-3.0.1.json on the Release: the third committed record, releases/witness/3.0.1.json, is the pipeline's own, byte for byte, held by digest. A fourth makes this bullet red again until it says what that run did. On v3.1.1 — run 35695633330, 2026-09-22 — it did: the build step wrote the record, the verify step refused it because the pypi approval had been given with an empty comment, the v2.2.0 reason, and the attach step was skipped; releases/witness/3.1.1.json is the fourth committed record, built outside the pipeline with the same builder over that run's inputs and a --review-file the maintainer designated afterwards, and PUBLICATION.md entry 23 says so. Four records, three of them hand-built; the rule about the approval comment is in the release procedure, and this is the second time it was not followed. On v3.2.0 — run 37459980438, 2026-10-06 — all six jobs succeeded: the pypi approval carried a comment naming the readiness report at the tagged commit, the build step wrote the record with review_file derived from it, the verify step read VERIFIED with --download --assets assets and a token, and the attach step put witness-3.2.0.json on the Release as its ninth asset; releases/witness/3.2.0.json is the fifth committed record and the pipeline's own, byte for byte, held by digest, and PUBLICATION.md entry 24 is the measurement. Five records, three of them hand-built and two the pipeline's; the rule about the approval comment was followed at the approval, the second time after v3.0.1's and the first since v3.1.1's was not. On v3.3.0 — run 37618296352, 2026-10-08 — all six jobs succeeded: the pypi approval, given the day after the tag was pushed, carried a comment naming the readiness report at the tagged commit, the build step wrote the record with review_file derived from it, the verify step read VERIFIED with --download --assets assets and a token, and the attach step put witness-3.3.0.json on the Release as its ninth asset; releases/witness/3.3.0.json is the sixth committed record and the pipeline's own, byte for byte, held by digest, and PUBLICATION.md entry 25 is the measurement. Six records, three of them hand-built and three the pipeline's; the rule about the approval comment was followed at the approval, the third time, after v3.0.1's and v3.2.0's. On v3.4.0 — run 38840201037, 2026-10-16 — all six jobs succeeded: the pypi approval, given the day the tag was pushed, carried a comment naming the readiness report at the tagged commit, the build step wrote the record with review_file derived from it, the verify step read VERIFIED with --download --assets assets and a token, and the attach step put witness-3.4.0.json on the Release as its ninth asset; releases/witness/3.4.0.json is the seventh committed record and the pipeline's own, byte for byte, held by digest, and PUBLICATION.md entry 26 is the measurement. Seven records, three of them hand-built and four the pipeline's; the rule about the approval comment was followed at the approval, the fourth time, after v3.0.1's, v3.2.0's and v3.3.0's. On v3.5.0 — run 38993508970, 2026-10-17 — all six jobs succeeded: the pypi approval, given the day the tag was pushed, carried a comment naming the readiness report at the tagged commit, the build step wrote the record with review_file derived from it, the verify step read VERIFIED with --download --assets assets and a token, and the attach step put witness-3.5.0.json on the Release as its ninth asset; releases/witness/3.5.0.json is the eighth committed record and the pipeline's own, byte for byte, held by digest, and PUBLICATION.md entry 27 is the measurement. Eight records, three of them hand-built and five the pipeline's; the rule about the approval comment was followed at the approval, the fifth time, after v3.0.1's, v3.2.0's, v3.3.0's and v3.4.0's. On v3.6.0 — run 39190348178, 2026-10-19 — all six jobs succeeded: the pypi approval, given the day the tag was pushed, carried a comment naming the readiness report at the tagged commit, the build step wrote the record with review_file derived from it, the verify step read VERIFIED with --download --assets assets and a token, and the attach step put witness-3.6.0.json on the Release as its ninth asset; releases/witness/3.6.0.json is the ninth committed record and the pipeline's own, byte for byte, held by digest, and PUBLICATION.md entry 28 is the measurement. Nine records, three of them hand-built and six the pipeline's; the rule about the approval comment was followed at the approval, the sixth time, after v3.0.1's, v3.2.0's, v3.3.0's, v3.4.0's and v3.5.0's.