From 348a676bfd933cc7f49f53e7fe615c72318b06b6 Mon Sep 17 00:00:00 2001 From: Yernat Yestekov <2068106+doublewhy@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:34:41 -0700 Subject: [PATCH 1/4] fix(release): gate publication on verified release SHA (#1125, #1110) --- .github/workflows/canonical-verification.yml | 122 ++++ .github/workflows/ci.yml | 80 +-- .github/workflows/release-please.yml | 576 +++++++++++++++++- ...ue-1110-required-container-release-gate.md | 68 +++ ...xact-sha-release-verification-preflight.md | 216 +++++++ docs/explain/releasing.md | 75 ++- docs/requirements/GOV-928/requirement.md | 16 +- docs/requirements/RUN-314/requirement.md | 5 + .../test_reference_backend_docker_gate.py | 88 +++ ...st_reference_backend_docker_integration.py | 45 +- .../python/tests/test_release_workflows.py | 391 ++++++++++++ noxfile.py | 8 +- release-please-config.json | 2 + 13 files changed, 1586 insertions(+), 106 deletions(-) create mode 100644 .github/workflows/canonical-verification.yml create mode 100644 docs/decisions/issue-1110-required-container-release-gate.md create mode 100644 docs/decisions/issue-1125-gov-928-exact-sha-release-verification-preflight.md create mode 100644 implementations/python/tests/test_reference_backend_docker_gate.py create mode 100644 implementations/python/tests/test_release_workflows.py diff --git a/.github/workflows/canonical-verification.yml b/.github/workflows/canonical-verification.yml new file mode 100644 index 000000000..e1a13c626 --- /dev/null +++ b/.github/workflows/canonical-verification.yml @@ -0,0 +1,122 @@ +name: Canonical Verification + +# One exact-SHA admission graph shared by protected-branch CI and release +# publication. It preserves the existing proof-bearing nox verification lane; +# optional and networked jobs remain separate in the calling CI workflow. + +on: + workflow_call: + inputs: + ref: + description: Exact 40-character commit SHA to verify + required: true + type: string + base-rev: + description: Exact comparison base SHA (falls back to the target parent) + required: false + default: "" + type: string + requirement-branch: + description: Branch name used to resolve an optional requirement UID + required: false + default: "" + type: string + +permissions: + contents: read + +jobs: + verify: + # Ubuntu 24.04 restricts unprivileged user namespaces through AppArmor. + # Keep the proof-bearing job on 22.04 so Bubblewrap enforces the sandbox + # without disabling a host security control on the runner. + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + ref: ${{ inputs.ref }} + + - name: Bind verification to the exact commit and resolve policy base + id: commit + env: + EXPECTED_SHA: ${{ inputs.ref }} + REQUESTED_BASE_SHA: ${{ inputs.base-rev }} + run: | + set -euo pipefail + if ! printf '%s\n' "${EXPECTED_SHA}" | grep -Eq '^[0-9a-f]{40}$'; then + echo "Canonical verification requires a full lowercase commit SHA, got '${EXPECTED_SHA}'" >&2 + exit 1 + fi + + actual_sha="$(git rev-parse HEAD)" + if [ "${actual_sha}" != "${EXPECTED_SHA}" ]; then + echo "Checkout mismatch: expected ${EXPECTED_SHA}, got ${actual_sha}" >&2 + exit 1 + fi + + base_sha="${REQUESTED_BASE_SHA}" + if ! printf '%s\n' "${base_sha}" | grep -Eq '^[0-9a-f]{40}$' \ + || [ "${base_sha}" = "0000000000000000000000000000000000000000" ] \ + || [ "${base_sha}" = "${EXPECTED_SHA}" ] \ + || ! git cat-file -e "${base_sha}^{commit}" 2>/dev/null; then + if ! base_sha="$(git rev-parse "${EXPECTED_SHA}^")"; then + echo "Cannot resolve a policy base for root commit ${EXPECTED_SHA}" >&2 + exit 1 + fi + fi + + echo "Verified exact commit ${EXPECTED_SHA}; policy base ${base_sha}" + echo "base_rev=${base_sha}" >> "${GITHUB_OUTPUT}" + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v8 + + - name: Restore pinned Isabelle archive + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: .cache/raes-sdl/tooling/archives/Isabelle2025-2_linux.tar.gz + key: isabelle-linux-x86-64-2025-2-a20a507bc7c1270d + + - name: Install proof sandbox + run: | + if ! command -v bwrap >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install --no-install-recommends -y bubblewrap + fi + + - name: Acquire pinned Isabelle distribution + run: uv run --project implementations/python --frozen python -m tools.isabelle_tool acquire + + - name: Resolve requirement UID from branch + id: requirement + env: + BRANCH: ${{ inputs.requirement-branch }} + run: | + REQ_UID="$(printf '%s\n' "${BRANCH}" | grep -oE '[A-Z]{3}-[0-9]{3}' | head -n1 || true)" + echo "Resolved branch='${BRANCH}' uid='${REQ_UID}'" + echo "uid=${REQ_UID}" >> "${GITHUB_OUTPUT}" + + - name: Run canonical verification graph + env: + RAES_REQUIREMENT_UID: ${{ steps.requirement.outputs.uid }} + GC_BASE_URL: ${{ vars.GC_BASE_URL }} + run: | + verify_args=(--base-rev "${{ steps.commit.outputs.base_rev }}") + if [ -n "${{ steps.requirement.outputs.uid }}" ]; then + verify_args+=(--requirement-uid "${{ steps.requirement.outputs.uid }}") + else + verify_args+=(--skip-requirement) + fi + uv tool run --from 'nox[uv]==2026.4.10' nox -f noxfile.py -s verify -- "${verify_args[@]}" + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-report + path: implementations/python/coverage.xml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01bc2d96c..cd6e2020b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,67 +17,35 @@ permissions: pull-requests: write jobs: + canonical: + permissions: + contents: read + uses: ./.github/workflows/canonical-verification.yml + with: + ref: ${{ github.sha }} + base-rev: ${{ github.event.pull_request.base.sha || github.event.before || github.sha }} + requirement-branch: ${{ github.head_ref || github.ref_name }} + + # The repository's dev/main branch protections require the historical + # `verify` check context. Reusable jobs report nested contexts, so preserve + # that stable contract with a same-run result join. The reusable call owns the + # existing proof-bearing nox graph; this join cannot turn any failed or + # skipped admission job into success. verify: - # Ubuntu 24.04 restricts unprivileged user namespaces through AppArmor. - # Keep the proof-bearing job on 22.04 so Bubblewrap enforces the sandbox - # without disabling a host security control on the runner. - runs-on: ubuntu-22.04 + needs: canonical + if: always() + runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 0 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.12" - - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v8 - - name: Restore pinned Isabelle archive - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: .cache/raes-sdl/tooling/archives/Isabelle2025-2_linux.tar.gz - key: isabelle-linux-x86-64-2025-2-a20a507bc7c1270d - - name: Install proof sandbox - run: | - if ! command -v bwrap >/dev/null 2>&1; then - sudo apt-get update - sudo apt-get install --no-install-recommends -y bubblewrap - fi - - name: Acquire pinned Isabelle distribution - run: uv run --project implementations/python --frozen python -m tools.isabelle_tool acquire - - name: Resolve policy base revision - id: base - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - echo "base_rev=${{ github.event.pull_request.base.sha }}" >> "$GITHUB_OUTPUT" - else - echo "base_rev=${{ github.event.before }}" >> "$GITHUB_OUTPUT" - fi - - name: Resolve requirement UID from branch - id: requirement - env: - BRANCH: ${{ github.head_ref || github.ref_name }} - run: | - REQ_UID="$(printf '%s\n' "$BRANCH" | grep -oE '[A-Z]{3}-[0-9]{3}' | head -n1 || true)" - echo "Resolved branch='$BRANCH' uid='$REQ_UID'" - echo "uid=$REQ_UID" >> "$GITHUB_OUTPUT" - - name: Run canonical verification graph + - name: Preserve the required canonical verification status env: - RAES_REQUIREMENT_UID: ${{ steps.requirement.outputs.uid }} - GC_BASE_URL: ${{ vars.GC_BASE_URL }} + CANONICAL_RESULT: ${{ needs.canonical.result }} run: | - verify_args=(--base-rev "${{ steps.base.outputs.base_rev }}") - if [ -n "${{ steps.requirement.outputs.uid }}" ]; then - verify_args+=(--requirement-uid "${{ steps.requirement.outputs.uid }}") - else - verify_args+=(--skip-requirement) + if [ "${CANONICAL_RESULT}" != "success" ]; then + echo "Canonical verification concluded with '${CANONICAL_RESULT}'" >&2 + exit 1 fi - uv tool run --from 'nox[uv]==2026.4.10' nox -f noxfile.py -s verify -- "${verify_args[@]}" - - name: Upload coverage report - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: coverage-report - path: implementations/python/coverage.xml fuzz: runs-on: ubuntu-latest diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index e0a407fc6..673c90402 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -4,16 +4,19 @@ name: Release Please # it maintains a release PR ("chore(main): release X.Y.Z") that bumps the version # (in implementations/python/packages/raes/_version.py via extra-files) and updates the # repo-root CHANGELOG.md from the Conventional Commits since the last release. -# Merging that PR tags `vX.Y.Z` and cuts the GitHub Release; the publish job then -# builds the corpus-bundled wheel/sdist (#537) and publishes to PyPI over OIDC -# trusted publishing. +# Merging that PR tags `vX.Y.Z` and creates a draft GitHub Release. The release +# commit is then resolved once to an exact SHA, passed through the same canonical +# verifier as CI, and only then built and published to PyPI over OIDC trusted +# publishing. Tested artifacts are attached before the draft is made public. +# The artifact still carries the corpus-bundling guarantee (#537). # # Feature PRs never touch CHANGELOG.md (release-please owns it) — no fragment # collisions. The version literal is in the dedicated RAES package version file; # `raes.__version__` derives from the installed distribution metadata. # # Caveat: the release PR is opened by GITHUB_TOKEN, so required CI checks do not -# auto-run on it — admin-merge it, or give release-please a PAT so checks run. +# auto-run on it. Exact-SHA verification below is therefore the non-bypassable +# publication gate even when a maintainer admin-merges that PR. # First release + PyPI setup: docs/explain/releasing.md. on: push: @@ -42,6 +45,7 @@ jobs: outputs: release_created: ${{ steps.rp.outputs.release_created }} tag_name: ${{ steps.rp.outputs.tag_name }} + sha: ${{ steps.rp.outputs.sha }} steps: - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 id: rp @@ -50,34 +54,209 @@ jobs: config-file: release-please-config.json manifest-file: .release-please-manifest.json - publish: + resolve-release: needs: release-please if: >- always() - && (needs.release-please.outputs.release_created == 'true' + && ((github.event_name == 'push' + && needs.release-please.result == 'success' + && needs.release-please.outputs.release_created == 'true') || github.event_name == 'workflow_dispatch') runs-on: ubuntu-latest - environment: pypi permissions: - contents: write # upload the built distributions to the Release - id-token: write # OIDC trusted publishing to PyPI (no stored token) + contents: read + outputs: + release_sha: ${{ steps.resolve.outputs.release_sha }} + base_sha: ${{ steps.resolve.outputs.base_sha }} + tag: ${{ steps.resolve.outputs.tag }} + release_id: ${{ steps.resolve.outputs.release_id }} + release_is_draft: ${{ steps.resolve.outputs.release_is_draft }} steps: - - name: Validate manual release tag - if: github.event_name == 'workflow_dispatch' + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + + - name: Resolve and bind the immutable release commit + id: resolve env: + EVENT_NAME: ${{ github.event_name }} GH_TOKEN: ${{ github.token }} - TAG: ${{ inputs.tag }} + INPUT_TAG: ${{ inputs.tag }} + RELEASE_PLEASE_TAG: ${{ needs.release-please.outputs.tag_name }} + RELEASE_PLEASE_SHA: ${{ needs.release-please.outputs.sha }} + run: | + set -euo pipefail + if [ "${EVENT_NAME}" = "workflow_dispatch" ]; then + tag="${INPUT_TAG}" + expected_sha="" + else + tag="${RELEASE_PLEASE_TAG}" + expected_sha="${RELEASE_PLEASE_SHA}" + fi + + if [[ ! "${tag}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "Expected a stable SemVer release tag such as v1.0.0, got '${tag}'" >&2 + exit 1 + fi + + release_json="$( + gh release view "${tag}" --repo "${GITHUB_REPOSITORY}" \ + --json databaseId,isDraft,tagName + )" + release_id="$(jq -r '.databaseId' <<<"${release_json}")" + release_is_draft="$(jq -r '.isDraft' <<<"${release_json}")" + release_tag="$(jq -r '.tagName' <<<"${release_json}")" + if ! printf '%s\n' "${release_id}" | grep -Eq '^[1-9][0-9]*$'; then + echo "Release '${tag}' has malformed database id '${release_id}'" >&2 + exit 1 + fi + if [ "${release_tag}" != "${tag}" ]; then + echo "Release lookup mismatch: expected tag ${tag}, got ${release_tag}" >&2 + exit 1 + fi + if [ "${release_is_draft}" != "true" ] && [ "${release_is_draft}" != "false" ]; then + echo "Release '${tag}' has malformed draft state '${release_is_draft}'" >&2 + exit 1 + fi + if [ "${EVENT_NAME}" != "workflow_dispatch" ] && [ "${release_is_draft}" != "true" ]; then + echo "Release Please must create '${tag}' as a draft before verification" >&2 + exit 1 + fi + + git fetch --force --tags origin + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + + if ! tag_sha="$(git rev-parse --verify "${tag}^{commit}")"; then + echo "Release tag '${tag}' does not resolve to a commit" >&2 + exit 1 + fi + if ! printf '%s\n' "${tag_sha}" | grep -Eq '^[0-9a-f]{40}$'; then + echo "Release tag '${tag}' resolved to malformed SHA '${tag_sha}'" >&2 + exit 1 + fi + + if [ -n "${expected_sha}" ]; then + if ! printf '%s\n' "${expected_sha}" | grep -Eq '^[0-9a-f]{40}$'; then + echo "Release Please emitted malformed release SHA '${expected_sha}'" >&2 + exit 1 + fi + if [ "${tag_sha}" != "${expected_sha}" ]; then + echo "Release Please SHA/tag mismatch: ${expected_sha} != ${tag_sha}" >&2 + exit 1 + fi + fi + + if ! git merge-base --is-ancestor "${tag_sha}" origin/main; then + echo "Release commit ${tag_sha} is not reachable from origin/main" >&2 + exit 1 + fi + if ! base_sha="$(git rev-parse "${tag_sha}^")"; then + echo "Release commit ${tag_sha} has no policy base" >&2 + exit 1 + fi + + echo "Resolved ${tag} to immutable release commit ${tag_sha}" + echo "release_sha=${tag_sha}" >> "${GITHUB_OUTPUT}" + echo "base_sha=${base_sha}" >> "${GITHUB_OUTPUT}" + echo "tag=${tag}" >> "${GITHUB_OUTPUT}" + echo "release_id=${release_id}" >> "${GITHUB_OUTPUT}" + echo "release_is_draft=${release_is_draft}" >> "${GITHUB_OUTPUT}" + + verify-release: + needs: resolve-release + if: needs.resolve-release.result == 'success' + permissions: + contents: read + uses: ./.github/workflows/canonical-verification.yml + with: + ref: ${{ needs.resolve-release.outputs.release_sha }} + base-rev: ${{ needs.resolve-release.outputs.base_sha }} + + integration-docker-release: + needs: [resolve-release, verify-release] + if: >- + always() + && needs.resolve-release.result == 'success' + && needs.verify-release.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ needs.resolve-release.outputs.release_sha }} + + - name: Bind real-container testing to the exact release commit + env: + EXPECTED_SHA: ${{ needs.resolve-release.outputs.release_sha }} run: | - case "${TAG}" in - v[0-9]*.[0-9]*.[0-9]*) ;; - *) echo "Expected a release tag such as v1.0.0, got '${TAG}'" >&2; exit 1 ;; - esac - gh release view "${TAG}" --repo "${GITHUB_REPOSITORY}" >/dev/null + set -euo pipefail + if ! printf '%s\n' "${EXPECTED_SHA}" | grep -Eq '^[0-9a-f]{40}$'; then + echo "Container integration requires a full lowercase commit SHA, got '${EXPECTED_SHA}'" >&2 + exit 1 + fi + actual_sha="$(git rev-parse HEAD)" + if [ "${actual_sha}" != "${EXPECTED_SHA}" ]; then + echo "Container integration checkout mismatch: expected ${EXPECTED_SHA}, got ${actual_sha}" >&2 + exit 1 + fi + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v8 + + - name: Require real-container release integration + env: + RAES_DOCKER_INTEGRATION_REQUIRED: "1" + DOCKER_JUNIT: ${{ runner.temp }}/raes-release-docker-junit.xml + run: | + set -euo pipefail + uv tool run --from 'nox[uv]==2026.4.10' nox -f noxfile.py \ + -s integration_docker -- --junitxml="${DOCKER_JUNIT}" + python - "${DOCKER_JUNIT}" <<'PY' + import sys + import xml.etree.ElementTree as ET + report = ET.parse(sys.argv[1]).getroot() + cases = report.findall(".//testcase") + skipped = [case for case in cases if case.find("skipped") is not None] + if not cases: + raise SystemExit("required Docker integration collected zero tests") + if skipped: + names = [f"{case.get('classname', '')}.{case.get('name', '')}" for case in skipped] + raise SystemExit(f"required Docker integration skipped tests: {names}") + print(f"required Docker integration passed {len(cases)} real-container tests without skips") + PY + + build-release: + needs: [resolve-release, verify-release, integration-docker-release] + if: >- + always() + && needs.resolve-release.result == 'success' + && needs.verify-release.result == 'success' + && needs.integration-docker-release.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: read + steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 - ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.sha }} + ref: ${{ needs.resolve-release.outputs.release_sha }} + + - name: Reconfirm the exact verified release checkout + env: + EXPECTED_SHA: ${{ needs.resolve-release.outputs.release_sha }} + run: | + set -euo pipefail + actual_sha="$(git rev-parse HEAD)" + if [ "${actual_sha}" != "${EXPECTED_SHA}" ]; then + echo "Publish checkout mismatch: expected ${EXPECTED_SHA}, got ${actual_sha}" >&2 + exit 1 + fi - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: @@ -89,39 +268,374 @@ jobs: - name: Build the corpus-bundled wheel + sdist run: uv build --out-dir dist implementations/python - - name: Verify the contract corpus is bundled in the wheel (#537) + - name: Verify the contract corpus is bundled in both distributions (#537) run: | python - <<'PY' import glob import sys + import tarfile import zipfile wheels = glob.glob("dist/raes-*.whl") + sdists = glob.glob("dist/raes-*.tar.gz") if len(wheels) != 1: sys.exit(f"expected exactly one wheel, found {wheels}") - names = zipfile.ZipFile(wheels[0]).namelist() - required = [ + if len(sdists) != 1: + sys.exit(f"expected exactly one sdist, found {sdists}") + expected_files = {wheels[0], sdists[0]} + unexpected = sorted(set(glob.glob("dist/*")) - expected_files) + if unexpected: + sys.exit(f"unexpected release distributions: {unexpected}") + + wheel_names = zipfile.ZipFile(wheels[0]).namelist() + required_wheel = [ "raes_contracts/_corpus/profiles/backend/provisioning-only.json", "raes_contracts/_corpus/fixtures/", "raes_contracts/_corpus/concept-authority/controlled-vocabularies-v1.json", "raes_contracts/_corpus/schemas/", ] - missing = [r for r in required if not any(n == r or n.startswith(r) for n in names)] - if missing: - sys.exit(f"wheel is missing corpus payload: {missing}") - print(f"corpus payload present: {sum(n.startswith('raes_contracts/_corpus/') for n in names)} files") + missing_wheel = [ + required + for required in required_wheel + if not any(name == required or name.startswith(required) for name in wheel_names) + ] + if missing_wheel: + sys.exit(f"wheel is missing corpus payload: {missing_wheel}") + + with tarfile.open(sdists[0], mode="r:gz") as archive: + sdist_names = archive.getnames() + required_sdist = [ + "/_corpus/profiles/backend/provisioning-only.json", + "/_corpus/fixtures/", + "/_corpus/concept-authority/controlled-vocabularies-v1.json", + "/_corpus/schemas/", + ] + missing_sdist = [ + required + for required in required_sdist + if not any( + name.endswith(required.rstrip("/")) or f"{required.rstrip('/')}/" in name + for name in sdist_names + ) + ] + if missing_sdist: + sys.exit(f"sdist is missing corpus payload: {missing_sdist}") + + print( + "corpus payload present in wheel " + f"({sum(name.startswith('raes_contracts/_corpus/') for name in wheel_names)} files) " + "and sdist " + f"({sum('/_corpus/' in name for name in sdist_names)} files)" + ) PY + - name: Smoke-test the installed release wheel (#537) + env: + SMOKE_VENV: ${{ runner.temp }}/raes-release-smoke + EXPECTED_TAG: ${{ needs.resolve-release.outputs.tag }} + run: | + set -euo pipefail + wheels=(dist/raes-*.whl) + if [ "${#wheels[@]}" -ne 1 ] || [ ! -f "${wheels[0]}" ]; then + echo "Expected exactly one release wheel, found: ${wheels[*]}" >&2 + exit 1 + fi + + uv venv "${SMOKE_VENV}" + uv pip install --python "${SMOKE_VENV}/bin/python" "${wheels[0]}" + smoke_home="$(mktemp -d "${RUNNER_TEMP}/raes-release-smoke-cwd.XXXXXX")" + report_path="${smoke_home}/conformance.json" + ( + cd "${smoke_home}" + env -u PYTHONPATH -u PYTHONHOME HOME="${smoke_home}" \ + "${SMOKE_VENV}/bin/raes" conformance backend --profile provisioning-only > "${report_path}" + ) + EXPECTED_VERSION="${EXPECTED_TAG#v}" REPORT_PATH="${report_path}" \ + "${SMOKE_VENV}/bin/python" - <<'PY' + import json + import os + from importlib.metadata import version + from pathlib import Path + + report = json.loads(Path(os.environ["REPORT_PATH"]).read_text(encoding="utf-8")) + installed_version = version("raes") + if installed_version != os.environ["EXPECTED_VERSION"]: + raise SystemExit( + f"installed release version {installed_version!r} does not match tag {os.environ['EXPECTED_VERSION']!r}" + ) + if report.get("profile") != "provisioning-only": + raise SystemExit(f"unexpected conformance profile: {report.get('profile')!r}") + if report.get("passed") is not True: + raise SystemExit("installed release wheel failed backend conformance") + if not report.get("cases"): + raise SystemExit("installed release wheel ran zero conformance cases") + print(f"installed release wheel passed {len(report['cases'])} conformance cases") + PY + + - name: Smoke-test the installed release sdist (#537) + env: + SMOKE_VENV: ${{ runner.temp }}/raes-release-sdist-smoke + EXPECTED_TAG: ${{ needs.resolve-release.outputs.tag }} + run: | + set -euo pipefail + sdists=(dist/raes-*.tar.gz) + if [ "${#sdists[@]}" -ne 1 ] || [ ! -f "${sdists[0]}" ]; then + echo "Expected exactly one release sdist, found: ${sdists[*]}" >&2 + exit 1 + fi + + uv venv "${SMOKE_VENV}" + uv pip install --no-cache --python "${SMOKE_VENV}/bin/python" "${sdists[0]}" + smoke_home="$(mktemp -d "${RUNNER_TEMP}/raes-release-sdist-smoke-cwd.XXXXXX")" + report_path="${smoke_home}/conformance.json" + ( + cd "${smoke_home}" + env -u PYTHONPATH -u PYTHONHOME HOME="${smoke_home}" \ + "${SMOKE_VENV}/bin/raes" conformance backend --profile provisioning-only > "${report_path}" + ) + EXPECTED_VERSION="${EXPECTED_TAG#v}" REPORT_PATH="${report_path}" \ + "${SMOKE_VENV}/bin/python" - <<'PY' + import json + import os + from importlib.metadata import version + from pathlib import Path + + report = json.loads(Path(os.environ["REPORT_PATH"]).read_text(encoding="utf-8")) + installed_version = version("raes") + if installed_version != os.environ["EXPECTED_VERSION"]: + raise SystemExit( + f"installed sdist version {installed_version!r} does not match tag {os.environ['EXPECTED_VERSION']!r}" + ) + if report.get("profile") != "provisioning-only": + raise SystemExit(f"unexpected conformance profile: {report.get('profile')!r}") + if report.get("passed") is not True: + raise SystemExit("installed release sdist failed backend conformance") + if not report.get("cases"): + raise SystemExit("installed release sdist ran zero conformance cases") + print(f"installed release sdist passed {len(report['cases'])} conformance cases") + PY + + - name: Upload the tested release distributions + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-distributions-${{ needs.resolve-release.outputs.release_sha }} + path: dist/ + if-no-files-found: error + retention-days: 7 + + publish-pypi: + needs: [resolve-release, verify-release, integration-docker-release, build-release] + if: >- + always() + && needs.resolve-release.result == 'success' + && needs.verify-release.result == 'success' + && needs.integration-docker-release.result == 'success' + && needs.build-release.result == 'success' + runs-on: ubuntu-latest + environment: pypi + permissions: + contents: read + id-token: write # OIDC trusted publishing to PyPI (no stored token) + steps: + - name: Download the tested release distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-distributions-${{ needs.resolve-release.outputs.release_sha }} + path: dist/ + + - name: Revalidate release identity immediately before PyPI + env: + GH_TOKEN: ${{ github.token }} + EXPECTED_SHA: ${{ needs.resolve-release.outputs.release_sha }} + EXPECTED_TAG: ${{ needs.resolve-release.outputs.tag }} + EXPECTED_RELEASE_ID: ${{ needs.resolve-release.outputs.release_id }} + EXPECTED_DRAFT: ${{ needs.resolve-release.outputs.release_is_draft }} + run: | + set -euo pipefail + if [[ ! "${EXPECTED_TAG}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "Expected a stable SemVer release tag, got '${EXPECTED_TAG}'" >&2 + exit 1 + fi + if ! printf '%s\n' "${EXPECTED_SHA}" | grep -Eq '^[0-9a-f]{40}$'; then + echo "Expected a full lowercase release SHA, got '${EXPECTED_SHA}'" >&2 + exit 1 + fi + if ! printf '%s\n' "${EXPECTED_RELEASE_ID}" | grep -Eq '^[1-9][0-9]*$'; then + echo "Expected a numeric release id, got '${EXPECTED_RELEASE_ID}'" >&2 + exit 1 + fi + if [ "${EXPECTED_DRAFT}" != "true" ] && [ "${EXPECTED_DRAFT}" != "false" ]; then + echo "Expected a boolean release draft state, got '${EXPECTED_DRAFT}'" >&2 + exit 1 + fi + + release_json="$( + gh release view "${EXPECTED_TAG}" --repo "${GITHUB_REPOSITORY}" \ + --json databaseId,isDraft,tagName + )" + current_release_id="$(jq -er '.databaseId | tostring' <<<"${release_json}")" + current_draft="$(jq -er '.isDraft | tostring' <<<"${release_json}")" + current_tag="$(jq -er '.tagName' <<<"${release_json}")" + if [ "${current_release_id}" != "${EXPECTED_RELEASE_ID}" ]; then + echo "Release object changed: expected id ${EXPECTED_RELEASE_ID}, got ${current_release_id}" >&2 + exit 1 + fi + if [ "${current_tag}" != "${EXPECTED_TAG}" ]; then + echo "Release tag changed: expected ${EXPECTED_TAG}, got ${current_tag}" >&2 + exit 1 + fi + if [ "${current_draft}" != "${EXPECTED_DRAFT}" ]; then + echo "Release draft state changed: expected ${EXPECTED_DRAFT}, got ${current_draft}" >&2 + exit 1 + fi + + ref_json="$( + gh api --method GET \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2022-11-28' \ + "repos/${GITHUB_REPOSITORY}/git/ref/tags/${EXPECTED_TAG}" + )" + current_ref="$(jq -er '.ref' <<<"${ref_json}")" + current_type="$(jq -er '.object.type' <<<"${ref_json}")" + current_sha="$(jq -er '.object.sha' <<<"${ref_json}")" + if [ "${current_ref}" != "refs/tags/${EXPECTED_TAG}" ]; then + echo "Tag ref lookup changed: expected refs/tags/${EXPECTED_TAG}, got ${current_ref}" >&2 + exit 1 + fi + + dereference_depth=0 + while [ "${current_type}" = "tag" ]; do + dereference_depth=$((dereference_depth + 1)) + if [ "${dereference_depth}" -gt 16 ]; then + echo "Release tag exceeded the bounded annotated-tag dereference depth" >&2 + exit 1 + fi + if ! printf '%s\n' "${current_sha}" | grep -Eq '^[0-9a-f]{40}$'; then + echo "Annotated release tag resolved to malformed object SHA '${current_sha}'" >&2 + exit 1 + fi + tag_json="$( + gh api --method GET \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2022-11-28' \ + "repos/${GITHUB_REPOSITORY}/git/tags/${current_sha}" + )" + current_type="$(jq -er '.object.type' <<<"${tag_json}")" + current_sha="$(jq -er '.object.sha' <<<"${tag_json}")" + done + if [ "${current_type}" != "commit" ]; then + echo "Release tag resolved to unsupported object type '${current_type}'" >&2 + exit 1 + fi + if ! printf '%s\n' "${current_sha}" | grep -Eq '^[0-9a-f]{40}$'; then + echo "Release tag resolved to malformed commit SHA '${current_sha}'" >&2 + exit 1 + fi + if [ "${current_sha}" != "${EXPECTED_SHA}" ]; then + echo "Release tag moved: expected ${EXPECTED_SHA}, got ${current_sha}" >&2 + exit 1 + fi + echo "Revalidated Release ${EXPECTED_RELEASE_ID}, ${EXPECTED_TAG}, and ${EXPECTED_SHA} immediately before PyPI" + - name: Publish to PyPI (OIDC trusted publishing) uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 with: packages-dir: dist - - name: Attach the distributions to the GitHub Release + publish-github: + needs: [resolve-release, verify-release, build-release, publish-pypi] + if: >- + always() + && needs.resolve-release.result == 'success' + && needs.verify-release.result == 'success' + && needs.build-release.result == 'success' + && needs.publish-pypi.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + ref: ${{ needs.resolve-release.outputs.release_sha }} + + - name: Download the tested release distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-distributions-${{ needs.resolve-release.outputs.release_sha }} + path: dist/ + + - name: Revalidate, attach, and publish the GitHub Release env: GH_TOKEN: ${{ github.token }} - TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || needs.release-please.outputs.tag_name }} - run: gh release upload "${TAG}" dist/* --clobber + EXPECTED_SHA: ${{ needs.resolve-release.outputs.release_sha }} + EXPECTED_TAG: ${{ needs.resolve-release.outputs.tag }} + EXPECTED_RELEASE_ID: ${{ needs.resolve-release.outputs.release_id }} + EXPECTED_DRAFT: ${{ needs.resolve-release.outputs.release_is_draft }} + run: | + set -euo pipefail + actual_sha="$(git rev-parse HEAD)" + if [ "${actual_sha}" != "${EXPECTED_SHA}" ]; then + echo "Release attachment checkout mismatch: expected ${EXPECTED_SHA}, got ${actual_sha}" >&2 + exit 1 + fi + + release_json="$( + gh release view "${EXPECTED_TAG}" --repo "${GITHUB_REPOSITORY}" \ + --json databaseId,isDraft,tagName + )" + current_release_id="$(jq -r '.databaseId' <<<"${release_json}")" + current_draft="$(jq -r '.isDraft' <<<"${release_json}")" + current_tag="$(jq -r '.tagName' <<<"${release_json}")" + if [ "${current_release_id}" != "${EXPECTED_RELEASE_ID}" ]; then + echo "Release object changed: expected id ${EXPECTED_RELEASE_ID}, got ${current_release_id}" >&2 + exit 1 + fi + if [ "${current_tag}" != "${EXPECTED_TAG}" ]; then + echo "Release tag changed: expected ${EXPECTED_TAG}, got ${current_tag}" >&2 + exit 1 + fi + if [ "${current_draft}" != "${EXPECTED_DRAFT}" ]; then + echo "Release draft state changed: expected ${EXPECTED_DRAFT}, got ${current_draft}" >&2 + exit 1 + fi + + git fetch --force --tags origin + current_tag_sha="$(git rev-parse --verify "${EXPECTED_TAG}^{commit}")" + if [ "${current_tag_sha}" != "${EXPECTED_SHA}" ]; then + echo "Release tag moved: expected ${EXPECTED_SHA}, got ${current_tag_sha}" >&2 + exit 1 + fi + + wheels=(dist/raes-*.whl) + sdists=(dist/raes-*.tar.gz) + if [ "${#wheels[@]}" -ne 1 ] || [ ! -f "${wheels[0]}" ] \ + || [ "${#sdists[@]}" -ne 1 ] || [ ! -f "${sdists[0]}" ]; then + echo "Expected one tested wheel and sdist before attachment" >&2 + exit 1 + fi + gh release upload "${EXPECTED_TAG}" "${wheels[0]}" "${sdists[0]}" --clobber + + git fetch --force --tags origin + current_tag_sha="$(git rev-parse --verify "${EXPECTED_TAG}^{commit}")" + if [ "${current_tag_sha}" != "${EXPECTED_SHA}" ]; then + echo "Release tag moved during attachment: expected ${EXPECTED_SHA}, got ${current_tag_sha}" >&2 + exit 1 + fi + if [ "${EXPECTED_DRAFT}" = "true" ]; then + gh release edit "${EXPECTED_TAG}" --repo "${GITHUB_REPOSITORY}" --draft=false --verify-tag + fi + + final_json="$( + gh release view "${EXPECTED_TAG}" --repo "${GITHUB_REPOSITORY}" \ + --json databaseId,isDraft,tagName + )" + if [ "$(jq -r '.databaseId' <<<"${final_json}")" != "${EXPECTED_RELEASE_ID}" ] \ + || [ "$(jq -r '.tagName' <<<"${final_json}")" != "${EXPECTED_TAG}" ] \ + || [ "$(jq -r '.isDraft' <<<"${final_json}")" != "false" ]; then + echo "GitHub Release finalization did not preserve the verified release identity" >&2 + exit 1 + fi # After a release is cut, the version bump + CHANGELOG land on `main`, so `dev` # falls one release commit behind. Open a back-merge PR so `dev` is resynced. @@ -130,8 +644,10 @@ jobs: # does not trigger, and repo auto-merge is off — merge it yourself (admin # override; enforce_admins is false). sync-dev: - needs: release-please - if: needs.release-please.outputs.release_created == 'true' + needs: [release-please, publish-github] + if: >- + needs.release-please.outputs.release_created == 'true' + && needs.publish-github.result == 'success' runs-on: ubuntu-latest permissions: contents: read diff --git a/docs/decisions/issue-1110-required-container-release-gate.md b/docs/decisions/issue-1110-required-container-release-gate.md new file mode 100644 index 000000000..256180957 --- /dev/null +++ b/docs/decisions/issue-1110-required-container-release-gate.md @@ -0,0 +1,68 @@ +# Issue 1110 Required Real-Container Release Gate + +Date: 2026-08-12 + +Issue: #1110. Requirements: GOV-928 and RUN-314. + +## Gap Claim + +RUN-314 claims a reference backend that realizes scenarios against real +infrastructure, but ordinary canonical verification is deliberately hermetic. +The only Docker integration job in CI is optional, `continue-on-error`, and +skips when the runtime or integration image is unavailable. A release could +therefore publish even though the exact release commit had never completed a +real container lifecycle. + +## Existing Surface Audit And Lineage + +- `.github/workflows/canonical-verification.yml` runs the proof-bearing + repository verification graph at an exact SHA. Making Docker a prerequisite + there would break its portable PR/branch contract. +- `.github/workflows/ci.yml` and `nox -s integration_docker` provide the existing + RUN-314 real-runtime lane, but intentionally preserve optional local and PR + behavior. +- `test_reference_backend_docker_integration.py`, the OCI driver, ADR-063, and + issue #197 own the established reference-backend realization family. The + release gate must exercise that family, not add a second container harness or + Docker-specific SDL surface. +- Issue #684 and GOV-928 already bind verification, artifacts, and publication + to one release SHA. The missing gate belongs in that same dependency graph. + +## Alternatives + +1. Keep the optional CI observation. Rejected because a skip or tolerated + failure is not release evidence. +2. Add Docker to the reusable canonical verifier. Rejected because that changes + a hermetic cross-context gate into a runner-dependent one for every PR. +3. Add a release-only, read-only exact-SHA job after canonical verification and + before build. Selected because it makes real-container evidence mandatory at + the publication boundary without weakening ordinary development portability. + +## Chosen Architecture + +The release job checks out the resolved 40-character release SHA, proves that +`HEAD` matches it, and runs the existing `integration_docker` nox session with +`RAES_DOCKER_INTEGRATION_REQUIRED=1`. Required mode converts missing +Docker/Podman and image-pull failures from skips to test failures. The scenario +pins Alpine to the reviewed multiarch digest +`sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc` +rather than resolving a mutable tag. + +The job writes pytest JUnit evidence and independently rejects zero collected +tests or any skipped case. `build-release` and `publish-pypi` require this job's +explicit `success`; it has only `contents: read` and no environment, release +write, secret, or OIDC authority. Optional CI/local execution keeps its prior +skip behavior. + +## Boundaries And Verification + +This gate proves the reviewed image can be pulled and that the exact release +code completes the current reference-backend real-container tests on the +GitHub-hosted runner. It does not establish support for every OCI runtime, +architecture, registry, or production topology, and a digest pin is not an +SBOM or provenance attestation. + +Policy tests assert the exact-SHA checkout, required-mode environment, digest, +no-skip/zero-test JUnit checks, build/publication dependencies, and unchanged +optional CI behavior. Fixture tests cover optional skip, required failure, +image unavailability, invalid mode, and successful admission. diff --git a/docs/decisions/issue-1125-gov-928-exact-sha-release-verification-preflight.md b/docs/decisions/issue-1125-gov-928-exact-sha-release-verification-preflight.md new file mode 100644 index 000000000..530843a37 --- /dev/null +++ b/docs/decisions/issue-1125-gov-928-exact-sha-release-verification-preflight.md @@ -0,0 +1,216 @@ +# Issue 1125 / GOV-928 Exact-SHA Release Verification Preflight + +Date: 2026-08-11 + +Issue: #1125. Requirement: GOV-928. Related delivery epic: #684. + +This note records the architecture and security preflight for making canonical +verification of the exact release commit a hard prerequisite for PyPI +publication. It is a focused remediation of the current Release Please path; it +does not change package dependencies, Python support, version calculation, +release-note ownership, contract semantics, or the release artifact format. + +## Binding Contract And Lineage + +- GOV-928 requires short-lived, identity-bound package publication and forbids + long-lived release tokens where the registry supports a stronger mechanism. + The incumbent `pypi` environment and GitHub OIDC trusted publisher satisfy the + credential half of that requirement and must remain the only PyPI authority. +- Issue #684 remains the open delivery issue for automatic PyPI publication. + Its original implementation details are historical: PR #689 replaced the + proposed semantic-release flow with Release Please, a committed version + literal, and `.github/workflows/release-please.yml`. +- Issue #537 / PR #543 established the package boundary: release distributions + must contain the normative contract corpus and must work when installed + without a source checkout. The existing package integration tests are the + executable acceptance contract and must not be weakened. +- `.github/workflows/canonical-verification.yml` owns the repository's existing + hermetic admission graph: `nox -s verify`, pinned Isabelle, and sandboxed + proof checks. Publication must depend on that graph, not on a smaller + release-specific subset. Optional and networked CI lanes keep their existing + development semantics outside this reusable verifier. +- `docs/explain/releasing.md` documents Release Please as the current operator + contract. Its existing caveat permits an admin merge of a bot-created release + PR without required checks, so branch protection alone is not sufficient + release evidence. + +## Preflight Finding + +The current `publish` job depends only on `release-please`. Canonical CI is a +different workflow triggered by the same push, so the two runs are concurrent +and unordered. A successful Release Please job can therefore publish even when +canonical verification for that commit fails, is cancelled, or has not +finished. The manual recovery path has the same gap. + +Polling check runs or branch status cannot close this gap safely. A branch is a +mutable name, status APIs are eventually observed external state, similarly +named checks can come from another workflow, and a new push can change the +commit between lookup and publication. The publication dependency must carry +one immutable commit SHA through resolution, verification, checkout, build, +and smoke testing in one Actions dependency graph. + +## Selected Design + +### One reusable canonical verifier + +Extract the incumbent `verify` job into a repository-owned reusable workflow +invoked with an exact 40-character commit SHA. The workflow must: + +1. reject a branch, tag, abbreviated SHA, malformed SHA, or missing commit; +2. check out the supplied SHA with full history and prove that `HEAD` equals it; +3. resolve a valid policy base SHA without changing the verification target; +4. preserve the current Python 3.12 runner, `uv`, Bubblewrap, pinned Isabelle + cache/acquisition, requirement-UID handling, canonical `nox -s verify` + invocation, and coverage artifact; +5. expose no publishing permission, environment, OIDC authority, or secret. + +The ordinary CI workflow must call this same reusable workflow for +`github.sha`. This makes the called graph, rather than a copied release-only +approximation, canonical. GitHub documents that a local reusable workflow is +loaded from the same commit as its caller, and that caller permissions can only +be maintained or reduced across the call: +. + +Both protected branches currently require the historical `verify` check +context. A reusable call reports a nested check context, so ordinary CI must +retain a same-run `verify` result-join job that runs with `if: always()` and +fails unless the canonical call result is exactly `success`. This compatibility +join preserves branch protection; it does not perform or substitute for +verification and it cannot convert failed, cancelled, or skipped verification +into a passing required check. + +### Resolve once, then carry the exact release SHA + +Release Please creates a draft GitHub Release and force-creates its tag so no +public Release or generated source archives precede admission. Add a +non-privileged release-resolution job before verification: + +- For an automatic release, consume Release Please's documented `sha` output + and require the emitted tag to resolve to that same commit. +- For a manual recovery release, resolve the supplied existing GitHub Release + tag once to its commit SHA. +- In both paths, require a full SHA, a stable `vX.Y.Z` release tag, a real parent + commit, ancestry from `origin/main`, and a stable Release object identity; + automatic releases must still be drafts. Emit the immutable release SHA, its + parent policy base, Release id/draft state, and validated tag as job outputs. + +The pinned Release Please action exposes `sha` as “the SHA that a GitHub release +was tagged at”; this output is preferable to assuming that a mutable branch or +the workflow event SHA is the release commit: +. + +The release verification job calls the reusable canonical verifier with the +resolved release SHA. A release-only read-only job then runs the existing +RUN-314 Docker integration at that SHA in required mode, rejecting unavailable +runtime/image state, skips, or zero tests. A read-only build job runs only after +that gate, checks out and builds the SHA rather than a branch or tag, executes +the artifact smokes, and uploads only the tested distributions. The PyPI job +has release resolution, exact verification, required real-container evidence, +and the read-only build as explicit successful prerequisites and downloads that +same-run artifact. Immediately after protected-environment approval and +artifact download, it freshly revalidates the Release id, draft state, exact +tag ref, and fully dereferenced commit SHA before invoking the pinned OIDC +publisher. A separate GitHub publication job runs only after PyPI and repeats +the identity checks before attachment and public finalization. No status API, +polling, workflow-name matching, or stale mutable-ref observation is an +admissible publication gate. + +### Test the built artifact before granting publication authority + +Keep the existing archive-level corpus probes, extend them to the sdist, and +run post-build smoke tests against both actual artifacts in `dist/`: + +1. create a fresh virtual environment outside the checkout; +2. install the built wheel and sdist into separate environments; +3. run `raes conformance backend --profile provisioning-only` from outside the + checkout; and +4. require the installed distribution version to match the release tag and a + JSON report for the requested profile with `passed: true` and at least one + executed case. + +This is intentionally consistent with +`implementations/python/tests/test_corpus_packaging.py`. The canonical verifier +continues to run that installed-distribution suite; the two release smokes prove +that every specific distribution about to be uploaded satisfies the contract. + +## Trust, Provenance, And Permission Boundaries + +- The resolution, verification, real-container, and build/smoke jobs receive only + `contents: read`. They do not receive `id-token: write`, the `pypi` + environment, or release-write authority. The tested distributions cross into + the publish job through a same-run immutable Actions artifact. +- The `publish-pypi` job remains the sole holder of `id-token: write` and keeps + the `pypi` environment. PyPI Trusted Publishing exchanges the workflow + identity for short-lived credentials and requires this permission; no API + token or inherited secret is introduced: + . +- Publishing-critical third-party actions remain pinned to full commit SHAs, + which GitHub identifies as the immutable action reference: + . +- This change does not claim to implement GOV-927 or add an SBOM/provenance + generator. If an SBOM or attestation step is present when this work is + integrated, it must remain derived from the exact-SHA `dist/` artifacts after + canonical verification and before upload; it must not move OIDC authority + into the reusable verifier or create a second publication path. +- A successful canonical verifier is necessary but not sufficient publishing + authority. The exact Release/tag binding, installed wheel and sdist smokes, + protected environment, OIDC trusted-publisher identity, artifact attachment, + and draft finalization all remain conjunctive. +- A protected `v*` tag ruleset that prevents deletion or movement outside the + approved release authority remains an external repository-administration + control. The workflow detects identity changes at both irreversible + boundaries but cannot make already-published PyPI bytes reversible, and it + intentionally does not grant repository code permission to mutate live + organization rulesets. + +## Alternatives Rejected + +- **Rely on release-PR branch protection:** bot-created release PR checks may not + run, and the documented admin-merge path bypasses them. +- **Poll CI/check-run status:** races mutable refs and external state, duplicates + check-selection policy, and cannot express a direct same-run dependency. +- **Copy selected CI steps into `publish`:** creates a second verification graph + that can silently drift and weakens the meaning of “canonical”. +- **Publish from the CI workflow:** broadens the proof job's permissions and + mixes untrusted-code verification with the OIDC deployment boundary. +- **Use the tag for the publish checkout after verification:** tags are names, + not the immutable value proved by the dependency. The validated SHA must be + carried explicitly. + +## Verification And Policy Tests + +Add YAML-focused tests that parse, rather than execute, the workflow graph and +assert these structural invariants: + +- CI and release both invoke the same local canonical verifier, and CI's stable + required `verify` context fails unless that reusable invocation succeeds; +- the verifier checks out its exact SHA input and fails on any `HEAD` mismatch; +- release resolution binds Release Please's SHA to the tag and main ancestry; +- a release-only job binds real Docker integration to that SHA and rejects an + unavailable runtime/image, skipped test, or empty collection; +- PyPI and GitHub publication require successful exact-SHA verification; the + build and attachment jobs check out that SHA, and the no-checkout PyPI job + freshly queries and dereferences the exact tag immediately before OIDC; +- no release step polls branch/check status; +- wheel and sdist archive checks and installed-artifact conformance smokes all + precede the SHA-pinned PyPI action; +- only `publish-pypi` has the `pypi` environment and `id-token: write`; +- GitHub attachment is a separate retryable job that revalidates the Release + and tag, attaches artifacts, and only then removes draft state; and +- proof acquisition, the canonical nox command, coverage upload, trusted + publishing, and GitHub Release attachment remain present. + +Run the focused workflow tests, the installed-wheel packaging tests, lint, the +repository policy checker, requirement governance, and `tools/verify_all.py`. + +## Traceability Plan + +- Add IMPLEMENTS links from GOV-928 to the release workflow, reusable canonical + verifier, this preflight, and the release runbook. +- Add TESTS links to the workflow-structure test and the installed-wheel corpus + acceptance test. +- Link issue #1125 as this repository-owned guarantee and #684 as the broader + release delivery lineage without rewriting its stale implementation proposal + as the current architecture. +- Move GOV-928 to ACTIVE only when the workflow, tests, and documentation land + together and all governance checks pass. diff --git a/docs/explain/releasing.md b/docs/explain/releasing.md index 3e7a0c0c4..b7314ed6b 100644 --- a/docs/explain/releasing.md +++ b/docs/explain/releasing.md @@ -8,7 +8,8 @@ Conventional Commit history on `main`. `raes` also ships the published contract corpus as package data, so `raes conformance backend` and SDL semantic validation work from an installed wheel. Every release binds the code and the corpus in one versioned artifact -(#537). +(#537). PyPI publication additionally requires the repository's canonical +verification graph to pass for the exact commit named by the release (GOV-928). ## How a release happens @@ -18,10 +19,30 @@ wheel. Every release binds the code and the corpus in one versioned artifact 2. On every push to `main`, `.github/workflows/release-please.yml` maintains a **release PR** titled `chore(main): release X.Y.Z` that bumps the version and regenerates `CHANGELOG.md` from the commits since the last release. -3. **Merge that release PR.** release-please tags `vX.Y.Z` and creates the GitHub - Release; the `publish` job then builds the corpus-bundled wheel + sdist, - verifies the corpus payload (#537), publishes to PyPI via OIDC, and attaches - the distributions to the Release. +3. **Merge that release PR.** Release Please tags `vX.Y.Z`, creates a **draft** + GitHub Release, and returns the commit SHA that it tagged. Forced tag creation + keeps draft releases discoverable by Release Please. The release workflow + requires that tag and SHA to match and that the commit belong to `main`. +4. The workflow invokes `.github/workflows/canonical-verification.yml` for that + exact SHA. This is the same proof-bearing `nox -s verify` gate used by CI. + It does not poll branch status or accept a check from another commit. +5. A separate read-only job checks out that SHA and must complete the RUN-314 + reference-backend tests against a real container runtime. Release-required + mode fails when the runtime or digest-pinned reviewed image is unavailable, + when pytest collects zero tests, or when any selected test skips. The + ordinary PR/local Docker lane remains optional. +6. A read-only job checks out the verified SHA, builds the corpus-bundled wheel + and sdist, checks the corpus in both archives, installs each exact artifact in + its own fresh environment, and runs `raes conformance backend --profile + provisioning-only` outside the checkout. +7. Only those tested distributions cross into the `pypi` environment. After any + environment approval and artifact download, the job freshly revalidates the + Release object id, draft state, exact tag ref, and fully dereferenced commit + SHA immediately before its pinned OIDC publisher runs. A separate GitHub-only + job performs the same identity checks again, attaches the artifacts, and + makes the draft public. Keeping these jobs separate means a failed + attachment/finalization can be retried without attempting a second PyPI + upload. Nothing is hand-run, and feature PRs never touch `CHANGELOG.md` (release-please owns it) — no fragment collisions. @@ -48,16 +69,53 @@ Use `feat:`/`fix:` for consumer-visible changes so release-please cuts a release (release-please rewrites it). `raes.__version__` derives from the installed `raes` distribution metadata. The `raes` and `raes-mcp` console scripts are the only current commands. +- `.github/workflows/canonical-verification.yml` — reusable exact-commit + verification graph called by both ordinary CI and the release workflow. Its + input is a full commit SHA, not a branch or tag; the proof-bearing nox job + checks out and binds itself to that value. +- `release-please-config.json` creates releases as drafts and forces the tag to + exist immediately. Only the gated GitHub publication job removes draft state. ## Caveat: the release PR and required checks The release PR is opened by `GITHUB_TOKEN`, so **required status checks do not -auto-run on it** (GitHub's recursion guard). Two options: +auto-run on the PR** (GitHub's recursion guard). Two review options remain: - **Admin-merge** the release PR (bypass the required checks for that PR), or - Give release-please a **PAT** (repo `contents`+`pull_requests`) as the `token` input so its PRs trigger checks normally. +Neither option can bypass publication verification. After the release PR lands, +the release workflow keeps the GitHub Release private as a draft while it runs +the canonical graph against the exact tagged commit. PyPI upload, +GitHub artifact attachment, and public Release finalization depend directly on +that successful graph. On automatic pushes, release resolution also requires +the Release Please job itself to finish successfully; an output from a skipped, +cancelled, or failed job is never sufficient. + +## External tag-protection control + +Configure a GitHub tag ruleset for `v*` that prevents tag deletion and updates +outside the explicitly approved release authority. The repository workflow +revalidates mutable GitHub state at both publication boundaries, but it cannot +make a tag immutable after PyPI accepts an artifact, and repository-owned code +must not grant itself permission to rewrite live organization rulesets. This +ruleset remains a maintainer-owned external control and a release-readiness +requirement. + +## Manual recovery publish + +`workflow_dispatch` accepts an existing GitHub Release tag when a prior upload +needs to be retried. The tag must be stable SemVer (`vX.Y.Z`), resolve to a +commit reachable from `main`, and have a policy base. The workflow resolves it +once to a full SHA and runs the same canonical verification, build, corpus +checks, exact wheel and sdist installation/conformance smokes, and OIDC +publication chain. PyPI upload and GitHub attachment are separate jobs, so use +GitHub's **re-run failed jobs** operation if attachment or finalization fails +after PyPI succeeds. +Manual dispatch is not a verification bypass and never builds from the current +branch head. + ## First release `main` starts at `0.18.0` (the manifest/pyproject baseline; the historical @@ -79,6 +137,11 @@ token stored): > `release-please.yml` (or add a second pending publisher) — the workflow filename > must match or only the PyPI publish step 403s. +The `pypi` environment and `id-token: write` permission exist only on the PyPI +upload job. Configure that environment's deployment branch policy for `main`. +Resolution, canonical verification, distribution installation, GitHub +attachment, and CLI execution cannot mint the PyPI publishing credential. + ## Pinning from a downstream backend ``` diff --git a/docs/requirements/GOV-928/requirement.md b/docs/requirements/GOV-928/requirement.md index ae92d29f8..5b35e951c 100644 --- a/docs/requirements/GOV-928/requirement.md +++ b/docs/requirements/GOV-928/requirement.md @@ -6,7 +6,7 @@ type: NON_FUNCTIONAL priority: MUST wave: 3 created_at: 2026-05-15T04:13:16.700063Z -updated_at: 2026-05-15T04:13:16.700063Z +updated_at: 2026-08-12T05:22:38Z --- # GOV-928 — Trusted Package Publishing @@ -18,3 +18,17 @@ The ecosystem shall use short-lived, identity-bound publishing mechanisms for pa ## Rationale Imported from the superseded governance backlog before deleting the old governance repo. Package publication is a supply-chain trust boundary and should be governed separately from implementation-level artifact integrity. + +## Traceability + +- IMPLEMENTS → CODE_FILE `.github/workflows/release-please.yml` (Exact-SHA-gated PyPI trusted publishing workflow) +- IMPLEMENTS → CODE_FILE `.github/workflows/canonical-verification.yml` (Read-only reusable canonical commit verifier) +- TESTS → TEST `implementations/python/tests/test_release_workflows.py` (Release dependency, exact-SHA, action pin, artifact smoke, and OIDC boundary policy tests) +- TESTS → TEST `implementations/python/tests/test_corpus_packaging.py` (Installed-wheel corpus, CLI conformance, and semantic-validation acceptance tests) +- TESTS → TEST `implementations/python/tests/test_reference_backend_docker_gate.py` (Required-mode runtime/image failure and reviewed digest policy tests) +- DOCUMENTS → DOCUMENTATION `docs/decisions/issue-1125-gov-928-exact-sha-release-verification-preflight.md` (Exact-SHA release verification architecture and trust-boundary preflight) +- DOCUMENTS → DOCUMENTATION `docs/explain/releasing.md` (Release operator contract and manual recovery constraints) +- IMPLEMENTS → GITHUB_ISSUE `684` (Automatic PyPI publishing delivery lineage) +- IMPLEMENTS → GITHUB_ISSUE `1125` (Repository-owned exact-SHA release verification guarantee) +- IMPLEMENTS → GITHUB_ISSUE `1110` (Exact-SHA real-container release admission) +- DOCUMENTS → DOCUMENTATION `docs/decisions/issue-1110-required-container-release-gate.md` (Required real-container release-gate architecture and evidence boundary) diff --git a/docs/requirements/RUN-314/requirement.md b/docs/requirements/RUN-314/requirement.md index ff1b05c67..5c6c96196 100644 --- a/docs/requirements/RUN-314/requirement.md +++ b/docs/requirements/RUN-314/requirement.md @@ -25,6 +25,8 @@ The ecosystem needs at least one concrete infrastructure-backed backend so its p - TESTS → TEST `implementations/python/tests/test_reference_backend_conformance.py` (Full-profile conformance + stub parity) - TESTS → TEST `implementations/python/tests/test_reference_backend_provenance.py` (SEM-218 realization provenance) - TESTS → TEST `implementations/python/tests/test_reference_backend_oci_driver.py` (OCI driver security + realization) +- TESTS → TEST `implementations/python/tests/test_reference_backend_docker_integration.py` (Real Docker/Podman realization and full-profile conformance) +- TESTS → TEST `implementations/python/tests/test_reference_backend_docker_gate.py` (Optional versus release-required runtime/image admission) - TESTS → TEST `implementations/python/tests/test_reference_backend_registry.py` (Target shape/contract + registry registration) - DOCUMENTS → GITHUB_ISSUE `197` (RUN-314 reference-emulation backend root) - IMPLEMENTS → GITHUB_ISSUE `1094` (Deterministic and cache-safe libvirt boot artifacts) @@ -37,3 +39,6 @@ The ecosystem needs at least one concrete infrastructure-backed backend so its p - IMPLEMENTS → CODE_FILE `implementations/python/packages/raes_backend_libvirt/techvault_concerns.py` (Fail-closed service protocol admission) - IMPLEMENTS → CODE_FILE `implementations/python/packages/raes_backend_libvirt/guest_appliance.py` (Protocol-bound guest listener and facts) - TESTS → TEST `implementations/python/tests/test_libvirt_backend_guest_certified.py` (Service protocol certification regressions) +- IMPLEMENTS → GITHUB_ISSUE `1110` (Exact-SHA real-container release admission) +- IMPLEMENTS → CODE_FILE `.github/workflows/release-please.yml` (Required release-only Docker integration before artifact build) +- DOCUMENTS → DOCUMENTATION `docs/decisions/issue-1110-required-container-release-gate.md` (Release evidence architecture and limitations) diff --git a/implementations/python/tests/test_reference_backend_docker_gate.py b/implementations/python/tests/test_reference_backend_docker_gate.py new file mode 100644 index 000000000..31bf95e3f --- /dev/null +++ b/implementations/python/tests/test_reference_backend_docker_gate.py @@ -0,0 +1,88 @@ +"""Policy regressions for optional versus release-required Docker integration.""" + +from __future__ import annotations + +import subprocess +from typing import NoReturn + +import pytest +import test_reference_backend_docker_integration as docker_integration + +_REVIEWED_ALPINE_DIGEST = "sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc" +_RUNTIME = "docker" + + +def test_docker_integration_uses_the_reviewed_multiarch_digest() -> None: + assert f"docker.io/library/alpine@{_REVIEWED_ALPINE_DIGEST}" == docker_integration._IMAGE + + +def test_optional_docker_integration_skips_without_runtime(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(docker_integration._REQUIRED_MODE_ENV, raising=False) + monkeypatch.setattr(docker_integration, "_available_runtime", lambda: None) + + with pytest.raises(pytest.skip.Exception, match="no container runtime"): + docker_integration._require_container_runtime() + + +def test_required_docker_integration_fails_without_runtime(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(docker_integration._REQUIRED_MODE_ENV, "1") + monkeypatch.setattr(docker_integration, "_available_runtime", lambda: None) + + with pytest.raises(pytest.fail.Exception, match="required real-container release gate unavailable"): + docker_integration._require_container_runtime() + + +@pytest.mark.parametrize("required", [False, True]) +def test_image_pull_failure_skips_only_when_optional( + monkeypatch: pytest.MonkeyPatch, + required: bool, +) -> None: + monkeypatch.setenv(docker_integration._REQUIRED_MODE_ENV, "1" if required else "0") + monkeypatch.setattr(docker_integration, "_available_runtime", lambda: _RUNTIME) + monkeypatch.setattr( + docker_integration.subprocess, + "run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 1), + ) + expected = pytest.fail.Exception if required else pytest.skip.Exception + + with pytest.raises(expected, match="integration image is not available"): + docker_integration._require_container_runtime() + + +@pytest.mark.parametrize("required", [False, True]) +def test_image_pull_exception_skips_only_when_optional( + monkeypatch: pytest.MonkeyPatch, + required: bool, +) -> None: + monkeypatch.setenv(docker_integration._REQUIRED_MODE_ENV, "1" if required else "0") + monkeypatch.setattr(docker_integration, "_available_runtime", lambda: _RUNTIME) + + def fail_pull(*_args, **_kwargs) -> NoReturn: + raise OSError("runtime invocation failed") + + monkeypatch.setattr(docker_integration.subprocess, "run", fail_pull) + expected = pytest.fail.Exception if required else pytest.skip.Exception + + with pytest.raises(expected, match="image pull failed"): + docker_integration._require_container_runtime() + + +def test_required_docker_integration_accepts_successful_pull(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(docker_integration._REQUIRED_MODE_ENV, "1") + monkeypatch.setattr(docker_integration, "_available_runtime", lambda: _RUNTIME) + monkeypatch.setattr( + docker_integration.subprocess, + "run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0), + ) + + assert docker_integration._require_container_runtime() == _RUNTIME + + +def test_invalid_required_mode_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(docker_integration._REQUIRED_MODE_ENV, "yes") + monkeypatch.setattr(docker_integration, "_available_runtime", lambda: _RUNTIME) + + with pytest.raises(pytest.fail.Exception, match="must be exactly 0 or 1"): + docker_integration._require_container_runtime() diff --git a/implementations/python/tests/test_reference_backend_docker_integration.py b/implementations/python/tests/test_reference_backend_docker_integration.py index 71f6e3726..8acec79a5 100644 --- a/implementations/python/tests/test_reference_backend_docker_integration.py +++ b/implementations/python/tests/test_reference_backend_docker_integration.py @@ -3,15 +3,18 @@ Marked ``@pytest.mark.docker`` so it is excluded from the default hermetic suite (``addopts = -m 'not fuzz and not integration and not docker'``). Run it explicitly with ``pytest -m docker`` / ``nox -s integration_docker``. -It also self-skips cleanly when no container runtime is available, so an -accidental ``-m docker`` run on a runtime-less host does not fail. +It self-skips cleanly for optional local/PR runs when no runtime or image is +available. The exact-SHA release gate sets ``RAES_DOCKER_INTEGRATION_REQUIRED=1`` +to turn every such condition into a hard failure. """ from __future__ import annotations +import os import shutil import subprocess import textwrap +from typing import NoReturn import pytest from raes import parse_sdl @@ -27,7 +30,8 @@ pytestmark = pytest.mark.docker -_IMAGE = "docker.io/library/alpine:3.20" +_REQUIRED_MODE_ENV = "RAES_DOCKER_INTEGRATION_REQUIRED" +_IMAGE = "docker.io/library/alpine@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc" _SCENARIO = f""" name: ref-docker nodes: @@ -57,13 +61,29 @@ def _available_runtime() -> str | None: return None -@pytest.fixture(scope="module") -def container_runtime() -> str: +def _required_mode() -> bool: + value = os.environ.get(_REQUIRED_MODE_ENV, "0") + if value not in {"0", "1"}: + pytest.fail(f"{_REQUIRED_MODE_ENV} must be exactly 0 or 1") + return value == "1" + + +def _unavailable(reason: str) -> NoReturn: + if _required_mode(): + pytest.fail(f"required real-container release gate unavailable: {reason}") + pytest.skip(reason) + + +def _require_container_runtime() -> str: + # Validate the release-mode selector even when the runtime and pull both + # succeed; a misspelled admission setting must never silently become an + # optional run. + _required_mode() runtime = _available_runtime() if runtime is None: - pytest.skip("no container runtime (docker/podman) available") - # Pre-pull the integration image; skip (not fail) if the host is offline - # or the registry is unreachable, so the test only runs when it can. + _unavailable("no container runtime (docker/podman) available") + # Pre-pull the reviewed multiarch image. Optional runs skip if the registry + # is unavailable; release-required mode fails closed. try: completed = subprocess.run( [runtime, "pull", _IMAGE], @@ -73,12 +93,17 @@ def container_runtime() -> str: check=False, ) except (OSError, subprocess.SubprocessError): - pytest.skip("container runtime present but image pull failed") + _unavailable("container runtime present but image pull failed") if completed.returncode != 0: - pytest.skip("integration image is not available (offline registry?)") + _unavailable("integration image is not available (offline registry?)") return runtime +@pytest.fixture(scope="module") +def container_runtime() -> str: + return _require_container_runtime() + + def test_real_container_provision_inventory_and_teardown(container_runtime: str): workspace = "raes-ref-it" # The scenario pins an explicit image source, so the operator allowlists it diff --git a/implementations/python/tests/test_release_workflows.py b/implementations/python/tests/test_release_workflows.py new file mode 100644 index 000000000..d36007abe --- /dev/null +++ b/implementations/python/tests/test_release_workflows.py @@ -0,0 +1,391 @@ +"""Policy tests for the exact-SHA release verification graph (#1125, GOV-928).""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +from pathlib import Path +from typing import Any + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[3] +WORKFLOWS = REPO_ROOT / ".github" / "workflows" +CANONICAL_PATH = WORKFLOWS / "canonical-verification.yml" +CI_PATH = WORKFLOWS / "ci.yml" +RELEASE_PATH = WORKFLOWS / "release-please.yml" +RELEASE_CONFIG_PATH = REPO_ROOT / "release-please-config.json" +DOCKER_INTEGRATION_PATH = ( + REPO_ROOT / "implementations" / "python" / "tests" / "test_reference_backend_docker_integration.py" +) + +LOCAL_CANONICAL_WORKFLOW = "./.github/workflows/canonical-verification.yml" +FULL_SHA_USE = re.compile(r"^[^@]+@[0-9a-f]{40}$") + + +def _load(path: Path) -> dict[str, Any]: + payload = yaml.safe_load(path.read_text(encoding="utf-8")) + assert isinstance(payload, dict) + # PyYAML follows YAML 1.1 and treats the top-level GitHub key ``on`` as the + # boolean True. Normalize only that known key after using the safe loader. + if True in payload and "on" not in payload: + payload["on"] = payload.pop(True) + return payload + + +def _named_step(job: dict[str, Any], name: str) -> dict[str, Any]: + matches = [step for step in job["steps"] if step.get("name") == name] + assert len(matches) == 1, f"expected one step named {name!r}, found {len(matches)}" + return matches[0] + + +def _uses(workflow: dict[str, Any]) -> list[str]: + refs: list[str] = [] + for job in workflow["jobs"].values(): + if "uses" in job: + refs.append(job["uses"]) + refs.extend(step["uses"] for step in job.get("steps", []) if "uses" in step) + return refs + + +def _run_pypi_identity_revalidation( + tmp_path: Path, + *, + release_json: str, + ref_json: str, + tag_json: str = "", +) -> subprocess.CompletedProcess[str]: + if shutil.which("bash") is None or shutil.which("jq") is None: + pytest.skip("the release identity shell policy requires bash and jq") + script = _named_step( + _load(RELEASE_PATH)["jobs"]["publish-pypi"], + "Revalidate release identity immediately before PyPI", + )["run"] + gh_stub = tmp_path / "gh" + gh_stub.write_text( + """#!/bin/sh +set -eu +case "$1" in + release) + printf '%s\n' "$RELEASE_JSON" + ;; + api) + case "$*" in + */git/ref/tags/*) printf '%s\n' "$REF_JSON" ;; + */git/tags/*) printf '%s\n' "$TAG_JSON" ;; + *) echo "unexpected gh api request: $*" >&2; exit 64 ;; + esac + ;; + *) echo "unexpected gh request: $*" >&2; exit 64 ;; +esac +""", + encoding="utf-8", + ) + gh_stub.chmod(0o700) + environment = { + **os.environ, + "PATH": f"{tmp_path}{os.pathsep}{os.environ['PATH']}", + "GH_TOKEN": "test-token", + "GITHUB_REPOSITORY": "OpenRAE/rae", + "EXPECTED_SHA": "a" * 40, + "EXPECTED_TAG": "v3.4.5", + "EXPECTED_RELEASE_ID": "1234", + "EXPECTED_DRAFT": "true", + "RELEASE_JSON": release_json, + "REF_JSON": ref_json, + "TAG_JSON": tag_json, + } + return subprocess.run( + ["bash", "-c", script], + check=False, + capture_output=True, + text=True, + env=environment, + ) + + +def test_canonical_verifier_requires_and_checks_out_an_exact_commit_sha() -> None: + workflow = _load(CANONICAL_PATH) + inputs = workflow["on"]["workflow_call"]["inputs"] + assert inputs["ref"]["required"] is True + assert inputs["ref"]["type"] == "string" + assert workflow["permissions"] == {"contents": "read"} + + job = workflow["jobs"]["verify"] + checkout = job["steps"][0] + assert checkout["with"]["fetch-depth"] == 0 + assert checkout["with"]["ref"] == "${{ inputs.ref }}" + + binding = _named_step(job, "Bind verification to the exact commit and resolve policy base") + script = binding["run"] + assert "^[0-9a-f]{40}$" in script + assert 'actual_sha="$(git rev-parse HEAD)"' in script + assert '"${actual_sha}" != "${EXPECTED_SHA}"' in script + assert 'git cat-file -e "${base_sha}^{commit}"' in script + assert 'git rev-parse "${EXPECTED_SHA}^"' in script + + +def test_canonical_verifier_preserves_proof_install_and_full_verify_graph() -> None: + workflow = _load(CANONICAL_PATH) + assert set(workflow["jobs"]) == {"verify"} + job = workflow["jobs"]["verify"] + assert job["runs-on"] == "ubuntu-22.04" + + step_names = [step.get("name") for step in job["steps"]] + assert "Restore pinned Isabelle archive" in step_names + assert "Install proof sandbox" in step_names + assert "Acquire pinned Isabelle distribution" in step_names + assert "Resolve requirement UID from branch" in step_names + + acquire = _named_step(job, "Acquire pinned Isabelle distribution") + assert "tools.isabelle_tool acquire" in acquire["run"] + verify = _named_step(job, "Run canonical verification graph") + assert "nox -f noxfile.py -s verify" in verify["run"] + assert "--skip-requirement" in verify["run"] + + coverage = _named_step(job, "Upload coverage report") + assert coverage["if"] == "always()" + assert coverage["with"]["path"] == "implementations/python/coverage.xml" + + +def test_ci_uses_the_same_canonical_verifier_for_github_sha() -> None: + workflow = _load(CI_PATH) + assert workflow["permissions"] == {"contents": "read", "pull-requests": "write"} + assert "interpreters" not in workflow["jobs"] + assert workflow["jobs"]["supply-chain"]["continue-on-error"] is True + canonical = workflow["jobs"]["canonical"] + assert canonical["uses"] == LOCAL_CANONICAL_WORKFLOW + assert canonical["with"]["ref"] == "${{ github.sha }}" + assert "github.event.pull_request.base.sha" in canonical["with"]["base-rev"] + assert canonical["with"]["requirement-branch"] == "${{ github.head_ref || github.ref_name }}" + + # dev/main branch protection requires the existing `verify` check context. + verify = workflow["jobs"]["verify"] + assert verify["needs"] == "canonical" + assert verify["if"] == "always()" + result_join = _named_step(verify, "Preserve the required canonical verification status") + assert result_join["env"]["CANONICAL_RESULT"] == "${{ needs.canonical.result }}" + assert '"${CANONICAL_RESULT}" != "success"' in result_join["run"] + assert "verify" in workflow["jobs"]["sonar"]["needs"] + + +def test_release_resolves_and_verifies_one_immutable_release_commit() -> None: + workflow = _load(RELEASE_PATH) + jobs = workflow["jobs"] + release_config = _load(RELEASE_CONFIG_PATH) + assert release_config["draft"] is True + assert release_config["force-tag-creation"] is True + assert jobs["release-please"]["outputs"]["sha"] == "${{ steps.rp.outputs.sha }}" + + resolve = jobs["resolve-release"] + assert resolve["needs"] == "release-please" + assert "github.event_name == 'push'" in resolve["if"] + assert "needs.release-please.result == 'success'" in resolve["if"] + assert "needs.release-please.outputs.release_created == 'true'" in resolve["if"] + assert resolve["permissions"] == {"contents": "read"} + resolution = _named_step(resolve, "Resolve and bind the immutable release commit")["run"] + assert 'expected_sha="${RELEASE_PLEASE_SHA}"' in resolution + assert 'tag_sha="$(git rev-parse --verify "${tag}^{commit}")"' in resolution + assert '"${tag_sha}" != "${expected_sha}"' in resolution + assert 'git merge-base --is-ancestor "${tag_sha}" origin/main' in resolution + assert '"${release_is_draft}" != "true"' in resolution + assert "release_sha=${tag_sha}" in resolution + assert "release_id=${release_id}" in resolution + assert "release_is_draft=${release_is_draft}" in resolution + + verify = jobs["verify-release"] + assert verify["needs"] == "resolve-release" + assert verify["uses"] == LOCAL_CANONICAL_WORKFLOW + assert verify["with"]["ref"] == "${{ needs.resolve-release.outputs.release_sha }}" + assert verify["with"]["base-rev"] == "${{ needs.resolve-release.outputs.base_sha }}" + + +def test_release_builds_and_smokes_the_verified_sha_before_publish() -> None: + workflow = _load(RELEASE_PATH) + jobs = workflow["jobs"] + build = jobs["build-release"] + assert set(build["needs"]) == {"resolve-release", "verify-release", "integration-docker-release"} + assert "needs.verify-release.result == 'success'" in build["if"] + assert "needs.integration-docker-release.result == 'success'" in build["if"] + assert build["permissions"] == {"contents": "read"} + + checkout = build["steps"][0] + assert checkout["with"]["ref"] == "${{ needs.resolve-release.outputs.release_sha }}" + binding = _named_step(build, "Reconfirm the exact verified release checkout")["run"] + assert 'actual_sha="$(git rev-parse HEAD)"' in binding + assert '"${actual_sha}" != "${EXPECTED_SHA}"' in binding + + names = [step.get("name") for step in build["steps"]] + corpus_index = names.index("Verify the contract corpus is bundled in both distributions (#537)") + wheel_smoke_index = names.index("Smoke-test the installed release wheel (#537)") + sdist_smoke_index = names.index("Smoke-test the installed release sdist (#537)") + upload_index = names.index("Upload the tested release distributions") + assert corpus_index < wheel_smoke_index < sdist_smoke_index < upload_index + + corpus = build["steps"][corpus_index]["run"] + assert "tarfile.open(sdists[0]" in corpus + assert "sdist is missing corpus payload" in corpus + + for smoke_index, distribution in ((wheel_smoke_index, "wheel"), (sdist_smoke_index, "sdist")): + smoke = build["steps"][smoke_index]["run"] + assert "uv pip install" in smoke + assert "env -u PYTHONPATH -u PYTHONHOME" in smoke + assert "conformance backend --profile provisioning-only" in smoke + assert 'installed_version = version("raes")' in smoke + assert 'installed_version != os.environ["EXPECTED_VERSION"]' in smoke + assert 'report.get("passed") is not True' in smoke + assert 'not report.get("cases")' in smoke + assert f"installed release {distribution}" in smoke + + +def test_release_requires_skip_free_real_docker_tests_at_the_exact_sha() -> None: + release = _load(RELEASE_PATH) + docker = release["jobs"]["integration-docker-release"] + assert set(docker["needs"]) == {"resolve-release", "verify-release"} + assert "needs.verify-release.result == 'success'" in docker["if"] + assert docker["permissions"] == {"contents": "read"} + assert "continue-on-error" not in docker + + checkout = docker["steps"][0] + assert checkout["with"]["ref"] == "${{ needs.resolve-release.outputs.release_sha }}" + binding = _named_step(docker, "Bind real-container testing to the exact release commit")["run"] + assert 'actual_sha="$(git rev-parse HEAD)"' in binding + assert '"${actual_sha}" != "${EXPECTED_SHA}"' in binding + + required = _named_step(docker, "Require real-container release integration") + assert required["env"]["RAES_DOCKER_INTEGRATION_REQUIRED"] == "1" + required_script = required["run"] + assert "-s integration_docker -- --junitxml=" in required_script + assert "if not cases:" in required_script + assert "if skipped:" in required_script + assert "collected zero tests" in required_script + assert "skipped tests" in required_script + + fixture = DOCKER_INTEGRATION_PATH.read_text(encoding="utf-8") + assert "RAES_DOCKER_INTEGRATION_REQUIRED" in fixture + assert "pytest.fail" in fixture + assert "sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc" in fixture + + optional = _load(CI_PATH)["jobs"]["integration-docker"] + assert optional["continue-on-error"] is True + assert "RAES_DOCKER_INTEGRATION_REQUIRED" not in str(optional) + + +def test_publication_is_split_retry_safe_and_finalizes_the_same_release() -> None: + workflow = _load(RELEASE_PATH) + jobs = workflow["jobs"] + publish_pypi = jobs["publish-pypi"] + assert set(publish_pypi["needs"]) == { + "resolve-release", + "verify-release", + "integration-docker-release", + "build-release", + } + assert "needs.verify-release.result == 'success'" in publish_pypi["if"] + assert "needs.integration-docker-release.result == 'success'" in publish_pypi["if"] + assert "needs.build-release.result == 'success'" in publish_pypi["if"] + assert publish_pypi["environment"] == "pypi" + assert publish_pypi["permissions"] == {"contents": "read", "id-token": "write"} + + for name, job in jobs.items(): + if name == "publish-pypi": + continue + assert job.get("environment") != "pypi" + assert job.get("permissions", {}).get("id-token") != "write" + + upload = _named_step(jobs["build-release"], "Upload the tested release distributions") + pypi_download = _named_step(publish_pypi, "Download the tested release distributions") + assert upload["with"]["name"] == pypi_download["with"]["name"] + assert pypi_download["with"]["path"] == "dist/" + pypi_names = [step.get("name") for step in publish_pypi["steps"]] + revalidate_index = pypi_names.index("Revalidate release identity immediately before PyPI") + publish_index = pypi_names.index("Publish to PyPI (OIDC trusted publishing)") + assert revalidate_index + 1 == publish_index + assert all(not step.get("uses", "").startswith("actions/checkout@") for step in publish_pypi["steps"]) + revalidation = publish_pypi["steps"][revalidate_index] + assert revalidation["env"] == { + "GH_TOKEN": "${{ github.token }}", + "EXPECTED_SHA": "${{ needs.resolve-release.outputs.release_sha }}", + "EXPECTED_TAG": "${{ needs.resolve-release.outputs.tag }}", + "EXPECTED_RELEASE_ID": "${{ needs.resolve-release.outputs.release_id }}", + "EXPECTED_DRAFT": "${{ needs.resolve-release.outputs.release_is_draft }}", + } + revalidation_script = revalidation["run"] + assert 'gh release view "${EXPECTED_TAG}"' in revalidation_script + assert '"${current_release_id}" != "${EXPECTED_RELEASE_ID}"' in revalidation_script + assert '"${current_draft}" != "${EXPECTED_DRAFT}"' in revalidation_script + assert '"${current_ref}" != "refs/tags/${EXPECTED_TAG}"' in revalidation_script + assert 'while [ "${current_type}" = "tag" ]' in revalidation_script + assert '"${current_sha}" != "${EXPECTED_SHA}"' in revalidation_script + + publish_github = jobs["publish-github"] + assert set(publish_github["needs"]) == { + "resolve-release", + "verify-release", + "build-release", + "publish-pypi", + } + assert "needs.publish-pypi.result == 'success'" in publish_github["if"] + assert publish_github["permissions"] == {"contents": "write"} + github_download = _named_step(publish_github, "Download the tested release distributions") + assert github_download["with"]["name"] == upload["with"]["name"] + finalization = _named_step(publish_github, "Revalidate, attach, and publish the GitHub Release")["run"] + assert '"${current_release_id}" != "${EXPECTED_RELEASE_ID}"' in finalization + assert '"${current_tag_sha}" != "${EXPECTED_SHA}"' in finalization + assert finalization.index("gh release upload") < finalization.index("gh release edit") + assert "--draft=false --verify-tag" in finalization + + sync = jobs["sync-dev"] + assert set(sync["needs"]) == {"release-please", "publish-github"} + assert "needs.publish-github.result == 'success'" in sync["if"] + + +def test_pre_pypi_identity_revalidation_dereferences_annotated_tag(tmp_path: Path) -> None: + result = _run_pypi_identity_revalidation( + tmp_path, + release_json='{"databaseId":1234,"isDraft":true,"tagName":"v3.4.5"}', + ref_json='{"ref":"refs/tags/v3.4.5","object":{"type":"tag","sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}', + tag_json='{"object":{"type":"commit","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}', + ) + + assert result.returncode == 0, result.stderr + assert "Revalidated Release 1234, v3.4.5" in result.stdout + + +def test_pre_pypi_identity_revalidation_rejects_replaced_release(tmp_path: Path) -> None: + result = _run_pypi_identity_revalidation( + tmp_path, + release_json='{"databaseId":9999,"isDraft":true,"tagName":"v3.4.5"}', + ref_json='{"ref":"refs/tags/v3.4.5","object":{"type":"commit","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}', + ) + + assert result.returncode != 0 + assert "Release object changed: expected id 1234, got 9999" in result.stderr + + +def test_pre_pypi_identity_revalidation_rejects_moved_tag(tmp_path: Path) -> None: + result = _run_pypi_identity_revalidation( + tmp_path, + release_json='{"databaseId":1234,"isDraft":true,"tagName":"v3.4.5"}', + ref_json='{"ref":"refs/tags/v3.4.5","object":{"type":"commit","sha":"cccccccccccccccccccccccccccccccccccccccc"}}', + ) + + assert result.returncode != 0 + assert f"Release tag moved: expected {'a' * 40}, got {'c' * 40}" in result.stderr + + +def test_release_gate_does_not_poll_mutable_check_or_branch_status() -> None: + release_text = RELEASE_PATH.read_text(encoding="utf-8").lower() + forbidden = ("gh run list", "check-runs", "/statuses/", "workflow_run") + assert all(token not in release_text for token in forbidden) + + +def test_publishing_workflows_pin_every_third_party_action_to_a_full_sha() -> None: + for path in (CANONICAL_PATH, CI_PATH, RELEASE_PATH): + for action_ref in _uses(_load(path)): + if action_ref.startswith("./"): + continue + assert FULL_SHA_USE.fullmatch(action_ref), f"{path.name}: unpinned action {action_ref!r}" diff --git a/noxfile.py b/noxfile.py index b529b5384..4b6875a67 100644 --- a/noxfile.py +++ b/noxfile.py @@ -901,7 +901,7 @@ def _finalize_parallel_coverage(session: nox.Session, coverage_dir: Path) -> Non def _run_docker_integration_tests(session: nox.Session, reporter: SessionReporter) -> None: reporter.run( "tests / pytest docker integration", - lambda: _run_pytest(session, "-m", "docker", "-v"), + lambda: _run_pytest(session, "-m", "docker", "-v", *session.posargs), ) @@ -1127,8 +1127,10 @@ def integration_docker(session: nox.Session) -> None: """Run the opt-in container-runtime integration tests (`docker` marker). Requires a real container runtime (docker/podman). The tests self-skip - cleanly when no runtime is available. This session is intentionally NOT - wired into `verify`, so the canonical verification graph stays hermetic. + cleanly when no runtime is available unless + `RAES_DOCKER_INTEGRATION_REQUIRED=1` selects the fail-closed release mode. + This session is intentionally NOT wired into `verify`, so the canonical + verification graph stays hermetic. """ reporter = SessionReporter(session, "integration_docker") try: diff --git a/release-please-config.json b/release-please-config.json index 3ac7ef616..ff0816749 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -1,5 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "draft": true, + "force-tag-creation": true, "include-component-in-tag": false, "packages": { ".": { From c39a354b7565290c1602ed916c41f18e4b194ecb Mon Sep 17 00:00:00 2001 From: Yernat Yestekov <2068106+doublewhy@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:01:03 -0700 Subject: [PATCH 2/4] test(release): standardize Docker gate assertions (#1110) --- .../python/tests/test_reference_backend_docker_gate.py | 9 +++++++-- implementations/python/tests/test_release_workflows.py | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/implementations/python/tests/test_reference_backend_docker_gate.py b/implementations/python/tests/test_reference_backend_docker_gate.py index 31bf95e3f..2e720ad3c 100644 --- a/implementations/python/tests/test_reference_backend_docker_gate.py +++ b/implementations/python/tests/test_reference_backend_docker_gate.py @@ -13,7 +13,9 @@ def test_docker_integration_uses_the_reviewed_multiarch_digest() -> None: - assert f"docker.io/library/alpine@{_REVIEWED_ALPINE_DIGEST}" == docker_integration._IMAGE + expected_image = f"docker.io/library/alpine@{_REVIEWED_ALPINE_DIGEST}" + + assert expected_image == docker_integration._IMAGE def test_optional_docker_integration_skips_without_runtime(monkeypatch: pytest.MonkeyPatch) -> None: @@ -77,7 +79,10 @@ def test_required_docker_integration_accepts_successful_pull(monkeypatch: pytest lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0), ) - assert docker_integration._require_container_runtime() == _RUNTIME + expected_runtime = _RUNTIME + actual_runtime = docker_integration._require_container_runtime() + + assert expected_runtime == actual_runtime def test_invalid_required_mode_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/implementations/python/tests/test_release_workflows.py b/implementations/python/tests/test_release_workflows.py index d36007abe..abf8f247b 100644 --- a/implementations/python/tests/test_release_workflows.py +++ b/implementations/python/tests/test_release_workflows.py @@ -155,7 +155,7 @@ def test_ci_uses_the_same_canonical_verifier_for_github_sha() -> None: workflow = _load(CI_PATH) assert workflow["permissions"] == {"contents": "read", "pull-requests": "write"} assert "interpreters" not in workflow["jobs"] - assert workflow["jobs"]["supply-chain"]["continue-on-error"] is True + assert "continue-on-error" not in workflow["jobs"]["supply-chain"] canonical = workflow["jobs"]["canonical"] assert canonical["uses"] == LOCAL_CANONICAL_WORKFLOW assert canonical["with"]["ref"] == "${{ github.sha }}" From bab92a6c82a4a00d5b92716a28cfab7c2c768c8e Mon Sep 17 00:00:00 2001 From: Yernat Yestekov <2068106+doublewhy@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:37:11 -0700 Subject: [PATCH 3/4] fix(release): bind retry-safe finalization by Release id (#1125) --- .github/workflows/release-please.yml | 74 +++++++- ...xact-sha-release-verification-preflight.md | 14 +- docs/explain/releasing.md | 8 +- .../python/tests/test_release_workflows.py | 173 +++++++++++++++++- 4 files changed, 258 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 673c90402..25e6e51c5 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -596,8 +596,17 @@ jobs: exit 1 fi if [ "${current_draft}" != "${EXPECTED_DRAFT}" ]; then - echo "Release draft state changed: expected ${EXPECTED_DRAFT}, got ${current_draft}" >&2 - exit 1 + if [ "${EXPECTED_DRAFT}" = "true" ] && [ "${current_draft}" = "false" ]; then + # A previous attempt may have made this exact Release public and + # then lost the API response or failed its final read. Prove the + # already-public assets byte-for-byte below before accepting it. + already_public_retry="true" + else + echo "Release draft state changed: expected ${EXPECTED_DRAFT}, got ${current_draft}" >&2 + exit 1 + fi + else + already_public_retry="false" fi git fetch --force --tags origin @@ -614,8 +623,55 @@ jobs: echo "Expected one tested wheel and sdist before attachment" >&2 exit 1 fi + + if [ "${already_public_retry}" = "true" ]; then + retry_dir="$(mktemp -d "${RUNNER_TEMP}/raes-release-retry.XXXXXX")" + gh release download "${EXPECTED_TAG}" --repo "${GITHUB_REPOSITORY}" \ + --dir "${retry_dir}" \ + --pattern "$(basename "${wheels[0]}")" \ + --pattern "$(basename "${sdists[0]}")" + if ! cmp -s "${wheels[0]}" "${retry_dir}/$(basename "${wheels[0]}")" \ + || ! cmp -s "${sdists[0]}" "${retry_dir}/$(basename "${sdists[0]}")"; then + echo "Already-public Release assets do not match the tested distributions" >&2 + exit 1 + fi + + retry_json="$( + gh release view "${EXPECTED_TAG}" --repo "${GITHUB_REPOSITORY}" \ + --json databaseId,isDraft,tagName + )" + if [ "$(jq -r '.databaseId' <<<"${retry_json}")" != "${EXPECTED_RELEASE_ID}" ] \ + || [ "$(jq -r '.tagName' <<<"${retry_json}")" != "${EXPECTED_TAG}" ] \ + || [ "$(jq -r '.isDraft' <<<"${retry_json}")" != "false" ]; then + echo "Already-public Release identity changed during retry verification" >&2 + exit 1 + fi + git fetch --force --tags origin + current_tag_sha="$(git rev-parse --verify "${EXPECTED_TAG}^{commit}")" + if [ "${current_tag_sha}" != "${EXPECTED_SHA}" ]; then + echo "Release tag moved during retry verification: expected ${EXPECTED_SHA}, got ${current_tag_sha}" >&2 + exit 1 + fi + echo "Exact Release ${EXPECTED_RELEASE_ID} was already public with the tested distributions" + exit 0 + fi + gh release upload "${EXPECTED_TAG}" "${wheels[0]}" "${sdists[0]}" --clobber + # Upload addresses a Release by tag. Re-read the object immediately + # afterward so a delete/recreate race cannot make a replacement + # Release public. Finalization itself addresses the numeric id. + prepublish_json="$( + gh release view "${EXPECTED_TAG}" --repo "${GITHUB_REPOSITORY}" \ + --json databaseId,isDraft,tagName + )" + if [ "$(jq -r '.databaseId' <<<"${prepublish_json}")" != "${EXPECTED_RELEASE_ID}" ] \ + || [ "$(jq -r '.tagName' <<<"${prepublish_json}")" != "${EXPECTED_TAG}" ] \ + || [ "$(jq -r '.isDraft' <<<"${prepublish_json}")" != "${EXPECTED_DRAFT}" ]; then + echo "Release identity changed during attachment; refusing public finalization" >&2 + exit 1 + fi + git fetch --force --tags origin current_tag_sha="$(git rev-parse --verify "${EXPECTED_TAG}^{commit}")" if [ "${current_tag_sha}" != "${EXPECTED_SHA}" ]; then @@ -623,7 +679,19 @@ jobs: exit 1 fi if [ "${EXPECTED_DRAFT}" = "true" ]; then - gh release edit "${EXPECTED_TAG}" --repo "${GITHUB_REPOSITORY}" --draft=false --verify-tag + finalized_json="$( + gh api --method PATCH \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2022-11-28' \ + "repos/${GITHUB_REPOSITORY}/releases/${EXPECTED_RELEASE_ID}" \ + -F draft=false + )" + if [ "$(jq -r '.id' <<<"${finalized_json}")" != "${EXPECTED_RELEASE_ID}" ] \ + || [ "$(jq -r '.tag_name' <<<"${finalized_json}")" != "${EXPECTED_TAG}" ] \ + || [ "$(jq -r '.draft' <<<"${finalized_json}")" != "false" ]; then + echo "GitHub Release finalization response changed the verified identity" >&2 + exit 1 + fi fi final_json="$( diff --git a/docs/decisions/issue-1125-gov-928-exact-sha-release-verification-preflight.md b/docs/decisions/issue-1125-gov-928-exact-sha-release-verification-preflight.md index 530843a37..537f22723 100644 --- a/docs/decisions/issue-1125-gov-928-exact-sha-release-verification-preflight.md +++ b/docs/decisions/issue-1125-gov-928-exact-sha-release-verification-preflight.md @@ -111,9 +111,13 @@ same-run artifact. Immediately after protected-environment approval and artifact download, it freshly revalidates the Release id, draft state, exact tag ref, and fully dereferenced commit SHA before invoking the pinned OIDC publisher. A separate GitHub publication job runs only after PyPI and repeats -the identity checks before attachment and public finalization. No status API, -polling, workflow-name matching, or stale mutable-ref observation is an -admissible publication gate. +the identity checks before attachment, re-reads the Release object after the +tag-addressed upload, and finalizes by the bound numeric Release id. A lost +successful-finalization response is retryable only when the same Release is +already public, both downloaded assets byte-match the tested distributions, +and the id, tag, and commit SHA still match. No status API, polling, +workflow-name matching, or stale mutable-ref observation is an admissible +publication gate. ### Test the built artifact before granting publication authority @@ -196,7 +200,9 @@ assert these structural invariants: precede the SHA-pinned PyPI action; - only `publish-pypi` has the `pypi` environment and `id-token: write`; - GitHub attachment is a separate retryable job that revalidates the Release - and tag, attaches artifacts, and only then removes draft state; and + and tag, attaches artifacts, revalidates the object after upload, and only + then removes draft state by numeric Release id. An already-public retry must + byte-match both attached distributions before it can succeed; and - proof acquisition, the canonical nox command, coverage upload, trusted publishing, and GitHub Release attachment remain present. diff --git a/docs/explain/releasing.md b/docs/explain/releasing.md index b7314ed6b..893b46aeb 100644 --- a/docs/explain/releasing.md +++ b/docs/explain/releasing.md @@ -40,9 +40,13 @@ verification graph to pass for the exact commit named by the release (GOV-928). Release object id, draft state, exact tag ref, and fully dereferenced commit SHA immediately before its pinned OIDC publisher runs. A separate GitHub-only job performs the same identity checks again, attaches the artifacts, and - makes the draft public. Keeping these jobs separate means a failed + re-reads the Release identity after attachment before making the exact + numeric Release id public. Keeping these jobs separate means a failed attachment/finalization can be retried without attempting a second PyPI - upload. + upload. If the public-finalization response was lost after GitHub applied + it, the retry accepts the already-public Release only after downloading and + byte-comparing both attached distributions and rechecking the id, tag, and + commit SHA. Nothing is hand-run, and feature PRs never touch `CHANGELOG.md` (release-please owns it) — no fragment collisions. diff --git a/implementations/python/tests/test_release_workflows.py b/implementations/python/tests/test_release_workflows.py index abf8f247b..cfc237eff 100644 --- a/implementations/python/tests/test_release_workflows.py +++ b/implementations/python/tests/test_release_workflows.py @@ -107,6 +107,116 @@ def _run_pypi_identity_revalidation( ) +def _run_github_finalization( + tmp_path: Path, + *, + release_states: list[str], + mismatched_download: bool = False, +) -> subprocess.CompletedProcess[str]: + if shutil.which("bash") is None or shutil.which("jq") is None: + pytest.skip("the release finalization shell policy requires bash and jq") + + script = _named_step( + _load(RELEASE_PATH)["jobs"]["publish-github"], + "Revalidate, attach, and publish the GitHub Release", + )["run"] + dist = tmp_path / "dist" + dist.mkdir() + (dist / "raes-3.4.5-py3-none-any.whl").write_bytes(b"tested wheel") + (dist / "raes-3.4.5.tar.gz").write_bytes(b"tested sdist") + + state_file = tmp_path / "release-states.jsonl" + state_file.write_text("\n".join(release_states) + "\n", encoding="utf-8") + state_counter = tmp_path / "release-state-counter" + state_counter.write_text("0\n", encoding="utf-8") + call_log = tmp_path / "gh-calls.log" + + gh_stub = tmp_path / "gh" + gh_stub.write_text( + """#!/bin/sh +set -eu +case "${1-}:${2-}" in + release:view) + index="$(cat "$STATE_COUNTER")" + index=$((index + 1)) + printf '%s\n' "$index" > "$STATE_COUNTER" + sed -n "${index}p" "$STATE_FILE" + ;; + release:upload) + printf '%s\n' upload >> "$CALL_LOG" + ;; + release:download) + printf '%s\n' download >> "$CALL_LOG" + shift 2 + destination="" + while [ "$#" -gt 0 ]; do + case "$1" in + --dir) destination="$2"; shift 2 ;; + *) shift ;; + esac + done + test -n "$destination" + mkdir -p "$destination" + cp "$TEST_DIST_SOURCE"/* "$destination"/ + if [ "$MISMATCH_DOWNLOAD" = "1" ]; then + printf '%s\n' tampered > "$destination/raes-3.4.5-py3-none-any.whl" + fi + ;; + api:*) + printf '%s\n' patch >> "$CALL_LOG" + printf '%s\n' '{"id":1234,"tag_name":"v3.4.5","draft":false}' + ;; + *) + echo "unexpected gh request: $*" >&2 + exit 64 + ;; +esac +""", + encoding="utf-8", + ) + gh_stub.chmod(0o700) + + git_stub = tmp_path / "git" + git_stub.write_text( + """#!/bin/sh +set -eu +case "${1-}:${2-}" in + fetch:*) exit 0 ;; + rev-parse:HEAD) printf '%s\n' "$EXPECTED_SHA" ;; + rev-parse:--verify) printf '%s\n' "$EXPECTED_SHA" ;; + *) echo "unexpected git request: $*" >&2; exit 64 ;; +esac +""", + encoding="utf-8", + ) + git_stub.chmod(0o700) + + environment = { + **os.environ, + "PATH": f"{tmp_path}{os.pathsep}{os.environ['PATH']}", + "GH_TOKEN": "test-token", + "GITHUB_REPOSITORY": "OpenRAE/rae", + "RUNNER_TEMP": str(tmp_path), + "EXPECTED_SHA": "a" * 40, + "EXPECTED_TAG": "v3.4.5", + "EXPECTED_RELEASE_ID": "1234", + "EXPECTED_DRAFT": "true", + "STATE_FILE": str(state_file), + "STATE_COUNTER": str(state_counter), + "CALL_LOG": str(call_log), + "TEST_DIST_SOURCE": str(dist), + "MISMATCH_DOWNLOAD": "1" if mismatched_download else "0", + } + return subprocess.run( + ["bash", "-c", script], + check=False, + capture_output=True, + text=True, + cwd=tmp_path, + env=environment, + ) + + def test_canonical_verifier_requires_and_checks_out_an_exact_commit_sha() -> None: workflow = _load(CANONICAL_PATH) inputs = workflow["on"]["workflow_call"]["inputs"] @@ -335,8 +445,13 @@ def test_publication_is_split_retry_safe_and_finalizes_the_same_release() -> Non finalization = _named_step(publish_github, "Revalidate, attach, and publish the GitHub Release")["run"] assert '"${current_release_id}" != "${EXPECTED_RELEASE_ID}"' in finalization assert '"${current_tag_sha}" != "${EXPECTED_SHA}"' in finalization - assert finalization.index("gh release upload") < finalization.index("gh release edit") - assert "--draft=false --verify-tag" in finalization + assert finalization.index("gh release upload") < finalization.index("prepublish_json") + assert finalization.index("prepublish_json") < finalization.index("--method PATCH") + assert '"repos/${GITHUB_REPOSITORY}/releases/${EXPECTED_RELEASE_ID}"' in finalization + assert "-F draft=false" in finalization + assert "Already-public Release assets do not match the tested distributions" in finalization + assert 'gh release download "${EXPECTED_TAG}"' in finalization + assert 'cmp -s "${wheels[0]}"' in finalization sync = jobs["sync-dev"] assert set(sync["needs"]) == {"release-please", "publish-github"} @@ -377,6 +492,60 @@ def test_pre_pypi_identity_revalidation_rejects_moved_tag(tmp_path: Path) -> Non assert f"Release tag moved: expected {'a' * 40}, got {'c' * 40}" in result.stderr +def test_github_finalization_revalidates_release_object_after_attachment(tmp_path: Path) -> None: + result = _run_github_finalization( + tmp_path, + release_states=[ + '{"databaseId":1234,"isDraft":true,"tagName":"v3.4.5"}', + '{"databaseId":9999,"isDraft":true,"tagName":"v3.4.5"}', + ], + ) + + assert result.returncode != 0 + assert "Release identity changed during attachment; refusing public finalization" in result.stderr + assert (tmp_path / "gh-calls.log").read_text(encoding="utf-8").splitlines() == ["upload"] + + +def test_github_finalization_uses_bound_id_and_accepts_verified_response(tmp_path: Path) -> None: + result = _run_github_finalization( + tmp_path, + release_states=[ + '{"databaseId":1234,"isDraft":true,"tagName":"v3.4.5"}', + '{"databaseId":1234,"isDraft":true,"tagName":"v3.4.5"}', + '{"databaseId":1234,"isDraft":false,"tagName":"v3.4.5"}', + ], + ) + + assert result.returncode == 0, result.stderr + assert (tmp_path / "gh-calls.log").read_text(encoding="utf-8").splitlines() == ["upload", "patch"] + + +def test_github_finalization_accepts_matching_already_public_retry(tmp_path: Path) -> None: + result = _run_github_finalization( + tmp_path, + release_states=[ + '{"databaseId":1234,"isDraft":false,"tagName":"v3.4.5"}', + '{"databaseId":1234,"isDraft":false,"tagName":"v3.4.5"}', + ], + ) + + assert result.returncode == 0, result.stderr + assert "was already public with the tested distributions" in result.stdout + assert (tmp_path / "gh-calls.log").read_text(encoding="utf-8").splitlines() == ["download"] + + +def test_github_finalization_rejects_mismatched_already_public_assets(tmp_path: Path) -> None: + result = _run_github_finalization( + tmp_path, + release_states=['{"databaseId":1234,"isDraft":false,"tagName":"v3.4.5"}'], + mismatched_download=True, + ) + + assert result.returncode != 0 + assert "Already-public Release assets do not match the tested distributions" in result.stderr + assert (tmp_path / "gh-calls.log").read_text(encoding="utf-8").splitlines() == ["download"] + + def test_release_gate_does_not_poll_mutable_check_or_branch_status() -> None: release_text = RELEASE_PATH.read_text(encoding="utf-8").lower() forbidden = ("gh run list", "check-runs", "/statuses/", "workflow_run") From 3ee2523a8261dd562c27fbe9b57f28dcd1daac55 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Fri, 14 Aug 2026 21:45:04 +0200 Subject: [PATCH 4/4] Prevent release verification cache writes --- .github/workflows/canonical-verification.yml | 4 +++- implementations/python/tests/test_release_workflows.py | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/canonical-verification.yml b/.github/workflows/canonical-verification.yml index 8daf1c021..719394a5a 100644 --- a/.github/workflows/canonical-verification.yml +++ b/.github/workflows/canonical-verification.yml @@ -77,7 +77,9 @@ jobs: uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v8 - name: Restore pinned Isabelle archive - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + # Untrusted PR and manual-release refs may consume this cache but must + # never populate the shared key used by protected-branch verification. + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .cache/raes-sdl/tooling/archives/Isabelle2025-2_linux.tar.gz key: isabelle-linux-x86-64-2025-2-a20a507bc7c1270d diff --git a/implementations/python/tests/test_release_workflows.py b/implementations/python/tests/test_release_workflows.py index da1f0d5c2..acb9900a4 100644 --- a/implementations/python/tests/test_release_workflows.py +++ b/implementations/python/tests/test_release_workflows.py @@ -250,6 +250,8 @@ def test_canonical_verifier_preserves_proof_install_and_full_verify_graph() -> N assert "Acquire pinned Isabelle distribution" in step_names assert "Resolve requirement UID from branch" in step_names + cache_restore = _named_step(job, "Restore pinned Isabelle archive") + assert cache_restore["uses"].startswith("actions/cache/restore@") acquire = _named_step(job, "Acquire pinned Isabelle distribution") assert "tools.isabelle_tool acquire" in acquire["run"] sandbox = _named_step(job, "Install proof sandbox")["run"]