blob: 2d4f465e6d724e1a19fcfe6b65159e327dd8e410 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
# Moves the dev stamp on `main`, on request.
#
# The marketplace is served straight from `main`, and `claude plugin update`
# compares version strings rather than commit SHAs. While the stamp sits still,
# an adopter running that command is told they are already current no matter how
# far behind they are — see docs/setup/marketplace.md.
#
# Run from the Actions tab: **bump dev version** -> *Run workflow*. It is
# deliberately manual rather than on-merge, because the judgement of *when a
# bump is wanted* is not mechanical — it is wanted before pointing anyone at
# `claude plugin update`, before announcing a change adopters should take, or
# when merged work has piled up behind a stale stamp, and not on the merges in
# between.
#
# Who can run it: `workflow_dispatch` is restricted by GitHub to accounts with
# write access to the repository, so this is committers-only without the
# workflow having to check anything. The run is attributed to whoever pressed
# the button — they are named as `Co-Authored-By` on the commit, and the
# Actions run records them as the actor.
#
# SHAPE.
#
# prepare -> branch carrying the signed candidate commit
# open-pr -> open the pull request as a draft
# cleanup -> delete the branch when something failed
#
# This workflow runs no checks of its own. It used to call `pre-commit.yml`,
# `tests.yml`, `rat.yml` and `zizmor.yml` against the candidate before opening
# the PR, which was a second copy of a suite the pull request already runs the
# moment it is marked ready — see below. One run of the gate is enough, and the
# run that matters is the one attached to the commit being merged.
#
# A pull request is also the only shape that works. The first version of this
# workflow committed straight to `main` and run #1 was refused outright:
#
# gh: protected branch 'main' check failed:
# 3 of 3 required status checks are expected.
#
# `main` requires three status checks, and a commit arriving by API has none —
# the API enforces protection exactly as a push would. Nothing about a bump is
# special enough to route around that, so the bump goes through review like
# every other change.
#
# WHY THE PULL REQUEST IS A DRAFT. GitHub raises no `pull_request` events for
# anything done with `GITHUB_TOKEN`, so a PR opened from here starts no checks
# of its own, and the checks this workflow ran are attached to the run's own
# commit rather than to the PR head — evidence in the run log, not green ticks
# on the PR. Left as a ready PR it would sit forever on "N of N required status
# checks are expected".
#
# Opening it as a draft turns that into a normal gesture: marking a draft ready
# raises `pull_request: ready_for_review`, which *is* a real event because a
# human does it. `pre-commit.yml`, `tests.yml`, `rat.yml` and `zizmor.yml` list
# that type explicitly — it is not in the default set — so pressing *Ready for
# review* starts the required checks against the PR head, exactly as an ordinary
# PR gets them. That is also why this workflow needs to run none of them itself.
# Review the diff, mark it ready, let the checks run, merge.
---
name: bump dev version
on: # yamllint disable-line rule:truthy
workflow_dispatch:
permissions: {}
jobs:
prepare:
name: Build the candidate commit
runs-on: ubuntu-slim
timeout-minutes: 10
permissions:
# Creating the temporary branch and committing the candidate onto it.
contents: write
outputs:
branch: ${{ steps.candidate.outputs.branch }}
version: ${{ steps.candidate.outputs.version }}
changed: ${{ steps.bump.outputs.changed }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# The commit is created through GitHub's API rather than `git push`,
# so the job never needs the checkout's credentials on disk.
persist-credentials: false
# The bump reads and rewrites the tip; no history is needed.
fetch-depth: 1
- uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
with:
enable-cache: true
# A distinct cache slot per job. `setup-uv` derives its key from the
# runner, arch and Python version only, so every job that enables the
# cache computes the *same* key and they race when they run
# concurrently — "Unable to reserve cache with key …, another job may be
# creating this cache", and the loser saves nothing. `cache-suffix`
# splits them, which also keeps each slot scoped to the dependency set
# that job actually installs.
cache-suffix: "bump-prepare"
# The documented steps, unchanged: edit `project.version`, then let
# check-family-plugins propagate it to every manifest and `uv lock`
# restamp the lockfile. `uv run` supplies the Python; `ubuntu-slim` is
# minimal and a system interpreter is not worth assuming.
- name: Bump the stamp and regenerate the manifests
id: bump
run: |
set -euo pipefail
uv run --project tools/dev python tools/dev/bump-dev-version.py
version=$(uv run --project tools/dev python tools/dev/bump-dev-version.py --print)
uv run --project tools/dev python tools/dev/check-family-plugins.py --fix
uv lock
if [[ -z "$(git status --porcelain)" ]]; then
echo "Stamp is already current; nothing to commit."
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Commit the candidate onto a temporary branch
if: steps.bump.outputs.changed == 'true'
id: candidate
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
VERSION: ${{ steps.bump.outputs.version }}
HEAD_SHA: ${{ github.sha }}
ACTOR: ${{ github.actor }}
ACTOR_ID: ${{ github.actor_id }}
RUN_ID: ${{ github.run_id }}
run: |
set -euo pipefail
branch="chore/bump-dev-version-${RUN_ID}"
# Attribute the bump to the maintainer who ran the workflow. The
# commit's *author* is whoever the token authenticates as — with
# GITHUB_TOKEN that is the Actions bot, and the mutation offers no way
# to set it — so the credit goes in a trailer instead. The address is
# GitHub's noreply form, which is stable and does not publish an
# address the account has chosen to keep private.
actor_name=$(gh api "users/$ACTOR" --jq '.name // .login')
coauthor="$actor_name <${ACTOR_ID}+${ACTOR}@users.noreply.github.com>"
{
echo "chore: bump dev version to $VERSION"
echo
echo "Move the dev stamp so adopters' \`claude plugin update\` picks up the"
echo "work merged since the previous stamp. The marketplace is served from"
echo "\`main\` and the update check compares version strings, so a frozen"
echo "suffix is a silent no-op: an adopter running that command is told they"
echo "are already current while sitting behind."
echo
echo "Run from the Actions tab by @${ACTOR}. Generated by"
echo ".github/workflows/bump-dev-version.yml with the documented steps: bump"
echo "\`project.version\` in \`pyproject.toml\`, then"
echo "\`tools/dev/check-family-plugins.py --fix\` and \`uv lock\` to propagate"
echo "to every manifest. The stamp is UTC at minute resolution."
echo
echo "Co-Authored-By: $coauthor"
} > "$RUNNER_TEMP/bump-message.txt"
# The branch has to exist before the mutation can commit onto it.
# It becomes the pull request's head; `cleanup` deletes it if the
# commit or the PR never happens.
gh api "repos/$REPO/git/refs" \
-f "ref=refs/heads/$branch" -f "sha=$HEAD_SHA" >/dev/null
# Published here, not after the commit below: the branch exists from
# this line onward, so if the mutation fails `cleanup` still needs the
# name to delete it. Emitting it at the end of the step would leak the
# branch on exactly the failure that creates one.
echo "branch=$branch" >> "$GITHUB_OUTPUT"
# Committed through GitHub's API, not `git commit`: the commit comes
# back signed by GitHub and shows as Verified, with no key material in
# CI.
uv run --project tools/dev python tools/dev/gh-signed-commit.py \
--branch "$branch" \
--expected-head-oid "$HEAD_SHA" \
--message-file "$RUNNER_TEMP/bump-message.txt" \
--repo "$REPO"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
open-pr:
name: Open the bump pull request
needs: prepare
if: needs.prepare.outputs.changed == 'true'
runs-on: ubuntu-slim
timeout-minutes: 5
permissions:
contents: read
pull-requests: write
steps:
- name: Create the pull request
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
BRANCH: ${{ needs.prepare.outputs.branch }}
VERSION: ${{ needs.prepare.outputs.version }}
ACTOR: ${{ github.actor }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
body=$(cat <<EOF
Moves the dev stamp to \`$VERSION\` so adopters' \`claude plugin update\` picks
up the work merged since the previous stamp. The marketplace is served from
\`main\` and the update check compares version strings, so a frozen suffix is a
silent no-op — see [\`docs/setup/marketplace.md\`](docs/setup/marketplace.md).
Requested from the Actions tab by @$ACTOR, who is credited on the commit. The
commit is signed by GitHub and the candidate was checked before this PR was
opened — prek, tests, rat and zizmor all passed in [the run that produced it]($RUN_URL).
Those runs are attached to that workflow run rather than to this PR, so the
required checks here start out unstarted: GitHub raises no events for anything
\`GITHUB_TOKEN\` does. **Press *Ready for review* to start them** — that raises
a real event, and the check workflows listen for it.
Opened by [\`bump-dev-version.yml\`](.github/workflows/bump-dev-version.yml).
EOF
)
# Draft on purpose — see the note at the top of this file. Marking it
# ready is what starts the required checks.
url=$(gh pr create --repo "$REPO" --base main --head "$BRANCH" --draft \
--title "chore: bump dev version to $VERSION" \
--body "$body")
echo "Opened $url" >> "$GITHUB_STEP_SUMMARY"
cleanup:
name: Drop the temporary branch
needs: [prepare, open-pr]
# Only when something above did not succeed. On the happy path the branch
# is the pull request's head and must stay. Without this a failed `prepare`
# or `open-pr` leaves an orphan candidate branch behind.
if: always() && needs.prepare.outputs.changed == 'true' && contains(needs.*.result, 'failure')
runs-on: ubuntu-slim
timeout-minutes: 5
permissions:
contents: write
steps:
- name: Delete the candidate branch
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
BRANCH: ${{ needs.prepare.outputs.branch }}
run: |
set -euo pipefail
if [[ -n "$BRANCH" ]]; then
gh api "repos/$REPO/git/refs/heads/$BRANCH" -X DELETE || true
echo "Run failed; candidate branch $BRANCH deleted and no PR opened." \
>> "$GITHUB_STEP_SUMMARY"
fi