feat(evidence): add v3 qualification and immutable recovery (#303) #16
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Propose the next release | |
| # Writes the version bump, the lock sync, and the changelog section, then opens | |
| # a pull request with them. It never merges, tags, or publishes. | |
| # | |
| # The release workflow refuses to guess a version: it requires pyproject.toml | |
| # and CHANGELOG.md to already carry the exact version being published. This | |
| # workflow produces that state as a reviewable diff, so the only manual step | |
| # left is reading the proposal and merging it. | |
| # | |
| # The branch push uses the built-in token. The pull request is opened with the | |
| # lifecycle App token, because a pull request opened with the built-in token | |
| # does not start any further workflow run, and this proposal has to be tested | |
| # before anybody merges it. The lifecycle App is never granted contents access, | |
| # so it cannot land the change it proposes. | |
| # | |
| # That same suppression bites on updates: a force-push made with the built-in | |
| # token emits a synchronize event GitHub ignores, so an updated proposal would | |
| # keep the checks from its previous head and sit unmergeable forever. Two things | |
| # handle it. An unchanged proposal is not pushed at all, so a head that already | |
| # passed is never disturbed. A changed proposal is closed and reopened with the | |
| # App token, which emits an event the built-in token did not create. | |
| on: | |
| push: | |
| branches: | |
| - main | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.event_name }}-${{ 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]' | |
| propose-release: | |
| needs: reject-lifecycle-app | |
| if: >- | |
| github.repository == 'OpenAdaptAI/openadapt-evals' && | |
| github.ref == 'refs/heads/main' && | |
| github.actor != 'openadapt-lifecycle[bot]' && | |
| github.triggering_actor != 'openadapt-lifecycle[bot]' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Checkout exact main | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| fetch-depth: 0 | |
| token: ${{ github.token }} | |
| - name: Set up Python | |
| uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 | |
| with: | |
| python-version: '3.12' | |
| - name: Plan the release | |
| id: plan | |
| run: | | |
| set -euo pipefail | |
| python scripts/plan_release.py | tee "$GITHUB_OUTPUT" | |
| - name: Write the bump, the lock, and the changelog | |
| if: steps.plan.outputs.released == 'true' | |
| run: | | |
| set -euo pipefail | |
| python scripts/plan_release.py --write | |
| python -m pip install --quiet uv==0.11.29 | |
| python scripts/verify_release_lock.py --write | |
| python scripts/verify_release_lock.py | |
| git diff --stat | |
| - name: Push the proposal branch | |
| if: steps.plan.outputs.released == 'true' | |
| id: push | |
| env: | |
| NEXT: ${{ steps.plan.outputs.next }} | |
| run: | | |
| set -euo pipefail | |
| git config user.name 'github-actions[bot]' | |
| git config user.email '41898282+github-actions[bot]@users.noreply.github.com' | |
| # An unchanged proposal must not be pushed. A force-push that changes | |
| # nothing still moves the head, and moving the head orphans the checks | |
| # that already passed on it. | |
| if git fetch --quiet origin release/next 2>/dev/null && git diff --quiet \ | |
| FETCH_HEAD -- pyproject.toml uv.lock CHANGELOG.md; then | |
| echo 'changed=false' >> "$GITHUB_OUTPUT" | |
| echo 'The open proposal already carries this exact content.' | |
| exit 0 | |
| fi | |
| git checkout -B release/next | |
| git add pyproject.toml uv.lock CHANGELOG.md | |
| git commit -m "chore(release): prepare ${NEXT}" | |
| git push --force-with-lease origin release/next | |
| echo 'changed=true' >> "$GITHUB_OUTPUT" | |
| - name: Create the lifecycle App pull-request token | |
| if: steps.plan.outputs.released == 'true' | |
| 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 identity | |
| if: steps.plan.outputs.released == 'true' | |
| env: | |
| ACTUAL_APP_SLUG: ${{ steps.lifecycle-app.outputs.app-slug }} | |
| ACTUAL_INSTALLATION_ID: ${{ steps.lifecycle-app.outputs.installation-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" | |
| - name: Open or update the release pull request | |
| if: steps.plan.outputs.released == 'true' | |
| env: | |
| GH_TOKEN: ${{ steps.lifecycle-app.outputs.token }} | |
| NEXT: ${{ steps.plan.outputs.next }} | |
| PREVIOUS: ${{ steps.plan.outputs.previous }} | |
| COUNT: ${{ steps.plan.outputs.changes }} | |
| CHANGED: ${{ steps.push.outputs.changed }} | |
| run: | | |
| set -euo pipefail | |
| existing=$(gh pr list --head release/next --state open --json number --jq '.[0].number // empty') | |
| body=$(printf '%s\n' \ | |
| "Prepares \`${NEXT}\`, up from \`${PREVIOUS}\`, across ${COUNT} commits." \ | |
| "" \ | |
| "Written by \`scripts/plan_release.py\` from the conventional commits since the last tag. Merging this does not publish anything. After it lands, dispatch **Release and publish** with version \`${NEXT}\` and the merge commit SHA." \ | |
| "" \ | |
| "Read the changelog section before merging. That is the whole point of this pull request.") | |
| if [ -n "$existing" ]; then | |
| gh pr edit "$existing" --title "chore(release): prepare ${NEXT}" --body "$body" | |
| echo "updated PR #${existing}" | |
| # A branch push made with the built-in token starts no workflow run, | |
| # so an updated proposal would keep the checks from its previous | |
| # head and sit unmergeable. Reopening emits an event the built-in | |
| # token did not create, which does start one. | |
| if [ "${CHANGED}" = 'true' ]; then | |
| gh pr close "$existing" | |
| gh pr reopen "$existing" | |
| echo "re-ran checks on PR #${existing}" | |
| fi | |
| else | |
| gh pr create \ | |
| --base main \ | |
| --head release/next \ | |
| --title "chore(release): prepare ${NEXT}" \ | |
| --body "$body" | |
| fi | |
| - name: Report a quiet run | |
| if: steps.plan.outputs.released != 'true' | |
| env: | |
| STAGED: ${{ steps.plan.outputs.staged }} | |
| run: | | |
| set -euo pipefail | |
| if [ "$STAGED" = 'true' ]; then | |
| echo 'A release is already staged on main and is waiting to be published.' | |
| else | |
| echo 'No releasable change since the last tag.' | |
| fi |