From 1b1e4602b09c29e19432016a50b30f13755fa25e Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 20 Aug 2026 12:15:08 -0400 Subject: [PATCH] ci: bind Capture releases to admitted artifacts --- .github/workflows/control-contract.yml | 6 +- .../workflows/production-qualification.yml | 93 +++- .github/workflows/release.yml | 407 +++++++++++++++--- .github/workflows/test.yml | 30 +- README.md | 5 +- docs/DESIGN.md | 6 +- scripts/check_changelog.py | 33 +- scripts/check_prepared_release.py | 119 +++++ scripts/check_release_ci.py | 10 + scripts/release_admission_candidate.py | 278 ++++++++++++ tests/test_changelog_contract.py | 26 ++ tests/test_release_qualification_gates.py | 165 +++++++ tests/test_release_workflow_contract.py | 71 +++ 13 files changed, 1164 insertions(+), 85 deletions(-) create mode 100644 scripts/check_prepared_release.py create mode 100644 scripts/release_admission_candidate.py create mode 100644 tests/test_release_workflow_contract.py diff --git a/.github/workflows/control-contract.yml b/.github/workflows/control-contract.yml index 0bd6533..098350f 100644 --- a/.github/workflows/control-contract.yml +++ b/.github/workflows/control-contract.yml @@ -28,12 +28,12 @@ jobs: runs-on: ${{ matrix.os }} timeout-minutes: 10 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: - version: "latest" + version: "0.11.29" - name: Set up Python run: uv python install 3.12 diff --git a/.github/workflows/production-qualification.yml b/.github/workflows/production-qualification.yml index b977cf4..835828b 100644 --- a/.github/workflows/production-qualification.yml +++ b/.github/workflows/production-qualification.yml @@ -7,6 +7,10 @@ on: description: "Exact 40-character main commit SHA to qualify" type: string required: true + candidate_version: + description: "Stable version already written into the candidate commit" + type: string + required: true concurrency: group: capture-production-qualification-${{ github.sha }} @@ -52,6 +56,14 @@ jobs: with: python-version: "3.12" + - name: Require a prepared versioned release commit + env: + CANDIDATE_VERSION: ${{ inputs.candidate_version }} + run: | + python scripts/check_prepared_release.py \ + --expected-sha "${GITHUB_SHA}" \ + --expected-version "${CANDIDATE_VERSION}" + - name: Install exact uv uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: @@ -120,6 +132,63 @@ jobs: if-no-files-found: error retention-days: 14 + control-contract: + name: Candidate control contract (${{ matrix.os }}) + needs: build-candidate + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + steps: + - name: Checkout the exact candidate source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Install exact uv + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + version: "0.11.29" + + - name: Download the exact candidate + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: capture-candidate-${{ github.sha }} + path: dist + + - name: Verify and install the exact wheel + shell: bash + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + from scripts.candidate_lifecycle import verify_manifest + verify_manifest(Path("dist"), Path("dist/SHA256SUMS")) + PY + control_root="${RUNNER_TEMP}/capture-control-${GITHUB_RUN_ID}-${GITHUB_JOB}" + control_python="${control_root}/bin/python" + if [ "${RUNNER_OS}" = "Windows" ]; then + control_python="${control_root}/Scripts/python.exe" + fi + uv venv --clear --python 3.12 "${control_root}" + uv pip install --python "${control_python}" \ + dist/*.whl pytest==9.1.1 pytest-timeout==2.4.0 + echo "CONTROL_PYTHON=${control_python}" >> "${GITHUB_ENV}" + + - name: Run the authenticated control contract from the installed wheel + shell: bash + run: | + set -euo pipefail + cd "${RUNNER_TEMP}" + "${CONTROL_PYTHON}" -m pytest \ + "${GITHUB_WORKSPACE}/tests/test_control.py" \ + -v --timeout=120 --import-mode=importlib + interactive-linux: name: Interactive qualification (Linux X64) needs: build-candidate @@ -152,11 +221,27 @@ jobs: set -euo pipefail qualification_root="${RUNNER_TEMP}/capture-qualification-${GITHUB_RUN_ID}-${GITHUB_JOB}" qualification_python="${qualification_root}/bin/python" - uv venv --clear --python 3.12 "${qualification_root}" + uv venv --clear --system-site-packages --python 3.12 "${qualification_root}" + wheel=(dist/*.whl) + if [ "${#wheel[@]}" -ne 1 ]; then + echo "Expected exactly one candidate wheel." + exit 1 + fi uv pip install --python "${qualification_python}" \ - dist/*.whl pytest==9.1.1 pytest-timeout==2.4.0 pynput==1.8.2 + "${wheel[0]}[linux]" pytest==9.1.1 pytest-timeout==2.4.0 pynput==1.8.2 echo "QUALIFICATION_PYTHON=${qualification_python}" >> "${GITHUB_ENV}" + - name: Require the reviewed Linux GI and AT-SPI runtime + shell: bash + run: | + set -euo pipefail + if [ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ]; then + echo "The qualification host has no interactive desktop D-Bus session." + exit 1 + fi + "${QUALIFICATION_PYTHON}" -c \ + "import gi; gi.require_version('Atspi', '2.0'); from gi.repository import Atspi; assert Atspi.Registry is not None" + - name: Require the reviewed external video tools shell: bash run: | @@ -184,6 +269,8 @@ jobs: cd "${RUNNER_TEMP}" "${QUALIFICATION_PYTHON}" -m pytest \ "${GITHUB_WORKSPACE}/tests/test_performance.py" \ + "${GITHUB_WORKSPACE}/tests/test_window_capture.py" \ + "${GITHUB_WORKSPACE}/tests/test_structural_observation.py" \ -m slow -v --timeout=300 --import-mode=importlib \ --junitxml="${GITHUB_WORKSPACE}/evidence/interactive-linux.xml" @@ -267,6 +354,7 @@ jobs: "${QUALIFICATION_PYTHON}" -m pytest \ "${GITHUB_WORKSPACE}/tests/test_performance.py" \ "${GITHUB_WORKSPACE}/tests/test_window_capture.py" \ + "${GITHUB_WORKSPACE}/tests/test_structural_observation.py" \ -m slow -v --timeout=300 --import-mode=importlib \ --junitxml="${GITHUB_WORKSPACE}/evidence/interactive-macos.xml" @@ -355,6 +443,7 @@ jobs: & $env:QUALIFICATION_PYTHON -m pytest ` "$env:GITHUB_WORKSPACE/tests/test_performance.py" ` "$env:GITHUB_WORKSPACE/tests/test_window_capture.py" ` + "$env:GITHUB_WORKSPACE/tests/test_structural_observation.py" ` -m slow -v --timeout=300 --import-mode=importlib ` "--junitxml=$env:GITHUB_WORKSPACE/evidence/interactive-windows.xml" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 05e6902..572dd68 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,12 +1,19 @@ name: Release and PyPI Publish -# Publication is a deliberate operation from protected main. Ordinary merges -# run Tests only; this workflow refuses to version or publish until that exact -# main commit has completed the full test.yml workflow (including the package -# archive contract). +# A release has two deliberate phases. # -# Requires ADMIN_TOKEN (GitHub PAT with repo scope) to push the release commit -# and tag through branch protection. PyPI publication uses Trusted Publishing. +# 1. prepare-candidate creates and pushes the semantic-release version commit, +# but it creates no tag, GitHub Release, package, or Production claim. It +# then dispatches hardware qualification for that exact versioned commit. +# 2. publish-candidate waits for exact-commit Tests and Production +# qualification, downloads the one wheel/sdist pair built by qualification, +# and publishes those unchanged bytes. It verifies exact PyPI parity before +# it emits an attested, explicitly not-admitted candidate for the canonical +# OpenAdaptAI/.github Production admission process. +# +# PyPI latest is never a Production selector. This workflow does not update a +# Production default. Only the canonical signed, expiring, revocable central +# admission ledger can select a Production release. on: workflow_dispatch: @@ -15,48 +22,152 @@ on: description: "Release operation" type: choice required: true - default: semantic-release + default: prepare-candidate options: - - semantic-release + - prepare-candidate + - publish-candidate concurrency: group: release cancel-in-progress: false +permissions: + contents: read + jobs: - semantic-release: - name: Version, tag, and publish the green main release train + prepare-candidate: + name: Prepare and dispatch the exact release candidate if: >- github.event_name == 'workflow_dispatch' && - inputs.operation == 'semantic-release' && + inputs.operation == 'prepare-candidate' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest permissions: - actions: read - id-token: write + actions: write contents: write - issues: write steps: - name: Checkout protected main at the dispatched commit - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + token: ${{ secrets.ADMIN_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Require the dispatched commit to remain protected main + shell: bash + run: | + set -euo pipefail + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + current_main="$(git rev-parse refs/remotes/origin/main)" + if [ "${current_main}" != "${GITHUB_SHA}" ]; then + echo "Refusing stale candidate preparation: main is ${current_main}, dispatched head is ${GITHUB_SHA}." + exit 1 + fi + if git log -1 --pretty=format:'%s' | grep -Eq '^chore: release [0-9]+\.[0-9]+\.[0-9]+$'; then + echo "The current main commit is already a prepared release. Use publish-candidate." + exit 1 + fi + + - name: Prepare the versioned release commit + id: prepare + uses: python-semantic-release/python-semantic-release@39dd2052f2ce8282a5d932c31d58a2ca06d2550e # v10.6.1 + with: + github_token: ${{ secrets.ADMIN_TOKEN }} + git_committer_name: "semantic-release" + git_committer_email: "bot@openadapt.ai" + commit: true + tag: false + push: true + changelog: true + vcs_release: false + build: false + + - name: Verify and dispatch the prepared candidate + if: steps.prepare.outputs.released == 'true' + env: + GH_TOKEN: ${{ secrets.ADMIN_TOKEN }} + RELEASE_SHA: ${{ steps.prepare.outputs.commit_sha }} + RELEASE_VERSION: ${{ steps.prepare.outputs.version }} + shell: bash + run: | + set -euo pipefail + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + if [ "$(git rev-parse refs/remotes/origin/main)" != "${RELEASE_SHA}" ]; then + echo "Prepared release commit did not become protected main." + exit 1 + fi + git checkout --detach "${RELEASE_SHA}" + python scripts/check_prepared_release.py \ + --expected-sha "${RELEASE_SHA}" \ + --expected-version "${RELEASE_VERSION}" + if git ls-remote --exit-code --tags origin "refs/tags/v${RELEASE_VERSION}" >/dev/null 2>&1; then + echo "Candidate preparation must not create the release tag." + exit 1 + fi + gh workflow run production-qualification.yml \ + --repo "${GITHUB_REPOSITORY}" \ + --ref main \ + -f candidate_sha="${RELEASE_SHA}" \ + -f candidate_version="${RELEASE_VERSION}" + + - name: Report that no release is required + if: steps.prepare.outputs.released != 'true' + run: echo "No release-worthy commit exists after the latest release." + + stage-candidate: + name: Stage the exact qualified candidate for publication + if: >- + github.event_name == 'workflow_dispatch' && + inputs.operation == 'publish-candidate' && + github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 70 + outputs: + version: ${{ steps.candidate.outputs.version }} + tag: ${{ steps.candidate.outputs.tag }} + permissions: + actions: read + attestations: write + contents: write + id-token: write + + steps: + - name: Checkout the prepared release commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 token: ${{ secrets.ADMIN_TOKEN }} - - name: Refuse a release commit replay - id: check_skip + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Bind the exact prepared commit and version + id: candidate shell: bash run: | set -euo pipefail - author="$(git log -1 --pretty=format:'%an')" - subject="$(git log -1 --pretty=format:'%s')" - if [ "${author}" = "semantic-release" ] || printf '%s' "${subject}" | grep -q '^chore: release'; then - echo "skip=true" >> "${GITHUB_OUTPUT}" + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + current_main="$(git rev-parse refs/remotes/origin/main)" + if [ "${current_main}" != "${GITHUB_SHA}" ]; then + echo "Refusing stale candidate publication: main is ${current_main}, dispatched head is ${GITHUB_SHA}." + exit 1 fi + version="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" + python scripts/check_prepared_release.py \ + --expected-sha "${GITHUB_SHA}" \ + --expected-version "${version}" + echo "version=${version}" >> "${GITHUB_OUTPUT}" + echo "tag=v${version}" >> "${GITHUB_OUTPUT}" - - name: Wait for exact-head test and production qualification evidence - if: steps.check_skip.outputs.skip != 'true' + - name: Wait for exact prepared-commit qualification + id: evidence env: GH_TOKEN: ${{ github.token }} shell: bash @@ -65,74 +176,254 @@ jobs: python scripts/check_release_ci.py \ --repository "${GITHUB_REPOSITORY}" \ --sha "${GITHUB_SHA}" \ - --timeout-seconds 2700 + --timeout-seconds 3600 \ + --output release-evidence.json + run_id="$(python -c 'import json; print(json.load(open("release-evidence.json"))["production-qualification.yml"])')" + echo "qualification_run_id=${run_id}" >> "${GITHUB_OUTPUT}" - - name: Require dispatched head to remain current protected main - if: steps.check_skip.outputs.skip != 'true' + - name: Require the prepared commit to remain protected main shell: bash run: | set -euo pipefail git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main current_main="$(git rev-parse refs/remotes/origin/main)" if [ "${current_main}" != "${GITHUB_SHA}" ]; then - echo "Refusing stale release dispatch: main is ${current_main}, dispatched head is ${GITHUB_SHA}." + echo "Refusing stale candidate publication: main advanced to ${current_main}." exit 1 fi + - name: Download the exact qualified archives + env: + GH_TOKEN: ${{ github.token }} + QUALIFICATION_RUN_ID: ${{ steps.evidence.outputs.qualification_run_id }} + shell: bash + run: | + set -euo pipefail + gh run download "${QUALIFICATION_RUN_ID}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "capture-candidate-${GITHUB_SHA}" \ + --dir dist + python - <<'PY' + from pathlib import Path + from scripts.candidate_lifecycle import verify_manifest + verify_manifest(Path("dist"), Path("dist/SHA256SUMS")) + PY + python scripts/verify_distribution.py dist/*.whl dist/*.tar.gz + python scripts/check_source_boundary.py --require-dist + mkdir -p evidence + mv dist/SHA256SUMS evidence/SHA256SUMS + + - name: Attest the exact qualified archives + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: | + dist/*.whl + dist/*.tar.gz + + - name: Create the immutable release tag + env: + TAG: ${{ steps.candidate.outputs.tag }} + shell: bash + run: | + set -euo pipefail + if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then + git fetch --no-tags origin "+refs/tags/${TAG}:refs/tags/${TAG}" + if [ "$(git rev-list -n 1 "${TAG}")" != "${GITHUB_SHA}" ]; then + echo "Existing ${TAG} does not identify the exact qualified commit." + exit 1 + fi + else + git config user.name "semantic-release" + git config user.email "bot@openadapt.ai" + git tag --annotate "${TAG}" --message "OpenAdapt Capture ${TAG}" "${GITHUB_SHA}" + git push origin "refs/tags/${TAG}" + fi + + - name: Retain the exact qualified release bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: capture-qualified-release-${{ github.sha }} + path: | + dist/*.whl + dist/*.tar.gz + evidence/SHA256SUMS + if-no-files-found: error + retention-days: 14 + + publish-pypi: + name: Publish the exact qualified archives to PyPI + needs: stage-candidate + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + id-token: write + steps: + - name: Download the exact qualified release bundle + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: capture-qualified-release-${{ github.sha }} + path: release-bundle + + - name: Publish the exact qualified archives to PyPI + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + packages-dir: release-bundle/dist/ + skip-existing: true + + finalize-candidate: + name: Emit the not-admitted candidate after exact PyPI parity + needs: [stage-candidate, publish-pypi] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: read + attestations: write + contents: write + id-token: write + steps: + - name: Checkout the prepared release commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + token: ${{ secrets.ADMIN_TOKEN }} + - name: Set up Python - if: steps.check_skip.outputs.skip != 'true' uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - - name: Install exact uv - if: steps.check_skip.outputs.skip != 'true' - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 - with: - version: "0.11.29" + - name: Require the exact prepared commit + env: + VERSION: ${{ needs.stage-candidate.outputs.version }} + shell: bash + run: | + set -euo pipefail + python scripts/check_prepared_release.py \ + --expected-sha "${GITHUB_SHA}" \ + --expected-version "${VERSION}" - - name: Python Semantic Release - if: steps.check_skip.outputs.skip != 'true' - id: release - uses: python-semantic-release/python-semantic-release@39dd2052f2ce8282a5d932c31d58a2ca06d2550e # v10.6.1 + - name: Download the exact qualified release bundle + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - github_token: ${{ secrets.ADMIN_TOKEN }} + name: capture-qualified-release-${{ github.sha }} - # Do not trust the semantic-release action's transient build directory. - # Rebuild the versioned tree and re-run the exact archive boundary guard - # before either external publication channel sees an artifact. - - name: Rebuild and verify release artifacts - if: steps.check_skip.outputs.skip != 'true' && steps.release.outputs.released == 'true' + - name: Verify the retained archive bytes shell: bash run: | set -euo pipefail - rm -rf dist - uv build --wheel --sdist - python scripts/verify_distribution.py dist/* + python - <<'PY' + from pathlib import Path + from scripts.candidate_lifecycle import verify_manifest + verify_manifest(Path("dist"), Path("evidence/SHA256SUMS")) + PY + python scripts/verify_distribution.py dist/*.whl dist/*.tar.gz python scripts/check_source_boundary.py --require-dist - - name: Publish to PyPI - if: steps.check_skip.outputs.skip != 'true' && steps.release.outputs.released == 'true' - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + - name: Checkout the canonical lifecycle authority + id: lifecycle + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: OpenAdaptAI/.github + ref: main + path: lifecycle-policy + persist-credentials: false - - name: Publish to GitHub Releases - if: steps.check_skip.outputs.skip != 'true' && steps.release.outputs.released == 'true' - uses: python-semantic-release/publish-action@5a5718ce47b892ef699f2972dae122297771d641 # v10.6.1 + - name: Validate the exact canonical lifecycle authority + id: authority + shell: bash + run: | + set -euo pipefail + authority_commit="$(git -C lifecycle-policy rev-parse HEAD)" + if [[ ! "${authority_commit}" =~ ^[0-9a-f]{40}$ ]]; then + echo "The lifecycle authority checkout is not an immutable commit." + exit 1 + fi + python lifecycle-policy/scripts/validate_production_lifecycle.py \ + --root lifecycle-policy + echo "commit=${authority_commit}" >> "${GITHUB_OUTPUT}" + + - name: Verify PyPI parity and write the admission candidate + env: + VERSION: ${{ needs.stage-candidate.outputs.version }} + POLICY_COMMIT: ${{ steps.authority.outputs.commit }} + shell: bash + run: | + set -euo pipefail + for attempt in $(seq 1 20); do + if python scripts/release_admission_candidate.py \ + --dist dist \ + --manifest evidence/SHA256SUMS \ + --version "${VERSION}" \ + --source-commit "${GITHUB_SHA}" \ + --policy lifecycle-policy/production-lifecycle-policy.json \ + --policy-commit "${POLICY_COMMIT}" \ + --output admission/openadapt-capture-release-admission-candidate.json; then + break + fi + if [ "${attempt}" -eq 20 ]; then + echo "PyPI did not expose the exact qualified archives in time." + exit 1 + fi + echo "Exact PyPI parity is not visible yet; retrying in 15 seconds." + sleep 15 + done + + - name: Attest the not-admitted candidate record + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: - github_token: ${{ secrets.ADMIN_TOKEN }} + subject-path: admission/openadapt-capture-release-admission-candidate.json - # Releases in openadapt-ml failed silently for 3 months (Mar-Jun - # 2026) while PyPI went stale; see OpenAdaptAI/OpenAdapt#999. - - name: File issue on release failure - if: failure() + - name: Retain the exact admission candidate + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: capture-release-admission-candidate-${{ needs.stage-candidate.outputs.tag }} + path: admission/openadapt-capture-release-admission-candidate.json + if-no-files-found: error + retention-days: 90 + + - name: Publish the GitHub Release and candidate record env: GH_TOKEN: ${{ secrets.ADMIN_TOKEN }} + TAG: ${{ needs.stage-candidate.outputs.tag }} + shell: bash + run: | + set -euo pipefail + if gh release view "${TAG}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then + echo "GitHub Release ${TAG} already exists; refusing to replace immutable assets." + exit 1 + fi + gh release create "${TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --verify-tag \ + --title "OpenAdapt Capture ${TAG}" \ + --generate-notes \ + dist/*.whl dist/*.tar.gz \ + evidence/SHA256SUMS \ + admission/openadapt-capture-release-admission-candidate.json + + report-release-failure: + name: Report a failed release transaction + if: >- + always() && + (needs.prepare-candidate.result == 'failure' || + needs.stage-candidate.result == 'failure' || + needs.publish-pypi.result == 'failure' || + needs.finalize-candidate.result == 'failure') + needs: [prepare-candidate, stage-candidate, publish-pypi, finalize-candidate] + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: File issue on release failure + env: + GH_TOKEN: ${{ github.token }} shell: bash run: | TITLE="Release workflow failed on main" BODY="The release workflow failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - Until this is fixed, merged fix/feat commits are NOT being published to PyPI, and users install stale versions." + The release is not a Production default. The central admission ledger remains the only Production authority." EXISTING=$(gh issue list --repo "${{ github.repository }}" --state open --search "in:title \"$TITLE\"" --json number --jq '.[0].number // empty') if [ -n "$EXISTING" ]; then gh issue comment "$EXISTING" --repo "${{ github.repository }}" --body "$BODY" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 71534c2..a2413f6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,12 +17,12 @@ jobs: python-version: ["3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: - version: "latest" + version: "0.11.29" - name: Set up Python ${{ matrix.python-version }} run: uv python install ${{ matrix.python-version }} @@ -78,12 +78,12 @@ jobs: if: github.event_name != 'pull_request' runs-on: windows-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: - version: "latest" + version: "0.11.29" - name: Set up Python run: uv python install 3.12 @@ -119,12 +119,12 @@ jobs: if: github.event_name != 'pull_request' runs-on: macos-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: - version: "latest" + version: "0.11.29" - name: Set up Python run: uv python install 3.12 @@ -142,12 +142,12 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: - version: "latest" + version: "0.11.29" - name: Set up Python run: uv python install 3.12 @@ -161,14 +161,14 @@ jobs: package-contract: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: - version: "latest" + version: "0.11.29" - name: Set up Python run: uv python install 3.12 diff --git a/README.md b/README.md index 269b374..21a289c 100644 --- a/README.md +++ b/README.md @@ -396,8 +396,9 @@ session is required. See the browser recorder remains the Flow Playwright recorder. The canonical -[production admission ledger](https://github.com/OpenAdaptAI/.github/blob/main/production-lifecycle-admissions.json) -binds each admitted claim scope to an exact release and evidence record. Package +[release policy](https://github.com/OpenAdaptAI/.github/blob/main/production-lifecycle-policy.json) +and [production admission ledger](https://github.com/OpenAdaptAI/.github/blob/main/production-lifecycle-admissions.json) +bind each admitted claim scope to an exact release and evidence record. Package metadata and repository copy do not select Production maturity. ## Optional extras diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 00745a2..ecd638f 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -8,8 +8,10 @@ structure into one local session. `openadapt-flow` consumes the session, applies the compiler and qualification contracts, and owns governed replay. Capture has no static Experimental, Beta, or Production package label. It is a -stable component with one canonical native role. A release becomes selectable -for a Production claim scope only through an active, signed entry in the +stable component with one canonical native role. A successful unit test, +runnable package, or newest PyPI version does not select a Production default. +A release becomes selectable for a Production claim scope only through an +active, signed entry in the [production admission ledger](https://github.com/OpenAdaptAI/.github/blob/main/production-lifecycle-admissions.json). An inactive release is not actively admitted. Exact-commit clean-install and interactive native qualification provide the evidence for admission. diff --git a/scripts/check_changelog.py b/scripts/check_changelog.py index c4781a0..e27de76 100644 --- a/scripts/check_changelog.py +++ b/scripts/check_changelog.py @@ -160,7 +160,12 @@ def validate_documents(changelog: str, pyproject: str) -> list[Release]: return releases -def validate_git_tags(releases: list[Release], repository: Path) -> None: +def validate_git_tags( + releases: list[Release], + repository: Path, + *, + prepared_version: str | None = None, +) -> None: """Require every stable Git tag to have exactly one changelog section.""" try: result = subprocess.run( @@ -188,7 +193,10 @@ def validate_git_tags(releases: list[Release], repository: Path) -> None: "stable Git tag(s) are absent from CHANGELOG.md: " + ", ".join(f"v{version}" for version in missing) ) - untagged = sorted(documented - tags, key=_version_key) + untagged = documented - tags + if prepared_version is not None: + untagged.discard(prepared_version) + untagged = sorted(untagged, key=_version_key) if untagged: raise ChangelogContractError( "CHANGELOG.md contains untagged stable release(s): " @@ -200,7 +208,26 @@ def check_repository(repository: Path) -> list[Release]: changelog = (repository / "CHANGELOG.md").read_text(encoding="utf-8") pyproject = (repository / "pyproject.toml").read_text(encoding="utf-8") releases = validate_documents(changelog, pyproject) - validate_git_tags(releases, repository) + prepared_version: str | None = None + try: + head = subprocess.run( + ["git", "log", "-1", "--pretty=format:%an%n%s"], + cwd=repository, + check=True, + capture_output=True, + text=True, + ).stdout.splitlines() + except (OSError, subprocess.CalledProcessError): + head = [] + author, subject = head if len(head) == 2 else ("", "") + match = re.fullmatch(r"chore: release (?P\d+\.\d+\.\d+)", subject) + if ( + author == "semantic-release" + and match is not None + and releases[0].version == match.group("version") + ): + prepared_version = match.group("version") + validate_git_tags(releases, repository, prepared_version=prepared_version) return releases diff --git a/scripts/check_prepared_release.py b/scripts/check_prepared_release.py new file mode 100644 index 0000000..14ce7f0 --- /dev/null +++ b/scripts/check_prepared_release.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Verify that one exact commit is a prepared, versioned release candidate.""" + +from __future__ import annotations + +import argparse +import re +import subprocess +from pathlib import Path + +try: + import tomllib +except ModuleNotFoundError: # Python 3.10 test matrix. + import tomli as tomllib + +try: + from scripts.check_changelog import validate_documents +except ModuleNotFoundError: # Direct ``python scripts/...`` execution. + from check_changelog import validate_documents + +HEX40 = re.compile(r"^[0-9a-f]{40}$") +STABLE_VERSION = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$") + + +class PreparedReleaseError(RuntimeError): + """The checked-out commit is not the exact prepared release candidate.""" + + +def _git(repository: Path, *args: str) -> str: + try: + result = subprocess.run( + ["git", *args], + cwd=repository, + check=True, + capture_output=True, + text=True, + ) + except (OSError, subprocess.CalledProcessError) as exc: + raise PreparedReleaseError(f"git {' '.join(args)} failed") from exc + return result.stdout.strip() + + +def validate_prepared_release( + repository: Path, + *, + expected_sha: str, + expected_version: str, +) -> dict[str, str]: + """Bind the release commit, package version, and maintained changelog.""" + + if HEX40.fullmatch(expected_sha) is None: + raise PreparedReleaseError("the expected source commit is not a lowercase SHA") + if STABLE_VERSION.fullmatch(expected_version) is None: + raise PreparedReleaseError("the expected release version is not stable SemVer") + + actual_sha = _git(repository, "rev-parse", "HEAD") + if actual_sha != expected_sha: + raise PreparedReleaseError( + f"the checkout is {actual_sha}, not prepared source commit {expected_sha}" + ) + if _git(repository, "status", "--porcelain"): + raise PreparedReleaseError("the prepared release checkout is not clean") + + subject = _git(repository, "log", "-1", "--pretty=format:%s") + expected_subject = f"chore: release {expected_version}" + if subject != expected_subject: + raise PreparedReleaseError( + f"the prepared release subject is {subject!r}, not {expected_subject!r}" + ) + author = _git(repository, "log", "-1", "--pretty=format:%an") + if author != "semantic-release": + raise PreparedReleaseError( + f"the prepared release author is {author!r}, not 'semantic-release'" + ) + + try: + project = tomllib.loads((repository / "pyproject.toml").read_text(encoding="utf-8"))[ + "project" + ] + except (OSError, KeyError, TypeError, tomllib.TOMLDecodeError) as exc: + raise PreparedReleaseError("pyproject.toml has no valid project metadata") from exc + if project.get("name") != "openadapt-capture": + raise PreparedReleaseError("the prepared package name is not openadapt-capture") + if project.get("version") != expected_version: + raise PreparedReleaseError("the prepared package version differs from the request") + + try: + releases = validate_documents( + (repository / "CHANGELOG.md").read_text(encoding="utf-8"), + (repository / "pyproject.toml").read_text(encoding="utf-8"), + ) + except (OSError, ValueError) as exc: + raise PreparedReleaseError("the maintained changelog is invalid") from exc + if releases[0].version != expected_version: + raise PreparedReleaseError("the newest changelog release differs from the request") + + return { + "source_commit": actual_sha, + "version": expected_version, + "tag": f"v{expected_version}", + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--repository", type=Path, default=Path.cwd()) + parser.add_argument("--expected-sha", required=True) + parser.add_argument("--expected-version", required=True) + args = parser.parse_args() + result = validate_prepared_release( + args.repository.resolve(), + expected_sha=args.expected_sha, + expected_version=args.expected_version, + ) + print(f"verified prepared release {result['tag']} at {result['source_commit']}") + + +if __name__ == "__main__": + main() diff --git a/scripts/check_release_ci.py b/scripts/check_release_ci.py index 2338bdd..80555cf 100644 --- a/scripts/check_release_ci.py +++ b/scripts/check_release_ci.py @@ -11,6 +11,7 @@ import urllib.parse import urllib.request from dataclasses import dataclass +from pathlib import Path from typing import Any, Callable EXPECTED_QUALIFICATION_JOBS = frozenset( @@ -22,6 +23,8 @@ "Interactive qualification (Linux X64)", "Interactive qualification (macOS ARM64)", "Interactive qualification (Windows X64)", + "Candidate control contract (macos-latest)", + "Candidate control contract (windows-latest)", } ) ACTIVE_STATES = frozenset({"queued", "in_progress", "waiting", "pending", "requested"}) @@ -175,6 +178,7 @@ def main() -> None: parser.add_argument("--sha", required=True) parser.add_argument("--timeout-seconds", type=int, default=2700) parser.add_argument("--interval-seconds", type=int, default=10) + parser.add_argument("--output", type=Path) args = parser.parse_args() if len(args.sha) != 40 or any(char not in "0123456789abcdef" for char in args.sha): raise SystemExit("--sha must be a lowercase 40-character Git commit SHA") @@ -188,6 +192,12 @@ def main() -> None: timeout_seconds=args.timeout_seconds, interval_seconds=args.interval_seconds, ) + if args.output is not None: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(evidence, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) print(f"exact-commit release evidence is complete: {json.dumps(evidence, sort_keys=True)}") diff --git a/scripts/release_admission_candidate.py b/scripts/release_admission_candidate.py new file mode 100644 index 0000000..e47a72a --- /dev/null +++ b/scripts/release_admission_candidate.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""Verify PyPI parity and emit a non-admitted release-candidate record. + +PyPI is an artifact authority. It is not the OpenAdapt Production selector. +Only an active record in the canonical OpenAdaptAI/.github admission ledger can +select a Production release. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import urllib.error +import urllib.parse +import urllib.request +from collections.abc import Callable, Mapping +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +try: + from scripts.candidate_lifecycle import verify_manifest +except ModuleNotFoundError: # Direct ``python scripts/...`` execution. + from candidate_lifecycle import verify_manifest + +SCHEMA_VERSION = "openadapt.production-release-admission-candidate/v1" +POLICY_SCHEMA = "openadapt.production-lifecycle-policy/v1" +AUTHORITY_REPOSITORY = "OpenAdaptAI/.github" +POLICY_PATH = "production-lifecycle-policy.json" +ADMISSIONS_PATH = "production-lifecycle-admissions.json" +TARGET_ID = "capture" +SOURCE_REPOSITORY = "OpenAdaptAI/openadapt-capture" +PROJECT = "openadapt-capture" +HEX40 = re.compile(r"^[0-9a-f]{40}$") +STABLE_VERSION = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$") + + +class AdmissionCandidateError(RuntimeError): + """The published release cannot become an admission candidate.""" + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _fetch_json(url: str) -> dict[str, Any]: + request = urllib.request.Request( + url, + headers={"User-Agent": "openadapt-capture-release-parity/1"}, + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + body = response.read(4 * 1024 * 1024) + value = json.loads(body) + except (OSError, urllib.error.URLError, ValueError, TypeError) as exc: + raise AdmissionCandidateError(f"PyPI parity request failed: {exc}") from exc + if not isinstance(value, dict): + raise AdmissionCandidateError("PyPI returned a non-object response") + return value + + +def _capture_target(policy: Mapping[str, Any]) -> dict[str, Any]: + if policy.get("schema_version") != POLICY_SCHEMA: + raise AdmissionCandidateError("the lifecycle policy schema is not supported") + revision = policy.get("revision") + if not isinstance(revision, int) or isinstance(revision, bool) or revision < 1: + raise AdmissionCandidateError("the lifecycle policy revision is invalid") + targets = policy.get("targets") + if not isinstance(targets, list): + raise AdmissionCandidateError("the lifecycle policy target list is invalid") + matches = [item for item in targets if isinstance(item, dict) and item.get("id") == TARGET_ID] + if len(matches) != 1: + raise AdmissionCandidateError("the lifecycle policy must contain one Capture target") + target = matches[0] + expected = { + "source_repository": SOURCE_REPOSITORY, + "release_kind": "public_package", + "required_claim_scope": "qualified_native_recorder_release", + "required_artifact_kinds": ["sdist", "wheel"], + "package_index_project": PROJECT, + "artifact_authority_by_kind": {"sdist": "pypi", "wheel": "pypi"}, + } + for key, value in expected.items(): + if target.get(key) != value: + raise AdmissionCandidateError(f"the Capture lifecycle policy {key} differs") + return target + + +def _artifact_kind(name: str) -> str: + if name.endswith(".whl"): + return "wheel" + if name.endswith(".tar.gz"): + return "sdist" + raise AdmissionCandidateError(f"unsupported release archive: {name}") + + +def verify_registry_parity( + *, + dist_dir: Path, + manifest_path: Path, + version: str, + get_json: Callable[[str], dict[str, Any]] = _fetch_json, +) -> tuple[str, list[dict[str, Any]]]: + """Require exact local/PyPI parity for one explicit version endpoint.""" + + if STABLE_VERSION.fullmatch(version) is None: + raise AdmissionCandidateError("the candidate version is not stable SemVer") + hashes = verify_manifest(dist_dir, manifest_path) + endpoint = ( + "https://pypi.org/pypi/" + f"{urllib.parse.quote(PROJECT, safe='')}/{urllib.parse.quote(version, safe='')}/json" + ) + metadata = get_json(endpoint) + info = metadata.get("info") + if not isinstance(info, dict) or info.get("name") != PROJECT or info.get("version") != version: + raise AdmissionCandidateError("PyPI project or version metadata differs") + files = metadata.get("urls") + if not isinstance(files, list): + raise AdmissionCandidateError("PyPI release files are invalid") + + expected_names = set(hashes) + published_names = {str(item.get("filename")) for item in files if isinstance(item, dict)} + if published_names != expected_names: + raise AdmissionCandidateError( + "PyPI and the exact candidate archive set differ: " + f"candidate={sorted(expected_names)}, pypi={sorted(published_names)}" + ) + + artifacts: list[dict[str, Any]] = [] + for name in sorted(expected_names): + local_path = dist_dir / name + matches = [ + item + for item in files + if isinstance(item, dict) + and item.get("filename") == name + and item.get("digests", {}).get("sha256") == hashes[name] + and item.get("size") == local_path.stat().st_size + and item.get("yanked") is False + ] + if len(matches) != 1: + raise AdmissionCandidateError(f"PyPI does not verify exact archive {name}") + item = matches[0] + url = item.get("url") + if not isinstance(url, str) or not url.startswith("https://"): + raise AdmissionCandidateError(f"PyPI archive URL is invalid for {name}") + kind = _artifact_kind(name) + expected_package_type = "bdist_wheel" if kind == "wheel" else "sdist" + if item.get("packagetype") != expected_package_type: + raise AdmissionCandidateError(f"PyPI archive type differs for {name}") + if _sha256(local_path) != hashes[name]: + raise AdmissionCandidateError( + f"the local archive changed after manifest verification: {name}" + ) + artifacts.append( + { + "name": name, + "kind": kind, + "authority": "pypi", + "url": url, + "sha256": f"sha256:{hashes[name]}", + "size_bytes": local_path.stat().st_size, + } + ) + return endpoint, artifacts + + +def build_admission_candidate( + *, + policy_path: Path, + policy_commit: str, + source_commit: str, + version: str, + endpoint: str, + artifacts: list[dict[str, Any]], + verified_at: datetime | None = None, +) -> dict[str, Any]: + """Build a record that cannot select or claim a Production default.""" + + if HEX40.fullmatch(policy_commit) is None or HEX40.fullmatch(source_commit) is None: + raise AdmissionCandidateError("policy and source commits must be lowercase SHAs") + if STABLE_VERSION.fullmatch(version) is None: + raise AdmissionCandidateError("the candidate version is not stable SemVer") + expected_endpoint = f"https://pypi.org/pypi/{PROJECT}/{version}/json" + if endpoint != expected_endpoint: + raise AdmissionCandidateError("the registry parity endpoint is not the exact version") + kinds = [item.get("kind") for item in artifacts if isinstance(item, dict)] + if set(kinds) != {"sdist", "wheel"} or len(kinds) != 2 or len(kinds) != len(artifacts): + raise AdmissionCandidateError("the admission candidate requires one wheel and one sdist") + if any(item.get("authority") != "pypi" for item in artifacts): + raise AdmissionCandidateError("the admission candidate artifact authority differs") + try: + policy_bytes = policy_path.read_bytes() + policy = json.loads(policy_bytes) + except (OSError, json.JSONDecodeError) as exc: + raise AdmissionCandidateError("the lifecycle policy is not valid JSON") from exc + if not isinstance(policy, dict): + raise AdmissionCandidateError("the lifecycle policy must be a JSON object") + target = _capture_target(policy) + now = (verified_at or datetime.now(timezone.utc)).astimezone(timezone.utc) + timestamp = now.replace(microsecond=0).isoformat().replace("+00:00", "Z") + return { + "schema_version": SCHEMA_VERSION, + "target": TARGET_ID, + "claim_scope": target["required_claim_scope"], + "release_status": "not_admitted", + "production_default": None, + "production_authority": { + "repository": AUTHORITY_REPOSITORY, + "source_commit": policy_commit, + "policy_path": POLICY_PATH, + "policy_revision": policy["revision"], + "policy_sha256": f"sha256:{hashlib.sha256(policy_bytes).hexdigest()}", + "admissions_path": ADMISSIONS_PATH, + "activation_required": True, + "pypi_latest_is_authority": False, + }, + "release": { + "kind": "public_package", + "version": version, + "tag": f"v{version}", + "source_commit": source_commit, + "immutable_release_url": ( + f"https://github.com/{SOURCE_REPOSITORY}/commit/{source_commit}" + ), + "artifacts": artifacts, + }, + "registry_parity": { + "project": PROJECT, + "version_endpoint": endpoint, + "verified_at": timestamp, + "exact_archive_set": True, + }, + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--dist", type=Path, required=True) + parser.add_argument("--manifest", type=Path, required=True) + parser.add_argument("--version", required=True) + parser.add_argument("--source-commit", required=True) + parser.add_argument("--policy", type=Path, required=True) + parser.add_argument("--policy-commit", required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + + endpoint, artifacts = verify_registry_parity( + dist_dir=args.dist, + manifest_path=args.manifest, + version=args.version, + ) + candidate = build_admission_candidate( + policy_path=args.policy, + policy_commit=args.policy_commit, + source_commit=args.source_commit, + version=args.version, + endpoint=endpoint, + artifacts=artifacts, + ) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(candidate, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print( + f"verified PyPI parity for {PROJECT} {args.version}; wrote a not-admitted release candidate" + ) + + +if __name__ == "__main__": + main() diff --git a/tests/test_changelog_contract.py b/tests/test_changelog_contract.py index b41f4f8..fd8b932 100644 --- a/tests/test_changelog_contract.py +++ b/tests/test_changelog_contract.py @@ -126,6 +126,32 @@ def test_git_tag_contract_refuses_an_incomplete_tag_inventory(monkeypatch) -> No validate_git_tags(releases, REPOSITORY) +def test_git_tag_contract_allows_only_the_prepared_head_to_be_untagged( + monkeypatch, +) -> None: + changelog, pyproject = _documents() + releases = validate_documents(changelog, pyproject) + prepared_version = releases[0].version + prior_tags = "\n".join(f"v{release.version}" for release in releases[1:]) + monkeypatch.setattr( + "scripts.check_changelog.subprocess.run", + lambda *args, **kwargs: SimpleNamespace(stdout=prior_tags), + ) + + validate_git_tags( + releases, + REPOSITORY, + prepared_version=prepared_version, + ) + + with pytest.raises(ChangelogContractError, match="untagged stable release"): + validate_git_tags( + releases, + REPOSITORY, + prepared_version=releases[1].version, + ) + + def test_source_distribution_refuses_a_version_without_release_notes(tmp_path: Path) -> None: changelog, pyproject = _documents() pyproject, next_version = _project_with_next_patch(changelog, pyproject) diff --git a/tests/test_release_qualification_gates.py b/tests/test_release_qualification_gates.py index 2a6b190..0a883b7 100644 --- a/tests/test_release_qualification_gates.py +++ b/tests/test_release_qualification_gates.py @@ -3,6 +3,9 @@ from __future__ import annotations import hashlib +import json +import subprocess +from datetime import datetime, timezone from pathlib import Path import pytest @@ -10,12 +13,21 @@ from scripts.candidate_lifecycle import CandidateLifecycleError, verify_manifest from scripts.check_display_topology import DisplayTopologyError, qualify_topology from scripts.check_junit_no_skips import JUnitQualificationError, check_reports +from scripts.check_prepared_release import ( + PreparedReleaseError, + validate_prepared_release, +) from scripts.check_release_ci import ( EXPECTED_QUALIFICATION_JOBS, ReleaseEvidenceError, check_once, validate_qualification_jobs, ) +from scripts.release_admission_candidate import ( + AdmissionCandidateError, + build_admission_candidate, + verify_registry_parity, +) def _write_manifest(dist: Path, archives: list[Path]) -> Path: @@ -51,6 +63,159 @@ def test_candidate_manifest_rejects_unaccounted_archive(tmp_path: Path) -> None: verify_manifest(tmp_path, manifest) +def _policy(path: Path) -> Path: + policy = { + "schema_version": "openadapt.production-lifecycle-policy/v1", + "revision": 1, + "targets": [ + { + "id": "capture", + "source_repository": "OpenAdaptAI/openadapt-capture", + "release_kind": "public_package", + "required_claim_scope": "qualified_native_recorder_release", + "required_artifact_kinds": ["sdist", "wheel"], + "package_index_project": "openadapt-capture", + "artifact_authority_by_kind": {"sdist": "pypi", "wheel": "pypi"}, + } + ], + } + path.write_text(json.dumps(policy), encoding="utf-8") + return path + + +def test_registry_parity_emits_only_an_explicit_not_admitted_candidate( + tmp_path: Path, +) -> None: + version = "1.3.0" + wheel = tmp_path / f"openadapt_capture-{version}-py3-none-any.whl" + sdist = tmp_path / f"openadapt_capture-{version}.tar.gz" + wheel.write_bytes(b"wheel") + sdist.write_bytes(b"sdist") + manifest = _write_manifest(tmp_path, [wheel, sdist]) + + def get_json(url: str) -> dict: + assert url == f"https://pypi.org/pypi/openadapt-capture/{version}/json" + return { + "info": {"name": "openadapt-capture", "version": version}, + # A newer index version is deliberately irrelevant. PyPI latest is + # not a Production selector. + "releases": {"99.0.0": []}, + "urls": [ + { + "filename": path.name, + "url": f"https://files.pythonhosted.org/{path.name}", + "size": path.stat().st_size, + "digests": {"sha256": hashlib.sha256(path.read_bytes()).hexdigest()}, + "yanked": False, + "packagetype": "bdist_wheel" if path.suffix == ".whl" else "sdist", + } + for path in (wheel, sdist) + ], + } + + endpoint, artifacts = verify_registry_parity( + dist_dir=tmp_path, + manifest_path=manifest, + version=version, + get_json=get_json, + ) + candidate = build_admission_candidate( + policy_path=_policy(tmp_path / "policy.json"), + policy_commit="a" * 40, + source_commit="b" * 40, + version=version, + endpoint=endpoint, + artifacts=artifacts, + verified_at=datetime(2026, 8, 20, tzinfo=timezone.utc), + ) + + assert candidate["release_status"] == "not_admitted" + assert candidate["production_default"] is None + assert candidate["production_authority"]["activation_required"] is True + assert candidate["production_authority"]["pypi_latest_is_authority"] is False + assert candidate["release"]["version"] == version + assert {item["kind"] for item in candidate["release"]["artifacts"]} == { + "sdist", + "wheel", + } + + +def test_registry_parity_rejects_changed_published_bytes(tmp_path: Path) -> None: + version = "1.3.0" + wheel = tmp_path / f"openadapt_capture-{version}-py3-none-any.whl" + sdist = tmp_path / f"openadapt_capture-{version}.tar.gz" + wheel.write_bytes(b"wheel") + sdist.write_bytes(b"sdist") + manifest = _write_manifest(tmp_path, [wheel, sdist]) + + def get_json(_url: str) -> dict: + return { + "info": {"name": "openadapt-capture", "version": version}, + "urls": [ + { + "filename": path.name, + "url": f"https://files.pythonhosted.org/{path.name}", + "size": path.stat().st_size, + "digests": {"sha256": "0" * 64}, + "yanked": False, + "packagetype": "bdist_wheel" if path.suffix == ".whl" else "sdist", + } + for path in (wheel, sdist) + ], + } + + with pytest.raises(AdmissionCandidateError, match="does not verify exact archive"): + verify_registry_parity( + dist_dir=tmp_path, + manifest_path=manifest, + version=version, + get_json=get_json, + ) + + +def test_prepared_release_binds_commit_version_and_changelog(tmp_path: Path) -> None: + repository = tmp_path / "repository" + repository.mkdir() + for name in ("pyproject.toml", "CHANGELOG.md"): + (repository / name).write_bytes((Path(__file__).parents[1] / name).read_bytes()) + subprocess.run(["git", "init", "-q"], cwd=repository, check=True) + subprocess.run(["git", "config", "user.name", "semantic-release"], cwd=repository, check=True) + subprocess.run( + ["git", "config", "user.email", "bot@openadapt.ai"], + cwd=repository, + check=True, + ) + subprocess.run(["git", "add", "pyproject.toml", "CHANGELOG.md"], cwd=repository, check=True) + subprocess.run( + ["git", "commit", "-q", "-m", "chore: release 1.2.2"], + cwd=repository, + check=True, + ) + sha = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=repository, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + assert ( + validate_prepared_release( + repository, + expected_sha=sha, + expected_version="1.2.2", + )["tag"] + == "v1.2.2" + ) + + with pytest.raises(PreparedReleaseError, match="subject"): + validate_prepared_release( + repository, + expected_sha=sha, + expected_version="1.2.3", + ) + + def test_display_topology_requires_stable_multiple_monitors() -> None: snapshot = { "coordinate_space": "virtual_desktop_pixels", diff --git a/tests/test_release_workflow_contract.py b/tests/test_release_workflow_contract.py new file mode 100644 index 0000000..c0104ab --- /dev/null +++ b/tests/test_release_workflow_contract.py @@ -0,0 +1,71 @@ +"""Static security contracts for the exact-artifact release transaction.""" + +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOWS = ROOT / ".github" / "workflows" +FULL_ACTION_SHA = re.compile(r"(?:-\s+)?uses:\s+[^\s@]+@[0-9a-f]{40}(?:\s+#.*)?$") + + +def _workflow(name: str) -> str: + return (WORKFLOWS / name).read_text(encoding="utf-8") + + +def test_every_release_bound_action_uses_an_immutable_commit() -> None: + for name in ( + "control-contract.yml", + "production-qualification.yml", + "release.yml", + "test.yml", + ): + action_lines = [line.strip() for line in _workflow(name).splitlines() if "uses:" in line] + assert action_lines, name + assert all(FULL_ACTION_SHA.fullmatch(line) for line in action_lines), (name, action_lines) + + +def test_release_transaction_never_rebuilds_the_qualified_archives() -> None: + workflow = _workflow("release.yml") + prepare = workflow.index("Prepare the versioned release commit") + evidence = workflow.index("Wait for exact prepared-commit qualification") + download = workflow.index("Download the exact qualified archives") + publish = workflow.index("Publish the exact qualified archives to PyPI") + parity = workflow.index("Verify PyPI parity and write the admission candidate") + assert prepare < evidence < download < publish < parity + assert "uv build" not in workflow + assert "release_status" not in workflow + + +def test_pypi_token_isolated_from_manifest_and_candidate_finalization() -> None: + workflow = _workflow("release.yml") + publish_job = workflow[workflow.index(" publish-pypi:") : workflow.index(" finalize-candidate:")] + assert "id-token: write" in publish_job + assert "contents: write" not in publish_job + assert "issues: write" not in publish_job + assert "packages-dir: release-bundle/dist/" in publish_job + assert "SHA256SUMS" not in publish_job + assert "scripts/" not in publish_job + assert "mv dist/SHA256SUMS evidence/SHA256SUMS" in workflow + + +def test_qualification_builds_once_after_version_preparation() -> None: + workflow = _workflow("production-qualification.yml") + prepared = workflow.index("Require a prepared versioned release commit") + build = workflow.index("Build the candidate once") + assert prepared < build + assert workflow.count("uv build --wheel --sdist") == 1 + assert "Candidate control contract (${{ matrix.os }})" in workflow + assert '"${wheel[0]}[linux]"' in workflow + assert "gi.require_version('Atspi', '2.0')" in workflow + assert workflow.count('"${GITHUB_WORKSPACE}/tests/test_structural_observation.py"') == 2 + assert workflow.count('"${GITHUB_WORKSPACE}/tests/test_window_capture.py"') == 2 + assert '"$env:GITHUB_WORKSPACE/tests/test_structural_observation.py"' in workflow + + +def test_pypi_latest_never_selects_a_production_default() -> None: + source = (ROOT / "scripts" / "release_admission_candidate.py").read_text(encoding="utf-8") + assert 'pypi_latest_is_authority": False' in source + assert '"production_default": None' in source + assert "/{urllib.parse.quote(version, safe='')}/json" in source