You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@magpie.apache.org by po...@apache.org on 2026/09/15 15:03:28 UTC

(magpie) branch main updated: feat(dev): add a maintainer-run workflow that moves the dev stamp (#1233)

This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/magpie.git


The following commit(s) were added to refs/heads/main by this push:
     new e3fad3a4 feat(dev): add a maintainer-run workflow that moves the dev stamp (#1233)
e3fad3a4 is described below

commit e3fad3a4afd3a299429a84e00f7d5881df36cc76
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Tue Sep 15 17:03:23 2026 +0200

    feat(dev): add a maintainer-run workflow that moves the dev stamp (#1233)
    
    The marketplace is served from `main` and `claude plugin update` compares
    version strings, so while the `.dev<stamp>` suffix sits still an adopter
    running that command is told they are already current however far behind
    they are. Moving it is three steps anyone can run, which is exactly why it
    gets forgotten.
    
    Adds `bump dev version`, a `workflow_dispatch` workflow that runs those
    three steps on `main`. Manual rather than on-merge on purpose: *when* a bump
    is wanted — before pointing anyone at `claude plugin update`, before
    announcing a change adopters should take, or once merged work has piled up —
    is a judgement, not something a merge trigger can make. GitHub restricts
    `workflow_dispatch` to accounts with write access, so the button is
    committers-only without the workflow checking anything itself, and whoever
    presses it is credited as `Co-Authored-By` on the commit.
    
    Two supporting scripts, because the parts that can fail silently deserve
    tests rather than inline YAML:
    
    - `bump-dev-version.py` moves the stamp in `pyproject.toml` and nothing
      else, so the same three steps read the same by hand or in CI. It refuses
      a version with no `.dev` suffix rather than stamping over a release, and
      refuses a stamp that is not 12 digits of UTC.
    - `gh-signed-commit.py` commits through GitHub's `createCommitOnBranch`
      rather than `git commit` + `git push`. The commit comes back signed by
      GitHub and shows as Verified with no key material in CI. It pins
      `expectedHeadOid`, so a merge landing mid-run fails the call instead of
      being overwritten, and it decomposes renames because the mutation has no
      rename concept.
    
    The commit 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 maintainer's credit goes in a trailer, using GitHub's
    noreply address form rather than publishing one the account keeps private.
    
    `main` must permit the commit: the API enforces branch protection exactly as
    a push would, so a rule requiring a pull request makes the job fail rather
    than bypass anything.
    
    Generated-by: Claude Opus 5
---
 .github/workflows/bump-dev-version.yml   | 137 ++++++++++++++++++++
 docs/setup/marketplace.md                |  17 ++-
 tools/dev/README.md                      |   2 +
 tools/dev/bump-dev-version.py            | 131 +++++++++++++++++++
 tools/dev/gh-signed-commit.py            | 213 +++++++++++++++++++++++++++++++
 tools/dev/tests/test_bump_dev_version.py | 124 ++++++++++++++++++
 tools/dev/tests/test_gh_signed_commit.py | 161 +++++++++++++++++++++++
 7 files changed, 782 insertions(+), 3 deletions(-)

diff --git a/.github/workflows/bump-dev-version.yml b/.github/workflows/bump-dev-version.yml
new file mode 100644
index 00000000..0f5029d9
--- /dev/null
+++ b/.github/workflows/bump-dev-version.yml
@@ -0,0 +1,137 @@
+# 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.
+#
+# `main` must permit the commit. It is created through GitHub's API, which
+# enforces branch protection exactly as a push would: a rule requiring a pull
+# request, or a linear-history rule, makes this job fail rather than bypass
+# anything. If it fails on first run, that is the rule talking.
+---
+name: bump dev version
+
+on:  # yamllint disable-line rule:truthy
+  workflow_dispatch:
+
+permissions: {}
+
+jobs:
+  bump:
+    runs-on: ubuntu-slim
+    timeout-minutes: 10
+    permissions:
+      # Committing the stamp onto `main`.
+      contents: write
+    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@20cfd1bf945f4377ade1205e4dbc17946fc9a30d  # v10.0.1
+        with:
+          enable-cache: true
+
+      # 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 stamp onto main
+        if: steps.bump.outputs.changed == 'true'
+        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: |
+          set -euo pipefail
+          # 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 <${...@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"
+
+          # 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. `expectedHeadOid` is the tip this run checked out, so a merge
+          # landing while the job was working makes this fail rather than
+          # overwrite it.
+          uv run --project tools/dev python tools/dev/gh-signed-commit.py \
+            --branch main \
+            --expected-head-oid "$HEAD_SHA" \
+            --message-file "$RUNNER_TEMP/bump-message.txt" \
+            --repo "$REPO"
diff --git a/docs/setup/marketplace.md b/docs/setup/marketplace.md
index 5051a755..91ccf35a 100644
--- a/docs/setup/marketplace.md
+++ b/docs/setup/marketplace.md
@@ -624,9 +624,20 @@ every plugin, which nobody discovers on their own.
 with every other over one line, for no gain on changes nobody is waiting for.
 Bump when the work needs to reach installed copies: before pointing anyone at
 `claude plugin update`, before announcing a change adopters should take, or
-when a batch of merged work has piled up behind a stale stamp. A bump is a
-one-line edit plus a regeneration, so it costs little whenever it is actually
-wanted.
+when a batch of merged work has piled up behind a stale stamp.
+
+**How to bump.** From the Actions tab: **bump dev version** -> *Run workflow*.
+[`bump-dev-version.yml`](../../.github/workflows/bump-dev-version.yml) runs the
+three steps below on `main`, commits through GitHub's API so the result shows as
+**Verified**, and credits whoever pressed the button as `Co-Authored-By`.
+`workflow_dispatch` is restricted by GitHub to accounts with write access, so
+the button is committers-only.
+
+It is manual on purpose: *when* a bump is wanted is the judgement above, not
+something a merge trigger can make. The same three steps still work by hand for
+a release or a local experiment —
+[`bump-dev-version.py`](../../tools/dev/bump-dev-version.py), then
+`check-family-plugins.py --fix` and `uv lock`.
 
 The stamp is minute-resolution and **UTC**, not local time: a repo with
 contributors in several timezones needs the string to sort in the order the
diff --git a/tools/dev/README.md b/tools/dev/README.md
index 0a0e973f..258d0bdd 100644
--- a/tools/dev/README.md
+++ b/tools/dev/README.md
@@ -54,6 +54,8 @@ installable for other members to depend on it.
 | [`check-skill-preflight.py`](check-skill-preflight.py) | Keeps the shared setup pre-flight block identical in every `skills/*/SKILL.md`, generated from the single source at [`preflight-block.md`](preflight-block.md). The check has to live in each skill body: no code runs on plugin install/upgrade on most harnesses, and a shared include would escape the family plugin root that AP1 forbids leaving — so one source, many generated copies, with `--fix` propagating and the hook preventing dr [...]
 | [`estimate-skill-tokens.py`](estimate-skill-tokens.py) | Estimates each marketplace plugin's **always-on** token cost — the frontmatter `name` + `description` every installed skill advertises on every turn, at ~4 chars/token — and prints it per family. `--check` compares the figures published in `docs/setup/marketplace.md` and `docs/quick-start.md` against the live frontmatter; `check-doc-sync.py` calls it, so an edited description that moves a published number fails the build. The `SK [...]
 | [`check-family-plugins.py`](check-family-plugins.py) | Validates the marketplace plugins against the skills' `family:` frontmatter — version parity across every ecosystem manifest, Agent Plugins 1.0 conformance, and one well-formed per-family plugin whose `skills/` symlinks match the family exactly. `--fix` regenerates them, which is how the prek hook runs it. |
+| [`bump-dev-version.py`](bump-dev-version.py) | Moves the `.dev<YYYYMMDDHHMM>` stamp on `project.version` in the root `pyproject.toml` — the single authority every manifest mirrors. Only the edit: `check-family-plugins.py --fix` and `uv lock` still follow, so the three steps read the same whether a human or CI runs them. The stamp is UTC at minute resolution and has to *move* for adopters to pick anything up (`claude plugin update` compares version strings, so a frozen suffix is a silen [...]
+| [`gh-signed-commit.py`](gh-signed-commit.py) | Commits the working tree through GitHub's `createCommitOnBranch` mutation instead of `git commit` + `git push`, so the commit is signed by GitHub and shows as **Verified** with no key material in CI. Collects changed and deleted paths from `git status --porcelain -z`, decomposes renames (the mutation has no rename concept), and pins `expectedHeadOid` so a concurrent push fails the call rather than being overwritten. Used by [`bump-dev-vers [...]
 | [`check-placeholders.sh`](check-placeholders.sh) | Fails the build on hardcoded project references in skill and tool docs, which must use `<PROJECT>` / `<project>` / `<tracker>` / `<upstream>` instead. Carries both casings and matches spaced variants. |
 | [`check-workspace-members.py`](check-workspace-members.py) | Catches a new `tools/<name>/pyproject.toml` that was never added to `[tool.uv.workspace] members` — an omission that silently drops the tool from both the pre-commit hooks and the CI pytest matrix. Also verifies each member's tests actually run: both surfaces key off `[tool.pytest.ini_options]`, so a project can carry a full `tests/` directory and be executed by nothing. Reports tests-without-config, config-without-tests, and [...]
 | [`run-workspace-check.sh`](run-workspace-check.sh) | Runs one static-check or test command across every workspace member, auto-discovering which members a given check applies to. The four `workspace-*` hooks call it, so adding a tool needs no edit to the pre-commit config. |
diff --git a/tools/dev/bump-dev-version.py b/tools/dev/bump-dev-version.py
new file mode 100644
index 00000000..3eb5da59
--- /dev/null
+++ b/tools/dev/bump-dev-version.py
@@ -0,0 +1,131 @@
+#!/usr/bin/env python3
+# 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.
+"""
+Move the dev stamp in ``pyproject.toml``.
+
+``project.version`` is the single authority every manifest mirrors, so a bump
+is one edit here followed by ``check-family-plugins.py --fix`` and ``uv lock``.
+This script is only the edit; it deliberately does not run the other two, so
+the same three steps read the same whether a human or the workflow performs
+them.
+
+The suffix is a UTC timestamp at minute resolution. It has to *move* for
+adopters to pick anything up: the marketplace is served from ``main`` and
+``claude plugin update`` compares version strings, so a frozen suffix is a
+silent no-op. See ``docs/setup/marketplace.md``.
+
+Usage:
+
+    bump-dev-version.py --print              # the current version, unchanged
+    bump-dev-version.py                      # stamp with "now", in UTC
+    bump-dev-version.py --stamp 202609151058 # stamp with a given value
+"""
+
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import re
+import sys
+from pathlib import Path
+
+#: ``version = "..."`` in the ``[project]`` table. Anchored to the line start so
+#: a dependency pin elsewhere in the file cannot match.
+VERSION_RE = re.compile(r'^version = "(?P<version>.+?)"', re.MULTILINE)
+
+#: A dev suffix: ``.dev`` plus a UTC ``YYYYMMDDHHMM`` stamp.
+STAMP_RE = re.compile(r"^[0-9]{12}$")
+
+
+class BumpError(RuntimeError):
+    """Raised when the version cannot be read or rewritten."""
+
+
+def read_version(text: str) -> str:
+    """Return ``project.version`` from a ``pyproject.toml`` body."""
+    match = VERSION_RE.search(text)
+    if not match:
+        raise BumpError('no `version = "..."` line found in pyproject.toml')
+    return match.group("version")
+
+
+def next_version(current: str, stamp: str) -> str:
+    """
+    Return *current* with its dev suffix replaced by *stamp*.
+
+    A released version carries no suffix, and stamping one would advertise a dev
+    build of a version that is already out; that is refused rather than guessed
+    at, because the release flow sets the next base version deliberately.
+    """
+    if not STAMP_RE.match(stamp):
+        raise BumpError(f"stamp must be 12 digits of UTC YYYYMMDDHHMM, got {stamp!r}")
+    base, separator, _ = current.partition(".dev")
+    if not separator:
+        raise BumpError(
+            f"version {current!r} carries no .dev suffix — it looks like a release. "
+            "Set the next base version first; this script only moves an existing stamp."
+        )
+    return f"{base}.dev{stamp}"
+
+
+def replace_version(text: str, current: str, new: str) -> str:
+    """Rewrite the single ``version =`` line, leaving the rest of the file byte-identical."""
+    updated, count = VERSION_RE.subn(f'version = "{new}"', text, count=1)
+    if count != 1:
+        raise BumpError(f"could not rewrite version {current!r} in pyproject.toml")
+    return updated
+
+
+def utc_stamp(now: dt.datetime | None = None) -> str:
+    """The UTC ``YYYYMMDDHHMM`` stamp for *now*."""
+    moment = now or dt.datetime.now(dt.UTC)
+    return moment.strftime("%Y%m%d%H%M")
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        "--pyproject", type=Path, default=Path("pyproject.toml"), help="path to pyproject.toml"
+    )
+    parser.add_argument("--stamp", help="UTC YYYYMMDDHHMM stamp; defaults to now")
+    parser.add_argument(
+        "--print", dest="print_only", action="store_true", help="print the current version and exit"
+    )
+    args = parser.parse_args(argv)
+
+    text = args.pyproject.read_text(encoding="utf-8")
+    current = read_version(text)
+    if args.print_only:
+        print(current)
+        return 0
+
+    new = next_version(current, args.stamp or utc_stamp())
+    if new == current:
+        print(f"bump-dev-version: already at {current}")
+        return 0
+    args.pyproject.write_text(replace_version(text, current, new), encoding="utf-8")
+    print(f"bump-dev-version: {current} -> {new}")
+    return 0
+
+
+if __name__ == "__main__":
+    try:
+        sys.exit(main())
+    except BumpError as exc:
+        print(f"bump-dev-version: {exc}", file=sys.stderr)
+        sys.exit(1)
diff --git a/tools/dev/gh-signed-commit.py b/tools/dev/gh-signed-commit.py
new file mode 100644
index 00000000..9d5eb347
--- /dev/null
+++ b/tools/dev/gh-signed-commit.py
@@ -0,0 +1,213 @@
+#!/usr/bin/env python3
+# 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.
+"""
+Commit the working tree's changes through GitHub's `createCommitOnBranch`.
+
+Why not `git commit` + `git push`: a commit made by a workflow with `git` is
+unsigned, and a branch that requires signatures rejects it. Commits created
+through this mutation are signed by GitHub itself and show as **Verified**,
+with no key material anywhere in CI.
+
+The mutation takes file *contents*, not a diff, so this collects the changed
+paths from `git status --porcelain` and sends each one base64-encoded.
+Deletions are sent as deletions. Nothing is staged and nothing is pushed —
+the commit is created server-side on a branch that must already exist.
+
+Usage:
+
+    gh-signed-commit.py --branch <name> --message-file <path> [--repo owner/name]
+
+`--repo` defaults to `GITHUB_REPOSITORY`. The branch's current tip is read
+back from the remote and sent as `expectedHeadOid`, so a concurrent push
+makes the mutation fail rather than silently clobber.
+"""
+
+from __future__ import annotations
+
+import argparse
+import base64
+import json
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+#: The mutation. `fileChanges` carries whole file contents; GitHub computes the
+#: tree, and signs the commit with its own key.
+MUTATION = """
+mutation ($input: CreateCommitOnBranchInput!) {
+  createCommitOnBranch(input: $input) {
+    commit {
+      oid
+      url
+    }
+  }
+}
+"""
+
+
+class CommitError(RuntimeError):
+    """Raised when the working tree or the arguments cannot produce a commit."""
+
+
+def split_message(text: str) -> tuple[str, str]:
+    """
+    Split a commit message into its headline and body.
+
+    The headline is the first line; the body is everything after the blank
+    line that follows it. A message with no body yields an empty body, which
+    the mutation accepts.
+    """
+    stripped = text.strip("\n")
+    if not stripped.strip():
+        raise CommitError("commit message is empty")
+    headline, _, body = stripped.partition("\n")
+    return headline.strip(), body.lstrip("\n")
+
+
+def collect_changes(root: Path, porcelain: str) -> tuple[list[str], list[str]]:
+    """
+    Split `git status --porcelain=v1 -z` output into changed and deleted paths.
+
+    Renames are reported by git as a rename record carrying both paths; they
+    are decomposed into a deletion of the old path and an addition of the new
+    one, because the mutation has no rename concept.
+    """
+    fields = [f for f in porcelain.split("\0") if f]
+    changed: list[str] = []
+    deleted: list[str] = []
+    index = 0
+    while index < len(fields):
+        entry = fields[index]
+        index += 1
+        if len(entry) < 4:
+            raise CommitError(f"unparsable git status entry: {entry!r}")
+        status, path = entry[:2], entry[3:]
+        if status[0] in ("R", "C"):
+            # A rename/copy record is followed by its source path.
+            if index >= len(fields):
+                raise CommitError(f"rename entry {entry!r} has no source path")
+            source = fields[index]
+            index += 1
+            if status[0] == "R":
+                deleted.append(source)
+            changed.append(path)
+            continue
+        if "D" in status:
+            deleted.append(path)
+        else:
+            changed.append(path)
+    return sorted(set(changed)), sorted(set(deleted))
+
+
+def build_payload(
+    *,
+    repo: str,
+    branch: str,
+    expected_head_oid: str,
+    headline: str,
+    body: str,
+    additions: list[tuple[str, bytes]],
+    deletions: list[str],
+) -> dict:
+    """Build the `gh api graphql --input` document for the mutation."""
+    if not additions and not deletions:
+        raise CommitError("nothing to commit")
+    file_changes: dict[str, list[dict[str, str]]] = {}
+    if additions:
+        file_changes["additions"] = [
+            {"path": path, "contents": base64.b64encode(content).decode("ascii")}
+            for path, content in additions
+        ]
+    if deletions:
+        file_changes["deletions"] = [{"path": path} for path in deletions]
+    return {
+        "query": MUTATION,
+        "variables": {
+            "input": {
+                "branch": {
+                    "repositoryNameWithOwner": repo,
+                    "branchName": branch,
+                },
+                "expectedHeadOid": expected_head_oid,
+                "message": {"headline": headline, "body": body},
+                "fileChanges": file_changes,
+            }
+        },
+    }
+
+
+def _run(args: list[str], *, cwd: Path | None = None, stdin: str | None = None) -> str:
+    result = subprocess.run(args, cwd=cwd, input=stdin, capture_output=True, text=True, check=False)
+    if result.returncode != 0:
+        raise CommitError(f"{' '.join(args)} failed: {result.stderr.strip()}")
+    return result.stdout
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("--branch", required=True, help="branch to commit onto; must already exist")
+    parser.add_argument("--message-file", required=True, type=Path, help="commit message file")
+    parser.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY"), help="owner/name")
+    parser.add_argument(
+        "--expected-head-oid",
+        help=(
+            "commit the branch must still point at; the mutation fails if it has moved. "
+            "Defaults to whatever the branch points at right now, which is a weaker "
+            "guarantee — pass the sha the work was based on where you have it."
+        ),
+    )
+    args = parser.parse_args(argv)
+
+    if not args.repo:
+        parser.error("--repo is required when GITHUB_REPOSITORY is unset")
+
+    root = Path(_run(["git", "rev-parse", "--show-toplevel"]).strip())
+    headline, body = split_message(args.message_file.read_text(encoding="utf-8"))
+    changed, deleted = collect_changes(root, _run(["git", "status", "--porcelain=v1", "-z"], cwd=root))
+    if not changed and not deleted:
+        print("gh-signed-commit: working tree is clean; nothing to commit")
+        return 0
+
+    additions = [(path, (root / path).read_bytes()) for path in changed]
+    head = args.expected_head_oid or _run(
+        ["gh", "api", f"repos/{args.repo}/git/ref/heads/{args.branch}", "--jq", ".object.sha"]
+    )
+    payload = build_payload(
+        repo=args.repo,
+        branch=args.branch,
+        expected_head_oid=head.strip(),
+        headline=headline,
+        body=body,
+        additions=additions,
+        deletions=deleted,
+    )
+    out = _run(
+        ["gh", "api", "graphql", "--input", "-", "--jq", ".data.createCommitOnBranch.commit.url"],
+        stdin=json.dumps(payload),
+    )
+    print(out.strip())
+    return 0
+
+
+if __name__ == "__main__":
+    try:
+        sys.exit(main())
+    except CommitError as exc:
+        print(f"gh-signed-commit: {exc}", file=sys.stderr)
+        sys.exit(1)
diff --git a/tools/dev/tests/test_bump_dev_version.py b/tools/dev/tests/test_bump_dev_version.py
new file mode 100644
index 00000000..e988abab
--- /dev/null
+++ b/tools/dev/tests/test_bump_dev_version.py
@@ -0,0 +1,124 @@
+# 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.
+
+"""Tests for ``bump-dev-version.py``.
+
+The failure this guards is silent: a bump that does not actually move the
+string leaves every adopter's ``claude plugin update`` answering "already at
+the latest version" while they sit behind ``main``. So the tests care about the
+version string being *different* and *well-formed*, and about the two cases
+where guessing would be worse than stopping — a released version with no dev
+suffix, and a malformed stamp.
+
+The script's filename is hyphenated, which is not an importable module name, so
+it is loaded through ``importlib.util``.
+"""
+
+from __future__ import annotations
+
+import datetime as dt
+import importlib.util
+from pathlib import Path
+from types import ModuleType
+
+import pytest
+
+_SCRIPT = Path(__file__).resolve().parents[1] / "bump-dev-version.py"
+
+PYPROJECT = """\
+[project]
+name = "apache-magpie"
+version = "0.2.0.dev202609141352"
+requires-python = ">=3.11"
+
+[tool.uv]
+# A pin that must not be mistaken for the project version.
+constraint-dependencies = ["ruff>=0.16.2"]
+"""
+
+
+def _load() -> ModuleType:
+    spec = importlib.util.spec_from_file_location("bump_dev_version", _SCRIPT)
+    assert spec and spec.loader
+    module = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(module)
+    return module
+
+
+mod = _load()
+
+
+# --- reading ------------------------------------------------------------------
+
+
+def test_reads_the_project_version() -> None:
+    assert mod.read_version(PYPROJECT) == "0.2.0.dev202609141352"
+
+
+def test_a_file_without_a_version_is_refused() -> None:
+    with pytest.raises(mod.BumpError):
+        mod.read_version("[project]\nname = 'x'\n")
+
+
+# --- stamping -----------------------------------------------------------------
+
+
+def test_the_stamp_replaces_the_previous_one() -> None:
+    assert mod.next_version("0.2.0.dev202609141352", "202609151058") == "0.2.0.dev202609151058"
+
+
+def test_a_release_version_is_refused_rather_than_guessed() -> None:
+    """Stamping 0.2.0 would advertise a dev build of a version already released."""
+    with pytest.raises(mod.BumpError, match="release"):
+        mod.next_version("0.2.0", "202609151058")
+
+
+@pytest.mark.parametrize("bad", ["2026-09-15", "20260915", "20260915105812", "", "abcdefghijkl"])
+def test_a_malformed_stamp_is_refused(bad: str) -> None:
+    with pytest.raises(mod.BumpError):
+        mod.next_version("0.2.0.dev202609141352", bad)
+
+
+def test_the_stamp_is_utc_not_local() -> None:
+    """A local-time stamp sorts wrong against a bump made in another timezone."""
+    moment = dt.datetime(2026, 9, 15, 10, 58, tzinfo=dt.UTC)
+    assert mod.utc_stamp(moment) == "202609151058"
+
+
+# --- rewriting ----------------------------------------------------------------
+
+
+def test_only_the_version_line_changes() -> None:
+    updated = mod.replace_version(PYPROJECT, "0.2.0.dev202609141352", "0.2.0.dev202609151058")
+    assert 'version = "0.2.0.dev202609151058"' in updated
+    # The dependency pin below it is untouched.
+    assert 'constraint-dependencies = ["ruff>=0.16.2"]' in updated
+    assert updated.count("version = ") == 1
+
+
+def test_end_to_end_rewrites_the_file(tmp_path: Path) -> None:
+    path = tmp_path / "pyproject.toml"
+    path.write_text(PYPROJECT, encoding="utf-8")
+    assert mod.main(["--pyproject", str(path), "--stamp", "202609151058"]) == 0
+    assert 'version = "0.2.0.dev202609151058"' in path.read_text(encoding="utf-8")
+
+
+def test_bumping_to_the_same_stamp_is_a_no_op(tmp_path: Path) -> None:
+    path = tmp_path / "pyproject.toml"
+    path.write_text(PYPROJECT, encoding="utf-8")
+    assert mod.main(["--pyproject", str(path), "--stamp", "202609141352"]) == 0
+    assert path.read_text(encoding="utf-8") == PYPROJECT
diff --git a/tools/dev/tests/test_gh_signed_commit.py b/tools/dev/tests/test_gh_signed_commit.py
new file mode 100644
index 00000000..ed165656
--- /dev/null
+++ b/tools/dev/tests/test_gh_signed_commit.py
@@ -0,0 +1,161 @@
+# 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.
+
+"""Tests for ``gh-signed-commit.py``.
+
+The three pure pieces are what can go wrong silently in CI: a message split the
+wrong way produces a commit whose body is in its subject, a mis-parsed
+``git status`` drops a file from the commit without failing, and a payload
+missing ``expectedHeadOid`` overwrites a concurrent push. Each is exercised
+here; the mutation call itself is not, because asserting that ``gh`` was
+invoked would test the mock rather than the code.
+
+The script's filename is hyphenated, which is not an importable module name, so
+it is loaded through ``importlib.util``.
+"""
+
+from __future__ import annotations
+
+import base64
+import importlib.util
+from pathlib import Path
+from types import ModuleType
+
+import pytest
+
+_SCRIPT = Path(__file__).resolve().parents[1] / "gh-signed-commit.py"
+
+
+def _load() -> ModuleType:
+    spec = importlib.util.spec_from_file_location("gh_signed_commit", _SCRIPT)
+    assert spec and spec.loader
+    module = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(module)
+    return module
+
+
+mod = _load()
+
+
+# --- message splitting --------------------------------------------------------
+
+
+def test_headline_and_body_split_on_the_blank_line() -> None:
+    headline, body = mod.split_message("chore: bump\n\nWhy it moved.\n\nGenerated-by: X\n")
+    assert headline == "chore: bump"
+    assert body == "Why it moved.\n\nGenerated-by: X"
+
+
+def test_a_subject_only_message_has_an_empty_body() -> None:
+    headline, body = mod.split_message("chore: bump\n")
+    assert headline == "chore: bump"
+    assert body == ""
+
+
+def test_an_empty_message_is_refused() -> None:
+    with pytest.raises(mod.CommitError):
+        mod.split_message("\n  \n")
+
+
+# --- git status parsing -------------------------------------------------------
+
+
+def _porcelain(*entries: str) -> str:
+    return "\0".join(entries) + "\0"
+
+
+def test_modified_and_added_paths_are_collected(tmp_path: Path) -> None:
+    changed, deleted = mod.collect_changes(tmp_path, _porcelain(" M pyproject.toml", "A  new.json"))
+    assert changed == ["new.json", "pyproject.toml"]
+    assert deleted == []
+
+
+def test_deletions_are_reported_separately(tmp_path: Path) -> None:
+    changed, deleted = mod.collect_changes(tmp_path, _porcelain(" D gone.json", " M kept.json"))
+    assert changed == ["kept.json"]
+    assert deleted == ["gone.json"]
+
+
+def test_a_rename_becomes_a_delete_plus_an_add(tmp_path: Path) -> None:
+    """The mutation has no rename; losing the source path would leave both files."""
+    changed, deleted = mod.collect_changes(tmp_path, _porcelain("R  after.json", "before.json"))
+    assert changed == ["after.json"]
+    assert deleted == ["before.json"]
+
+
+def test_a_rename_without_its_source_is_refused(tmp_path: Path) -> None:
+    with pytest.raises(mod.CommitError):
+        mod.collect_changes(tmp_path, _porcelain("R  after.json"))
+
+
+def test_paths_containing_spaces_survive(tmp_path: Path) -> None:
+    """`-z` output is not quoted, so a space is just a space."""
+    changed, _ = mod.collect_changes(tmp_path, _porcelain(" M docs/a file.md"))
+    assert changed == ["docs/a file.md"]
+
+
+# --- payload ------------------------------------------------------------------
+
+
+def test_payload_pins_the_expected_head_and_encodes_contents() -> None:
+    payload = mod.build_payload(
+        repo="apache/magpie",
+        branch="chore/bump",
+        expected_head_oid="deadbeef",
+        headline="chore: bump",
+        body="Why.",
+        additions=[("pyproject.toml", b'version = "0.2.0"\n')],
+        deletions=["old.json"],
+    )
+    variables = payload["variables"]["input"]
+    assert variables["expectedHeadOid"] == "deadbeef"
+    assert variables["branch"] == {
+        "repositoryNameWithOwner": "apache/magpie",
+        "branchName": "chore/bump",
+    }
+    assert variables["message"] == {"headline": "chore: bump", "body": "Why."}
+    addition = variables["fileChanges"]["additions"][0]
+    assert addition["path"] == "pyproject.toml"
+    assert base64.b64decode(addition["contents"]) == b'version = "0.2.0"\n'
+    assert variables["fileChanges"]["deletions"] == [{"path": "old.json"}]
+
+
+def test_payload_omits_empty_change_kinds() -> None:
+    payload = mod.build_payload(
+        repo="apache/magpie",
+        branch="b",
+        expected_head_oid="sha",
+        headline="h",
+        body="",
+        additions=[("a.json", b"{}")],
+        deletions=[],
+    )
+    assert "deletions" not in payload["variables"]["input"]["fileChanges"]
+
+
+def test_an_empty_changeset_is_refused() -> None:
+    """A mutation with no file changes creates an empty commit on the branch."""
+    with pytest.raises(mod.CommitError):
+        mod.build_payload(
+            repo="apache/magpie",
+            branch="b",
+            expected_head_oid="sha",
+            headline="h",
+            body="",
+            additions=[],
+            deletions=[],
+        )