From 78495e0b51749f90c28503b795d5a34ba3dbfb05 Mon Sep 17 00:00:00 2001 From: Juan Sugg Date: Thu, 9 Jul 2026 17:39:03 -0300 Subject: [PATCH 1/3] ci(release): gate publish workflows --- .github/workflows/python-publish-testpypi.yml | 136 ++++++++++++++- .github/workflows/python-publish.yml | 161 +++++++++++++++--- 2 files changed, 272 insertions(+), 25 deletions(-) diff --git a/.github/workflows/python-publish-testpypi.yml b/.github/workflows/python-publish-testpypi.yml index 8e72cfc..e055296 100644 --- a/.github/workflows/python-publish-testpypi.yml +++ b/.github/workflows/python-publish-testpypi.yml @@ -47,7 +47,10 @@ jobs: uses: ./.github/workflows/darwin-x86_64-validation.yml build-distributions: - needs: [verify-ci, linux-cli-validation, darwin-validation] + needs: + - verify-ci + - linux-cli-validation + - darwin-validation runs-on: ubuntu-latest steps: @@ -59,20 +62,140 @@ jobs: uses: actions/setup-python@v6 with: python-version: "3.12" + - name: Install uv + run: python -m pip install --upgrade pip uv + - name: Cache uv + uses: actions/cache@v5 + with: + path: ~/.cache/uv + key: uv-${{ runner.os }}-testpypi-build-${{ hashFiles('uv.lock') }} + restore-keys: | + uv-${{ runner.os }}-testpypi-build- - name: Build distributions - run: | - python -m pip install --upgrade pip build twine - python -m build + run: uv build - name: Check distribution metadata - run: python -m twine check --strict dist/* + run: uvx --from twine twine check --strict dist/* + - name: Smoke-test wheel installation + run: ./scripts/workflows/smoke_test_wheel_install.sh 'dist/*.whl' + - name: Record distribution hashes + run: | + cd dist + sha256sum -- * > SHA256SUMS - name: Upload distributions artifact uses: actions/upload-artifact@v7 with: name: python-package-distributions path: dist/ + if-no-files-found: error + retention-days: 14 + + release-required: + runs-on: ubuntu-latest + needs: + - verify-ci + - linux-cli-validation + - darwin-validation + - build-distributions + if: ${{ always() && github.event.release.prerelease == true }} + + steps: + - name: Evaluate required TestPyPI gates + env: + VERIFY_CI_RESULT: ${{ needs.verify-ci.result }} + LINUX_CLI_RESULT: ${{ needs.linux-cli-validation.result }} + DARWIN_RESULT: ${{ needs.darwin-validation.result }} + BUILD_RESULT: ${{ needs.build-distributions.result }} + run: | + set -euo pipefail + + { + echo "### TestPyPI release-required gates" + echo + echo "| Gate | Result |" + echo "| --- | --- |" + echo "| verify-ci | ${VERIFY_CI_RESULT} |" + echo "| linux-cli-validation | ${LINUX_CLI_RESULT} |" + echo "| darwin-validation | ${DARWIN_RESULT} |" + echo "| build-distributions | ${BUILD_RESULT} |" + } >> "$GITHUB_STEP_SUMMARY" + + for gate in VERIFY_CI_RESULT LINUX_CLI_RESULT DARWIN_RESULT BUILD_RESULT; do + if [[ "${!gate}" != "success" ]]; then + echo "Required TestPyPI gate failed or skipped: ${gate}=${!gate}" >&2 + exit 1 + fi + done + + release-evidence: + runs-on: ubuntu-latest + needs: + - verify-ci + - release-required + - build-distributions + if: ${{ needs.release-required.result == 'success' }} + + steps: + - name: Download distributions artifact + uses: actions/download-artifact@v8 + with: + name: python-package-distributions + path: dist/ + - name: Write TestPyPI release evidence bundle + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_ID: ${{ github.event.release.id }} + RUN_ID: ${{ github.run_id }} + HEAD_SHA: ${{ needs.verify-ci.outputs.head_sha }} + REQUIRED_RESULT: ${{ needs.release-required.result }} + BUILD_RESULT: ${{ needs.build-distributions.result }} + run: | + set -euo pipefail + mkdir -p release-evidence + python - <<'PY' + import json + import os + from pathlib import Path + + hashes_path = Path("dist/SHA256SUMS") + payload = { + "target": "testpypi", + "release_tag": os.environ["RELEASE_TAG"], + "release_id": os.environ["RELEASE_ID"], + "run_id": os.environ["RUN_ID"], + "head_sha": os.environ["HEAD_SHA"], + "required_result": os.environ["REQUIRED_RESULT"], + "build_result": os.environ["BUILD_RESULT"], + "dist_hashes": hashes_path.read_text(encoding="utf-8").splitlines(), + } + Path("release-evidence/release-evidence.json").write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + PY + - name: Upload TestPyPI release evidence artifact + uses: actions/upload-artifact@v7 + with: + name: testpypi-release-evidence + path: release-evidence/ + if-no-files-found: error + retention-days: 30 + - name: Write TestPyPI evidence summary + run: | + { + echo "### TestPyPI release evidence" + echo + echo "- Evidence artifact: \`testpypi-release-evidence\`" + echo "- Distribution artifact: \`python-package-distributions\`" + echo "- Head SHA: \`${{ needs.verify-ci.outputs.head_sha }}\`" + echo + echo "TestPyPI publishing is guarded by \`SER_ALLOW_TESTPYPI_PUBLISH=true\` until the environment and trusted publisher are verified." + } >> "$GITHUB_STEP_SUMMARY" publish-to-testpypi: - needs: [verify-ci, linux-cli-validation, darwin-validation, build-distributions] + needs: + - release-required + - release-evidence + if: ${{ github.event.release.prerelease == true && vars.SER_ALLOW_TESTPYPI_PUBLISH == 'true' }} runs-on: ubuntu-latest environment: @@ -80,6 +203,7 @@ jobs: url: https://test.pypi.org/p/ser permissions: actions: read + contents: read id-token: write steps: diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index edadbe9..452485e 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -52,7 +52,7 @@ jobs: with: run_accurate_research: false - selfhosted-gpu-validation: + release-advisory-selfhosted-gpu-validation: needs: verify-ci uses: ./.github/workflows/linux-selfhosted-gpu-validation.yml with: @@ -60,20 +60,19 @@ jobs: run_xpu: false run_accurate_research: false - full-dataset-quality-gate: + release-advisory-full-dataset-quality-gate: needs: verify-ci uses: ./.github/workflows/full-dataset-quality-gate-regression.yml with: run_training: true + require_pass: false build-distributions: needs: - [ - verify-ci, - linux-cli-validation, - darwin-validation, - macos-mps-validation, - ] + - verify-ci + - linux-cli-validation + - darwin-validation + - macos-mps-validation runs-on: ubuntu-latest steps: @@ -85,27 +84,150 @@ jobs: uses: actions/setup-python@v6 with: python-version: "3.12" + - name: Install uv + run: python -m pip install --upgrade pip uv + - name: Cache uv + uses: actions/cache@v5 + with: + path: ~/.cache/uv + key: uv-${{ runner.os }}-release-build-${{ hashFiles('uv.lock') }} + restore-keys: | + uv-${{ runner.os }}-release-build- - name: Build distributions - run: | - python -m pip install --upgrade pip build twine - python -m build + run: uv build - name: Check distribution metadata - run: python -m twine check --strict dist/* + run: uvx --from twine twine check --strict dist/* + - name: Smoke-test wheel installation + run: ./scripts/workflows/smoke_test_wheel_install.sh 'dist/*.whl' + - name: Record distribution hashes + run: | + cd dist + sha256sum -- * > SHA256SUMS - name: Upload distributions artifact uses: actions/upload-artifact@v7 with: name: python-package-distributions path: dist/ + if-no-files-found: error + retention-days: 14 + + release-required: + runs-on: ubuntu-latest + needs: + - verify-ci + - linux-cli-validation + - darwin-validation + - macos-mps-validation + - build-distributions + if: ${{ always() && github.event.release.prerelease == false }} + + steps: + - name: Evaluate required release gates + env: + VERIFY_CI_RESULT: ${{ needs.verify-ci.result }} + LINUX_CLI_RESULT: ${{ needs.linux-cli-validation.result }} + DARWIN_RESULT: ${{ needs.darwin-validation.result }} + MACOS_MPS_RESULT: ${{ needs.macos-mps-validation.result }} + BUILD_RESULT: ${{ needs.build-distributions.result }} + run: | + set -euo pipefail + + { + echo "### Release-required gates" + echo + echo "| Gate | Result |" + echo "| --- | --- |" + echo "| verify-ci | ${VERIFY_CI_RESULT} |" + echo "| linux-cli-validation | ${LINUX_CLI_RESULT} |" + echo "| darwin-validation | ${DARWIN_RESULT} |" + echo "| macos-mps-validation | ${MACOS_MPS_RESULT} |" + echo "| build-distributions | ${BUILD_RESULT} |" + echo + echo "Self-hosted GPU and full-dataset gates are advisory until runner/quality ownership is verified." + } >> "$GITHUB_STEP_SUMMARY" + + for gate in \ + VERIFY_CI_RESULT \ + LINUX_CLI_RESULT \ + DARWIN_RESULT \ + MACOS_MPS_RESULT \ + BUILD_RESULT; do + if [[ "${!gate}" != "success" ]]; then + echo "Required release gate failed or skipped: ${gate}=${!gate}" >&2 + exit 1 + fi + done + + release-evidence: + runs-on: ubuntu-latest + needs: + - verify-ci + - release-required + - build-distributions + if: ${{ needs.release-required.result == 'success' }} + + steps: + - name: Download distributions artifact + uses: actions/download-artifact@v8 + with: + name: python-package-distributions + path: dist/ + - name: Write release evidence bundle + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_ID: ${{ github.event.release.id }} + RUN_ID: ${{ github.run_id }} + HEAD_SHA: ${{ needs.verify-ci.outputs.head_sha }} + REQUIRED_RESULT: ${{ needs.release-required.result }} + BUILD_RESULT: ${{ needs.build-distributions.result }} + run: | + set -euo pipefail + mkdir -p release-evidence + python - <<'PY' + import json + import os + from pathlib import Path + + hashes_path = Path("dist/SHA256SUMS") + payload = { + "target": "pypi", + "release_tag": os.environ["RELEASE_TAG"], + "release_id": os.environ["RELEASE_ID"], + "run_id": os.environ["RUN_ID"], + "head_sha": os.environ["HEAD_SHA"], + "required_result": os.environ["REQUIRED_RESULT"], + "build_result": os.environ["BUILD_RESULT"], + "dist_hashes": hashes_path.read_text(encoding="utf-8").splitlines(), + } + Path("release-evidence/release-evidence.json").write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + PY + - name: Upload release evidence artifact + uses: actions/upload-artifact@v7 + with: + name: pypi-release-evidence + path: release-evidence/ + if-no-files-found: error + retention-days: 30 + - name: Write release evidence summary + run: | + { + echo "### PyPI release evidence" + echo + echo "- Evidence artifact: \`pypi-release-evidence\`" + echo "- Distribution artifact: \`python-package-distributions\`" + echo "- Head SHA: \`${{ needs.verify-ci.outputs.head_sha }}\`" + echo + echo "Production publishing is additionally guarded by \`SER_ALLOW_PYPI_PUBLISH=true\` until PyPI ownership/trusted-publisher state is verified." + } >> "$GITHUB_STEP_SUMMARY" publish-to-pypi: needs: - [ - verify-ci, - linux-cli-validation, - darwin-validation, - macos-mps-validation, - build-distributions, - ] + - release-required + - release-evidence + if: ${{ github.event.release.prerelease == false && vars.SER_ALLOW_PYPI_PUBLISH == 'true' }} runs-on: ubuntu-latest environment: @@ -113,6 +235,7 @@ jobs: url: https://pypi.org/p/ser permissions: actions: read + contents: read id-token: write steps: From a5087a6e22064a01af4f38b58747af3e9d7a3b3d Mon Sep 17 00:00:00 2001 From: Juan Sugg Date: Thu, 9 Jul 2026 17:39:05 -0300 Subject: [PATCH 2/3] docs(release): add runbooks --- .gitignore | 4 ++ README.md | 2 + SECURITY.md | 29 ++++++++++++++ docs/release-and-rollback.md | 75 ++++++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 SECURITY.md create mode 100644 docs/release-and-rollback.md diff --git a/.gitignore b/.gitignore index f565cd3..eb4fcc9 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,7 @@ ser/models/*.json .coverage .coverage.* coverage.xml +htmlcov/ +reports/ + +docs/ci/ci-cd-quality-implementation-journal.md diff --git a/README.md b/README.md index ea94725..9e2a452 100644 --- a/README.md +++ b/README.md @@ -137,5 +137,7 @@ uv run pytest -q \ - Contributor guide: [CONTRIBUTING.md](https://github.com/jsugg/ser/blob/main/CONTRIBUTING.md) - Compatibility details: [docs/compatibility-matrix.md](https://github.com/jsugg/ser/blob/main/docs/compatibility-matrix.md) - Hardware validation workflows: [docs/ci/hardware-validation.md](https://github.com/jsugg/ser/blob/main/docs/ci/hardware-validation.md) +- Release and rollback runbook: [docs/release-and-rollback.md](https://github.com/jsugg/ser/blob/main/docs/release-and-rollback.md) +- Security policy: [SECURITY.md](https://github.com/jsugg/ser/blob/main/SECURITY.md) - Architecture decisions index: [docs/adr/README.md](https://github.com/jsugg/ser/blob/main/docs/adr/README.md) - License: [LICENSE](https://github.com/jsugg/ser/blob/main/LICENSE) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a7fe384 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,29 @@ +# Security Policy + +## Supported versions + +`ser` is a Python package and CLI. Security fixes target the current default branch and the latest +published package line that is controlled by this repository. + +Production PyPI publishing is currently blocked until ownership and trusted-publisher settings for +the `ser` distribution are verified. + +## Reporting a vulnerability + +Report suspected vulnerabilities privately by one of these paths: + +1. Use GitHub private vulnerability reporting for `jsugg/ser` when it is available in the + repository Security tab. +2. If private reporting is unavailable, email the maintainer listed in `pyproject.toml`. + +Do not open a public issue with exploit details. Include affected versions, reproduction steps, +impact, and any relevant logs or proof-of-concept artifacts that do not expose secrets. + +## Handling and disclosure + +The maintainer triages reports, prepares a fix on a private branch when needed, and publishes a +patched release only after the TestPyPI rehearsal and release evidence controls in +[`docs/release-and-rollback.md`](docs/release-and-rollback.md) pass. + +If a released package is unsafe, follow the yank/patched-release runbook in +[`docs/release-and-rollback.md`](docs/release-and-rollback.md). diff --git a/docs/release-and-rollback.md b/docs/release-and-rollback.md new file mode 100644 index 0000000..8474faa --- /dev/null +++ b/docs/release-and-rollback.md @@ -0,0 +1,75 @@ +# Release, TestPyPI, and Rollback Runbook + +This runbook is mandatory before publishing package artifacts from this repository. + +## Current publishing status + +Production publishing is blocked by default. The workflow job `publish-to-pypi` requires repository +variable `SER_ALLOW_PYPI_PUBLISH=true`. The TestPyPI workflow similarly requires +`SER_ALLOW_TESTPYPI_PUBLISH=true`. + +Leave both variables unset until all checks below are complete: + +- GitHub environments `pypi` and `testpypi` exist with expected reviewers and branch/tag policy. +- PyPI and TestPyPI projects are controlled by this repository's maintainer. +- Trusted publisher entries point to `jsugg/ser` and the exact publish workflow/environment. +- A TestPyPI prerelease rehearsal has produced a release evidence artifact. + +Live state captured on 2026-07-05 showed PyPI project `ser` version `0.1.0` pointing at another +repository and TestPyPI project `ser` returning 404. Treat that as a production blocker until a +fresh registry/admin check proves otherwise. + +## Required release sequence + +1. Run or refresh the live-state export in + [`docs/ci/ci-cd-quality-implementation-journal.md`](ci/ci-cd-quality-implementation-journal.md). +2. Verify the target distribution name and trusted-publisher configuration in PyPI/TestPyPI. +3. Ensure default CI passed for the exact release commit. +4. Create a prerelease and run the TestPyPI workflow. +5. Review the `testpypi-release-evidence` artifact: + - release tag and release id; + - release commit SHA; + - required gate result; + - build result; + - distribution SHA256 hashes. +6. Install from TestPyPI in a clean environment and smoke-test the CLI. +7. Only after the rehearsal passes, set `SER_ALLOW_PYPI_PUBLISH=true` for the production release + window. +8. Publish the GitHub release. +9. Review the `pypi-release-evidence` artifact and PyPI project files. +10. Remove or reset `SER_ALLOW_PYPI_PUBLISH` after the release window unless continuous production + publishing has been explicitly approved. + +## Advisory release lanes + +Self-hosted GPU validation and the full-dataset quality gate are advisory until runner inventory, +dataset availability, and release ownership are verified. If a maintainer promotes either lane to +required, update the publish workflow and the workflow contract tests in the same change. + +## Yanking a bad release + +1. Confirm the affected version and artifact hashes from the release evidence bundle. +2. Yank the affected release in PyPI, recording the reason in the PyPI project history. +3. Update the GitHub release notes with impact, mitigation, and replacement-version guidance. +4. Prepare a patched release from a clean branch. +5. Run the full required release sequence, including TestPyPI rehearsal. +6. Publish the patched version and link it from the yanked release notes. +7. If credentials or trusted-publisher settings were involved, rotate or disable the affected + setting before the patched release. + +There is no automatic service rollback for `ser`; rollback means yanking the package and publishing +a corrected version. + +## Emergency bypass + +Use environment or ruleset bypass only for urgent security fixes when the normal path is unavailable. +Record: + +- reason for bypass; +- actor approving and performing the bypass; +- gates skipped; +- follow-up validation commands; +- rollback action taken if the bypassed release fails. + +After any bypass, run the skipped gates as soon as possible and append evidence to the implementation +journal. From 2f2b601165664cc7f0d390b51673a827a782dd3c Mon Sep 17 00:00:00 2001 From: Juan Sugg Date: Thu, 9 Jul 2026 18:22:59 -0300 Subject: [PATCH 3/3] docs(release): fix runbook link --- docs/release-and-rollback.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-and-rollback.md b/docs/release-and-rollback.md index 8474faa..ef01b88 100644 --- a/docs/release-and-rollback.md +++ b/docs/release-and-rollback.md @@ -22,7 +22,7 @@ fresh registry/admin check proves otherwise. ## Required release sequence 1. Run or refresh the live-state export in - [`docs/ci/ci-cd-quality-implementation-journal.md`](ci/ci-cd-quality-implementation-journal.md). + internal CI/CD quality implementation journal. 2. Verify the target distribution name and trusted-publisher configuration in PyPI/TestPyPI. 3. Ensure default CI passed for the exact release commit. 4. Create a prerelease and run the TestPyPI workflow.