From 07b5699bb16e8cbf1bf76ee1bdc5d12fd224d88a Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 26 Aug 2026 13:54:47 -0400 Subject: [PATCH] ci: bind launcher releases to the release app --- .github/release-health.json | 14 +- .github/workflows/release-and-publish.yml | 448 ++++++++++++++++------ docs/platform-manifest.md | 20 +- scripts/check_release_health.py | 13 +- tests/test_platform_manifest_drift.py | 22 +- tests/test_release_lock.py | 141 +++++-- 6 files changed, 467 insertions(+), 191 deletions(-) diff --git a/.github/release-health.json b/.github/release-health.json index ce183d88d..0db0dc33f 100644 --- a/.github/release-health.json +++ b/.github/release-health.json @@ -2,13 +2,11 @@ "$comment": [ "Input to scripts/check_release_health.py. One entry per RELEASE LANE:", "a tag namespace plus the workflow that is supposed to publish it.", - "This repository's release-and-publish.yml runs on every push to main under", - "concurrency group 'release' with cancel-in-progress: false, so GitHub", - "cancels the PENDING run whenever a third push arrives. Runs 30275153205", - "and 30276970325 are exactly that, and both were harmless because a later", - "run released the same commits. The detector is state-based for this", - "reason: it asks whether anything is still unpublished, not whether a run", - "was cancelled." + "release-and-publish.yml accepts a reviewed version from protected main.", + "The founder-approved release App creates only its annotated tag.", + "The tag run publishes through the protected pypi environment and verifies", + "the PyPI and GitHub bytes. The detector remains state-based: it asks", + "whether a release is still unpublished, not whether one run failed." ], "repository": "OpenAdaptAI/OpenAdapt", "branch": "main", @@ -27,7 +25,7 @@ "unreleased_grace_hours": 4, "tag_without_release_grace_hours": 1, "pypi_lag_grace_minutes": 30, - "remediation": "# release-and-publish.yml runs on push to main; re-run the newest run on main.\ngh run rerun --repo OpenAdaptAI/OpenAdapt \"$(gh run list --repo OpenAdaptAI/OpenAdapt --workflow=release-and-publish.yml --branch main --limit 1 --json databaseId --jq '.[0].databaseId')\"" + "remediation": "# First merge the reviewed version, changelog, lockfile, and candidate state. Then create the tag from exact main.\ngh workflow run release-and-publish.yml --repo OpenAdaptAI/OpenAdapt --ref main -f version=\n# If that exact tag run partially failed, rerun it. Do not create a recovery tag.\ngh run rerun --repo OpenAdaptAI/OpenAdapt " } ] } diff --git a/.github/workflows/release-and-publish.yml b/.github/workflows/release-and-publish.yml index 1e4ce898b..7f11c0a09 100644 --- a/.github/workflows/release-and-publish.yml +++ b/.github/workflows/release-and-publish.yml @@ -1,81 +1,138 @@ name: Release and PyPI Publish -# Release credentials are deliberately separated from build and publication: -# - release: may update protected main and create the tag/release; -# - build-and-attest: can only read source and issue artifact attestations; -# - publish-pypi: receives only the project-scoped PyPI token; -# - publish-github: may only attach assets to the GitHub Release. -# - reconcile-platform-manifest: updates main only after PyPI exposes the exact -# released bytes, then validates the complete platform contract. +# A release starts from a version, changelog, and lockfile that reached the +# exact protected main branch through review. The release App can create only +# the annotated tag. It cannot push a version commit or any other main commit. # -# Every third-party action is pinned to a full commit SHA. Dependabot should -# propose reviewed SHA updates rather than following mutable tags. +# A protected v* tag starts the build, attestation, PyPI Trusted Publishing, +# GitHub Release, and publication-verification jobs. Rerun that exact tag run +# after a partial publication failure. Do not create a recovery tag. on: + workflow_dispatch: + inputs: + version: + description: "Reviewed version on the exact protected main branch" + required: true + type: string push: - branches: - - main + tags: + - "v*" permissions: contents: read -# Temporary compatibility pin for GitPython 3.1.60. Upstream PR #1477 removes -# python-semantic-release's use of Actor.name_email_regex. Return to the pinned -# published actions after upstream ships that fix in a release. -env: - SEMANTIC_RELEASE_SOURCE: "python-semantic-release @ git+https://github.com/python-semantic-release/python-semantic-release.git@4ad93f1f2a70e092612e9b1709c01ebbb0d35434" - +# A tag run is the recovery unit. Do not cancel it when another release starts. concurrency: - group: release + group: release-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: false jobs: - release: + create-release-tag: + if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + environment: release-identity permissions: contents: write - outputs: - released: ${{ steps.release.outputs.released || 'false' }} - tag: ${{ steps.release.outputs.tag || '' }} steps: - - name: Checkout repository + - name: Create a repository-scoped release App token + id: release-app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }} + private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + + - name: Checkout the exact dispatched main commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.sha }} fetch-depth: 0 - token: ${{ secrets.ADMIN_TOKEN }} + token: ${{ steps.release-app.outputs.token }} - - name: Skip the generated release commit - id: check_skip - run: | - if [ "$(git log -1 --pretty=format:'%an')" = "OpenAdapt Bot" ]; then - echo "skip=true" >> "$GITHUB_OUTPUT" - fi - - - name: Set up Python for Semantic Release - if: steps.check_skip.outputs.skip != 'true' + - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - - name: Install compatible Semantic Release - if: steps.check_skip.outputs.skip != 'true' - run: python -m pip install --disable-pip-version-check "$SEMANTIC_RELEASE_SOURCE" + - name: Require an exact reviewed release candidate + id: candidate + env: + REQUESTED_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if [ "$GITHUB_EVENT_NAME" != "workflow_dispatch" ]; then + echo "Tag creation requires workflow_dispatch." >&2 + exit 1 + fi + if [ "$GITHUB_REF" != "refs/heads/main" ]; then + echo "Tag creation requires refs/heads/main, not $GITHUB_REF." >&2 + exit 1 + fi + if [ "$GITHUB_REPOSITORY" != "OpenAdaptAI/OpenAdapt" ]; then + echo "Tag creation requires OpenAdaptAI/OpenAdapt." >&2 + exit 1 + fi + + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + current_main="$(git rev-parse refs/remotes/origin/main)" + if [ "$current_main" != "$GITHUB_SHA" ]; then + echo "The dispatched commit is stale. main=$current_main dispatch=$GITHUB_SHA." >&2 + exit 1 + fi + + project_version="$(python - <<'PY' + import tomllib + from pathlib import Path + + with Path("pyproject.toml").open("rb") as stream: + print(tomllib.load(stream)["project"]["version"]) + PY + )" + if [ -z "$REQUESTED_VERSION" ] || [ "$REQUESTED_VERSION" != "$project_version" ]; then + echo "Version mismatch: input=$REQUESTED_VERSION pyproject=$project_version." >&2 + exit 1 + fi - - name: Python Semantic Release - if: steps.check_skip.outputs.skip != 'true' - id: release + tag="v${project_version}" + python - "$project_version" <<'PY' + import re + import sys + from pathlib import Path + + version = sys.argv[1] + changelog = Path("CHANGELOG.md").read_text(encoding="utf-8") + headers = re.findall(r"^## v([^ ]+) \(\d{4}-\d{2}-\d{2}\)$", changelog, re.MULTILINE) + if not headers or headers[0] != version: + actual = headers[0] if headers else "missing" + raise SystemExit( + f"CHANGELOG.md must start with v{version}; found v{actual}." + ) + PY + python scripts/verify_release_lock.py + + git fetch --force --tags origin + if git show-ref --verify --quiet "refs/tags/$tag"; then + echo "Tag $tag already exists. Rerun its tag workflow for recovery." >&2 + exit 1 + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + - name: Create and push only the annotated release tag env: - GH_TOKEN: ${{ secrets.ADMIN_TOKEN }} + RELEASE_TAG: ${{ steps.candidate.outputs.tag }} run: | - git config user.name "OpenAdapt Bot" - git config user.email "bot@openadapt.ai" - export GIT_COMMIT_AUTHOR="OpenAdapt Bot " - semantic-release -v version + set -euo pipefail + git config user.name "openadapt-release[bot]" + git config user.email "openadapt-release[bot]@users.noreply.github.com" + git tag -a "$RELEASE_TAG" "$GITHUB_SHA" -m "OpenAdapt ${RELEASE_TAG#v}" + git push origin "refs/tags/$RELEASE_TAG" build-and-attest: - needs: release - if: needs.release.outputs.released == 'true' + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest permissions: contents: read @@ -83,25 +140,82 @@ jobs: attestations: write steps: - - name: Checkout released tag + - name: Checkout the exact release tag uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ needs.release.outputs.tag }} + ref: ${{ github.ref }} + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" + - name: Require the release App tag and exact candidate state + env: + EXPECTED_ACTOR: openadapt-release[bot] + run: | + set -euo pipefail + if [ "$GITHUB_EVENT_NAME" != "push" ] || [ "$GITHUB_REF_TYPE" != "tag" ]; then + echo "Publication requires a tag push event." >&2 + exit 1 + fi + if [ "$GITHUB_ACTOR" != "$EXPECTED_ACTOR" ]; then + echo "Publication requires $EXPECTED_ACTOR, not $GITHUB_ACTOR." >&2 + exit 1 + fi + + project_version="$(python - <<'PY' + import tomllib + from pathlib import Path + + with Path("pyproject.toml").open("rb") as stream: + print(tomllib.load(stream)["project"]["version"]) + PY + )" + expected_tag="v${project_version}" + if [ "$GITHUB_REF" != "refs/tags/$expected_tag" ] || [ "$GITHUB_REF_NAME" != "$expected_tag" ]; then + echo "Tag mismatch: ref=$GITHUB_REF version=$project_version." >&2 + exit 1 + fi + if [ "$(git rev-list -n 1 "$GITHUB_REF_NAME")" != "$(git rev-parse HEAD)" ]; then + echo "The release tag does not resolve to the checked-out commit." >&2 + exit 1 + fi + + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + if ! git merge-base --is-ancestor HEAD refs/remotes/origin/main; then + echo "The release tag is not contained in protected main." >&2 + exit 1 + fi + + python - "$project_version" <<'PY' + import re + import sys + from pathlib import Path + + version = sys.argv[1] + changelog = Path("CHANGELOG.md").read_text(encoding="utf-8") + headers = re.findall(r"^## v([^ ]+) \(\d{4}-\d{2}-\d{2}\)$", changelog, re.MULTILINE) + if not headers or headers[0] != version: + actual = headers[0] if headers else "missing" + raise SystemExit( + f"CHANGELOG.md must start with v{version}; found v{actual}." + ) + PY + python scripts/verify_release_lock.py + - name: Install uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: version: "0.11.29" enable-cache: false + - name: Check the public source boundary + run: python scripts/check_source_boundary.py + - name: Build exact release artifacts run: | - python scripts/verify_release_lock.py uv build --wheel --sdist python scripts/verify_release_artifacts.py @@ -115,7 +229,7 @@ jobs: - name: Transfer release artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: release-dists-${{ needs.release.outputs.tag }} + name: release-dists-${{ github.ref_name }} path: | dist/*.whl dist/*.tar.gz @@ -123,153 +237,259 @@ jobs: retention-days: 1 publish-pypi: - needs: [release, build-and-attest] + needs: build-and-attest + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest + environment: pypi permissions: contents: read + id-token: write steps: - name: Download attested release artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: release-dists-${{ needs.release.outputs.tag }} + name: release-dists-${{ github.ref_name }} path: dist/ - # The launcher currently uses a project-scoped API token. GitHub's - # repository attestation above remains verifiable; PyPI-native PEP 740 - # attestations require a separate Trusted Publisher settings migration. - - name: Publish to PyPI + - name: Publish to PyPI with Trusted Publishing uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: - user: __token__ - password: ${{ secrets.PYPI_TOKEN }} skip-existing: true - attestations: false publish-github: - needs: [release, build-and-attest] + needs: [build-and-attest, publish-pypi] + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest permissions: contents: write steps: - # The publish action resolves the configured release group from the - # current branch. Keep this checkout attached to main; the exact release - # is still selected by `tag` and the exact bytes come from the attested - # artifact transfer below. - - name: Checkout release branch + - name: Checkout the exact release tag uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: main + ref: ${{ github.ref }} fetch-depth: 0 - - name: Set up Python for Semantic Release + - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - - name: Install compatible Semantic Release - run: python -m pip install --disable-pip-version-check "$SEMANTIC_RELEASE_SOURCE" - - name: Download attested release artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: release-dists-${{ needs.release.outputs.tag }} + name: release-dists-${{ github.ref_name }} path: dist/ - - name: Attach artifacts to GitHub Release + - name: Publish the GitHub Release and exact artifacts env: GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ needs.release.outputs.tag }} - run: semantic-release -v publish --tag "$RELEASE_TAG" + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + python - "$RELEASE_TAG" > /tmp/release-notes.md <<'PY' + import re + import sys + from pathlib import Path + + tag = sys.argv[1] + changelog = Path("CHANGELOG.md").read_text(encoding="utf-8") + match = re.search( + rf"^## {re.escape(tag)} \(\d{{4}}-\d{{2}}-\d{{2}}\)\n" + rf"(?P.*?)(?=^## v|\Z)", + changelog, + re.MULTILINE | re.DOTALL, + ) + if match is None: + raise SystemExit(f"CHANGELOG.md has no release notes for {tag}.") + print(match.group("body").strip()) + PY + + if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release edit "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --title "$RELEASE_TAG" \ + --notes-file /tmp/release-notes.md \ + --latest + else + gh release create "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --title "$RELEASE_TAG" \ + --notes-file /tmp/release-notes.md \ + --latest + fi + gh release upload "$RELEASE_TAG" dist/*.whl dist/*.tar.gz \ + --repo "$GITHUB_REPOSITORY" \ + --clobber - reconcile-platform-manifest: - needs: [release, publish-pypi] - if: >- - ${{ needs.release.outputs.released == 'true' && - needs.publish-pypi.result == 'success' }} + verify-publication: + needs: [build-and-attest, publish-pypi, publish-github] + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest permissions: - contents: write + contents: read + issues: write steps: - - name: Checkout released main + - name: Checkout the exact release tag uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: main + ref: ${{ github.ref }} fetch-depth: 0 - token: ${{ secrets.ADMIN_TOKEN }} - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - - name: Regenerate and validate the published platform manifest + - name: Download the attested source artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-dists-${{ github.ref_name }} + path: dist/ + + - name: Bind the published PyPI files to the exact release artifacts + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} run: | + set -euo pipefail for attempt in $(seq 1 12); do - if python scripts/generate_platform_manifest.py; then + if python scripts/generate_platform_manifest.py \ + --output /tmp/published-platform-manifest.json \ + --report-output /tmp/published-platform-report.md; then break fi if [ "$attempt" -eq 12 ]; then echo "PyPI did not expose the released launcher artifacts in time." >&2 exit 1 fi - echo "Published artifacts are not visible yet; retrying in 15 seconds." sleep 15 done - # --require-network: at the release gate an unreachable PyPI must - # fail. Elsewhere it only warns, because an index outage is not - # evidence of drift and a flaky guard stops being read. - python scripts/validate_platform_manifest.py --require-network - python scripts/validate_platform_manifest.py --offline + python scripts/validate_platform_manifest.py \ + --manifest /tmp/published-platform-manifest.json \ + --report /tmp/published-platform-report.md \ + --require-network + + python - <<'PY' + import json + import urllib.request + from pathlib import Path + + manifest = json.loads( + Path("/tmp/published-platform-manifest.json").read_text(encoding="utf-8") + ) + target = Path("pypi-dist") + target.mkdir() + for artifact in manifest["components"]["launcher"]["artifacts"]: + urllib.request.urlretrieve(artifact["url"], target / artifact["filename"]) + PY + python scripts/verify_release_artifacts.py dist \ + | sed 's# dist/# #' > /tmp/source.sha256 + python scripts/verify_release_artifacts.py pypi-dist \ + | sed 's# pypi-dist/# #' > /tmp/pypi.sha256 + diff -u /tmp/source.sha256 /tmp/pypi.sha256 + + - name: Bind the GitHub Release files to the exact release artifacts + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + mkdir github-dist + gh release download "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --pattern "*.whl" \ + --pattern "*.tar.gz" \ + --dir github-dist + python scripts/verify_release_artifacts.py github-dist \ + | sed 's# github-dist/# #' > /tmp/github.sha256 + diff -u /tmp/source.sha256 /tmp/github.sha256 + + - name: Record whether the committed platform manifest needs a reviewed update + id: manifest-drift + run: | + set -euo pipefail + if cmp -s platform-manifest.json /tmp/published-platform-manifest.json && \ + cmp -s docs/platform-compatibility-report.md /tmp/published-platform-report.md; then + echo "current=true" >> "$GITHUB_OUTPUT" + else + echo "current=false" >> "$GITHUB_OUTPUT" + fi - - name: Commit the reconciled manifest + - name: Retain the exact published platform manifest + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: published-platform-manifest-${{ github.ref_name }} + path: | + /tmp/published-platform-manifest.json + /tmp/published-platform-report.md + if-no-files-found: error + retention-days: 30 + + - name: Report the required reviewed manifest update + if: steps.manifest-drift.outputs.current != 'true' + env: + GH_TOKEN: ${{ github.token }} + TITLE: "platform-manifest.json needs the published launcher release" + BODY: >- + Release ${{ github.ref_name }} passed PyPI and GitHub byte verification. + The committed platform manifest still needs a reviewed update. Download + the generated manifest and report from + ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}. run: | - if git diff --quiet -- \ - platform-manifest.json docs/platform-compatibility-report.md; then - echo "The platform BOM already matches the published release." - exit 0 + set -euo pipefail + existing="$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ + --search "in:title \"$TITLE\"" --json number,title \ + --jq '[.[] | select(.title == env.TITLE)][0].number // empty')" + if [ -n "$existing" ]; then + gh issue edit "$existing" --repo "$GITHUB_REPOSITORY" --body "$BODY" + else + gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body "$BODY" fi - git config user.name "OpenAdapt Bot" - git config user.email "bot@openadapt.ai" - git add platform-manifest.json docs/platform-compatibility-report.md - git commit -m "chore(release): reconcile platform BOM" - git push origin HEAD:main report-release-failure: needs: - [release, build-and-attest, publish-pypi, publish-github, reconcile-platform-manifest] + - create-release-tag + - build-and-attest + - publish-pypi + - publish-github + - verify-publication if: >- ${{ always() && - (needs.release.result == 'failure' || + (needs.create-release-tag.result == 'failure' || needs.build-and-attest.result == 'failure' || needs.publish-pypi.result == 'failure' || needs.publish-github.result == 'failure' || - needs.reconcile-platform-manifest.result == 'failure') }} + needs.verify-publication.result == 'failure') }} runs-on: ubuntu-latest permissions: issues: write steps: - - name: File or update release failure issue + - name: File or update the release failure issue env: GH_TOKEN: ${{ github.token }} - RELEASE_RESULT: ${{ needs.release.result }} + CREATE_TAG_RESULT: ${{ needs.create-release-tag.result }} BUILD_RESULT: ${{ needs.build-and-attest.result }} PYPI_RESULT: ${{ needs.publish-pypi.result }} GITHUB_RESULT: ${{ needs.publish-github.result }} - MANIFEST_RESULT: ${{ needs.reconcile-platform-manifest.result }} + VERIFY_RESULT: ${{ needs.verify-publication.result }} run: | - TITLE="Release workflow failed on main" + set -euo pipefail + TITLE="Release workflow failed" BODY="The release workflow failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - release=${RELEASE_RESULT}, build-and-attest=${BUILD_RESULT}, publish-pypi=${PYPI_RESULT}, publish-github=${GITHUB_RESULT}, reconcile-platform-manifest=${MANIFEST_RESULT}. + create-tag=${CREATE_TAG_RESULT}, build-and-attest=${BUILD_RESULT}, publish-pypi=${PYPI_RESULT}, publish-github=${GITHUB_RESULT}, verify-publication=${VERIFY_RESULT}. - Until this is fixed, do not assume that the Git tag, PyPI files, GitHub assets, and attestations are mutually complete." - EXISTING=$(gh issue list --repo "${{ github.repository }}" --state open --search "in:title \"$TITLE\"" --json number --jq '.[0].number // empty') - if [ -n "$EXISTING" ]; then - gh issue comment "$EXISTING" --repo "${{ github.repository }}" --body "$BODY" + If the annotated tag exists, rerun this exact tag workflow. Do not create a recovery tag." + existing="$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ + --search "in:title \"$TITLE\"" --json number,title \ + --jq '[.[] | select(.title == env.TITLE)][0].number // empty')" + if [ -n "$existing" ]; then + gh issue edit "$existing" --repo "$GITHUB_REPOSITORY" --body "$BODY" else - gh issue create --repo "${{ github.repository }}" --title "$TITLE" --body "$BODY" + gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body "$BODY" fi diff --git a/docs/platform-manifest.md b/docs/platform-manifest.md index 39f330699..e4c0b8dbb 100644 --- a/docs/platform-manifest.md +++ b/docs/platform-manifest.md @@ -44,11 +44,11 @@ ahead of PyPI), pass `--allow-unreleased-launcher`; the manifest still records the published version. The launcher release workflow regenerates and validates the manifest after the -new launcher artifacts are visible on PyPI, then commits the exact published -URLs and digests back to `main`. This ordering is intentional: the manifest -cannot truthfully name a launcher release before its immutable artifacts exist. -If publication or reconciliation fails, `main` remains red and the release -workflow opens or updates a failure issue rather than weakening validation. +new launcher artifacts are visible on PyPI. It retains the exact generated +files as a workflow artifact and opens an issue for a reviewed update to +`main`. The release App can't push a manifest commit to the protected branch. +The manifest also can't name a launcher release before its immutable artifacts +exist. Regenerate and commit the manifest manually after other component releases. The daily validator catches component, sidecar, and public-status drift. @@ -124,12 +124,10 @@ are properties of the check rather than of the manifest: The schedule is now daily and the job is stdlib-only (no install, no cache), costing seconds a day. 2. **The check failed for a normal condition after every release, so a real failure was - invisible.** Two conditions were classified as errors when they are normal: - the semantic-release version commit leaves `pyproject.toml` ahead of the - not-yet-reconciled manifest, and PyPI's `info.version` lags an upload by - minutes. Both now warn, while every digest, URL, and filename comparison - stays fatal. A guard that cries wolf at every release gets ignored, and - that is exactly what happened. + invisible.** The reviewed version commit leaves `pyproject.toml` ahead of + the published manifest. PyPI's `info.version` can also lag an upload by + several minutes. Both states now warn. Every digest, URL, and filename + mismatch still fails. The old false failures hid a real failure. A failed scheduled or dispatched run also files (or comments on) a `platform-manifest.json has drifted` issue, so drift has an owner rather than diff --git a/scripts/check_release_health.py b/scripts/check_release_health.py index 87f3f2089..8c03b271f 100644 --- a/scripts/check_release_health.py +++ b/scripts/check_release_health.py @@ -20,13 +20,12 @@ ``skipped`` are not ``failure``. The check is deliberately state-based rather than event-based. "A release run -was cancelled" is not the alarm; "a release-worthy commit or a tag is still -unpublished" is. That distinction matters because the launcher's release -workflow fires on every push to main under a ``concurrency: release`` group, so -routine supersession cancellations are constant and benign. A guard that fired -on them would go the way of the platform-manifest drift check, which was -correct but had been benignly red after every single release until nobody could -tell its real signal from its noise. +failed" is not the alarm; "a release-worthy commit or a tag is still +unpublished" is. The launcher's reviewed main-branch dispatch creates one +annotated tag, and the tag run is the recovery unit. A guard that fired on one +failed run would go the way of the platform-manifest drift check, which was +correct but had been benignly red after every release until nobody could tell +its real signal from its noise. Detectors (per configured release lane in ``.github/release-health.json``): diff --git a/tests/test_platform_manifest_drift.py b/tests/test_platform_manifest_drift.py index babfcbcc9..8770706f8 100644 --- a/tests/test_platform_manifest_drift.py +++ b/tests/test_platform_manifest_drift.py @@ -299,12 +299,12 @@ def test_propagation_lag_still_verifies_digests(flow_component: dict) -> None: def test_release_in_flight_warns_instead_of_failing() -> None: - """The second guaranteed false red must not recur either. + """A reviewed version candidate can be ahead of the published manifest. - python-semantic-release pushes a version-bump commit to main before the - reconcile job regenerates the manifest. For that window pyproject.toml is - ahead of the manifest, which is correct -- the manifest records only - PUBLISHED versions. Failing here made main red after every release. + The version and changelog reach protected main through a pull request + before the release App creates its tag. For that window pyproject.toml is + ahead of the manifest, which is correct because the manifest records only + published versions. """ report = Report() compare_launcher_to_pyproject("1.8.0", "1.9.0", report) @@ -341,14 +341,14 @@ def test_matching_launcher_versions_are_silent() -> None: assert report.warnings == [] -def test_release_reconciliation_commits_every_generated_bom_file() -> None: - """A launcher release must not leave the generated report stale.""" +def test_release_retains_generated_bom_without_pushing_main() -> None: + """Publication emits an exact reviewed-update input without bypassing main.""" workflow = RELEASE_WORKFLOW_PATH.read_text(encoding="utf-8") - generated_files = "platform-manifest.json docs/platform-compatibility-report.md" - normalized = " ".join(workflow.replace("\\", "").split()) - assert f"git diff --quiet -- {generated_files}" in normalized - assert f"git add {generated_files}" in normalized + assert "git push origin HEAD:main" not in workflow + assert "published-platform-manifest.json" in workflow + assert "published-platform-report.md" in workflow + assert "platform-manifest.json needs the published launcher release" in workflow def test_every_manifest_component_is_covered(manifest: dict) -> None: diff --git a/tests/test_release_lock.py b/tests/test_release_lock.py index 1c081a6a8..e03a19d27 100644 --- a/tests/test_release_lock.py +++ b/tests/test_release_lock.py @@ -69,22 +69,19 @@ def test_release_lock_sync_changes_only_editable_root_and_is_idempotent(tmp_path assert (tmp_path / "uv.lock").read_text(encoding="utf-8") == after -def test_release_workflow_syncs_and_verifies_lock_before_build(): - metadata = (ROOT / "pyproject.toml").read_text(encoding="utf-8") +def test_release_workflow_checks_candidate_and_source_boundary_before_build(): workflow = (ROOT / ".github/workflows/release-and-publish.yml").read_text( encoding="utf-8" ) - sync_index = metadata.index("python scripts/verify_release_lock.py --write") - stage_index = metadata.index("git add uv.lock") - assert sync_index < stage_index - verify_index = workflow.index("python scripts/verify_release_lock.py") + boundary_index = workflow.index("python scripts/check_source_boundary.py") build_index = workflow.index("uv build --wheel --sdist") artifact_index = workflow.index("python scripts/verify_release_artifacts.py") attest_index = workflow.index("- name: Attest release artifacts") transfer_index = workflow.index("- name: Transfer release artifacts") - assert verify_index < build_index < artifact_index < attest_index < transfer_index + assert verify_index < boundary_index < build_index + assert build_index < artifact_index < attest_index < transfer_index def test_release_workflow_pins_actions_and_separates_permissions(): @@ -100,55 +97,111 @@ def test_release_workflow_pins_actions_and_separates_permissions(): assert document["permissions"] == {"contents": "read"} jobs = document["jobs"] - assert jobs["release"]["permissions"] == {"contents": "write"} + assert jobs["create-release-tag"]["permissions"] == {"contents": "write"} assert jobs["build-and-attest"]["permissions"] == { "contents": "read", "id-token": "write", "attestations": "write", } - assert jobs["publish-pypi"]["permissions"] == {"contents": "read"} + assert jobs["publish-pypi"]["permissions"] == { + "contents": "read", + "id-token": "write", + } assert jobs["publish-github"]["permissions"] == {"contents": "write"} + assert jobs["verify-publication"]["permissions"] == { + "contents": "read", + "issues": "write", + } assert jobs["report-release-failure"]["permissions"] == {"issues": "write"} -def test_release_workflow_uses_the_reviewed_gitpython_compatibility_fix(): +def test_release_workflow_app_creates_only_an_exact_reviewed_tag(): workflow_path = ROOT / ".github/workflows/release-and-publish.yml" document = yaml.safe_load(workflow_path.read_text(encoding="utf-8")) workflow = workflow_path.read_text(encoding="utf-8") - source = document["env"]["SEMANTIC_RELEASE_SOURCE"] - assert source.endswith("@4ad93f1f2a70e092612e9b1709c01ebbb0d35434") - assert "Upstream PR #1477" in workflow + assert "ADMIN_TOKEN" not in workflow + assert "secrets.PYPI_TOKEN" not in workflow + assert "git push origin HEAD:main" not in workflow + assert "semantic-release -v version" not in workflow + assert "workflow_dispatch:" in workflow + assert "tags:" in workflow + assert '- "v*"' in workflow jobs = document["jobs"] - release_steps = jobs["release"]["steps"] - publish_steps = jobs["publish-github"]["steps"] - release_install = next( - step - for step in release_steps - if step["name"] == "Install compatible Semantic Release" + create = jobs["create-release-tag"] + assert create["environment"] == "release-identity" + assert "github.event_name == 'workflow_dispatch'" in create["if"] + app = next( + step for step in create["steps"] if step.get("id") == "release-app" ) - publish_install = next( + assert app["uses"].startswith("actions/create-github-app-token@") + assert app["with"] == { + "app-id": "${{ vars.OPENADAPT_RELEASE_APP_ID }}", + "private-key": "${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }}", + "owner": "${{ github.repository_owner }}", + "repositories": "${{ github.event.repository.name }}", + "permission-contents": "write", + } + + candidate = next( + step for step in create["steps"] if step.get("id") == "candidate" + ) + assert 'GITHUB_REF" != "refs/heads/main' in candidate["run"] + assert 'current_main" != "$GITHUB_SHA' in candidate["run"] + assert 'REQUESTED_VERSION" != "$project_version' in candidate["run"] + assert "CHANGELOG.md must start with" in candidate["run"] + assert "Tag $tag already exists" in candidate["run"] + + tag = next( step - for step in publish_steps - if step["name"] == "Install compatible Semantic Release" + for step in create["steps"] + if step["name"] == "Create and push only the annotated release tag" ) - assert release_install["run"].endswith('"$SEMANTIC_RELEASE_SOURCE"') - assert publish_install["run"] == release_install["run"] + assert 'git tag -a "$RELEASE_TAG" "$GITHUB_SHA"' in tag["run"] + assert 'git push origin "refs/tags/$RELEASE_TAG"' in tag["run"] - release = next( - step for step in release_steps if step["name"] == "Python Semantic Release" + +def test_release_workflow_publishes_from_the_exact_app_tag_with_oidc(): + workflow_path = ROOT / ".github/workflows/release-and-publish.yml" + document = yaml.safe_load(workflow_path.read_text(encoding="utf-8")) + jobs = document["jobs"] + + build = jobs["build-and-attest"] + assert "github.event_name == 'push'" in build["if"] + guard = next( + step + for step in build["steps"] + if step["name"] == "Require the release App tag and exact candidate state" ) + assert guard["env"]["EXPECTED_ACTOR"] == "openadapt-release[bot]" + assert 'GITHUB_REF_TYPE" != "tag' in guard["run"] + assert 'GITHUB_ACTOR" != "$EXPECTED_ACTOR' in guard["run"] + assert 'GITHUB_REF_NAME" != "$expected_tag' in guard["run"] + assert "git merge-base --is-ancestor HEAD refs/remotes/origin/main" in guard["run"] + + pypi = jobs["publish-pypi"] + assert pypi["environment"] == "pypi" + publish = next( + step for step in pypi["steps"] if step["name"].startswith("Publish to PyPI") + ) + assert publish["uses"].startswith("pypa/gh-action-pypi-publish@") + assert publish["with"] == {"skip-existing": True} + + publish_steps = jobs["publish-github"]["steps"] publish = next( step for step in publish_steps - if step["name"] == "Attach artifacts to GitHub Release" + if step["name"] == "Publish the GitHub Release and exact artifacts" ) - assert "semantic-release -v version" in release["run"] - assert release["env"]["GH_TOKEN"] == "${{ secrets.ADMIN_TOKEN }}" - assert publish["run"] == 'semantic-release -v publish --tag "$RELEASE_TAG"' assert publish["env"]["GH_TOKEN"] == "${{ github.token }}" - assert publish["env"]["RELEASE_TAG"] == "${{ needs.release.outputs.tag }}" + assert publish["env"]["RELEASE_TAG"] == "${{ github.ref_name }}" + assert "gh release create" in publish["run"] + assert "--verify-tag" in publish["run"] + assert "gh release edit" in publish["run"] + assert "gh release upload" in publish["run"] + assert "--clobber" in publish["run"] + assert "semantic-release" not in publish["run"] def test_release_workflow_publishes_the_attested_bytes_to_both_destinations(): @@ -186,22 +239,30 @@ def test_release_workflow_publishes_the_attested_bytes_to_both_destinations(): if step["name"] == "Download attested release artifacts" ) pypi_publish = next( - step for step in pypi_steps if step["name"] == "Publish to PyPI" + step for step in pypi_steps if step["name"].startswith("Publish to PyPI") ) github_publish = next( step for step in github_steps - if step["name"] == "Attach artifacts to GitHub Release" + if step["name"] == "Publish the GitHub Release and exact artifacts" ) assert pypi_download["with"]["name"] == transfer["with"]["name"] assert github_download["with"]["name"] == transfer["with"]["name"] - assert pypi_publish["with"]["attestations"] is False - assert github_publish["env"]["RELEASE_TAG"] == ( - "${{ needs.release.outputs.tag }}" - ) + assert pypi_publish["with"] == {"skip-existing": True} + assert github_publish["env"]["RELEASE_TAG"] == "${{ github.ref_name }}" checkout = next( - step for step in github_steps if step["name"] == "Checkout release branch" + step for step in github_steps if step["name"] == "Checkout the exact release tag" + ) + assert checkout["with"] == {"ref": "${{ github.ref }}", "fetch-depth": 0} + + verification = jobs["verify-publication"] + verification_text = "\n".join( + str(step.get("run", "")) for step in verification["steps"] ) - assert checkout["name"] == "Checkout release branch" - assert checkout["with"] == {"ref": "main", "fetch-depth": 0} + assert "generate_platform_manifest.py" in verification_text + assert "--require-network" in verification_text + assert "urllib.request.urlretrieve" in verification_text + assert "gh release download" in verification_text + assert "diff -u /tmp/source.sha256 /tmp/pypi.sha256" in verification_text + assert "diff -u /tmp/source.sha256 /tmp/github.sha256" in verification_text