From 92dbc976cbc09ba910191c8d071fd8f7451557bd Mon Sep 17 00:00:00 2001 From: abrichr Date: Sat, 17 Oct 2026 19:38:02 -0400 Subject: [PATCH] ci(release-package): tag and publish an immutable GitHub Release after PyPI Each package release is tagged -v; v* stays the launcher's. Before the upload, preflight-github runs as the openadapt-release App and requires immutable releases plus active creation, update and deletion rules for that exact package tag. The build also requires the package CHANGELOG to open with the version and the tag not to exist. After PyPI accepts the files, publish-github creates the annotated tag at the published commit as the App, then a draft release with the same sdist and wheel and the CHANGELOG notes. It verifies the draft, publishes it, and verifies the immutable release and its attestation. verify_release_hosting accepts only the three package tag prefixes besides v. --- .github/workflows/release-package.yml | 304 +++++++++++++++++++++++++- scripts/verify_release_hosting.py | 11 +- tests/test_release_hosting.py | 40 ++++ 3 files changed, 352 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-package.yml b/.github/workflows/release-package.yml index 0e0acbee4..92f1c8be0 100644 --- a/.github/workflows/release-package.yml +++ b/.github/workflows/release-package.yml @@ -15,6 +15,15 @@ # version that already exists on PyPI. The publish job holds the only # id-token permission and runs in the package's environment, so the # environment's protection rules gate the upload. +# +# Each release is tagged -v (openadapt-flow-v2.2.0), +# never v*, which belongs to the launcher. The tag rulesets for each package +# pattern let only the openadapt-release App create those tags and make them +# immutable; preflight-github refuses to start if those rules or immutable +# releases are not in force. Only after the PyPI upload succeeds does +# publish-github, as the openadapt-release App, create the annotated tag at +# the dispatched commit and an immutable GitHub Release. The release carries +# the same sdist and wheel, with notes from packages//CHANGELOG.md. name: Release package on: @@ -44,6 +53,7 @@ jobs: timeout-minutes: 15 outputs: project: ${{ steps.check.outputs.project }} + tag: ${{ steps.check.outputs.tag }} steps: - name: Require the tip of main env: @@ -89,10 +99,27 @@ jobs: raise else: sys.exit(f"{expected} {version} is already on PyPI") + import re + changelog = open(f"packages/{pkg}/CHANGELOG.md", encoding="utf-8").read() + 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" + sys.exit(f"packages/{pkg}/CHANGELOG.md must start with v{version}; found v{actual}") with open(os.environ["GITHUB_OUTPUT"], "a") as out: out.write(f"project={expected}\n") + out.write(f"tag={expected}-v{version}\n") print(f"releasing {expected} {version}") PY + tag="$(sed -n 's/^tag=//p' "$GITHUB_OUTPUT" | tail -n 1)" + status=0 + git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null || status=$? + if [ "$status" -eq 0 ]; then + echo "Tag $tag already exists." >&2 + exit 1 + elif [ "$status" -ne 2 ]; then + echo "Could not check whether tag $tag exists (git exit $status)." >&2 + exit 1 + fi - name: Build sdist and wheel env: PACKAGE: ${{ inputs.package }} @@ -108,9 +135,59 @@ jobs: path: dist/ if-no-files-found: error + preflight-github: + name: check release hosting for ${{ needs.build.outputs.tag }} + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: release-identity + permissions: + contents: read + steps: + - 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-administration: read + permission-metadata: read + - name: Require the exact release App identity + env: + ACTUAL_APP_SLUG: ${{ steps.release-app.outputs.app-slug }} + EXPECTED_APP_SLUG: openadapt-release + run: | + set -euo pipefail + if [ "$ACTUAL_APP_SLUG" != "$EXPECTED_APP_SLUG" ]; then + echo "Release hosting checks require the $EXPECTED_APP_SLUG App, not $ACTUAL_APP_SLUG." >&2 + exit 1 + fi + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - name: Require immutable releases and closed rules for the package tag + env: + GH_TOKEN: ${{ steps.release-app.outputs.token }} + RELEASE_TAG: ${{ needs.build.outputs.tag }} + run: | + set -euo pipefail + if [ "$GITHUB_REPOSITORY" != "OpenAdaptAI/OpenAdapt" ]; then + echo "Package releases require OpenAdaptAI/OpenAdapt." >&2 + exit 1 + fi + python scripts/verify_release_hosting.py \ + --repository "$GITHUB_REPOSITORY" \ + --tag "$RELEASE_TAG" + publish: name: publish ${{ needs.build.outputs.project }} ${{ inputs.version }} - needs: build + needs: [build, preflight-github] runs-on: ubuntu-latest timeout-minutes: 10 environment: @@ -127,3 +204,228 @@ jobs: uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: packages-dir: dist/ + + publish-github: + name: tag and release ${{ needs.build.outputs.tag }} + needs: [build, publish] + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: release-identity + permissions: + contents: read + steps: + - 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-administration: read + permission-contents: write + permission-metadata: read + + - name: Require the exact release App identity + env: + ACTUAL_APP_SLUG: ${{ steps.release-app.outputs.app-slug }} + EXPECTED_APP_SLUG: openadapt-release + run: | + set -euo pipefail + if [ "$ACTUAL_APP_SLUG" != "$EXPECTED_APP_SLUG" ]; then + echo "Tagging and GitHub publication require the $EXPECTED_APP_SLUG App, not $ACTUAL_APP_SLUG." >&2 + exit 1 + fi + + - name: Checkout the exact published commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + token: ${{ steps.release-app.outputs.token }} + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: dist-${{ inputs.package }} + path: dist/ + + - name: Install the pinned GitHub release verification CLI + id: release-cli + run: | + set -euo pipefail + archive=/tmp/gh_2.98.0_linux_amd64.tar.gz + curl --fail --location --silent --show-error \ + https://github.com/cli/cli/releases/download/v2.98.0/gh_2.98.0_linux_amd64.tar.gz \ + --output "$archive" + echo "3b8ac6b30336802fc1a858d7c084e11cdf24ac1a761ca90b68022d7d729208de $archive" \ + | sha256sum --check --strict + tar --extract --gzip --file "$archive" --directory /tmp + cli=/tmp/gh_2.98.0_linux_amd64/bin/gh + test -x "$cli" + "$cli" version + echo "path=$cli" >> "$GITHUB_OUTPUT" + + - name: Create the annotated package tag at the published commit + env: + GH_TOKEN: ${{ steps.release-app.outputs.token }} + RELEASE_TAG: ${{ needs.build.outputs.tag }} + PROJECT: ${{ needs.build.outputs.project }} + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + test "$RELEASE_TAG" = "${PROJECT}-v${VERSION}" + python scripts/verify_release_hosting.py \ + --repository "$GITHUB_REPOSITORY" \ + --tag "$RELEASE_TAG" + + # main may have moved since the upload; the tag still names the + # exact commit that was built and published, which main contains. + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + if ! git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/main; then + echo "The published commit $GITHUB_SHA is not on main." >&2 + exit 1 + fi + + git fetch --force --tags origin + if git show-ref --verify --quiet "refs/tags/$RELEASE_TAG"; then + # A rerun after a partial failure: keep the tag only if it + # already names the published commit. + tag_commit="$(git rev-list -n 1 "refs/tags/$RELEASE_TAG")" + if [ "$tag_commit" != "$GITHUB_SHA" ]; then + echo "Tag $RELEASE_TAG names $tag_commit, not the published $GITHUB_SHA." >&2 + exit 1 + fi + exit 0 + fi + git -c user.name="openadapt-release[bot]" \ + -c user.email="openadapt-release[bot]@users.noreply.github.com" \ + tag -a "$RELEASE_TAG" "$GITHUB_SHA" -m "$PROJECT $VERSION" + git push origin "refs/tags/$RELEASE_TAG" + + - name: Publish the immutable GitHub Release with the exact artifacts + env: + GH_TOKEN: ${{ steps.release-app.outputs.token }} + GH_CLI: ${{ steps.release-cli.outputs.path }} + RELEASE_TAG: ${{ needs.build.outputs.tag }} + PROJECT: ${{ needs.build.outputs.project }} + PACKAGE: ${{ inputs.package }} + VERSION: ${{ inputs.version }} + EXPECTED_AUTHOR: openadapt-release[bot] + run: | + set -euo pipefail + python - "$PACKAGE" "$VERSION" > /tmp/release-notes.md <<'PY' + import re + import sys + from pathlib import Path + + package, version = sys.argv[1], sys.argv[2] + changelog = Path(f"packages/{package}/CHANGELOG.md").read_text(encoding="utf-8") + match = re.search( + rf"^## v{re.escape(version)} \(\d{{4}}-\d{{2}}-\d{{2}}\)\n" + rf"(?P.*?)(?=^## v|\Z)", + changelog, + re.MULTILINE | re.DOTALL, + ) + if match is None: + raise SystemExit(f"packages/{package}/CHANGELOG.md has no notes for v{version}.") + print(match.group("body").strip()) + PY + test -s /tmp/release-notes.md + + git fetch --force origin "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" + tag_commit="$(git rev-list -n 1 "refs/tags/$RELEASE_TAG")" + if [ "$tag_commit" != "$GITHUB_SHA" ]; then + echo "The remote tag $RELEASE_TAG does not name the published commit." >&2 + exit 1 + fi + + release_url="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" + fetch_release() { + curl --silent --show-error \ + --output /tmp/github-release.json \ + --write-out '%{http_code}' \ + --header "Accept: application/vnd.github+json" \ + --header "Authorization: Bearer $GH_TOKEN" \ + --header "X-GitHub-Api-Version: 2026-03-10" \ + "$release_url" + } + verify() { + python scripts/verify_github_release.py \ + --metadata /tmp/github-release.json \ + --tag "$RELEASE_TAG" \ + --author "$EXPECTED_AUTHOR" \ + --expected-dir dist \ + "$@" + } + + release_status="$(fetch_release)" + if [ "$release_status" = "404" ]; then + "$GH_CLI" release create "$RELEASE_TAG" \ + dist/*.whl dist/*.tar.gz \ + --repo "$GITHUB_REPOSITORY" \ + --draft \ + --verify-tag \ + --latest=false \ + --title "$PROJECT $VERSION" \ + --notes-file /tmp/release-notes.md + release_status="$(fetch_release)" + fi + if [ "$release_status" != "200" ]; then + echo "GitHub returned HTTP $release_status for the exact release." >&2 + exit 1 + fi + + release_state="$(python -c \ + 'import json; print("draft" if json.load(open("/tmp/github-release.json"))["draft"] else "published")')" + if [ "$release_state" = "draft" ]; then + verify --state draft --allow-missing --missing-output /tmp/missing-release-assets.txt + while IFS= read -r missing_asset; do + "$GH_CLI" release upload "$RELEASE_TAG" "dist/$missing_asset" \ + --repo "$GITHUB_REPOSITORY" + done < /tmp/missing-release-assets.txt + + release_status="$(fetch_release)" + test "$release_status" = "200" + mkdir /tmp/draft-release-assets + "$GH_CLI" release download "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --dir /tmp/draft-release-assets + verify --downloaded-dir /tmp/draft-release-assets --state draft + + "$GH_CLI" release edit "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --draft=false + release_status="$(fetch_release)" + if [ "$release_status" != "200" ]; then + echo "GitHub returned HTTP $release_status after publication." >&2 + exit 1 + fi + fi + + mkdir /tmp/github-release-assets + "$GH_CLI" release download "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --dir /tmp/github-release-assets + verify --downloaded-dir /tmp/github-release-assets + + for attempt in $(seq 1 12); do + if "$GH_CLI" release verify "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --format json > /tmp/github-release-attestation.json; then + break + fi + if [ "$attempt" -eq 12 ]; then + echo "GitHub did not produce a valid release attestation in time." >&2 + exit 1 + fi + sleep 10 + done + for artifact in dist/*.whl dist/*.tar.gz; do + "$GH_CLI" release verify-asset "$RELEASE_TAG" "$artifact" \ + --repo "$GITHUB_REPOSITORY" + done + echo "Published https://github.com/$GITHUB_REPOSITORY/releases/tag/$RELEASE_TAG" diff --git a/scripts/verify_release_hosting.py b/scripts/verify_release_hosting.py index edc0371ba..82dd01f86 100644 --- a/scripts/verify_release_hosting.py +++ b/scripts/verify_release_hosting.py @@ -12,7 +12,12 @@ from typing import Any REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") -RELEASE_TAG = re.compile(r"^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") +# The launcher tags v. A package from packages/ tags +# -v; the project names are an exact list. +RELEASE_TAG = re.compile( + r"^(?:openadapt-(?:agent|flow|types)-)?" + r"v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$" +) REQUIRED_TAG_RULES = frozenset({"creation", "update", "deletion"}) @@ -73,7 +78,9 @@ def verify_release_hosting_documents( ) -> None: """Validate immutable releases and active creation/update/deletion tag rules.""" if RELEASE_TAG.fullmatch(tag) is None: - raise ValueError("release tag must be an exact stable vX.Y.Z value") + raise ValueError( + "release tag must be an exact stable vX.Y.Z or -vX.Y.Z value" + ) if immutable.get("enabled") is not True: raise ValueError("GitHub immutable releases are not enabled") diff --git a/tests/test_release_hosting.py b/tests/test_release_hosting.py index 414c022e8..818f2e3ff 100644 --- a/tests/test_release_hosting.py +++ b/tests/test_release_hosting.py @@ -83,3 +83,43 @@ def test_release_hosting_fails_closed_on_an_unsupported_exclude_pattern() -> Non MODULE.verify_release_hosting_documents( {"enabled": True}, [ambiguous], "v2.0.0" ) + + +def _package_ruleset(pattern: str, *rule_types: str) -> dict: + ruleset = _ruleset(*rule_types) + ruleset["conditions"]["ref_name"]["include"] = [pattern] + return ruleset + + +@pytest.mark.parametrize("package", ["openadapt-flow", "openadapt-agent", "openadapt-types"]) +def test_release_hosting_accepts_a_package_tag_under_its_own_rules(package: str) -> None: + MODULE.verify_release_hosting_documents( + {"enabled": True}, + [ + _package_ruleset(f"refs/tags/{package}-v*", "creation"), + _package_ruleset(f"refs/tags/{package}-v*", "update", "deletion"), + ], + f"{package}-v2.2.0", + ) + + +def test_release_hosting_launcher_rules_do_not_cover_a_package_tag() -> None: + with pytest.raises(ValueError, match="lacks active tag rules"): + MODULE.verify_release_hosting_documents( + {"enabled": True}, + [_ruleset("creation"), _ruleset("update", "deletion")], + "openadapt-flow-v2.2.0", + ) + + +@pytest.mark.parametrize( + "tag", + ["openadapt-v2.2.0", "openadapt-capture-v1.0.0", "flow-v2.2.0", "openadapt-flow-2.2.0"], +) +def test_release_hosting_refuses_a_tag_outside_the_package_list(tag: str) -> None: + with pytest.raises(ValueError, match="release tag must be"): + MODULE.verify_release_hosting_documents( + {"enabled": True}, + [_ruleset("creation", "update", "deletion")], + tag, + )