Skip to content

ci(release): block publishing on known runtime dependency advisories - #630

Merged
yeongseon merged 5 commits into
mainfrom
ci/release-runtime-advisory-gate
Oct 24, 2026
Merged

yeongseon merged 5 commits into
mainfrom
ci/release-runtime-advisory-gate

Conversation

@yeongseon

@yeongseon yeongseon commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

Related Issue

Refs #624

This is the release-gate half of #624; the Bandit half merged in #629. I am deliberately not using Closes: every item of #624 is implemented except that "on supported Pythons" is met with the oldest and newest supported Python (3.11, 3.14), not all four minors (see "Decisions to confirm"). If you read that item as "every minor", add "3.12" and "3.13" to the matrix (four more cells) and close #624 with this PR.

Summary

publish waits for the tests and the Azure certification, but nothing stops a tag from being uploaded while the wheel's runtime dependencies carry a known advisory. dependency-audit.yml cannot be that gate (both audit steps are continue-on-error), and pip-audit itself passes vacuously: with --no-deps --disable-pip an empty requirements file exits 0, and without --strict a package it cannot audit exits 0 (both reproduced with pip-audit==2.10.1, see the tables below).

This adds one job, one small module and one waiver file, and nothing else in the release chain changes: OIDC, the pinned PyPA publisher, exact-artifact reuse, the e2e certification and dependency-audit.yml (still reporting-only) are untouched.

The new job has never run in GitHub Actions, and PR CI cannot run it. publish-pypi.yml starts only on v* tags and manual dispatch; I did not trigger it or any release. Everything below was verified by simulation, static analysis and tests. Before the next tag, please run the same steps once from a throwaway branch (a workflow with on: push that builds the wheel, uploads it as dist, and contains this job). I did not dispatch dependency-audit.yml either: it audits the environment without the candidate wheel, --no-deps --disable-pip --strict or the waiver tool, so a run would say nothing about this job.

This changes release/publishing workflow and a security gate. AGENTS.md ("High-impact security, credential, workflow and release changes need explicit independent scrutiny") applies. Please review the runtime-dependency-audit job and the publish.needs line before merging.

Changes

  • .github/workflows/publish-pypi.yml (+63/-2): new job runtime-dependency-audit (needs: build, permissions: contents: read, fail-fast: false) and runtime-dependency-audit added to publish.needs. Each cell: checkout (persist-credentials: false), setup-python, download-artifact of the dist that build produced, then in one step python -m venv, pip install "$(ls dist/*.whl)${EXTRAS:-}", pip freeze --exclude azure-functions-logging > req.txt, test -s req.txt, ignores=$(python tools/runtime_audit_args.py security/runtime-advisory-exceptions.txt), pip install pip-audit==2.10.1, pip_audit --no-deps --disable-pip --strict --desc off -f json -r req.txt $ignores | tee pip-audit.json. The JSON report is uploaded with if: always() (30 days; artifact name carries github.run_attempt so a "re-run failed jobs" cannot hit a duplicate-name conflict). All uses: reuse the canonical pins that lint_release_workflows.py enforces. $ignores is intentionally unquoted (one --ignore-vuln=ID word per waiver); ids are regex-validated by the tool, and a comment plus # shellcheck disable=SC2086 record that.
  • tools/runtime_audit_args.py (new, 56 lines, the only new module): reads security/runtime-advisory-exceptions.txt, where each non-comment line is ADVISORY-ID owner added expires reason..., and prints --ignore-vuln=ID arguments. It exits 1 (printing nothing) on a malformed line, an id that does not match [A-Z]+-[0-9A-Za-z-]+, an expired line, a line whose added is in the future, or a window longer than 90 days. The future-date rule is needed: without it, an added date far ahead defeats the 90-day cap.
  • security/runtime-advisory-exceptions.txt (new): comments only, so nothing is waived.
  • tests/test_runtime_advisory_exceptions.py, tests/test_runtime_audit_workflow.py (new, 135 lines, 21 tests): parser rules one by one plus the command's exit codes; and a YAML-parsing test that publish.needs contains the job, it has no continue-on-error/|| true, contents: read only, audits the dist artifact, keeps --strict --no-deps --disable-pip and test -s, and covers base and [otel] on the oldest and newest Python in the package classifiers.
  • SECURITY.md (+21): what runs where, the gate's rules and trade-offs, and the waiver format.
  • AGENTS.md, docs/release_process.md, docs/contributing.md: add the tier to the release chain/tier tables. .github/workflows/release-please.yml: the same tier name in a header comment and in the post-release Job Summary text (text only, no step, permission or trigger changes). These five files are outside the planned file list; say so if you would rather drop them.

Not in this PR: any Bandit change, policy JSON or schema, changes to the three workflow lints (none hardcodes the old needs list), dependency-audit.yml, and #616/#617/#618 and the Release PR (untouched).

Size: 10 files, +288/-8, against the plan's ~+140. The difference is tests (135 lines for 21 cases; the plan sketched three), the Python matrix plus the report upload, and the doc consistency edits above.

Decisions to confirm

  1. Python coverage: 3.11 and 3.14, not one and not all four. The runtime closure is not the same on every supported Python. I installed the real wheel (and [otel]) on each interpreter and froze it:

    Python base [otel] adds
    3.11, 3.12 azure-functions 1.25.0, MarkupSafe 3.0.4, Werkzeug 3.1.9 opentelemetry-api 1.45.1, typing_extensions 4.16.0
    3.13, 3.14 azure-functions 2.3.0, MarkupSafe 3.0.4, Werkzeug 3.1.9 opentelemetry-api 1.45.1, typing_extensions 4.16.0

    A single interpreter would leave one azure-functions major unaudited. 3.11 and 3.14 cover both closures (3.12 resolved like 3.11, 3.13 like 3.14), so the matrix is 4 cells instead of the 8 that ci(security): verify scan results and audit release dependencies #618 used. This relies on dependency support being monotonic across minors, which holds for Requires-Python-driven resolution (an environment marker keyed to one exact minor would escape it); a test ties the matrix to the package classifiers so adding 3.15 fails until the matrix is updated. All eight closures audit clean today. (Related context, not addressed here: test(compat): certify azure-functions SDK major-version compatibility explicitly #613 asks to certify the azure-functions major versions; this PR only audits both for advisories.)

  2. Any known advisory blocks. pip-audit reports no severity. The gate therefore also blocks when PyPI or the advisory service is unreachable, or when a new advisory lands between the Release PR and the tag. Recovery is a retry (gh workflow run publish-pypi.yml --ref vX.Y.Z) or, for an unfixable advisory, a dated waiver on main before the Release PR is merged (the job reads the file from the tagged commit).

  3. Matrix key is python, not python-version. tools/lint_hatch_matrix.py reads any python-version list as a Hatch test matrix. With that key the lint passes today only because the unrelated pytest-results.xml artifact path matches its "direct pytest" heuristic; I confirmed it flags publish-pypi.yml once that path is renamed. With python it is clean either way.

Verification

Local rows ran on Python 3.11.15 with Hatch 1.18.1 (worktree .venv from CI=1 make install) on the final tree (head f728a95).

Command Environment Result
make check-all Py 3.11.15 exit 0: 3 workflow lints and the doc-link lint OK; ruff check and ruff format --check OK (83 files); mypy strict src tests OK (62 files); 870 passed, 4 skipped, 4 deselected; coverage 96.26% (gate 95%); make security printed bandit: 4647 lines analysed, no findings
make build Py 3.11.15 exit 0 (sdist and wheel 0.15.3)
mypy tools/runtime_audit_args.py (strict, alone) Py 3.11.15 OK
mkdocs build --strict Py 3.11.15 OK
pytest tests/test_runtime_advisory_exceptions.py tests/test_runtime_audit_workflow.py --no-cov Py 3.12.13, 3.13.13, 3.14.4 21 passed on each
the full suite inside unshare -rn (no network; curl pypi.org fails to resolve) Py 3.11.15 870 passed, 4 skipped, coverage 96.26%
each of the 5 commits exported with git archive: new tests, test_release_workflow_pins.py, test_workflow_pin_hygiene.py, the 3 lints Py 3.11.15 green at every commit
actionlint 1.7.12 with shellcheck on both edited workflows no new finding (2 existing SC2012 infos in the cookbook jobs); expression syntax and contexts of the new name:/artifact-name expressions validate
zizmor 1.31.1 on publish-pypi.yml, regular/pedantic/auditor personas 17 findings before and 17 after at every persona; none new

Simulated step. The run: block is extracted from the committed YAML (byte-identical, checked) and run with bash -e in a scratch "runner": a fresh interpreter venv first on PATH (3.11 or 3.14), a copy of the checkout, the candidate wheel in dist/, RUNNER_TEMP, EXTRAS from the matrix, an empty cache, the real PyPI and the real advisory service, pip-audit==2.10.1. Synthetic candidates are real wheels named azure-functions-logging built for the negative cases.

Case Result
real wheel, base and [otel], on 3.11 and on 3.14 (4 cells) exit 0 in each, No known vulnerabilities found; the JSON report lists the audited closure (3 packages base, 5 with [otel], 0 vulnerabilities)
candidate pinning urllib3==1.24.1 exit 1: Found 25 known vulnerabilities in 1 package (pipefail propagates through tee)
same, every advisory id waived with a dated, owned exception exit 0: No known vulnerabilities found, 25 ignored; the log prints the waivers in effect
same, all but one id waived exit 1: Found 1 known vulnerability, ignored 24
candidate requiring a package absent from PyPI (installed from a local find-links dir) exit 1: Dependency not found on PyPI and could not be audited
same, --strict deleted from the step (control) exit 0, No known vulnerabilities found: the vacuous pass that --strict closes
candidate with no dependencies (empty closure) exit 1 at test -s req.txt; pip-audit is never installed. (Why the check exists: pip-audit==2.10.1 --no-deps --disable-pip --strict -r on an empty file exits 0 with No known vulnerabilities found.)
waiver expired / 91-day window / added in the future / no reason / impossible date / id GHSA-x;touch-pwned / valid line followed by an expired one exit 1 each, before pip-audit is installed; the message names the line; the step dies at the tool, so the id never reaches a shell expansion
valid waiver for an advisory that is not present exit 0; the log prints Advisory exceptions in effect: --ignore-vuln=...

Mutation checks (each applied to a scratch copy of the committed files, new tests run): 19 of 20 mutations are killed: dropping the job from publish.needs; continue-on-error at job or step level; || true; deleting --strict, --no-deps, --disable-pip or test -s req.txt; auditing a different artifact; dropping [otel] or 3.14 from the matrix; adding id-token: write; needs no longer build; and in the tool: ignoring expiry, the 90-day cap, the future-added rule, the advisory-id check, the exit code, or comment handling. The one survivor is a deliberate control (a comment-only edit). My first version of the workflow test let "delete --strict" survive because a comment mentioned the flag; the committed test inspects only non-comment command tokens.

GitHub Actions on the final head f728a95 (23 check-runs, polled until none was pending):

Check Result
required contexts bandit, semgrep, Analyze, PR title, ci-required all success; diffed against the branch-protection list, none missing; 22 check-runs success, 1 skipped (dependabot-auto-merge), 0 failed or incomplete; mergeable_state: clean
bandit job log bandit: 4647 lines analysed, no findings (the new tool is in the scanned scope)
Test (Py 3.11) / Test (Py 3.14) 870 passed, 4 skipped, coverage 96.26% (gate 95%) / 872 passed, 2 skipped; the 21 new tests are included
Lint, type-check & security (Py 3.11) ruff, ruff format --check and mypy strict clean; the changed-files format check covered the 3 new Python files
publish-pypi.yml not run: a pull request cannot start it

Not run

  • The job in GitHub Actions (see the callout above). Unverified there: actions/setup-python for 3.14, download-artifact/upload-artifact behavior with the new names, runner image differences, and a PyPI/advisory-service outage during the step.
  • publish-pypi.yml itself, any tag, any release, and dependency-audit.yml.
  • Cells for Python 3.12 and 3.13 (their closures were resolved and audited by hand, not run as cells).
  • [otel] together with a waiver, and a waiver for an alias id rather than the primary id.

Review Evidence

  • Review source and reviewed commit SHA: none yet (author's own verification only, head f728a95). Independent review requested.
  • Material findings and disposition (fixed / acknowledged-wont-fix / deferred-with-issue), or "none": none yet.
  • Unresolved blockers, or "none": none known. Do not merge before an independent review of the release-workflow change.
  • R3 (workflow permissions, release/publishing, security-sensitive redaction/exposure, telemetry trust boundaries, review-policy files): yes (release/publishing workflow and a security gate); human authorization record: none yet.

Compatibility and documentation

  • This change affects the public API, supported versions, or output format
  • Breaking change (describe the migration)
  • Documentation updated, or not needed because: SECURITY.md describes the gate and the waiver format; AGENTS.md, docs/release_process.md and docs/contributing.md list the new tier. No public API change; the gate can block a release only, never an install.

yeongseon and others added 5 commits October 24, 2026 22:05
The release-time dependency audit (next commit) needs a way to waive an advisory that has no fix yet without turning the gate into a permanent ignore list. tools/runtime_audit_args.py reads security/runtime-advisory-exceptions.txt, one 'ADVISORY-ID owner added expires reason...' line each, and prints the matching --ignore-vuln arguments.

A line fails the run (exit 1, nothing printed) when it is malformed, when its advisory id is not [A-Z]+-[0-9A-Za-z-]+ (the arguments reach the shell unquoted), when it has expired, when it is dated in the future, or when it is valid for more than 90 days. Without the future-date check an 'added' date far ahead would defeat the 90-day cap. The file ships with comments only, so no advisory is waived. The gate runs only on release tags, so these rules are pinned by tests rather than by PR CI.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
publish waited for tests and certification only, so a tag could be uploaded with a known advisory in the wheel's runtime closure. dependency-audit.yml cannot gate that (both of its audit steps are continue-on-error), and pip-audit itself passes vacuously: an empty requirements file, or a package it cannot audit, exits 0 unless --strict is set (reproduced with pip-audit 2.10.1).

Add the runtime-dependency-audit job (needs: build, contents: read) and make publish wait for it. Each matrix cell installs the downloaded dist wheel, the exact artifact publish uploads, into a clean venv, runs 'pip freeze --exclude azure-functions-logging', 'test -s' and 'pip-audit==2.10.1 --no-deps --disable-pip --strict', and uploads the JSON report even on failure. Advisory waivers come from security/runtime-advisory-exceptions.txt through tools/runtime_audit_args.py; an invalid or expired waiver stops the step before pip-audit runs.

The matrix is base and [otel] on Python 3.11 and 3.14. azure-functions resolves to 1.25.0 on 3.11-3.12 and to 2.3.0 on 3.13-3.14 (resolved on all four), so the oldest and newest interpreter cover every distinct closure with four cells instead of eight. The matrix key is 'python', not 'python-version': tools/lint_hatch_matrix.py reads any python-version list as a Hatch test matrix and would pass this file only because an unrelated 'pytest-results.xml' path happens to match its heuristic.

Untouched: OIDC, the permissions of every other job, exact-artifact reuse, the e2e certification, the pinned publisher and dependency-audit.yml (still reporting-only). The job cannot run on a pull request, because the workflow starts only on v* tags and manual dispatch. tests/test_runtime_audit_workflow.py therefore parses the YAML and fails if the job leaves publish.needs, gains continue-on-error, loses --strict, --no-deps, --disable-pip or 'test -s', stops auditing the dist artifact, or drops a closure or the oldest/newest supported Python.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Add an 'Automated security controls' section to SECURITY.md that states what runs where: Bandit, Semgrep and CodeQL on pull requests as required checks, the reporting-only weekly pip-audit, and the new release gate that blocks publish. It records why the gate audits Python 3.11 and 3.14, that any advisory blocks because pip-audit reports no severity, that an unreachable PyPI blocks the release until a retry passes, the waiver format with its 90-day limit, that the waiver file is read from the tagged commit, and that pull-request CI does not run the gate.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
AGENTS.md, docs/release_process.md and docs/contributing.md each enumerate the tiers that gate a release. Add runtime-dependency-audit to the chain in all three and to the tier tables in the first two, so none of them describes a release that no longer matches publish-pypi.yml. The three change together because they state the same list.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
release-please.yml lists the publish tiers in a header comment and in the Job Summary written after a release is created. Add runtime-dependency-audit to both. Text only: no step, permission or trigger changes.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The security-sensitive publishing gate needs independent human sign-off and validation of the new job in GitHub Actions before release use.

No open issues 🎉

What changed in this PR

Adds the runtime-advisory release gate for #624, complementing the Bandit safeguards in #629. Publishing now depends on auditing the candidate wheel’s runtime dependencies while preserving existing release gates and exact-artifact reuse.

Changes:

  • Audit base and [otel] dependencies on Python 3.11 and 3.14, blocking publication on advisories, empty dependency lists, or unauditable packages.
  • Add validated, dated advisory waivers and regression tests for waiver rules and workflow safeguards.
  • Document the new gate and retain per-cell audit reports.
File Description
tools/​runtime_audit_args.py Validates dated waivers and generates audit arguments.
tests/​test_runtime_audit_workflow.py Checks publish dependencies, audit safeguards, and matrix coverage.
tests/​test_runtime_advisory_exceptions.py Tests waiver validation and command failure behavior.
security/​runtime-advisory-exceptions.txt Adds the waiver file with no active exceptions.
SECURITY.md Explains security controls, blocking behavior, and waivers.
docs/​release_process.md Adds the audit to release-gate documentation.
docs/​contributing.md Updates the documented publishing sequence.
AGENTS.md Includes the audit in release guidance.
.github/​workflows/​release-please.yml Updates comments and job-summary text only.
.github/​workflows/​publish-pypi.yml Adds the audit matrix, report uploads, and publish dependency.

Review effort: Balanced

@yeongseon

Copy link
Copy Markdown
Owner Author

R3 authorization for head f728a95eb44d916da4def6a0feb93efc589c4bcd.

The maintainer delegated the merge decision to an Oracle review. The Oracle (AI) reviewed this change at head f728a95eb44d916da4def6a0feb93efc589c4bcd through code inspection and independent probes and returned GO (workflow identical to main apart from the new job and its publish.needs entry; pins verified against upstream tags; base and otel closures pass on 3.11 and 3.14; vulnerable pin, unauditable package, empty closure, expired, malformed, over-90-day and hostile exception entries and a dead proxy all fail closed; three workflow mutations caught; 870 passed 96.26%). Changes since that head are merges of main only; required checks were re-run on this exact head and are green. This is advisory AI evidence, not line-by-line human review or human approval. Refs #624 stays open: only Python 3.11 and 3.14 are audited, which the maintainer should accept explicitly or extend to 3.12 and 3.13. This changes the release workflow and has never run in Actions: on the first real tag run, verify that all four audit cells execute, download dist, produce non-empty candidate-excluded closures and finish before publication.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ci(security): fail closed on vacuous Bandit scans and block releases on runtime advisories

2 participants