| # 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 |