diff --git a/.github/scripts/bot-pull-request.sh b/.github/scripts/bot-pull-request.sh
new file mode 100755
index 0000000..81123ed
--- /dev/null
+++ b/.github/scripts/bot-pull-request.sh
@@ -0,0 +1,260 @@
+#!/usr/bin/env bash
+# Moves a pull request opened by this repository's own automation (the spec sync or
+# release-please) to an automatic merge, or holds it for a person (docs/DESIGN.md sections 3.1
+# and 13). Identical in all seven server SDK repositories.
+#
+# bot-pull-request.sh spec-sync
[passed|failed]
+# bot-pull-request.sh release
+# bot-pull-request.sh catch-up []
+#
+# spec-sync without passed|failed reads the conclusion of the latest finished Spec sync run on
+# main. catch-up starts every workflow that runs release-please (.github/workflows/*.yml using
+# googleapis/release-please-action) on main when main's newest commit has no run of it: a merge
+# made by auto-merge that GITHUB_TOKEN turned on starts no push workflow. Given a head branch
+# whose open pull request has auto-merge on, it first waits up to three minutes for that merge.
+#
+# A pull request opened with GITHUB_TOKEN gets its pull_request CI run only after a maintainer
+# approves it (this repository is public). workflow_dispatch is exempt, so the script starts
+# ci.yml on the pull request's branch: its checks land on the head commit, which is what the
+# required "CI passed" check on main reads. It then turns on auto-merge (squash), so GitHub
+# merges only once the required checks pass.
+#
+# A pull request waits for a person (label needs-alex, one comment, auto-merge off) when:
+# - a spec sync failed a check (usually a missing openapi/overlay.json entry), is a draft, or
+# asks for a major version ("!" or a BREAKING CHANGE footer: an API change never does);
+# - a release pull request is for a new major version (an SDK design change);
+# - it carries commits that did not come from the automation;
+# - main has no required status checks, so auto-merge would not wait for CI.
+# The script removes its own needs-alex label once the reason is gone. It leaves alone a pull
+# request labelled do-not-merge, one a person labelled needs-alex, and one whose auto-merge a
+# person turned off.
+#
+# Needs GH_TOKEN (the workflow token with actions, contents, issues and pull-requests write),
+# GITHUB_REPOSITORY, gh and jq.
+set -euo pipefail
+
+usage() {
+ echo "usage: bot-pull-request.sh spec-sync [passed|failed] | release | catch-up []" >&2
+ exit 2
+}
+
+kind="${1:-}"
+repo="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is not set}"
+base="main"
+hold_label="needs-alex"
+person_hold_label="do-not-merge"
+bot_email="41898282+github-actions[bot]@users.noreply.github.com"
+
+case "$kind" in
+ spec-sync)
+ branch="${2:-}"
+ checks="${3:-}"
+ [ -n "$branch" ] || usage
+ if [ -z "$checks" ]; then
+ conclusion="$(gh run list --repo "$repo" --workflow spec-sync.yml --branch "$base" --status completed \
+ --limit 1 --json conclusion --jq '.[0].conclusion // "success"')"
+ checks=failed
+ if [ "$conclusion" = success ]; then
+ checks=passed
+ fi
+ fi
+ case "$checks" in passed | failed) ;; *) usage ;; esac
+ numbers="$(gh pr list --repo "$repo" --base "$base" --head "$branch" --state open \
+ --json number --jq '.[].number')"
+ ;;
+ release)
+ checks=passed
+ numbers="$(gh pr list --repo "$repo" --base "$base" --state open --label 'autorelease: pending' \
+ --json number,headRefName --jq '.[] | select(.headRefName | startswith("release-please--")) | .number')"
+ ;;
+ catch-up)
+ branch="${2:-}"
+ if [ -n "$branch" ]; then
+ for _ in $(seq 1 18); do
+ waiting="$(gh pr list --repo "$repo" --base "$base" --head "$branch" --state open \
+ --json autoMergeRequest --jq '[.[] | select(.autoMergeRequest != null)] | length')"
+ [ "$waiting" -eq 0 ] && break
+ sleep 10
+ done
+ fi
+ sha="$(gh api "repos/${repo}/commits/${base}" --jq '.sha')"
+ grep -l 'googleapis/release-please-action' .github/workflows/*.yml | while read -r file; do
+ workflow="$(basename "$file")"
+ runs="$(gh api "repos/${repo}/actions/workflows/${workflow}/runs?head_sha=${sha}&per_page=1" --jq '.total_count')"
+ if [ "$runs" -eq 0 ]; then
+ gh workflow run "$workflow" --repo "$repo" --ref "$base"
+ echo "Started ${workflow} on ${base} (${sha:0:10}): no run of it had started for that commit."
+ else
+ echo "${workflow} already ran on ${base} (${sha:0:10})."
+ fi
+ done
+ exit 0
+ ;;
+ *) usage ;;
+esac
+
+is_bot() {
+ case "$1" in
+ github-actions | 'github-actions[bot]' | app/github-actions) return 0 ;;
+ *) return 1 ;;
+ esac
+}
+
+# Turns auto-merge off and, unless the label is there already, labels and comments once.
+hold() {
+ local number="$1" reason="$2" labelled="$3"
+ echo "::warning::Pull request #${number} waits for a person: ${reason}"
+ gh pr merge "$number" --repo "$repo" --disable-auto > /dev/null 2>&1 || true
+ if [ "$labelled" = true ]; then
+ return 0
+ fi
+ gh label create "$hold_label" --repo "$repo" --color D93F0B --force \
+ --description "Automation stopped: a person merges or closes this pull request" > /dev/null
+ gh pr edit "$number" --repo "$repo" --add-label "$hold_label" > /dev/null
+ gh pr comment "$number" --repo "$repo" --body "$(
+ cat << EOF
+**Automatic merge is off:** ${reason}.
+
+A person decides: review the pull request, then merge it with **Squash and merge**, or close it. The automation (\`.github/scripts/bot-pull-request.sh\`) checks again on its next run and removes the \`${hold_label}\` label itself once the reason is gone. To keep this pull request from merging in any case, add the \`${person_hold_label}\` label (docs/DESIGN.md section 13).
+EOF
+ )" > /dev/null
+}
+
+handle() {
+ local number="$1"
+ local pr title body draft head sha author labelled ours events last_auto reason
+ pr="$(gh pr view "$number" --repo "$repo" \
+ --json title,body,isDraft,headRefName,headRefOid,labels,autoMergeRequest,author)"
+ title="$(jq -r '.title' <<< "$pr")"
+ body="$(jq -r '.body // ""' <<< "$pr")"
+ draft="$(jq -r '.isDraft' <<< "$pr")"
+ head="$(jq -r '.headRefName' <<< "$pr")"
+ sha="$(jq -r '.headRefOid' <<< "$pr")"
+ author="$(jq -r '.author.login' <<< "$pr")"
+
+ if ! is_bot "$author"; then
+ echo "#${number} was opened by ${author}, not by this repository's automation; left alone."
+ return 0
+ fi
+ if jq -e --arg l "$person_hold_label" '.labels | any(.name == $l)' <<< "$pr" > /dev/null; then
+ echo "#${number} is labelled ${person_hold_label}; left alone."
+ return 0
+ fi
+
+ # shellcheck disable=SC2016 # GraphQL variables, not shell ones.
+ events="$(gh api graphql -F owner="${repo%%/*}" -F name="${repo#*/}" -F number="$number" -f query='
+ query($owner: String!, $name: String!, $number: Int!) {
+ repository(owner: $owner, name: $name) {
+ pullRequest(number: $number) {
+ timelineItems(last: 100, itemTypes: [LABELED_EVENT, AUTO_MERGE_ENABLED_EVENT, AUTO_MERGE_DISABLED_EVENT]) {
+ nodes {
+ __typename
+ ... on LabeledEvent { actor { login } label { name } }
+ ... on AutoMergeEnabledEvent { actor { login } }
+ ... on AutoMergeDisabledEvent { actor { login } reasonCode }
+ }
+ }
+ }
+ }
+ }' --jq '.data.repository.pullRequest.timelineItems.nodes')"
+
+ # A person who turned auto-merge off keeps it off. Any disable by a person counts, whatever
+ # its reasonCode says; one with no actor (GitHub itself) or by the automation does not.
+ local disabler
+ last_auto="$(jq -c '[.[] | select(.__typename != "LabeledEvent")] | last // {}' <<< "$events")"
+ disabler="$(jq -r 'select(.__typename == "AutoMergeDisabledEvent") | .actor.login // ""' <<< "$last_auto")"
+ if [ -n "$disabler" ] && ! is_bot "$disabler"; then
+ echo "#${number}: ${disabler} turned auto-merge off; left alone."
+ return 0
+ fi
+
+ labelled=false
+ ours=false
+ if jq -e --arg l "$hold_label" '.labels | any(.name == $l)' <<< "$pr" > /dev/null; then
+ labelled=true
+ if is_bot "$(jq -r --arg l "$hold_label" \
+ '[.[] | select(.__typename == "LabeledEvent" and .label.name == $l)] | last | .actor.login // ""' \
+ <<< "$events")"; then
+ ours=true
+ else
+ echo "#${number}: a person labelled it ${hold_label}; left alone."
+ return 0
+ fi
+ fi
+
+ reason=""
+ case "$kind" in
+ spec-sync)
+ if [ "$checks" != passed ]; then
+ reason="the latest spec sync run failed (the Checks section says which check; the fix is usually an openapi/overlay.json entry or the name upstream)"
+ elif [ "$draft" = true ]; then
+ reason="the spec sync pull request is a draft"
+ elif grep -qE '^[a-z]+(\([^)]*\))?!:' <<< "$title" || grep -qE '^BREAKING[ -]CHANGE:' <<< "$body"; then
+ reason="it asks for a major version, and an API change never needs one (docs/DESIGN.md section 13)"
+ fi
+ ;;
+ release)
+ local version current
+ version="$(grep -oE '[0-9]+\.[0-9]+\.[0-9]+' <<< "$title" | tail -n 1 || true)"
+ current="$(gh api "repos/${repo}/contents/.release-please-manifest.json?ref=${base}" \
+ -H 'Accept: application/vnd.github.raw+json' | jq -r '.["."] // ""')"
+ if [ -z "$version" ] || [ -z "$current" ]; then
+ reason="the release version could not be read from the title or .release-please-manifest.json"
+ elif [ "${version%%.*}" -gt "${current%%.*}" ]; then
+ reason="it releases a new major version (${current} to ${version}), which is an SDK design change (docs/DESIGN.md section 13)"
+ fi
+ ;;
+ esac
+
+ if [ -z "$reason" ]; then
+ local people
+ people="$(gh api "repos/${repo}/pulls/${number}/commits?per_page=100" \
+ --jq ".[] | select(.commit.author.email != \"${bot_email}\") | .sha[0:10]")"
+ if [ -n "$people" ]; then
+ reason="it has commits that did not come from the automation ($(tr '\n' ' ' <<< "$people" | sed 's/ $//'))"
+ fi
+ fi
+ if [ -z "$reason" ]; then
+ local required
+ required="$(gh api "repos/${repo}/branches/${base}" \
+ --jq '(.protection.required_status_checks.contexts // []) | length')"
+ if [ "$required" -eq 0 ]; then
+ reason="${base} has no required status checks, so auto-merge would not wait for CI"
+ fi
+ fi
+
+ if [ -n "$reason" ]; then
+ hold "$number" "$reason" "$labelled"
+ return 0
+ fi
+ if [ "$ours" = true ]; then
+ gh pr edit "$number" --repo "$repo" --remove-label "$hold_label" > /dev/null
+ fi
+
+ # CI for the head commit, unless a run is already there that is not waiting for approval.
+ local started
+ started="$(gh api "repos/${repo}/actions/workflows/ci.yml/runs?head_sha=${sha}&per_page=100" \
+ --jq '[.workflow_runs[] | select(.conclusion != "action_required" and .conclusion != "cancelled")] | length')"
+ if [ "$started" -eq 0 ]; then
+ gh workflow run ci.yml --repo "$repo" --ref "$head"
+ echo "#${number}: started CI on ${head} (${sha:0:10})."
+ else
+ echo "#${number}: CI already runs on ${sha:0:10}."
+ fi
+
+ if [ "$(jq -r '.autoMergeRequest != null' <<< "$pr")" = true ]; then
+ echo "#${number}: auto-merge is on."
+ else
+ gh pr merge "$number" --repo "$repo" --auto --squash --match-head-commit "$sha"
+ echo "#${number}: auto-merge on (squash) for ${sha:0:10}; GitHub merges once the required checks pass."
+ fi
+ echo "- #${number} ${title}: CI started and auto-merge on" >> "${GITHUB_STEP_SUMMARY:-/dev/null}"
+}
+
+if [ -z "$numbers" ]; then
+ echo "No open ${kind} pull request."
+ exit 0
+fi
+for number in $numbers; do
+ handle "$number"
+done
diff --git a/.github/scripts/wait-for-registry.sh b/.github/scripts/wait-for-registry.sh
new file mode 100755
index 0000000..74168f5
--- /dev/null
+++ b/.github/scripts/wait-for-registry.sh
@@ -0,0 +1,59 @@
+#!/usr/bin/env bash
+# Waits until a package registry serves a released version, and fails the release run loudly
+# when it does not (docs/DESIGN.md section 13). Identical in all seven server SDK repositories.
+#
+# wait-for-registry.sh [minutes]
+#
+# is the registry's name for it: didit-sdk, @didit-protocol/sdk-node,
+# github.com/didit-protocol/sdk-go, me.didit:didit-java, didit-protocol/sdk-php, Didit.
+# may carry the tag's "v". Checks every 30 seconds; the default wait is 30 minutes.
+set -euo pipefail
+
+registry="${1:?usage: wait-for-registry.sh [minutes]}"
+package="${2:?usage: wait-for-registry.sh [minutes]}"
+version="${3:?usage: wait-for-registry.sh [minutes]}"
+minutes="${4:-30}"
+version="${version#v}"
+
+case "$registry" in
+ pypi) url="https://pypi.org/pypi/${package}/${version}/json" ;;
+ npm) url="https://registry.npmjs.org/${package/\//%2F}/${version}" ;;
+ go) url="https://proxy.golang.org/${package}/@v/v${version}.info" ;;
+ maven)
+ group="${package%%:*}"
+ artifact="${package#*:}"
+ url="https://repo1.maven.org/maven2/${group//.//}/${artifact}/${version}/${artifact}-${version}.pom"
+ ;;
+ packagist) url="https://repo.packagist.org/p2/${package}.json" ;;
+ rubygems) url="https://rubygems.org/api/v2/rubygems/${package}/versions/${version}.json" ;;
+ nuget)
+ id="$(tr '[:upper:]' '[:lower:]' <<< "$package")"
+ lower="$(tr '[:upper:]' '[:lower:]' <<< "$version")"
+ url="https://api.nuget.org/v3-flatcontainer/${id}/${lower}/${id}.nuspec"
+ ;;
+ *)
+ echo "::error::Unknown registry '${registry}'."
+ exit 2
+ ;;
+esac
+
+served() {
+ local body
+ body="$(curl --fail --silent --show-error --location --max-time 30 "$url" 2> /dev/null)" || return 1
+ if [ "$registry" = packagist ]; then
+ # One metadata file lists every version; Packagist names them after the tags (v1.2.3).
+ jq -e --arg v "$version" \
+ '[.packages[]?[]? | .version] | any(. == $v or . == ("v" + $v))' > /dev/null <<< "$body"
+ fi
+}
+
+deadline=$((SECONDS + minutes * 60))
+until served; do
+ if [ "$SECONDS" -ge "$deadline" ]; then
+ echo "::error::${registry} does not serve ${package} ${version} after ${minutes} minutes (${url}). The release is not published: check the publish job and the registry."
+ exit 1
+ fi
+ sleep 30
+done
+echo "${registry} serves ${package} ${version}: ${url}"
+echo "- ${registry} serves \`${package}\` ${version}: ${url}" >> "${GITHUB_STEP_SUMMARY:-/dev/null}"
diff --git a/.github/workflows/bot-pull-requests.yml b/.github/workflows/bot-pull-requests.yml
new file mode 100644
index 0000000..14a1a5b
--- /dev/null
+++ b/.github/workflows/bot-pull-requests.yml
@@ -0,0 +1,59 @@
+# Takes the pull requests this repository's automation opens, the spec sync (chore/spec-sync)
+# and release-please (release-please--*), to a merge without a person (docs/DESIGN.md sections
+# 3.1 and 13). Identical in all seven server SDK repositories.
+#
+# For each such pull request .github/scripts/bot-pull-request.sh starts ci.yml on its branch
+# (a pull request opened with GITHUB_TOKEN gets its pull_request run only after a maintainer
+# approves it; workflow_dispatch needs no approval) and turns on auto-merge with squash, so
+# GitHub merges once the required "CI passed" check on main passes. A spec sync that failed a
+# check, anything that asks for a major version, and commits from people wait for a person
+# (label needs-alex). Then it starts the release workflows on main when the newest commit has
+# no run of them: a merge made by that auto-merge starts no push workflow.
+#
+# Runs when a spec sync, release or CI run finishes, every 30 minutes as a safety net, and by
+# hand.
+name: Bot pull requests
+
+on:
+ workflow_run:
+ workflows: ["Spec sync", "Release Please", "Release", "Publish", "CI"]
+ types: [completed]
+ branches: [main, chore/spec-sync, "release-please--**"]
+ schedule:
+ - cron: "7,37 * * * *"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: bot-pull-requests
+ cancel-in-progress: false
+
+jobs:
+ bot-pull-requests:
+ name: CI and auto-merge for the automation's pull requests
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ permissions:
+ actions: write # start ci.yml and the release workflows
+ contents: write # auto-merge
+ issues: write # the needs-alex label
+ pull-requests: write
+ env:
+ GH_TOKEN: ${{ github.token }}
+ steps:
+ - name: Check out the scripts and workflows
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ sparse-checkout: .github
+ - name: The spec sync pull request
+ run: bash .github/scripts/bot-pull-request.sh spec-sync chore/spec-sync
+ - name: The release pull request
+ run: bash .github/scripts/bot-pull-request.sh release
+ - name: Release workflows for a merge that started none
+ env:
+ # After a CI run on a bot branch, wait for the merge its auto-merge is about to make.
+ HEAD_BRANCH: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.name == 'CI' && github.event.workflow_run.head_branch != 'main' && github.event.workflow_run.head_branch || '' }}
+ run: bash .github/scripts/bot-pull-request.sh catch-up "$HEAD_BRANCH"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f85bbd8..2784a73 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -218,3 +218,25 @@ jobs:
export DIDIT_SANDBOX_BASE_URL="$SANDBOX_BASE_URL"
fi
uv run pytest -m live
+
+ # The one check main requires (docs/DESIGN.md section 13): auto-merge of the spec sync and
+ # release pull requests waits for it. It passes only when every job above passed; the live
+ # sandbox tests are not part of it.
+ ci-passed:
+ name: CI passed
+ if: always()
+ needs: [lint, typecheck, codegen, test, build, secret-scan]
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - name: Every job passed
+ env:
+ RESULTS: ${{ join(needs.*.result, ' ') }}
+ run: |
+ for result in $RESULTS; do
+ if [ "$result" != success ]; then
+ echo "::error::A CI job ended with '${result}'; see the jobs above."
+ exit 1
+ fi
+ done
+ echo "Every CI job passed."
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 6420573..038b279 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -109,3 +109,24 @@ jobs:
uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
with:
packages-dir: dist/
+
+ # The release is done only when the registry serves it (docs/DESIGN.md section 13); this job
+ # fails loudly when it does not. It runs nothing from the release and holds no credentials.
+ registry:
+ name: PyPI serves the release
+ needs: publish
+ runs-on: ubuntu-latest
+ timeout-minutes: 40
+ permissions:
+ contents: read
+ env:
+ TAG: ${{ github.event.release.tag_name || inputs.tag }}
+ steps:
+ - name: Check out the scripts
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ github.event.repository.default_branch }}
+ persist-credentials: false
+ sparse-checkout: .github/scripts
+ - name: Wait until PyPI serves the version
+ run: bash .github/scripts/wait-for-registry.sh pypi didit-sdk "$TAG" 30
diff --git a/AGENTS.md b/AGENTS.md
index c63d9cb..b338a64 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,7 +1,7 @@
# Didit Python SDK (`sdk-python`)
The official Didit server-side library for Python: `didit-sdk` on PyPI (name transfer pending, see docs/DESIGN.md 2.1).
-Status: released (1.0.0 on 2026-10-29). Merging the release-please pull request publishes the next version.
+Status: released (1.0.0 on 2026-10-29). Merging the release-please pull request publishes the next version; the spec sync and release pull requests merge themselves once CI passes, except a major version (docs/DESIGN.md section 13).
## Read first
diff --git a/CLAUDE.md b/CLAUDE.md
index c63d9cb..b338a64 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,7 +1,7 @@
# Didit Python SDK (`sdk-python`)
The official Didit server-side library for Python: `didit-sdk` on PyPI (name transfer pending, see docs/DESIGN.md 2.1).
-Status: released (1.0.0 on 2026-10-29). Merging the release-please pull request publishes the next version.
+Status: released (1.0.0 on 2026-10-29). Merging the release-please pull request publishes the next version; the spec sync and release pull requests merge themselves once CI passes, except a major version (docs/DESIGN.md section 13).
## Read first
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index eb201f7..a01c69a 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -57,6 +57,7 @@ When an operation carries `x-didit-sdk` upstream, the upstream value wins and th
### 3.1 Spec sync
Every repository has `.github/workflows/spec-sync.yml`, run daily and on `workflow_dispatch`.
+mintlify-docs starts it in all seven repositories through `workflow_dispatch` as soon as docs.didit.me serves a changed OpenAPI document (`server-sdk-spec-sync.yml` there); mintlify-docs `main` documents only what production serves, so no SDK gets an operation or field before the API serves it.
It downloads the upstream files, and when any of them changed it regenerates the code, runs the full test suite and opens a pull request titled `(spec): sync API spec YYYY-MM-DD`.
The type follows section 13 and is the same in every repository: `feat(spec)` when the sync adds or changes anything a caller can observe (an operation, parameter, field, enum value or response shape), a minor release; `chore(spec)` when only descriptions, examples or other documentation changed, which releases nothing on its own.
The commit never carries `!` or a `BREAKING CHANGE:` footer: a change that mirrors the API is never a major version (section 13).
@@ -264,7 +265,9 @@ Ruby: `net/http` from the standard library, `jwt` only for Authentication.
- Releases are cut by release-please from conventional commits; `CHANGELOG.md` is generated and never edited by hand.
- Release workflows publish with trusted publishing where the registry supports it (PyPI, npm with provenance, RubyGems and NuGet), Maven Central through the Central Portal with signed artifacts (token and signing key in the `maven-central` environment), Packagist through its GitHub hook, and Go module tags announced to proxy.golang.org.
- 1.0.0 shipped on 2026-10-29 with Alex's approval, and the repositories are public.
-- Every release after that publishes straight from the repository: merging the release-please pull request is the approval, and the workflow creates the vX.Y.Z tag and publishes.
+- Every release after that publishes straight from the repository, with no person in the loop for API changes (Alex, 2026-11-07). `.github/workflows/bot-pull-requests.yml` starts CI on the spec sync and release-please pull requests and turns on auto-merge with squash, so each merges once the required `CI passed` check passes. Merging the release pull request creates the vX.Y.Z tag and publishes, and the release workflow then waits until the registry serves the version, or fails.
+- A pull request opened with `GITHUB_TOKEN` gets its `pull_request` CI run only after a maintainer approves it (the repositories are public), so that workflow starts `ci.yml` with `workflow_dispatch`, whose checks land on the same head commit. A merge made by that auto-merge starts no push workflow, so it also starts the release workflows on `main` when the newest commit has no run of them.
+- A person decides, and the pull request gets the `needs-alex` label instead of auto-merge, when a spec sync failed a check, when a pull request asks for a major version (a release pull request for a new major version, or a sync commit with `!` or a `BREAKING CHANGE:` footer), or when it carries commits from people. The `do-not-merge` label, or turning auto-merge off by hand, keeps any of these pull requests from merging.
- A release created with `GITHUB_TOKEN` does not trigger other workflows, so a repository that publishes from a separate `release.yml` has release-please start it with `workflow_dispatch` for the new tag. No personal access token is stored.
## 14. Documentation and positioning