diff --git a/.github/workflows/complex-visual.yml b/.github/workflows/complex-visual.yml index f54ff5e9..38f67eca 100644 --- a/.github/workflows/complex-visual.yml +++ b/.github/workflows/complex-visual.yml @@ -9,11 +9,27 @@ on: workflow_dispatch: concurrency: - group: complex-visual-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false jobs: + reject-lifecycle-app: + permissions: {} + runs-on: ubuntu-latest + steps: + - name: Reject the lifecycle App + env: + ACTOR: ${{ github.actor }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + run: | + test "$ACTOR" != 'openadapt-lifecycle[bot]' + test "$TRIGGERING_ACTOR" != 'openadapt-lifecycle[bot]' + headed-pixel-campaign: + needs: reject-lifecycle-app + if: >- + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/.github/workflows/evidence-freshness.yml b/.github/workflows/evidence-freshness.yml index 75f40522..2ed19312 100644 --- a/.github/workflows/evidence-freshness.yml +++ b/.github/workflows/evidence-freshness.yml @@ -16,14 +16,30 @@ on: - '.github/workflows/evidence-freshness.yml' concurrency: - group: evidence-freshness-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: false permissions: contents: read jobs: + reject-lifecycle-app: + permissions: {} + runs-on: ubuntu-latest + steps: + - name: Reject the lifecycle App + env: + ACTOR: ${{ github.actor }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + run: | + test "$ACTOR" != 'openadapt-lifecycle[bot]' + test "$TRIGGERING_ACTOR" != 'openadapt-lifecycle[bot]' + freshness: + needs: reject-lifecycle-app + if: >- + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' runs-on: ubuntu-latest steps: - name: Checkout repository diff --git a/.github/workflows/notify-docs.yml b/.github/workflows/notify-docs.yml deleted file mode 100644 index 7090baf1..00000000 --- a/.github/workflows/notify-docs.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: Notify docs -on: - push: - branches: [main] -jobs: - notify: - runs-on: ubuntu-latest - steps: - - uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3 - with: - token: ${{ secrets.DOCS_DISPATCH_TOKEN }} - repository: OpenAdaptAI/openadapt-maintenance - event-type: repo-updated - client-payload: '{"repo": "${{ github.repository }}", "sha": "${{ github.sha }}"}' diff --git a/.github/workflows/production-lifecycle-evidence.yml b/.github/workflows/production-lifecycle-evidence.yml new file mode 100644 index 00000000..027d6677 --- /dev/null +++ b/.github/workflows/production-lifecycle-evidence.yml @@ -0,0 +1,191 @@ +name: Propose production lifecycle evidence + +on: + workflow_dispatch: + inputs: + candidate_ref: + description: Exact same-repository evidence-candidate branch ref + required: true + type: string + candidate_commit: + description: Exact 40-character evidence-candidate commit + required: true + type: string + idempotency_key: + description: Domain-bound SHA-256 proposal key + required: true + type: string + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + propose-evidence: + if: >- + github.repository == 'OpenAdaptAI/openadapt-evals' && + github.ref == 'refs/heads/main' && + github.event_name == 'workflow_dispatch' && + github.actor == 'openadapt-lifecycle[bot]' && + github.triggering_actor == 'openadapt-lifecycle[bot]' && + github.actor_id == vars.OPENADAPT_LIFECYCLE_ACTOR_ID + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: production-lifecycle-evidence + permissions: + contents: write + steps: + - name: Checkout exact main + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + token: ${{ github.token }} + + - name: Create the lifecycle App pull-request token + id: lifecycle-app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ vars.OPENADAPT_LIFECYCLE_APP_ID }} + private-key: ${{ secrets.OPENADAPT_LIFECYCLE_APP_PRIVATE_KEY }} + owner: OpenAdaptAI + repositories: openadapt-evals + permission-pull-requests: write + + - name: Verify the lifecycle App installation + env: + ACTUAL_APP_SLUG: ${{ steps.lifecycle-app.outputs.app-slug }} + ACTUAL_INSTALLATION_ID: ${{ steps.lifecycle-app.outputs.installation-id }} + EXPECTED_APP_ID: ${{ vars.OPENADAPT_LIFECYCLE_APP_ID }} + EXPECTED_INSTALLATION_ID: ${{ vars.OPENADAPT_LIFECYCLE_INSTALLATION_ID }} + run: | + set -euo pipefail + test "$ACTUAL_APP_SLUG" = 'openadapt-lifecycle' + test "$ACTUAL_INSTALLATION_ID" = "$EXPECTED_INSTALLATION_ID" + case "$EXPECTED_APP_ID:$EXPECTED_INSTALLATION_ID" in + *[!0-9:]*|:*|*:) + echo '::error::The lifecycle App and installation IDs must be positive integers.' + exit 1 + ;; + esac + test "$EXPECTED_APP_ID" -gt 0 + test "$EXPECTED_INSTALLATION_ID" -gt 0 + + - name: Bind the candidate to exact main + id: candidate + env: + CANDIDATE_REF: ${{ inputs.candidate_ref }} + CANDIDATE_COMMIT: ${{ inputs.candidate_commit }} + IDEMPOTENCY_KEY: ${{ inputs.idempotency_key }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + if ! printf '%s' "$CANDIDATE_REF" | \ + grep -Eq '^refs/heads/production-lifecycle-candidates/[0-9a-f]{64}$'; then + echo '::error::The candidate ref is outside the production lifecycle namespace.' + exit 1 + fi + if ! printf '%s' "$CANDIDATE_COMMIT" | grep -Eq '^[0-9a-f]{40}$'; then + echo '::error::The candidate commit is not a full commit ID.' + exit 1 + fi + if ! printf '%s' "$IDEMPOTENCY_KEY" | grep -Eq '^sha256:[0-9a-f]{64}$'; then + echo '::error::The idempotency key is not a SHA-256 digest.' + exit 1 + fi + + expected=$(python - \ + "$GITHUB_REPOSITORY" "$GITHUB_SHA" "$CANDIDATE_REF" "$CANDIDATE_COMMIT" <<'PY' + import hashlib + import sys + + domain = b"OpenAdapt production lifecycle evidence proposal v1\0" + fields = [value.encode("utf-8") for value in sys.argv[1:]] + print("sha256:" + hashlib.sha256(domain + b"\0".join(fields)).hexdigest()) + PY + ) + test "$IDEMPOTENCY_KEY" = "$expected" + + remote_commit=$(git ls-remote origin "$CANDIDATE_REF" | cut -f1) + test "$remote_commit" = "$CANDIDATE_COMMIT" + git fetch --no-tags origin "$CANDIDATE_REF" + test "$(git rev-parse FETCH_HEAD)" = "$CANDIDATE_COMMIT" + test "$(git rev-parse "${CANDIDATE_COMMIT}^")" = "$GITHUB_SHA" + + invalid_status=$(git diff --name-status "$GITHUB_SHA" "$CANDIDATE_COMMIT" | \ + awk '$1 !~ /^(A|M)$/ {print}') + if [ -n "$invalid_status" ]; then + echo '::error::The candidate deletes, renames, or changes a file type.' + printf '%s\n' "$invalid_status" + exit 1 + fi + invalid_path=$(git diff --name-only "$GITHUB_SHA" "$CANDIDATE_COMMIT" | \ + grep -Ev '^(docs/eval_results/production_acceptance/[^/].*|docs/eval_results/PUBLISHED_EVIDENCE\.json|docs/eval_results/PRODUCTION_READINESS\.md)$' || true) + if [ -n "$invalid_path" ]; then + echo '::error::The candidate changes files outside the reviewed evidence paths.' + printf '%s\n' "$invalid_path" + exit 1 + fi + test -n "$(git diff --name-only "$GITHUB_SHA" "$CANDIDATE_COMMIT")" + + key_hex=${IDEMPOTENCY_KEY#sha256:} + echo "review_branch=automation/production-lifecycle-evidence-${key_hex}" >> "$GITHUB_OUTPUT" + + - name: Install uv + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + version: "0.11.29" + + - name: Verify the candidate evidence + env: + CANDIDATE_COMMIT: ${{ inputs.candidate_commit }} + run: | + set -euo pipefail + git checkout --detach "$CANDIDATE_COMMIT" + uv sync --locked --extra dev --no-sources + uv run --no-sources pytest -q \ + tests/test_import_production_acceptance.py \ + tests/test_published_evidence_freshness.py + uv run --no-sources python scripts/check_source_boundary.py + + - name: Push the review branch with the workflow token + env: + CANDIDATE_COMMIT: ${{ inputs.candidate_commit }} + REVIEW_BRANCH: ${{ steps.candidate.outputs.review_branch }} + run: | + set -euo pipefail + existing=$(git ls-remote --heads origin "refs/heads/$REVIEW_BRANCH" | cut -f1) + if [ -n "$existing" ]; then + git fetch --no-tags origin "refs/heads/$REVIEW_BRANCH" + test "$(git rev-parse "FETCH_HEAD^{tree}")" = \ + "$(git rev-parse "$CANDIDATE_COMMIT^{tree}")" + else + git push origin "$CANDIDATE_COMMIT:refs/heads/$REVIEW_BRANCH" + fi + + - name: Open the evidence pull request with the lifecycle App + env: + CANDIDATE_COMMIT: ${{ inputs.candidate_commit }} + GH_TOKEN: ${{ steps.lifecycle-app.outputs.token }} + REVIEW_BRANCH: ${{ steps.candidate.outputs.review_branch }} + run: | + set -euo pipefail + number=$(gh pr list \ + --repo "$GITHUB_REPOSITORY" \ + --base main \ + --head "$REVIEW_BRANCH" \ + --state open \ + --json number \ + --jq '.[0].number // empty') + if [ -n "$number" ]; then + echo "Evidence pull request #$number already exists." + exit 0 + fi + gh pr create \ + --repo "$GITHUB_REPOSITORY" \ + --base main \ + --head "$REVIEW_BRANCH" \ + --title 'docs(evidence): review production lifecycle candidate' \ + --body "This PR carries the production lifecycle evidence candidate at ${CANDIDATE_COMMIT}. The workflow accepted only the reviewed evidence paths and ran the focused checks. It didn't admit a release or change main." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 459aea0b..ac839e5f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,76 +1,310 @@ -name: Release and PyPI Publish - -# Requires ADMIN_TOKEN secret (GitHub PAT with repo scope) to push release -# commits to protected branches. Without it, the tag gets created but the -# version bump commit is rejected by branch protection, orphaning the tag. +name: Release and publish on: + workflow_dispatch: + inputs: + version: + description: Exact reviewed version on protected main + required: true + type: string + source_commit: + description: Full protected-main commit SHA to tag + required: true + type: string push: - branches: - - main + tags: + - 'v*' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: false jobs: - release: + reject-lifecycle-app: + if: github.event_name == 'workflow_dispatch' + permissions: {} runs-on: ubuntu-latest - concurrency: release - permissions: - id-token: write - contents: write + steps: + - name: Reject the lifecycle App + env: + ACTOR: ${{ github.actor }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + run: | + test "$ACTOR" != 'openadapt-lifecycle[bot]' + test "$TRIGGERING_ACTOR" != 'openadapt-lifecycle[bot]' + authorize-release-tag: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + permissions: {} + runs-on: ubuntu-latest steps: - - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Accept tags from the release App only + env: + ACTOR: ${{ github.actor }} + run: | + set -euo pipefail + test "$ACTOR" = 'openadapt-release[bot]' + + create-release-tag: + needs: reject-lifecycle-app + if: >- + github.event_name == 'workflow_dispatch' && + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' + runs-on: ubuntu-latest + environment: release-identity + permissions: + contents: read + steps: + - name: Checkout exact protected main + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - token: ${{ secrets.ADMIN_TOKEN }} + persist-credentials: false - - name: Check if should skip - id: check_skip + - name: Bind the request to the reviewed release state + id: release + env: + REQUESTED_SOURCE_COMMIT: ${{ inputs.source_commit }} + REQUESTED_VERSION: ${{ inputs.version }} run: | - if [ "$(git log -1 --pretty=format:'%an')" = "semantic-release" ]; then - echo "skip=true" >> $GITHUB_OUTPUT + set -euo pipefail + test "$GITHUB_REPOSITORY" = 'OpenAdaptAI/openadapt-evals' + test "$GITHUB_REF" = 'refs/heads/main' + if ! printf '%s' "$REQUESTED_SOURCE_COMMIT" | grep -Eq '^[0-9a-f]{40}$'; then + echo '::error::The requested source commit is not a full commit ID.' + exit 1 fi + if ! printf '%s' "$REQUESTED_VERSION" | \ + grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'; then + echo '::error::The requested version is not a stable X.Y.Z version.' + exit 1 + fi + test "$GITHUB_SHA" = "$REQUESTED_SOURCE_COMMIT" + test "$(git rev-parse HEAD)" = "$REQUESTED_SOURCE_COMMIT" + git fetch --no-tags origin \ + '+refs/heads/main:refs/remotes/origin/main' + test "$(git rev-parse refs/remotes/origin/main)" = "$REQUESTED_SOURCE_COMMIT" + python scripts/verify_release_lock.py - - name: Python Semantic Release - if: steps.check_skip.outputs.skip != 'true' - id: release - uses: python-semantic-release/python-semantic-release@7b3f71697ccfbaef884e1e754b6364e974b134cf # v9.15.2 + project_version=$(python - <<'PY' + import tomllib + from pathlib import Path + + document = tomllib.loads(Path('pyproject.toml').read_text(encoding='utf-8')) + print(document['project']['version']) + PY + ) + test "$project_version" = "$REQUESTED_VERSION" + first_release_heading=$(grep -Em1 \ + '^## v[0-9]+\.[0-9]+\.[0-9]+ \([0-9]{4}-[0-9]{2}-[0-9]{2}\)$' \ + CHANGELOG.md) + printf '%s\n' "$first_release_heading" | \ + grep -Eq "^## v${REQUESTED_VERSION} \([0-9]{4}-[0-9]{2}-[0-9]{2}\)$" + + release_tag="v${REQUESTED_VERSION}" + if git ls-remote --exit-code --tags origin \ + "refs/tags/${release_tag}" >/dev/null 2>&1; then + git fetch --force origin \ + "refs/tags/${release_tag}:refs/tags/${release_tag}" + test "$(git cat-file -t "refs/tags/${release_tag}")" = 'tag' + test "$(git rev-parse "refs/tags/${release_tag}^{commit}")" = \ + "$REQUESTED_SOURCE_COMMIT" + tag_exists=true + else + latest=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' \ + --sort=-version:refname | head -1) + if [ -n "$latest" ]; then + python - "$REQUESTED_VERSION" "${latest#v}" <<'PY' + import re + import sys + + def stable(value: str) -> tuple[int, int, int]: + match = re.fullmatch( + r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", + value, + ) + if match is None: + raise SystemExit(f"REFUSED: invalid stable version {value!r}") + return tuple(int(part) for part in match.groups()) + + if stable(sys.argv[1]) <= stable(sys.argv[2]): + raise SystemExit( + f"REFUSED: requested version {sys.argv[1]} does not follow {sys.argv[2]}" + ) + PY + fi + tag_exists=false + fi + { + echo "exists=${tag_exists}" + echo "source_commit=${REQUESTED_SOURCE_COMMIT}" + echo "tag=${release_tag}" + echo "version=${REQUESTED_VERSION}" + } >> "$GITHUB_OUTPUT" + + - name: Create the release App token + if: steps.release.outputs.exists != 'true' + id: release-app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: - github_token: ${{ secrets.ADMIN_TOKEN }} + app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }} + private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }} + owner: OpenAdaptAI + repositories: openadapt-evals + permission-contents: write + + - name: Verify the release App identity + if: steps.release.outputs.exists != 'true' + env: + APP_SLUG: ${{ steps.release-app.outputs.app-slug }} + APP_TOKEN: ${{ steps.release-app.outputs.token }} + EXPECTED_APP_ID: ${{ vars.OPENADAPT_RELEASE_APP_ID }} + run: | + set -euo pipefail + test "$APP_SLUG" = 'openadapt-release' + test -n "$APP_TOKEN" + case "$EXPECTED_APP_ID" in + ''|*[!0-9]*) + echo '::error::OPENADAPT_RELEASE_APP_ID must be a positive integer.' + exit 1 + ;; + esac + test "$EXPECTED_APP_ID" -gt 0 + + - name: Create and push only the annotated release tag + if: steps.release.outputs.exists != 'true' + env: + APP_TOKEN: ${{ steps.release-app.outputs.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + RELEASE_VERSION: ${{ steps.release.outputs.version }} + SOURCE_COMMIT: ${{ steps.release.outputs.source_commit }} + run: | + set -euo pipefail + git fetch --no-tags origin \ + '+refs/heads/main:refs/remotes/origin/main' + test "$(git rev-parse refs/remotes/origin/main)" = "$SOURCE_COMMIT" + test "$(git rev-parse HEAD)" = "$SOURCE_COMMIT" + if git ls-remote --exit-code --tags origin \ + "refs/tags/${RELEASE_TAG}" >/dev/null 2>&1; then + echo '::error::The release tag appeared after validation.' + exit 1 + fi + git config user.name 'OpenAdapt Release App' + git config user.email 'openadapt-release[bot]@users.noreply.github.com' + git tag -a "$RELEASE_TAG" "$SOURCE_COMMIT" \ + -m "OpenAdapt Evals ${RELEASE_VERSION}" + auth=$(printf 'x-access-token:%s' "$APP_TOKEN" | base64 | tr -d '\n') + export GIT_CONFIG_COUNT=1 + export GIT_CONFIG_KEY_0=http.https://github.com/.extraheader + export GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${auth}" + git push origin "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" + + publish-pypi: + needs: authorize-release-tag + if: >- + github.event_name == 'push' && + startsWith(github.ref, 'refs/tags/v') && + needs.authorize-release-tag.result == 'success' && + github.actor == 'openadapt-release[bot]' + runs-on: ubuntu-latest + environment: pypi + permissions: + contents: read + id-token: write + steps: + - name: Checkout the exact release tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Verify the exact tag and release state + run: | + set -euo pipefail + test "$GITHUB_REPOSITORY" = 'OpenAdaptAI/openadapt-evals' + test "$(git cat-file -t "$GITHUB_REF")" = 'tag' + test "$(git rev-parse "${GITHUB_REF}^{commit}")" = "$GITHUB_SHA" + version=$(python - <<'PY' + import tomllib + from pathlib import Path + + document = tomllib.loads(Path('pyproject.toml').read_text(encoding='utf-8')) + print(document['project']['version']) + PY + ) + test "$GITHUB_REF" = "refs/tags/v${version}" + first_release_heading=$(grep -Em1 \ + '^## v[0-9]+\.[0-9]+\.[0-9]+ \([0-9]{4}-[0-9]{2}-[0-9]{2}\)$' \ + CHANGELOG.md) + printf '%s\n' "$first_release_heading" | \ + grep -Eq "^## v${version} \([0-9]{4}-[0-9]{2}-[0-9]{2}\)$" + git fetch --no-tags origin \ + '+refs/heads/main:refs/remotes/origin/main' + git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/main + python scripts/verify_release_lock.py + + - name: Install uv + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + version: "0.11.29" + + - name: Build the exact tag + run: uv build - name: Verify the public distribution boundary - if: steps.check_skip.outputs.skip != 'true' && steps.release.outputs.released == 'true' run: python scripts/check_source_boundary.py --require-dist - # v1.14.0 bundles twine 6.1.0 and packaging 25.0, which reject the - # Metadata-Version 2.5 that current hatchling emits. That failed the - # 0.91.0 publish after the tag, the release commit and the GitHub - # release had already landed. v1.14.2 bundles twine 7.0.0 and packaging - # 26.2, and is the same pin openadapt-flow and openadapt-capture use. - - name: Publish to PyPI - if: steps.check_skip.outputs.skip != 'true' && steps.release.outputs.released == 'true' + - name: Publish to PyPI with OIDC uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + print-hash: true + skip-existing: true - - name: Publish to GitHub Releases - if: steps.check_skip.outputs.skip != 'true' && steps.release.outputs.released == 'true' - uses: python-semantic-release/publish-action@b9c41d4b0754dee5a6c7188d42b33f66e3a8aafd # v9.15.2 + publish-github-release: + needs: + - authorize-release-tag + - publish-pypi + if: >- + github.event_name == 'push' && + startsWith(github.ref, 'refs/tags/v') && + needs.authorize-release-tag.result == 'success' && + needs.publish-pypi.result == 'success' && + github.actor == 'openadapt-release[bot]' + runs-on: ubuntu-latest + environment: pypi + permissions: + contents: write + steps: + - name: Checkout the exact release tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - github_token: ${{ secrets.ADMIN_TOKEN }} + fetch-depth: 0 + persist-credentials: false - # Releases in openadapt-ml failed silently for 3 months (Mar-Jun - # 2026) while PyPI went stale; see OpenAdaptAI/OpenAdapt#999. - - name: File issue on release failure - if: failure() + - name: Publish the exact GitHub release env: - GH_TOKEN: ${{ secrets.ADMIN_TOKEN }} + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} run: | - TITLE="Release workflow failed on main" - BODY="The release workflow failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - - Until this is fixed, merged fix/feat commits are NOT being published to PyPI, and users install stale versions." - 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" + set -euo pipefail + test "$(git cat-file -t "$GITHUB_REF")" = 'tag' + test "$(git rev-parse "${GITHUB_REF}^{commit}")" = "$GITHUB_SHA" + if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + state=$(gh release view "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --json isDraft,isPrerelease,tagName) + test "$(jq -r '.tagName' <<<"$state")" = "$RELEASE_TAG" + test "$(jq -r '.isDraft' <<<"$state")" = 'false' + test "$(jq -r '.isPrerelease' <<<"$state")" = 'false' + echo 'The exact GitHub release already exists.' else - gh issue create --repo "${{ github.repository }}" --title "$TITLE" --body "$BODY" + gh release create "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --generate-notes \ + --title "$RELEASE_TAG" fi diff --git a/tests/test_release_contract.py b/tests/test_release_contract.py index 95089c5b..463aa59c 100644 --- a/tests/test_release_contract.py +++ b/tests/test_release_contract.py @@ -72,15 +72,65 @@ def test_release_configuration_is_fail_closed() -> None: assert metadata.index("python -m pip install uv==0.11.29") < metadata.index( "python scripts/verify_release_lock.py --write" ) < metadata.index("git add uv.lock") < metadata.index("uv build") - assert "run: uv build" not in workflow - assert "astral-sh/setup-uv" not in workflow - assert "actions/setup-python" not in workflow - assert workflow.count("secrets.ADMIN_TOKEN") >= 3 + assert "environment: release-identity" in workflow + assert "environment: pypi" in workflow + assert "actions/create-github-app-token@" in workflow + assert "vars.OPENADAPT_RELEASE_APP_ID" in workflow + assert "secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY" in workflow + assert "permission-contents: write" in workflow + assert "permission-pull-requests: write" not in workflow + assert re.search(r"(?m)^ workflow_dispatch:\s*$", workflow) + assert re.search(r"(?m)^ version:\s*$", workflow) + assert re.search(r"(?m)^ source_commit:\s*$", workflow) + assert re.search(r"(?m)^ push:\s*$", workflow) + assert re.search(r"(?m)^ tags:\s*$", workflow) + assert not re.search(r"(?m)^ branches:\s*$", workflow) + assert "github.event_name == 'workflow_dispatch'" in workflow + assert "github.workflow }}-${{ github.event_name }}-${{ github.ref" in workflow + assert "github.actor == 'openadapt-release[bot]'" in workflow + assert "reject-lifecycle-app:" in workflow + assert "github.actor != 'openadapt-lifecycle[bot]'" in workflow + assert "github.triggering_actor != 'openadapt-lifecycle[bot]'" in workflow + assert "test \"$GITHUB_REF\" = 'refs/heads/main'" in workflow + assert 'test "$GITHUB_SHA" = "$REQUESTED_SOURCE_COMMIT"' in workflow + assert "refs/remotes/origin/main" in workflow + assert 'git tag -a "$RELEASE_TAG" "$SOURCE_COMMIT"' in workflow + assert "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" in workflow + app_pushes = [ + line.strip() for line in workflow.splitlines() if line.strip().startswith("git push") + ] + assert app_pushes == [ + 'git push origin "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}"' + ] + assert "refs/heads/main:refs/heads/main" not in workflow + assert "gh pr create" not in workflow + assert "automation/release" not in workflow + assert "python-semantic-release" not in workflow + assert "run: uv build" in workflow + assert "python scripts/check_source_boundary.py --require-dist" in workflow + assert "pypa/gh-action-pypi-publish@" in workflow + assert "id-token: write" in workflow + assert "skip-existing: true" in workflow + assert "first_release_heading=$(grep -Em1" in workflow + assert "gh release create" in workflow + assert "ADMIN_TOKEN" not in workflow + assert "PYPI_API_TOKEN" not in workflow assert "secrets.GITHUB_TOKEN" not in workflow assert 'version: "0.11.29"' in test_workflow assert "uv sync --locked --extra dev --no-sources" in test_workflow +def test_tag_publication_allows_an_exact_failed_run_to_be_retried() -> None: + workflow = (ROOT / ".github/workflows/release.yml").read_text(encoding="utf-8") + authorize_job = workflow.split(" create-release-tag:", 1)[0] + publish_jobs = workflow.split(" publish-pypi:", 1)[1] + assert "test \"$ACTOR\" = 'openadapt-release[bot]'" in authorize_job + assert publish_jobs.count("github.actor == 'openadapt-release[bot]'") == 2 + assert "github.triggering_actor == 'openadapt-release[bot]'" not in authorize_job + assert "github.triggering_actor == 'openadapt-release[bot]'" not in publish_jobs + assert "gh release view" in publish_jobs + + def test_all_third_party_actions_are_commit_pinned() -> None: action_pattern = re.compile(r"uses:\s*([^\s@]+)@([^\s#]+)") for path in (ROOT / ".github" / "workflows").glob("*.yml"): diff --git a/tests/test_workflow_identity_contract.py b/tests/test_workflow_identity_contract.py new file mode 100644 index 00000000..ab468cb5 --- /dev/null +++ b/tests/test_workflow_identity_contract.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOWS = ROOT / ".github" / "workflows" +LIFECYCLE_ACTOR = "openadapt-lifecycle[bot]" + + +def _workflow(name: str) -> str: + return (WORKFLOWS / name).read_text(encoding="utf-8") + + +def test_production_lifecycle_evidence_is_app_only() -> None: + workflow = _workflow("production-lifecycle-evidence.yml") + assert re.search(r"(?m)^ workflow_dispatch:\s*$", workflow) + assert not re.search( + r"(?m)^ (pull_request|pull_request_target|push|release|schedule|repository_dispatch|workflow_call):\s*$", + workflow, + ) + assert "environment: production-lifecycle-evidence" in workflow + assert "github.repository == 'OpenAdaptAI/openadapt-evals'" in workflow + assert "github.ref == 'refs/heads/main'" in workflow + assert "github.event_name == 'workflow_dispatch'" in workflow + assert f"github.actor == '{LIFECYCLE_ACTOR}'" in workflow + assert f"github.triggering_actor == '{LIFECYCLE_ACTOR}'" in workflow + assert "github.actor_id == vars.OPENADAPT_LIFECYCLE_ACTOR_ID" in workflow + assert "vars.OPENADAPT_LIFECYCLE_APP_ID" in workflow + assert "vars.OPENADAPT_LIFECYCLE_INSTALLATION_ID" in workflow + assert "secrets.OPENADAPT_LIFECYCLE_APP_PRIVATE_KEY" in workflow + assert "permission-pull-requests: write" in workflow + assert "contents: write" in workflow + assert "token: ${{ github.token }}" in workflow + assert "gh pr create" in workflow + assert not re.search(r"git\s+push[^\n]*(?:refs/heads/)?main", workflow) + assert "permission-contents: write" not in workflow + assert "cancel-in-progress: false" in workflow + assert "github.workflow" in workflow + assert "github.event_name" in workflow + + +def test_manual_non_lifecycle_workflows_reject_the_lifecycle_app() -> None: + for name, job in ( + ("complex-visual.yml", "headed-pixel-campaign"), + ("evidence-freshness.yml", "freshness"), + ): + workflow = _workflow(name) + assert "reject-lifecycle-app:" in workflow + assert "permissions: {}" in workflow + assert workflow.count(LIFECYCLE_ACTOR) >= 4 + assert re.search(rf"(?ms)^ {job}:\n.*? needs: reject-lifecycle-app", workflow) + assert "github.actor != 'openadapt-lifecycle[bot]'" in workflow + assert "github.triggering_actor != 'openadapt-lifecycle[bot]'" in workflow + assert "cancel-in-progress: false" in workflow + group = re.search(r"(?m)^ group: (.+)$", workflow) + assert group is not None + assert "github.workflow" in group.group(1) + assert "github.event_name" in group.group(1) + + +def test_legacy_docs_pat_dispatch_is_removed() -> None: + assert not (WORKFLOWS / "notify-docs.yml").exists() + combined = "\n".join( + path.read_text(encoding="utf-8") for path in WORKFLOWS.glob("*.yml") + ) + assert "DOCS_DISPATCH_TOKEN" not in combined + assert "peter-evans/repository-dispatch" not in combined