Skip to content

feat(evidence): add v3 qualification and immutable recovery (#303) #16

feat(evidence): add v3 qualification and immutable recovery (#303)

feat(evidence): add v3 qualification and immutable recovery (#303) #16

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