From 54b85c849e9600786f95fca680c6d69a9566bcc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Sat, 31 Oct 2026 14:26:14 +0100 Subject: [PATCH 01/14] =?UTF-8?q?feat(skill):=20=E2=9C=A8=20add=20the=20ad?= =?UTF-8?q?opt-release-tools=20agent=20skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walks a repository admin through the adoption: inspect, plan, the adoption PR, the admin's commands, the first release. The agent reads and writes files; the admin runs every command that changes GitHub. Assisted-by: Pi:claude-opus-5-5 --- README.md | 2 + docs/adopting.md | 16 ++ docs/developing.md | 3 + skills/adopt-release-tools/SKILL.md | 211 ++++++++++++++++++ .../adopt-release-tools/publish-workflow.md | 88 ++++++++ tests/test_skill.py | 88 ++++++++ 6 files changed, 408 insertions(+) create mode 100644 skills/adopt-release-tools/SKILL.md create mode 100644 skills/adopt-release-tools/publish-workflow.md create mode 100644 tests/test_skill.py diff --git a/README.md b/README.md index 05d184f..e815813 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ shows the documentation of that version. (and once per organisation, [One-time organisation setup](docs/organisation-setup.md)). 3. Start a release: **Actions → Release → Run workflow** on `main` ([Releasing](docs/releasing.md)). +Or let a coding agent walk you through it: [the adopt-release-tools skill](docs/adopting.md#let-an-agent-do-it). + ## Pages - [Home](docs/index.md) diff --git a/docs/adopting.md b/docs/adopting.md index fe1b4de..4ef132b 100644 --- a/docs/adopting.md +++ b/docs/adopting.md @@ -7,6 +7,22 @@ rather than through `vars` inheritance or `secrets: inherit`: the reusable workf declare them as a required input and a required secret, so a stub that forgets one fails before anything runs, and no other secret of the repository reaches release-tools. +## Let an agent do it + +Repository admins can hand the adoption to a coding agent with the +`adopt-release-tools` [agent skill](https://agentskills.io), which ships in +[`skills/adopt-release-tools`](https://github.com/Algorithmiq/release-tools/tree/main/skills/adopt-release-tools): + +```bash +npx skills add Algorithmiq/release-tools --skill adopt-release-tools +``` + +or copy that folder into your agent's skills directory. Then ask the agent to adopt +release-tools in your repository. It reads the repository and these pages at the latest +release, writes the files and opens the adoption PR; you run every command that changes +GitHub, from the commands it prepares. It follows this page and +[Per-repository setup](repository-setup.md), which stay the reference. + ## `.github/workflows/release.yml` Set `version-files` to the files that hold the version number, such as `VERSION` or diff --git a/docs/developing.md b/docs/developing.md index 81e37a9..634b814 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -24,6 +24,9 @@ just test # the release package, cliff.toml, the actions, and that the examp reference uses the same release-tools tag and git-cliff version. - `tests/test_leaks.py` checks that no tracked file holds an AWS account ID, ARN or endpoint, or names an `Algorithmiq/` repository other than the public ones. +- `tests/test_skill.py` checks the agent skill in `skills/adopt-release-tools/`: its + frontmatter, the major version it is written for, and that every release-tools path + and heading it reads exists. On Nix, `nix develop` (or [direnv](https://direnv.net) with the tracked `.envrc`) gives a shell with Python 3.12, uv, just and git; run the same commands inside it. The tools diff --git a/skills/adopt-release-tools/SKILL.md b/skills/adopt-release-tools/SKILL.md new file mode 100644 index 0000000..1263937 --- /dev/null +++ b/skills/adopt-release-tools/SKILL.md @@ -0,0 +1,211 @@ +--- +name: adopt-release-tools +description: Use when adopting release-tools, Algorithmiq's release process, in a repository, or when a repository should stop publishing its releases by hand through the GitHub UI. +metadata: + release-tools-major: "1" +--- + +# Adopt release-tools + +You move one repository onto release-tools together with its admin. The work splits in +two, and stays split even when the admin offers you their token: + +- **You** read the repository and GitHub, write the files, push the adoption branch and + open the adoption PR. +- **The admin** runs every command that changes GitHub: repository settings, labels, + tags, rulesets, the organisation's variable and secret lists, `gh workflow + disable`/`enable`, the merge of the adoption PR and the first release. Give each one in + order, and wait until the admin reports it done. + +Beyond the adoption branch and its PR, you run reads only. A command block with a write +in it (`gh api -X …`, `gh label create`, `gh variable set`, `git push` of a tag) is text +for the admin: hand it over as text, even when you only want to test it. Every command +you hand over names this repository, the App ID and the ruleset ids literally, or guards +each variable it uses (`${REPO:?}`, `${APP_ID:?}`), so that a missing value stops it +before `gh api` runs. + +Work through the phases in order. Each ends on a **done** check. A **blocker** is what +stops the adoption until someone outside it acts: a default branch other than `main`, a +pre-release tag without its final, a published version above the newest final tag, +missing `CODEARTIFACT_*` organisation variables, no admin access. Stop on a blocker, say +exactly what is missing and who can resolve it, and go on once it is resolved. +Everything the plan does itself (sharing the bot credentials, installing the app, +creating rulesets, the settings, rewriting the publish workflow for the index it +publishes to today) is a **to-do** in the plan. Inspect only reads, so a second run of this skill finds where an interrupted +adoption stopped: pass quickly over every phase whose done check already holds. + +The adopting repository is private or internal, and release-tools is public. Anything +you report upstream (an issue, a PR, a comment) describes the problem in general terms, +without this repository's name, files or contents. + +## Phase 0: The source + +release-tools' documentation is the source of every stub, command and setting: read it +from a clone of the latest release, and copy stubs and commands from there. + +```bash +tag=$(gh release view -R Algorithmiq/release-tools --json tagName --jq .tagName) +rt=$(mktemp -d)/release-tools +git clone --quiet --depth 1 --branch "$tag" https://github.com/Algorithmiq/release-tools "$rt" +``` + +- `$rt/docs/adopting.md`: the stubs, the publish workflow, the `justfile` recipe, the + PR template. +- `$rt/docs/repository-setup.md`: the admin's steps, numbered 1 to 8. +- `$rt/docs/caveats.md`: what the team needs to know afterwards. +- `$rt/examples/stubs/`: the files to copy. They pin an example tag: write `$tag` + in every `Algorithmiq/release-tools` reference instead. + +This skill is written for the major version in `release-tools-major` above. If `$tag` +has another major, that is a blocker: the skill is out of date (`npx skills update`). + +**Done:** `$tag` is known, its major matches, and `$rt` exists. + +## Phase 1: Inspect + +Read only. Fill in a **facts table**, one row per fact below, with the value its command +returned. Set `REPO=Algorithmiq/`, and work in an up-to-date clone of the +repository on its default branch. + +| Fact | How to read it | Target, or blocker | +| --- | --- | --- | +| Admin access | `gh api repos/$REPO --jq .permissions.admin`; `gh auth status` lists the `admin:org` scope | `true`, else a blocker. Without `admin:org`, an organisation owner runs the organisation steps | +| Default branch | `gh api repos/$REPO --jq .default_branch` | `main`. Anything else is a blocker: the admin renames it (`$rt/docs/repository-setup.md`) | +| Version source | `pyproject.toml` (`[project] version` or `dynamic`), the build backend, a `VERSION` file | `version-files`: `"pyproject.toml"` for a static version; `"VERSION"` when the build reads it, or when it is kept in step with the tags next to setuptools-scm; `""` when the version comes only from the tag | +| Newest final tag | `git tag --list 'v*' --sort=-v:refname` | The newest `vX.Y.Z` | +| Newest tag | the same list | A pre-release (`v0.4.0rc1`, `v0.4.0b1`) newer than every final tag, without its final, is a blocker: the admin releases that final the old way first | +| Published versions | `gh release list -R $REPO`; PyPI: `curl -s https://pypi.org/pypi//json \| jq -r '.releases \| keys[]'`; CodeArtifact: `aws codeartifact list-package-versions --format pypi --package …`, which needs AWS credentials | A final version newer than the newest final tag is a blocker: the admin tags that version's commit `vX.Y.Z` first. An index you cannot read goes to the admin in phase 2 | +| Baseline | the rows above | The newest final tag; `v0.0.0` only with no final tags and nothing published | +| Publish workflow | the workflow that uploads (`twine`, `pypa/gh-action-pypi-publish`, `codeartifact`) | Variant: PyPI, CodeArtifact or none; its triggers; each job, what it does and its `if:` | +| `release` triggers | the `on:` of every workflow; a job named `release:` is not a trigger | Every workflow listed, with what it does on a release: phase 3 moves each one | +| Inlined CodeArtifact values | every workflow and job: AWS account, role ARNs, domain, region, repository | Each value with its file, job and the `vars.CODEARTIFACT_*` it becomes | +| Tag-push workflows at the `v0.0.0` commit | baseline `v0.0.0` only: `git show :.github/workflows/`, a `tags:` under `push:` | None that publish; otherwise the admin disables that workflow around the push of `v0.0.0` | +| Old process | `release` triggers, `workflow_dispatch` routes to publishing, `.github/release.yml` | Listed; the adoption PR removes them | +| Files | the PR template (`.github/pull_request_template.md`, any case), `justfile`, `.github/dependabot.yml`, `CHANGELOG.md` | Present or absent | +| Settings | `gh api repos/$REPO --jq '{allow_squash_merge, allow_merge_commit, allow_rebase_merge, allow_auto_merge, delete_branch_on_merge, squash_merge_commit_title, squash_merge_commit_message}'` | As step 3 of `$rt/docs/repository-setup.md` sets them | +| Immutable releases | `gh api repos/$REPO/immutable-releases --jq .enabled` | `true` | +| `backport` label | `gh label list -R $REPO --search backport` | Present | +| Rulesets | `gh api repos/$REPO/rulesets --jq '.[].id'`, then for each `gh api repos/$REPO/rulesets/ --jq '{id, name, target, include: .conditions.ref_name.include, rules: [.rules[].type], bypass: .bypass_actors}'` | `release tags` and `release branches` as steps 5 and 6 create them. The default branch's rulesets are those whose `include` holds `~DEFAULT_BRANCH`, `refs/heads/main` or `~ALL`, whatever their name (such as `Protect main`): note their ids and the checks they already require | +| App ID | `gh api orgs/Algorithmiq/installations --jq '.installations[] \| select(.app_slug == "algorithmiq-release-bot") \| .app_id'` | A number, for the rulesets | +| App installed on the repository | not readable with a user token | The admin confirms it: Organisation settings → GitHub Apps → algorithmiq-release-bot → Configure | +| Bot credentials | `gh api orgs/Algorithmiq/actions/variables/RELEASE_BOT_CLIENT_ID/repositories --jq '[.repositories[].name]'`; the same for `secrets/RELEASE_BOT_PRIVATE_KEY` | The repository is in both lists | +| CodeArtifact variables | when any workflow uses CodeArtifact: `gh api repos/$REPO/actions/organization-variables --jq '[.variables[].name]'` | All six of `$rt/docs/organisation-setup.md`, step 5. A missing one is a blocker for an organisation owner | + +**Done:** every row holds what its command returned; "cannot check" only with the +error or the missing credential that stopped it. The blockers are listed apart from the +to-dos. + +## Phase 2: Plan + +Show the admin the facts table, then the plan: + +1. **The admin's decisions:** the baseline, and for `v0.0.0` its commit (the last one + that should not appear in the changelog); the repository's own CI checks to require + on `release/*` next to the PR checks; the number of approvals there. +2. **The adoption PR:** every file it adds, changes or removes (phase 3). +3. **The admin's commands,** in order, each filled in for this repository (phases 4 and + 5). +4. **What changes for the team:** squash-merge only; releases start from the Release + workflow instead of the GitHub UI; every pre-release is published to the package + index; workflows with `release` triggers move to tag pushes. + +When you could not read the package index, ask the admin to run the filled-in +`list-package-versions` command and report every version it lists. Settle the +baseline on that answer: `v0.0.0` only once the admin confirms the index holds no +version of the package. + +With an existing baseline tag, add that `CHANGELOG.md` gets the releases before +adoption, generated in the adoption PR, where the admin completes them. + +**Done:** the admin has answered for the package index, approves the plan explicitly, +and no blocker from phase 1 is open. + +## Phase 3: The adoption PR + +On a new branch off the default branch: + +- **The three stubs** from `$rt/examples/stubs/` (`release.yml`, `backport.yml`, + `pr-checks.yml`) into `.github/workflows/`, with `$tag` and the `version-files` of the + facts table. +- **The publish workflow:** follow [publish-workflow.md](publish-workflow.md). +- **Every inlined CodeArtifact value** of the facts table becomes its + `vars.CODEARTIFACT_*` variable, in every workflow and job that holds one, not only + the upload (publish-workflow.md, "The CodeArtifact variables"). +- **`just changelog`:** the recipe for a public release-tools from + `$rt/docs/adopting.md`, in the `justfile` (a new one if there is none). +- **The PR template:** the Migration section and the backport checklist item from + `$rt/docs/adopting.md`, above any later heading of the existing template. +- **`CHANGELOG.md`:** with an existing baseline tag, a `## X.Y.Z (YYYY-MM-DD)` section + for every final release up to it, generated as step 2 of + `$rt/docs/repository-setup.md` shows; an empty file with `v0.0.0` only. An existing + `CHANGELOG.md` stays only if its release headings read `## X.Y.Z (YYYY-MM-DD)`; + otherwise regenerate it and tell the admin. +- **Dependabot:** the `release-tools` group from `$rt/docs/versioning.md`, merged into + `.github/dependabot.yml`. +- **The old process removed:** the publishing `workflow_dispatch` routes, + `.github/release.yml`, and the `release` trigger of every workflow (tests, image + builds, documentation), each replaced as publish-workflow.md says so that its work + still runs for every release. + +Title the PR by the convention it introduces, for example `ci: 👷 adopt release-tools`. +Its description lists, for every release before adoption, the section to compare with +`gh release view vX.Y.Z`, and the stray groups git-cliff made from capitalised types +(such as `### Fix`). Push the branch and open the PR; the admin merges it after phase 4. + +**Done:** every `Algorithmiq/release-tools` reference uses `$tag` +(`grep -rn 'Algorithmiq/release-tools' .github justfile`); no workflow has a `release` +trigger; `grep -rnE 'arn:aws[:]|\b[0-9]{12}\b' .github/workflows` finds nothing; the +done check of publish-workflow.md holds; actionlint passes on `.github/workflows/` +(`uvx --from actionlint-py actionlint`); and the PR is open. + +## Phase 4: The admin's steps before the merge + +Steps 1 to 7 of `$rt/docs/repository-setup.md`, as filled-in commands, one at a time, +in this order. After each, re-read its row of the facts table. + +1. **Bot credentials** (step 1): `--repos` replaces the list, so give the full current + list from phase 1 with this repository added, for the variable and the secret. +2. **App installation** (step 1, web UI). +3. **Settings, immutable releases, the `backport` label** (step 3). +4. **The baseline** (step 2): `v0.0.0` only when the plan says so, before the tag + ruleset, with a tag-push publishing workflow disabled around it if phase 1 found one. +5. **The tag ruleset** (step 5), with the App ID. +6. **The `release/*` ruleset** (step 6), with the App ID and the checks the admin chose. +7. **The package index** (step 7): PyPI, the Trusted Publisher (web UI); CodeArtifact + and no package, nothing. + +**Done:** re-running phase 1 shows each of these rows at its target. + +## Phase 5: After the merge + +The admin squash-merges the adoption PR. Then step 8 of +`$rt/docs/repository-setup.md`, on the default branch's rulesets from phase 1, whatever +their name: the default branch requires `PR checks / PR title` and +`PR checks / Migration notes`, and the release bot bypasses every one of those rulesets +with a `pull_request` or `required_status_checks` rule, without which every changelog +PR waits for an approval. Hand over step 8's command once per ruleset, with its id +written in place of the `id=$(…)` line. A default branch without a +`required_status_checks` rule gets one first. If a ruleset required other checks for +PR titles, they switch to the new names now (`$rt/docs/caveats.md`, check names). + +**Done:** `gh api repos/$REPO/rulesets/` shows both checks required on the default +branch, and the App ID among the bypass actors of each of those rulesets. + +## Phase 6: The first release, and the report + +Hand over the first release: check that no deprecation is due for removal, then +**Actions → Release → Run workflow** on `main` with a bump (`minor` or `major`) and a +stage (`rc` by default; `beta` or `alpha` to start earlier), or +`gh workflow run release.yml -R --ref main -f bump=minor -f stage=rc` with +the repository written in. Every pre-release is published to the package index. + +End with a report: + +- what was done, and by whom; +- what is still waiting for a human, with its command; +- from `$rt/docs/caveats.md`, what matters to the team from now on: the changelog PR + merges itself only with the bypass of phase 5; an older release line runs the + release-tools version its branch pins; workflows on `release` events no longer run. + +**Done:** the report is written. The adoption is complete when its waiting list is +empty. diff --git a/skills/adopt-release-tools/publish-workflow.md b/skills/adopt-release-tools/publish-workflow.md new file mode 100644 index 0000000..b265c11 --- /dev/null +++ b/skills/adopt-release-tools/publish-workflow.md @@ -0,0 +1,88 @@ +# Restructuring the publish workflow + +Read with `SKILL.md`, phase 3. `$rt` is the clone of release-tools at `$tag`. + +## The target + +`$rt/docs/adopting.md`, "The publish workflow" gives the shape: + +- triggered by `push: tags: ["v*"]` only, with `cancel-in-progress: false`; +- **build** (with the repository's version check) → **draft_release** → **upload** → + **publish_release**, always last: publishing the GitHub release makes it immutable; +- it runs for every tag the release bot pushes: alphas, betas, release candidates and + finals. + +## The variant + +| The repository publishes to | Start from | Upload | +| --- | --- | --- | +| PyPI | `$rt/examples/stubs/deploy.yml` | Trusted Publishing; the admin adds the publisher (step 7) | +| AWS CodeArtifact | `$rt/examples/stubs/deploy-codeartifact.yml`, see `$rt/docs/adopting.md`, "Publishing to AWS CodeArtifact" | assumes `vars.CODEARTIFACT_WRITE_ROLE`, runs `twine upload` | +| nothing | `$rt/examples/stubs/deploy.yml` without `upload_pypi`, and without `build` when nothing is attached | none: `publish_release` needs `draft_release` | + +Keep the existing file name (usually `deploy.yml`) and the repository's build: its +steps, runners, Python versions, wheels, and the authentication a build with private +dependencies needs. A build step that assumes a CodeArtifact read role stays, with +`vars.CODEARTIFACT_READ_ROLE`. + +## The CodeArtifact variables + +Every inlined CodeArtifact value, in every workflow and job (builds, image and +documentation jobs, the upload), becomes the organisation variable that holds it: + +| Inlined value | Becomes | +| --- | --- | +| AWS account (`--domain-owner`, the account in a role ARN) | `vars.CODEARTIFACT_DOMAIN_OWNER` | +| `--domain` | `vars.CODEARTIFACT_DOMAIN` | +| `--region`, `aws-region` | `vars.CODEARTIFACT_REGION` | +| `--repository` | `vars.CODEARTIFACT_REPOSITORY` | +| the role a build or an image job assumes to read packages | `vars.CODEARTIFACT_READ_ROLE` | +| the role the upload assumes | `vars.CODEARTIFACT_WRITE_ROLE` | + +When `gh variable list --org Algorithmiq` shows a variable's value and it differs from +the inlined one (another repository name, say), the PR description says so for the admin +to confirm. + +## Each existing job + +Place every job of the existing workflow: + +- **Build jobs** become `build`, or several jobs that `draft_release` needs. Every file + to attach is uploaded as an artifact; `draft_release` and the upload download them + (`pattern` and `merge-multiple: true` for several artifacts), and every glob of + `draft-release`'s `files` matches. +- **The version check stays.** When a `VERSION` file is kept in step with the tags, the + bot now writes it (`version-files`), and the check compares it with the tag. +- **The upload** becomes the variant's upload job. Inlined CodeArtifact values (account, + roles, domain, region, repository) become the `vars.CODEARTIFACT_*` variables, in the + build too. +- **Jobs that ship something for the tag** (container images, documentation) stay, and + every one of them joins the `needs` of `publish_release`: the release is published + only once everything it ships exists. +- **Jobs that also run for pull requests or pushes to the default branch** (test builds, + preview images) move to a separate workflow, for example `build.yml`, with their own + triggers and `cancel-in-progress: true`: a publish run is never cancelled, a PR build + should be. + +## The old triggers + +- `release` triggers leave the publish workflow, and so does any `workflow_dispatch` + input or job that publishes ("force upload"): only the bot's tags publish. +- **Other workflows with a `release` trigger** (tests, image builds) get + `push: tags: ["v*"]` instead, or move into the publish workflow when they ship + something: release-tools publishes with `GITHUB_TOKEN`, which starts no workflows. +- `.github/release.yml`, the configuration of GitHub's generated notes, goes: the + `release-notes` action writes the notes. + +## What stays as it is + +- The repository's pins of third-party actions; only `Algorithmiq/release-tools` + references move to `$tag`. +- Permissions per job, as in the stub: `contents: write` for `draft_release` and + `publish_release`; `id-token: write` where a job assumes an AWS role or uses Trusted + Publishing. + +**Done:** the workflow triggers on `v*` tags only; `publish_release` needs every job +that ships something; no job reads `github.event.release`; no workflow has a `release` +trigger; every CodeArtifact value reads a `vars.CODEARTIFACT_*` variable; actionlint +passes. diff --git a/tests/test_skill.py b/tests/test_skill.py new file mode 100644 index 0000000..273ccdf --- /dev/null +++ b/tests/test_skill.py @@ -0,0 +1,88 @@ +# Copyright 2026 Algorithmiq +# +# Licensed 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 of the adopt-release-tools agent skill: frontmatter, version, references. + +The skill reads release-tools from a clone of the latest release (`$rt`), so every +`$rt/...` path it names must exist here, and every page heading it quotes too. +""" + +from __future__ import annotations + +import re +from typing import Any + +import yaml +from conftest import ROOT + +SKILL = ROOT / "skills" / "adopt-release-tools" +RELEASE_TOOLS_REF = re.compile(r"Algorithmiq/release-tools/([\w./-]+)@([\w.-]+)") +CLONE_PATH = re.compile(r"\$rt/([\w./-]*[\w-])") +# `$rt/docs/page.md`, "Heading" +QUOTED_HEADING = re.compile(r'`\$rt/(docs/[\w-]+\.md)`, "([^"]+)"') +MARKDOWN_LINK = re.compile(r"\]\(([^)#\s]+)\)") + + +def frontmatter() -> dict[str, Any]: + """Return the YAML frontmatter of SKILL.md.""" + _, front, _ = (SKILL / "SKILL.md").read_text(encoding="utf-8").split("---\n", 2) + return yaml.safe_load(front) + + +def skill_text() -> str: + """Return every Markdown file of the skill, concatenated.""" + return "\n".join(p.read_text(encoding="utf-8") for p in sorted(SKILL.glob("*.md"))) + + +def test_frontmatter() -> None: + front = frontmatter() + assert set(front) == {"name", "description", "metadata"} + assert front["name"] == SKILL.name + assert front["description"].startswith("Use when") + assert len(front["description"]) <= 500 + + +def test_written_for_the_major_the_stubs_pin() -> None: + stub = (ROOT / "examples" / "stubs" / "release.yml").read_text(encoding="utf-8") + (tag,) = {ref for _, ref in RELEASE_TOOLS_REF.findall(stub)} + major = tag.removeprefix("v").split(".")[0] + assert frontmatter()["metadata"]["release-tools-major"] == major + + +def test_every_release_tools_path_exists() -> None: + paths = set(CLONE_PATH.findall(skill_text())) + assert "examples/stubs/deploy-codeartifact.yml" in paths + assert sorted(p for p in paths if not (ROOT / p).exists()) == [] + + +def test_every_quoted_heading_exists() -> None: + quoted = QUOTED_HEADING.findall(skill_text()) + assert quoted + for page, heading in quoted: + lines = (ROOT / page).read_text(encoding="utf-8").splitlines() + assert any(re.fullmatch(rf"#+ {re.escape(heading)}", line) for line in lines), ( + page, + heading, + ) + + +def test_links_between_skill_files_resolve() -> None: + for target in MARKDOWN_LINK.findall(skill_text()): + if not target.startswith("http"): + assert (SKILL / target).exists(), target + + +def test_no_pinned_release_tools_reference() -> None: + # The skill pins the latest release at run time; a tag written here would go stale. + assert not RELEASE_TOOLS_REF.search(skill_text()) From ac7a0c5445fa874dc9c11fb59a832adfebe9227d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Sat, 31 Oct 2026 14:32:47 +0100 Subject: [PATCH 02/14] =?UTF-8?q?fix(skill):=20=F0=9F=90=9B=20paginate=20l?= =?UTF-8?q?ist=20reads=20and=20add=20repositories=20to=20the=20bot=20crede?= =?UTF-8?q?ntials=20one=20by=20one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Pi:claude-opus-5-5 --- skills/adopt-release-tools/SKILL.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/skills/adopt-release-tools/SKILL.md b/skills/adopt-release-tools/SKILL.md index 1263937..c375ad5 100644 --- a/skills/adopt-release-tools/SKILL.md +++ b/skills/adopt-release-tools/SKILL.md @@ -85,11 +85,11 @@ repository on its default branch. | Settings | `gh api repos/$REPO --jq '{allow_squash_merge, allow_merge_commit, allow_rebase_merge, allow_auto_merge, delete_branch_on_merge, squash_merge_commit_title, squash_merge_commit_message}'` | As step 3 of `$rt/docs/repository-setup.md` sets them | | Immutable releases | `gh api repos/$REPO/immutable-releases --jq .enabled` | `true` | | `backport` label | `gh label list -R $REPO --search backport` | Present | -| Rulesets | `gh api repos/$REPO/rulesets --jq '.[].id'`, then for each `gh api repos/$REPO/rulesets/ --jq '{id, name, target, include: .conditions.ref_name.include, rules: [.rules[].type], bypass: .bypass_actors}'` | `release tags` and `release branches` as steps 5 and 6 create them. The default branch's rulesets are those whose `include` holds `~DEFAULT_BRANCH`, `refs/heads/main` or `~ALL`, whatever their name (such as `Protect main`): note their ids and the checks they already require | -| App ID | `gh api orgs/Algorithmiq/installations --jq '.installations[] \| select(.app_slug == "algorithmiq-release-bot") \| .app_id'` | A number, for the rulesets | +| Rulesets | `gh api --paginate repos/$REPO/rulesets --jq '.[].id'`, then for each `gh api repos/$REPO/rulesets/ --jq '{id, name, target, include: .conditions.ref_name.include, rules: [.rules[].type], bypass: .bypass_actors}'` | `release tags` and `release branches` as steps 5 and 6 create them. The default branch's rulesets are those whose `include` holds `~DEFAULT_BRANCH`, `refs/heads/main` or `~ALL`, whatever their name (such as `Protect main`): note their ids and the checks they already require | +| App ID | `gh api --paginate orgs/Algorithmiq/installations --jq '.installations[] \| select(.app_slug == "algorithmiq-release-bot") \| .app_id'` | A number, for the rulesets | | App installed on the repository | not readable with a user token | The admin confirms it: Organisation settings → GitHub Apps → algorithmiq-release-bot → Configure | -| Bot credentials | `gh api orgs/Algorithmiq/actions/variables/RELEASE_BOT_CLIENT_ID/repositories --jq '[.repositories[].name]'`; the same for `secrets/RELEASE_BOT_PRIVATE_KEY` | The repository is in both lists | -| CodeArtifact variables | when any workflow uses CodeArtifact: `gh api repos/$REPO/actions/organization-variables --jq '[.variables[].name]'` | All six of `$rt/docs/organisation-setup.md`, step 5. A missing one is a blocker for an organisation owner | +| Bot credentials | `gh api --paginate orgs/Algorithmiq/actions/variables/RELEASE_BOT_CLIENT_ID/repositories --jq '.repositories[].name'`; the same for `secrets/RELEASE_BOT_PRIVATE_KEY` | The repository is in both lists. Phase 4 adds it one repository at a time; `--repos` is not used, because it replaces the whole list | +| CodeArtifact variables | when any workflow uses CodeArtifact: `gh api --paginate repos/$REPO/actions/organization-variables --jq '.variables[].name'` | All six of `$rt/docs/organisation-setup.md`, step 5. A missing one is a blocker for an organisation owner | **Done:** every row holds what its command returned; "cannot check" only with the error or the missing credential that stopped it. The blockers are listed apart from the @@ -163,8 +163,12 @@ done check of publish-workflow.md holds; actionlint passes on `.github/workflows Steps 1 to 7 of `$rt/docs/repository-setup.md`, as filled-in commands, one at a time, in this order. After each, re-read its row of the facts table. -1. **Bot credentials** (step 1): `--repos` replaces the list, so give the full current - list from phase 1 with this repository added, for the variable and the secret. +1. **Bot credentials** (step 1): add this repository to the variable and the secret + one by one, with its id from `gh api repos/$REPO --jq .id` written in: + `gh api -X PUT orgs/Algorithmiq/actions/variables/RELEASE_BOT_CLIENT_ID/repositories/` + and + `gh api -X PUT orgs/Algorithmiq/actions/secrets/RELEASE_BOT_PRIVATE_KEY/repositories/`. + Not `--repos`: it replaces the whole list, and drops every repository left out. 2. **App installation** (step 1, web UI). 3. **Settings, immutable releases, the `backport` label** (step 3). 4. **The baseline** (step 2): `v0.0.0` only when the plan says so, before the tag From 0cedbcfa037f419c0dfbe4860085be413bdeea49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Sat, 31 Oct 2026 14:52:21 +0100 Subject: [PATCH 03/14] =?UTF-8?q?feat(skill):=20=E2=9C=A8=20sharpen=20the?= =?UTF-8?q?=20adopt-release-tools=20skill=20after=20dry=20runs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Pi:claude-opus-5-5 --- skills/adopt-release-tools/SKILL.md | 73 ++++++++++++------- .../adopt-release-tools/publish-workflow.md | 15 ++-- 2 files changed, 54 insertions(+), 34 deletions(-) diff --git a/skills/adopt-release-tools/SKILL.md b/skills/adopt-release-tools/SKILL.md index c375ad5..5f72d13 100644 --- a/skills/adopt-release-tools/SKILL.md +++ b/skills/adopt-release-tools/SKILL.md @@ -17,12 +17,18 @@ two, and stays split even when the admin offers you their token: disable`/`enable`, the merge of the adoption PR and the first release. Give each one in order, and wait until the admin reports it done. -Beyond the adoption branch and its PR, you run reads only. A command block with a write -in it (`gh api -X …`, `gh label create`, `gh variable set`, `git push` of a tag) is text -for the admin: hand it over as text, even when you only want to test it. Every command -you hand over names this repository, the App ID and the ruleset ids literally, or guards -each variable it uses (`${REPO:?}`, `${APP_ID:?}`), so that a missing value stops it -before `gh api` runs. +Beyond the adoption branch and its PR, you run reads only. A command with a write in it +(`gh api -X …`, `gh label create`, `gh variable set`, `git push` of a tag) is text for +the admin: hand it over as text, even when you only want to test it. Write every +document, plan and command file with your file-writing tool, never through a shell +heredoc or `echo`, so that no command inside it can run. A JSON payload goes into a +file you write (`release-tags.json`), which the command reads with `--input `: no +command you hand over holds a heredoc. Each one names this repository, the App ID and +the ruleset ids literally, or guards each variable it uses (`${REPO:?}`, `${APP_ID:?}`), +so that a missing value stops it before `gh api` runs. A value you cannot read (a role +ARN, an organisation variable's value) you ask the admin for in phase 2 and write in; +until then the command waits with its question. No write command holds a +``. Work through the phases in order. Each ends on a **done** check. A **blocker** is what stops the adoption until someone outside it acts: a default branch other than `main`, a @@ -31,7 +37,10 @@ missing `CODEARTIFACT_*` organisation variables, no admin access. Stop on a bloc exactly what is missing and who can resolve it, and go on once it is resolved. Everything the plan does itself (sharing the bot credentials, installing the app, creating rulesets, the settings, rewriting the publish workflow for the index it -publishes to today) is a **to-do** in the plan. Inspect only reads, so a second run of this skill finds where an interrupted +publishes to today) is a **to-do** in the plan. What you cannot read (the package +index, a role ARN) is a **question** for the admin in phase 2. What you cannot check +(an AWS role's trust policy) the plan and the report mark **not verified**, never a +fact. Inspect only reads, so a second run of this skill finds where an interrupted adoption stopped: pass quickly over every phase whose done check already holds. The adopting repository is private or internal, and release-tools is public. Anything @@ -71,21 +80,22 @@ repository on its default branch. | --- | --- | --- | | Admin access | `gh api repos/$REPO --jq .permissions.admin`; `gh auth status` lists the `admin:org` scope | `true`, else a blocker. Without `admin:org`, an organisation owner runs the organisation steps | | Default branch | `gh api repos/$REPO --jq .default_branch` | `main`. Anything else is a blocker: the admin renames it (`$rt/docs/repository-setup.md`) | +| Visibility | `gh api repos/$REPO --jq .visibility` | As read: `public`, `internal` or `private`. The organisation variables (visibility `private`) reach `internal` and `private` repositories | | Version source | `pyproject.toml` (`[project] version` or `dynamic`), the build backend, a `VERSION` file | `version-files`: `"pyproject.toml"` for a static version; `"VERSION"` when the build reads it, or when it is kept in step with the tags next to setuptools-scm; `""` when the version comes only from the tag | | Newest final tag | `git tag --list 'v*' --sort=-v:refname` | The newest `vX.Y.Z` | | Newest tag | the same list | A pre-release (`v0.4.0rc1`, `v0.4.0b1`) newer than every final tag, without its final, is a blocker: the admin releases that final the old way first | -| Published versions | `gh release list -R $REPO`; PyPI: `curl -s https://pypi.org/pypi//json \| jq -r '.releases \| keys[]'`; CodeArtifact: `aws codeartifact list-package-versions --format pypi --package …`, which needs AWS credentials | A final version newer than the newest final tag is a blocker: the admin tags that version's commit `vX.Y.Z` first. An index you cannot read goes to the admin in phase 2 | +| Published versions | `gh release list -R $REPO`; PyPI: `curl -s https://pypi.org/pypi//json \| jq -r '.releases \| keys[]'`; CodeArtifact: `aws codeartifact list-package-versions --format pypi --package …`, which needs AWS credentials | A final version newer than the newest final tag is a blocker: the admin tags that version's commit `vX.Y.Z` first. An index you cannot read is a question for phase 2, not a blocker | | Baseline | the rows above | The newest final tag; `v0.0.0` only with no final tags and nothing published | | Publish workflow | the workflow that uploads (`twine`, `pypa/gh-action-pypi-publish`, `codeartifact`) | Variant: PyPI, CodeArtifact or none; its triggers; each job, what it does and its `if:` | | `release` triggers | the `on:` of every workflow; a job named `release:` is not a trigger | Every workflow listed, with what it does on a release: phase 3 moves each one | | Inlined CodeArtifact values | every workflow and job: AWS account, role ARNs, domain, region, repository | Each value with its file, job and the `vars.CODEARTIFACT_*` it becomes | | Tag-push workflows at the `v0.0.0` commit | baseline `v0.0.0` only: `git show :.github/workflows/`, a `tags:` under `push:` | None that publish; otherwise the admin disables that workflow around the push of `v0.0.0` | -| Old process | `release` triggers, `workflow_dispatch` routes to publishing, `.github/release.yml` | Listed; the adoption PR removes them | +| Old process | `release` triggers, `workflow_dispatch` routes to publishing, `.github/release.yml`, docs that say to publish by creating a release on GitHub | Listed; the adoption PR removes them and points those docs to **Actions → Release** | | Files | the PR template (`.github/pull_request_template.md`, any case), `justfile`, `.github/dependabot.yml`, `CHANGELOG.md` | Present or absent | | Settings | `gh api repos/$REPO --jq '{allow_squash_merge, allow_merge_commit, allow_rebase_merge, allow_auto_merge, delete_branch_on_merge, squash_merge_commit_title, squash_merge_commit_message}'` | As step 3 of `$rt/docs/repository-setup.md` sets them | | Immutable releases | `gh api repos/$REPO/immutable-releases --jq .enabled` | `true` | | `backport` label | `gh label list -R $REPO --search backport` | Present | -| Rulesets | `gh api --paginate repos/$REPO/rulesets --jq '.[].id'`, then for each `gh api repos/$REPO/rulesets/ --jq '{id, name, target, include: .conditions.ref_name.include, rules: [.rules[].type], bypass: .bypass_actors}'` | `release tags` and `release branches` as steps 5 and 6 create them. The default branch's rulesets are those whose `include` holds `~DEFAULT_BRANCH`, `refs/heads/main` or `~ALL`, whatever their name (such as `Protect main`): note their ids and the checks they already require | +| Rulesets | `gh api --paginate repos/$REPO/rulesets --jq '.[].id'`, then for each `gh api repos/$REPO/rulesets/ --jq '{id, name, source_type, enforcement, target, include: .conditions.ref_name.include, rules: [.rules[].type], bypass: .bypass_actors}'` | `release tags` and `release branches` as steps 5 and 6 create them. The default branch's rulesets are those whose `include` holds `~DEFAULT_BRANCH`, `~ALL` or `refs/heads/`, whatever their name (such as `Protect main`): note their ids, `source_type`, `enforcement` and the checks they already require | | App ID | `gh api --paginate orgs/Algorithmiq/installations --jq '.installations[] \| select(.app_slug == "algorithmiq-release-bot") \| .app_id'` | A number, for the rulesets | | App installed on the repository | not readable with a user token | The admin confirms it: Organisation settings → GitHub Apps → algorithmiq-release-bot → Configure | | Bot credentials | `gh api --paginate orgs/Algorithmiq/actions/variables/RELEASE_BOT_CLIENT_ID/repositories --jq '.repositories[].name'`; the same for `secrets/RELEASE_BOT_PRIVATE_KEY` | The repository is in both lists. Phase 4 adds it one repository at a time; `--repos` is not used, because it replaces the whole list | @@ -101,7 +111,10 @@ Show the admin the facts table, then the plan: 1. **The admin's decisions:** the baseline, and for `v0.0.0` its commit (the last one that should not appear in the changelog); the repository's own CI checks to require - on `release/*` next to the PR checks; the number of approvals there. + on `release/*` next to the PR checks, only ones that run on every PR to `release/*` + (a workflow with `paths` or `paths-ignore` filters, or `branches` that leave out + `release/*`, skips PRs, and its required check would block them); the number of + approvals there. 2. **The adoption PR:** every file it adds, changes or removes (phase 3). 3. **The admin's commands,** in order, each filled in for this repository (phases 4 and 5). @@ -109,15 +122,15 @@ Show the admin the facts table, then the plan: workflow instead of the GitHub UI; every pre-release is published to the package index; workflows with `release` triggers move to tag pushes. -When you could not read the package index, ask the admin to run the filled-in -`list-package-versions` command and report every version it lists. Settle the -baseline on that answer: `v0.0.0` only once the admin confirms the index holds no -version of the package. +Ask your **questions** with the plan. For a package index you could not read, the admin +runs the filled-in `list-package-versions` command and reports every version it lists. +Settle the baseline on that answer: `v0.0.0` only once the admin confirms the index +holds no version of the package. With an existing baseline tag, add that `CHANGELOG.md` gets the releases before adoption, generated in the adoption PR, where the admin completes them. -**Done:** the admin has answered for the package index, approves the plan explicitly, +**Done:** the admin has answered every question, approves the plan explicitly, and no blocker from phase 1 is open. ## Phase 3: The adoption PR @@ -128,9 +141,8 @@ On a new branch off the default branch: `pr-checks.yml`) into `.github/workflows/`, with `$tag` and the `version-files` of the facts table. - **The publish workflow:** follow [publish-workflow.md](publish-workflow.md). -- **Every inlined CodeArtifact value** of the facts table becomes its - `vars.CODEARTIFACT_*` variable, in every workflow and job that holds one, not only - the upload (publish-workflow.md, "The CodeArtifact variables"). +- **Every inlined CodeArtifact value** of the facts table, in every workflow and job, + not only the upload: mapped as publish-workflow.md, "The CodeArtifact variables" says. - **`just changelog`:** the recipe for a public release-tools from `$rt/docs/adopting.md`, in the `justfile` (a new one if there is none). - **The PR template:** the Migration section and the backport checklist item from @@ -145,7 +157,8 @@ On a new branch off the default branch: - **The old process removed:** the publishing `workflow_dispatch` routes, `.github/release.yml`, and the `release` trigger of every workflow (tests, image builds, documentation), each replaced as publish-workflow.md says so that its work - still runs for every release. + still runs for every release. Every doc of the old process points to + **Actions → Release** instead. Title the PR by the convention it introduces, for example `ci: 👷 adopt release-tools`. Its description lists, for every release before adoption, the section to compare with @@ -154,9 +167,13 @@ Its description lists, for every release before adoption, the section to compare **Done:** every `Algorithmiq/release-tools` reference uses `$tag` (`grep -rn 'Algorithmiq/release-tools' .github justfile`); no workflow has a `release` -trigger; `grep -rnE 'arn:aws[:]|\b[0-9]{12}\b' .github/workflows` finds nothing; the -done check of publish-workflow.md holds; actionlint passes on `.github/workflows/` -(`uvx --from actionlint-py actionlint`); and the PR is open. +trigger; `grep -rnF` finds none of the facts table's inlined CodeArtifact values in +`.github/workflows`; the done check of publish-workflow.md holds; the PR is open; and +actionlint passes on `.github/workflows/`: `uvx --from actionlint-py actionlint`, or, +when that cannot download its binary, +`docker run --rm -v "$PWD":/repo -w /repo rhysd/actionlint:latest` or +`nix run nixpkgs#actionlint`. When none runs, the PR description says +`actionlint not run: `. ## Phase 4: The admin's steps before the merge @@ -173,8 +190,10 @@ in this order. After each, re-read its row of the facts table. 3. **Settings, immutable releases, the `backport` label** (step 3). 4. **The baseline** (step 2): `v0.0.0` only when the plan says so, before the tag ruleset, with a tag-push publishing workflow disabled around it if phase 1 found one. -5. **The tag ruleset** (step 5), with the App ID. -6. **The `release/*` ruleset** (step 6), with the App ID and the checks the admin chose. +5. **The tag ruleset** (step 5): its JSON, with the App ID, in `release-tags.json`, and + step 5's `gh api -X POST` with `--input release-tags.json`. +6. **The `release/*` ruleset** (step 6): the same with `release-branches.json`, with the + App ID, the checks and the approvals the admin chose. 7. **The package index** (step 7): PyPI, the Trusted Publisher (web UI); CodeArtifact and no package, nothing. @@ -188,7 +207,9 @@ their name: the default branch requires `PR checks / PR title` and `PR checks / Migration notes`, and the release bot bypasses every one of those rulesets with a `pull_request` or `required_status_checks` rule, without which every changelog PR waits for an approval. Hand over step 8's command once per ruleset, with its id -written in place of the `id=$(…)` line. A default branch without a +written in place of the `id=$(…)` line. An organisation ruleset (`source_type` +`Organization`) cannot change from the repository: hand it to an organisation owner, +saying that it covers every repository it targets. A default branch without a `required_status_checks` rule gets one first. If a ruleset required other checks for PR titles, they switch to the new names now (`$rt/docs/caveats.md`, check names). diff --git a/skills/adopt-release-tools/publish-workflow.md b/skills/adopt-release-tools/publish-workflow.md index b265c11..1eeda87 100644 --- a/skills/adopt-release-tools/publish-workflow.md +++ b/skills/adopt-release-tools/publish-workflow.md @@ -22,8 +22,7 @@ Read with `SKILL.md`, phase 3. `$rt` is the clone of release-tools at `$tag`. Keep the existing file name (usually `deploy.yml`) and the repository's build: its steps, runners, Python versions, wheels, and the authentication a build with private -dependencies needs. A build step that assumes a CodeArtifact read role stays, with -`vars.CODEARTIFACT_READ_ROLE`. +dependencies needs. A build step that assumes a CodeArtifact read role stays. ## The CodeArtifact variables @@ -32,12 +31,14 @@ documentation jobs, the upload), becomes the organisation variable that holds it | Inlined value | Becomes | | --- | --- | -| AWS account (`--domain-owner`, the account in a role ARN) | `vars.CODEARTIFACT_DOMAIN_OWNER` | +| AWS account (`--domain-owner`) | `vars.CODEARTIFACT_DOMAIN_OWNER` | | `--domain` | `vars.CODEARTIFACT_DOMAIN` | | `--region`, `aws-region` | `vars.CODEARTIFACT_REGION` | | `--repository` | `vars.CODEARTIFACT_REPOSITORY` | -| the role a build or an image job assumes to read packages | `vars.CODEARTIFACT_READ_ROLE` | -| the role the upload assumes | `vars.CODEARTIFACT_WRITE_ROLE` | +| the role ARN a build or an image job assumes to read packages | `vars.CODEARTIFACT_READ_ROLE` | +| the role ARN the upload assumes | `vars.CODEARTIFACT_WRITE_ROLE` | + +A role ARN maps whole to its role variable, never split into account and name. When `gh variable list --org Algorithmiq` shows a variable's value and it differs from the inlined one (another repository name, say), the PR description says so for the admin @@ -53,9 +54,7 @@ Place every job of the existing workflow: `draft-release`'s `files` matches. - **The version check stays.** When a `VERSION` file is kept in step with the tags, the bot now writes it (`version-files`), and the check compares it with the tag. -- **The upload** becomes the variant's upload job. Inlined CodeArtifact values (account, - roles, domain, region, repository) become the `vars.CODEARTIFACT_*` variables, in the - build too. +- **The upload** becomes the variant's upload job. - **Jobs that ship something for the tag** (container images, documentation) stay, and every one of them joins the `needs` of `publish_release`: the release is published only once everything it ships exists. From 00792ee8e9ff1073f784d0fcc7516799a9027a5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Sat, 31 Oct 2026 14:56:47 +0100 Subject: [PATCH 04/14] =?UTF-8?q?fix(skill):=20=F0=9F=90=9B=20make=20the?= =?UTF-8?q?=20CodeArtifact=20done=20check=20and=20the=20handed-over=20comm?= =?UTF-8?q?ands=20precise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Pi:claude-opus-5-5 --- skills/adopt-release-tools/SKILL.md | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/skills/adopt-release-tools/SKILL.md b/skills/adopt-release-tools/SKILL.md index 5f72d13..a8674c1 100644 --- a/skills/adopt-release-tools/SKILL.md +++ b/skills/adopt-release-tools/SKILL.md @@ -22,8 +22,10 @@ Beyond the adoption branch and its PR, you run reads only. A command with a writ the admin: hand it over as text, even when you only want to test it. Write every document, plan and command file with your file-writing tool, never through a shell heredoc or `echo`, so that no command inside it can run. A JSON payload goes into a -file you write (`release-tags.json`), which the command reads with `--input `: no -command you hand over holds a heredoc. Each one names this repository, the App ID and +file you write outside the clone, in a directory you create with `mktemp -d`, so that it +never reaches the adoption PR. The command reads it with `--input` and the file's +absolute path, and you show the file's content next to the command: no command you +hand over holds a heredoc. Each one names this repository, the App ID and the ruleset ids literally, or guards each variable it uses (`${REPO:?}`, `${APP_ID:?}`), so that a missing value stops it before `gh api` runs. A value you cannot read (a role ARN, an organisation variable's value) you ask the admin for in phase 2 and write in; @@ -167,8 +169,10 @@ Its description lists, for every release before adoption, the section to compare **Done:** every `Algorithmiq/release-tools` reference uses `$tag` (`grep -rn 'Algorithmiq/release-tools' .github justfile`); no workflow has a `release` -trigger; `grep -rnF` finds none of the facts table's inlined CodeArtifact values in -`.github/workflows`; the done check of publish-workflow.md holds; the PR is open; and +trigger; `grep -rnF` finds neither the AWS account nor any role ARN of the facts table +in `.github/workflows` (the other values, such as a `pypi` repository or a region, can +appear legitimately, as in the stub's `--format pypi`, so the check leaves them out); +the done check of publish-workflow.md holds; the PR is open; and actionlint passes on `.github/workflows/`: `uvx --from actionlint-py actionlint`, or, when that cannot download its binary, `docker run --rm -v "$PWD":/repo -w /repo rhysd/actionlint:latest` or @@ -181,17 +185,22 @@ Steps 1 to 7 of `$rt/docs/repository-setup.md`, as filled-in commands, one at a in this order. After each, re-read its row of the facts table. 1. **Bot credentials** (step 1): add this repository to the variable and the secret - one by one, with its id from `gh api repos/$REPO --jq .id` written in: - `gh api -X PUT orgs/Algorithmiq/actions/variables/RELEASE_BOT_CLIENT_ID/repositories/` - and - `gh api -X PUT orgs/Algorithmiq/actions/secrets/RELEASE_BOT_PRIVATE_KEY/repositories/`. + one by one: + + ```bash + REPO_ID=$(gh api "repos/${REPO:?}" --jq .id) + gh api -X PUT "orgs/Algorithmiq/actions/variables/RELEASE_BOT_CLIENT_ID/repositories/${REPO_ID:?}" + gh api -X PUT "orgs/Algorithmiq/actions/secrets/RELEASE_BOT_PRIVATE_KEY/repositories/${REPO_ID:?}" + ``` + Not `--repos`: it replaces the whole list, and drops every repository left out. 2. **App installation** (step 1, web UI). 3. **Settings, immutable releases, the `backport` label** (step 3). 4. **The baseline** (step 2): `v0.0.0` only when the plan says so, before the tag ruleset, with a tag-push publishing workflow disabled around it if phase 1 found one. -5. **The tag ruleset** (step 5): its JSON, with the App ID, in `release-tags.json`, and - step 5's `gh api -X POST` with `--input release-tags.json`. +5. **The tag ruleset** (step 5): its JSON, with the App ID, in `release-tags.json` in + the `mktemp -d` directory, shown in full, and step 5's `gh api -X POST` with + `--input` and that file's absolute path. 6. **The `release/*` ruleset** (step 6): the same with `release-branches.json`, with the App ID, the checks and the approvals the admin chose. 7. **The package index** (step 7): PyPI, the Trusted Publisher (web UI); CodeArtifact From 24942ee3b77b1b4760a8cf19cad2417767989a8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Sat, 31 Oct 2026 15:09:19 +0100 Subject: [PATCH 05/14] =?UTF-8?q?fix(skill):=20=F0=9F=90=9B=20write=20ever?= =?UTF-8?q?y=20file=20with=20the=20file=20tool=20and=20hand=20over=20liter?= =?UTF-8?q?al=20payload=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Pi:claude-opus-5-5 --- skills/adopt-release-tools/SKILL.md | 38 +++++++++++++++++------------ 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/skills/adopt-release-tools/SKILL.md b/skills/adopt-release-tools/SKILL.md index a8674c1..c33bd7d 100644 --- a/skills/adopt-release-tools/SKILL.md +++ b/skills/adopt-release-tools/SKILL.md @@ -15,17 +15,22 @@ two, and stays split even when the admin offers you their token: - **The admin** runs every command that changes GitHub: repository settings, labels, tags, rulesets, the organisation's variable and secret lists, `gh workflow disable`/`enable`, the merge of the adoption PR and the first release. Give each one in - order, and wait until the admin reports it done. + order, and wait until the admin reports it done. Write "you run" for each of them, + never "the commands run" or "I run". Beyond the adoption branch and its PR, you run reads only. A command with a write in it (`gh api -X …`, `gh label create`, `gh variable set`, `git push` of a tag) is text for -the admin: hand it over as text, even when you only want to test it. Write every -document, plan and command file with your file-writing tool, never through a shell -heredoc or `echo`, so that no command inside it can run. A JSON payload goes into a -file you write outside the clone, in a directory you create with `mktemp -d`, so that it -never reaches the adoption PR. The command reads it with `--input` and the file's -absolute path, and you show the file's content next to the command: no command you -hand over holds a heredoc. Each one names this repository, the App ID and +the admin: hand it over as text, even when you only want to test it. +**File tool only:** write every file with your file-writing tool, never a shell +heredoc, `echo`, `printf` or `tee`: the adoption PR's files (workflows, PR template, +`dependabot.yml`, `justfile`, `CHANGELOG.md`), the payload files, and every plan, +report or document. A heredoc only feeds a script that reads. A JSON payload goes into +a file outside the clone, so that it never reaches the adoption PR: even in a dry run, +create the directory with `mktemp -d`, write the file there, and put its literal +absolute path in the command's `--input`, never a variable (`${DIR:?}`). Show the +file's content next to the command, and tell the admin to save it at that path, or edit +the path, if they run the command on another machine: no command you hand over holds +a heredoc. Each one names this repository, the App ID and the ruleset ids literally, or guards each variable it uses (`${REPO:?}`, `${APP_ID:?}`), so that a missing value stops it before `gh api` runs. A value you cannot read (a role ARN, an organisation variable's value) you ask the admin for in phase 2 and write in; @@ -101,7 +106,7 @@ repository on its default branch. | App ID | `gh api --paginate orgs/Algorithmiq/installations --jq '.installations[] \| select(.app_slug == "algorithmiq-release-bot") \| .app_id'` | A number, for the rulesets | | App installed on the repository | not readable with a user token | The admin confirms it: Organisation settings → GitHub Apps → algorithmiq-release-bot → Configure | | Bot credentials | `gh api --paginate orgs/Algorithmiq/actions/variables/RELEASE_BOT_CLIENT_ID/repositories --jq '.repositories[].name'`; the same for `secrets/RELEASE_BOT_PRIVATE_KEY` | The repository is in both lists. Phase 4 adds it one repository at a time; `--repos` is not used, because it replaces the whole list | -| CodeArtifact variables | when any workflow uses CodeArtifact: `gh api --paginate repos/$REPO/actions/organization-variables --jq '.variables[].name'` | All six of `$rt/docs/organisation-setup.md`, step 5. A missing one is a blocker for an organisation owner | +| CodeArtifact variables | when any workflow uses CodeArtifact: `gh api --paginate repos/$REPO/actions/organization-variables --jq '.variables[].name'` | All six of `$rt/docs/organisation-setup.md`, step 5. A missing one is a blocker for an organisation owner. If no workflow assumes a read role, say so: `CODEARTIFACT_READ_ROLE` is an organisation value for an owner to confirm, and you do not ask the admin for its ARN | **Done:** every row holds what its command returned; "cannot check" only with the error or the missing credential that stopped it. The blockers are listed apart from the @@ -113,10 +118,11 @@ Show the admin the facts table, then the plan: 1. **The admin's decisions:** the baseline, and for `v0.0.0` its commit (the last one that should not appear in the changelog); the repository's own CI checks to require - on `release/*` next to the PR checks, only ones that run on every PR to `release/*` - (a workflow with `paths` or `paths-ignore` filters, or `branches` that leave out - `release/*`, skips PRs, and its required check would block them); the number of - approvals there. + on `release/*` next to the PR checks: start from those the default branch's rulesets + already require, add others only if they run on every PR to `release/*` (a workflow + with `paths` or `paths-ignore` filters, or `branches` that leave out `release/*`, + skips PRs, and its required check would block them), and list those left out and + why; the number of approvals there. 2. **The adoption PR:** every file it adds, changes or removes (phase 3). 3. **The admin's commands,** in order, each filled in for this repository (phases 4 and 5). @@ -200,7 +206,7 @@ in this order. After each, re-read its row of the facts table. ruleset, with a tag-push publishing workflow disabled around it if phase 1 found one. 5. **The tag ruleset** (step 5): its JSON, with the App ID, in `release-tags.json` in the `mktemp -d` directory, shown in full, and step 5's `gh api -X POST` with - `--input` and that file's absolute path. + `--input` and that file's literal absolute path. 6. **The `release/*` ruleset** (step 6): the same with `release-branches.json`, with the App ID, the checks and the approvals the admin chose. 7. **The package index** (step 7): PyPI, the Trusted Publisher (web UI); CodeArtifact @@ -239,7 +245,9 @@ End with a report: - what is still waiting for a human, with its command; - from `$rt/docs/caveats.md`, what matters to the team from now on: the changelog PR merges itself only with the bypass of phase 5; an older release line runs the - release-tools version its branch pins; workflows on `release` events no longer run. + release-tools version its branch pins; workflows on `release` events no longer run; +- last, as in any dry-run report: "Commands run that were not reads: none", or their + list. **Done:** the report is written. The adoption is complete when its waiting list is empty. From 47e33d57d572b63020f55ef8d49bbdad783edf4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Sat, 31 Oct 2026 15:31:12 +0100 Subject: [PATCH 06/14] =?UTF-8?q?fix(skill):=20=F0=9F=90=9B=20take=20an=20?= =?UTF-8?q?admin-named=20tag,=20refuse=20a=20release=20older=20than=20the?= =?UTF-8?q?=20skill,=20and=20tighten=20done=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Pi:claude-opus-5-5 --- skills/adopt-release-tools/SKILL.md | 44 ++++++++++++++++++----------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/skills/adopt-release-tools/SKILL.md b/skills/adopt-release-tools/SKILL.md index c33bd7d..94799da 100644 --- a/skills/adopt-release-tools/SKILL.md +++ b/skills/adopt-release-tools/SKILL.md @@ -21,10 +21,12 @@ two, and stays split even when the admin offers you their token: Beyond the adoption branch and its PR, you run reads only. A command with a write in it (`gh api -X …`, `gh label create`, `gh variable set`, `git push` of a tag) is text for the admin: hand it over as text, even when you only want to test it. -**File tool only:** write every file with your file-writing tool, never a shell -heredoc, `echo`, `printf` or `tee`: the adoption PR's files (workflows, PR template, -`dependabot.yml`, `justfile`, `CHANGELOG.md`), the payload files, and every plan, -report or document. A heredoc only feeds a script that reads. A JSON payload goes into +**File tool only:** write every file whose content you compose with your file-writing +tool, never a shell heredoc, `echo`, `printf` or `tee`: the adoption PR's files +(workflows, PR template, `dependabot.yml`, `justfile`, your edits to `CHANGELOG.md`), +the payload files, and every plan, report or document. A tool's own output is fine: +git-cliff's `--output`, or a stub copied with `cp` and then edited with the file tool. +A heredoc only feeds a script that reads. A JSON payload goes into a file outside the clone, so that it never reaches the adoption PR: even in a dry run, create the directory with `mktemp -d`, write the file there, and put its literal absolute path in the command's `--input`, never a variable (`${DIR:?}`). Show the @@ -57,7 +59,9 @@ without this repository's name, files or contents. ## Phase 0: The source release-tools' documentation is the source of every stub, command and setting: read it -from a clone of the latest release, and copy stubs and commands from there. +from a clone of one release, and copy stubs and commands from there. If the admin names +a release-tools tag (such as a release candidate for a pilot), use that tag: set `tag` +to it instead of the first line below, which reads the latest final release only. ```bash tag=$(gh release view -R Algorithmiq/release-tools --json tagName --jq .tagName) @@ -74,8 +78,11 @@ git clone --quiet --depth 1 --branch "$tag" https://github.com/Algorithmiq/relea This skill is written for the major version in `release-tools-major` above. If `$tag` has another major, that is a blocker: the skill is out of date (`npx skills update`). +If a path under `$rt` that this skill names is missing, the release predates the skill: +that is a blocker too; use a newer tag, or ask the admin for one. -**Done:** `$tag` is known, its major matches, and `$rt` exists. +**Done:** `$tag` is the admin's tag or the latest release, its major matches, `$rt` +exists, and every path under `$rt` that this skill names exists in it. ## Phase 1: Inspect @@ -171,19 +178,21 @@ On a new branch off the default branch: Title the PR by the convention it introduces, for example `ci: 👷 adopt release-tools`. Its description lists, for every release before adoption, the section to compare with `gh release view vX.Y.Z`, and the stray groups git-cliff made from capitalised types -(such as `### Fix`). Push the branch and open the PR; the admin merges it after phase 4. +(such as `### Fix`). Before you push, run actionlint on `.github/workflows/`: +`uvx --from actionlint-py actionlint`, or, when that cannot download its binary, +`docker run --rm -v "$PWD":/repo -w /repo rhysd/actionlint:1.7.12` or +`nix run nixpkgs#actionlint`, and fix what it reports. When none runs, the PR +description says `actionlint not run: `. Then push the branch and open the +PR; the admin merges it after phase 4. **Done:** every `Algorithmiq/release-tools` reference uses `$tag` (`grep -rn 'Algorithmiq/release-tools' .github justfile`); no workflow has a `release` -trigger; `grep -rnF` finds neither the AWS account nor any role ARN of the facts table -in `.github/workflows` (the other values, such as a `pypi` repository or a region, can -appear legitimately, as in the stub's `--format pypi`, so the check leaves them out); -the done check of publish-workflow.md holds; the PR is open; and -actionlint passes on `.github/workflows/`: `uvx --from actionlint-py actionlint`, or, -when that cannot download its binary, -`docker run --rm -v "$PWD":/repo -w /repo rhysd/actionlint:latest` or -`nix run nixpkgs#actionlint`. When none runs, the PR description says -`actionlint not run: `. +trigger; in `.github/workflows`, `grep -rnF` finds no role ARN of the facts table, and +no line of `grep -rniE 'codeartifact|--domain-owner'` holds the AWS account (which other +services, such as a container registry, can use; values such as a `pypi` repository or +a region appear in the stub itself, so the check leaves them out); the done check of +publish-workflow.md holds; actionlint passed before the push, or the PR description +says why it did not run; and the PR is open. ## Phase 4: The admin's steps before the merge @@ -229,7 +238,8 @@ saying that it covers every repository it targets. A default branch without a PR titles, they switch to the new names now (`$rt/docs/caveats.md`, check names). **Done:** `gh api repos/$REPO/rulesets/` shows both checks required on the default -branch, and the App ID among the bypass actors of each of those rulesets. +branch by a ruleset with `enforcement` `active`, and the App ID among the bypass actors +of each of those rulesets. ## Phase 6: The first release, and the report From 299a788c4fe5427ef2982d9b301568ba59f41570 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Sat, 31 Oct 2026 20:08:15 +0100 Subject: [PATCH 07/14] =?UTF-8?q?fix(skill):=20=F0=9F=90=9B=20describe=20t?= =?UTF-8?q?he=20CodeArtifact=20upload=20with=20uv=20publish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Pi:claude-opus-5-5 --- skills/adopt-release-tools/SKILL.md | 2 +- skills/adopt-release-tools/publish-workflow.md | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/skills/adopt-release-tools/SKILL.md b/skills/adopt-release-tools/SKILL.md index 94799da..f9dcc73 100644 --- a/skills/adopt-release-tools/SKILL.md +++ b/skills/adopt-release-tools/SKILL.md @@ -100,7 +100,7 @@ repository on its default branch. | Newest tag | the same list | A pre-release (`v0.4.0rc1`, `v0.4.0b1`) newer than every final tag, without its final, is a blocker: the admin releases that final the old way first | | Published versions | `gh release list -R $REPO`; PyPI: `curl -s https://pypi.org/pypi//json \| jq -r '.releases \| keys[]'`; CodeArtifact: `aws codeartifact list-package-versions --format pypi --package …`, which needs AWS credentials | A final version newer than the newest final tag is a blocker: the admin tags that version's commit `vX.Y.Z` first. An index you cannot read is a question for phase 2, not a blocker | | Baseline | the rows above | The newest final tag; `v0.0.0` only with no final tags and nothing published | -| Publish workflow | the workflow that uploads (`twine`, `pypa/gh-action-pypi-publish`, `codeartifact`) | Variant: PyPI, CodeArtifact or none; its triggers; each job, what it does and its `if:` | +| Publish workflow | the workflow that uploads (`twine`, `uv publish`, `pypa/gh-action-pypi-publish`, `codeartifact`) | Variant: PyPI, CodeArtifact or none; its triggers; each job, what it does and its `if:` | | `release` triggers | the `on:` of every workflow; a job named `release:` is not a trigger | Every workflow listed, with what it does on a release: phase 3 moves each one | | Inlined CodeArtifact values | every workflow and job: AWS account, role ARNs, domain, region, repository | Each value with its file, job and the `vars.CODEARTIFACT_*` it becomes | | Tag-push workflows at the `v0.0.0` commit | baseline `v0.0.0` only: `git show :.github/workflows/`, a `tags:` under `push:` | None that publish; otherwise the admin disables that workflow around the push of `v0.0.0` | diff --git a/skills/adopt-release-tools/publish-workflow.md b/skills/adopt-release-tools/publish-workflow.md index 1eeda87..7453ac0 100644 --- a/skills/adopt-release-tools/publish-workflow.md +++ b/skills/adopt-release-tools/publish-workflow.md @@ -17,7 +17,7 @@ Read with `SKILL.md`, phase 3. `$rt` is the clone of release-tools at `$tag`. | The repository publishes to | Start from | Upload | | --- | --- | --- | | PyPI | `$rt/examples/stubs/deploy.yml` | Trusted Publishing; the admin adds the publisher (step 7) | -| AWS CodeArtifact | `$rt/examples/stubs/deploy-codeartifact.yml`, see `$rt/docs/adopting.md`, "Publishing to AWS CodeArtifact" | assumes `vars.CODEARTIFACT_WRITE_ROLE`, runs `twine upload` | +| AWS CodeArtifact | `$rt/examples/stubs/deploy-codeartifact.yml`, see `$rt/docs/adopting.md`, "Publishing to AWS CodeArtifact" | attests the files, assumes `vars.CODEARTIFACT_WRITE_ROLE`, runs `uv publish` | | nothing | `$rt/examples/stubs/deploy.yml` without `upload_pypi`, and without `build` when nothing is attached | none: `publish_release` needs `draft_release` | Keep the existing file name (usually `deploy.yml`) and the repository's build: its @@ -54,7 +54,8 @@ Place every job of the existing workflow: `draft-release`'s `files` matches. - **The version check stays.** When a `VERSION` file is kept in step with the tags, the bot now writes it (`version-files`), and the check compares it with the tag. -- **The upload** becomes the variant's upload job. +- **The upload** becomes the variant's upload job, as the stub has it: its `twine` or + `uv publish` step is replaced, not kept, and `pyproject.toml` stays as it is. - **Jobs that ship something for the tag** (container images, documentation) stay, and every one of them joins the `needs` of `publish_release`: the release is published only once everything it ships exists. From e13fbee3ffbcd6302311984a6c47ad1273aa2fc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Thu, 5 Nov 2026 10:38:19 +0100 Subject: [PATCH 08/14] =?UTF-8?q?docs(setup):=20=F0=9F=93=9D=20add=20a=20r?= =?UTF-8?q?epository=20to=20the=20bot=20credentials=20by=20id=20and=20find?= =?UTF-8?q?=20the=20rulesets=20of=20main=20by=20rule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 adds the repository to the variable and the secret one at a time: --repos replaces each list. Step 8 finds every ruleset that applies to main, whatever its name, through rules/branches/main, and its jq adds the PR checks, a required_status_checks rule when there is none, and the bot's bypass without duplicating them, so it can run again. adopting.md says that a VERSION file kept in step with the tags next to setuptools-scm belongs in version-files. Assisted-by: Pi:claude-opus-5-5 --- docs/adopting.md | 3 +- docs/organisation-setup.md | 3 ++ docs/repository-setup.md | 58 ++++++++++++++++++++++++++++---------- 3 files changed, 48 insertions(+), 16 deletions(-) diff --git a/docs/adopting.md b/docs/adopting.md index 4ef132b..3c0aa9c 100644 --- a/docs/adopting.md +++ b/docs/adopting.md @@ -27,7 +27,8 @@ GitHub, from the commands it prepares. It follows this page and Set `version-files` to the files that hold the version number, such as `VERSION` or `pyproject.toml` (whose `[project]` version is updated). Leave it empty if the version -comes from the git tag (setuptools-scm, hatch-vcs). `stage` chooses where a release +comes only from the git tag (setuptools-scm, hatch-vcs). A `VERSION` file kept in step +with the tags next to setuptools-scm goes in too: the release bot then writes it. `stage` chooses where a release starts (alpha, beta or rc) and promotes a release in beta to release candidates; see [Releasing](releasing.md). diff --git a/docs/organisation-setup.md b/docs/organisation-setup.md index 5d1c2e7..54d47c8 100644 --- a/docs/organisation-setup.md +++ b/docs/organisation-setup.md @@ -28,6 +28,9 @@ An organisation owner does this once. The commands need `gh` with the `admin:org --repos release-tools,AEGISS < algorithmiq-release-bot.*.private-key.pem ``` + A repository that adopts the process later is added to both lists on its own + ([Per-repository setup](repository-setup.md), step 1). + 3. **Allow the actions.** If the organisation restricts which actions and reusable workflows may run, allow `Algorithmiq/release-tools/*` and the actions it uses (`actions/checkout`, `actions/create-github-app-token`, diff --git a/docs/repository-setup.md b/docs/repository-setup.md index 44cfd05..8af423f 100644 --- a/docs/repository-setup.md +++ b/docs/repository-setup.md @@ -10,7 +10,15 @@ which retargets the open PRs; everyone then updates their clones. 1. **Install the app and share the credentials** with the repository: add it to the app's installation (Organisation settings → GitHub Apps → algorithmiq-release-bot → - Configure), and to the `--repos` lists of the variable and the secret. + Configure), and to the repositories of the variable and the secret, one at a time. + Not with `--repos`: it replaces each list, and drops every repository left out. + + ```bash + REPO_ID=$(gh api repos/$REPO --jq .id) + gh api -X PUT orgs/Algorithmiq/actions/variables/RELEASE_BOT_CLIENT_ID/repositories/$REPO_ID + gh api -X PUT orgs/Algorithmiq/actions/secrets/RELEASE_BOT_PRIVATE_KEY/repositories/$REPO_ID + ``` + 2. **The baseline: where versions count from and the changelog starts.** The release workflow counts versions from the newest final tag `vX.Y.Z`, and the changelog starts there. Settle it before the tag ruleset (step 5) exists. @@ -112,25 +120,45 @@ which retargets the open PRs; everyone then updates their clones. ([One-time organisation setup](organisation-setup.md), step 5) and the roles' trust policies cover every repository. The first pre-release is published straight away. 8. **After the stubs are on `main`: require the PR checks on `main` and let the bot - bypass the `main` ruleset.** `pull_request_target` runs the workflow file of the base - branch, so the checks never report until the stub is on `main`; requiring them earlier - blocks every PR, including the one adding them. The bypass lets the bot merge its - changelog PR at once, without a review or the PR checks (see [Caveats](caveats.md)); - without it, each changelog PR waits for a maintainer's approval. + bypass the rulesets of `main`.** `pull_request_target` runs the workflow file of the + base branch, so the checks never report until the stub is on `main`; requiring them + earlier blocks every PR, including the one adding them. The bypass lets the bot merge + its changelog PR at once, without a review or the PR checks (see + [Caveats](caveats.md)); without it, each changelog PR waits for a maintainer's + approval. + + The rulesets that apply to `main`, whatever their name: + + ```bash + gh api repos/$REPO/rules/branches/main --jq '[.[] | {ruleset_id, ruleset_source_type}] | unique' + ``` + + Run the following once for each `Repository` one with a `pull_request` or + `required_status_checks` rule, with its id. It adds the two checks, and a + `required_status_checks` rule if the ruleset has none, and the bot to the bypass + list; what is already there stays once, so running it again changes nothing. ```bash - id=$(gh api repos/$REPO/rulesets --jq '.[] | select(.name == "main") | .id') - gh api repos/$REPO/rulesets/$id | jq --argjson app "$APP_ID" '{name, target, enforcement, conditions, - rules: (.rules | map(if .type == "required_status_checks" - then .parameters.required_status_checks += [{"context": "PR checks / PR title", "integration_id": 15368}, - {"context": "PR checks / Migration notes", "integration_id": 15368}] - else . end)), - bypass_actors: (.bypass_actors + [{"actor_id": $app, "actor_type": "Integration", "bypass_mode": "always"}])}' \ + id= + gh api repos/$REPO/rulesets/$id | jq --argjson app "$APP_ID" ' + [{"context": "PR checks / PR title", "integration_id": 15368}, + {"context": "PR checks / Migration notes", "integration_id": 15368}] as $checks + | {name, target, enforcement, conditions, + rules: (if any(.rules[]; .type == "required_status_checks") then .rules + else .rules + [{"type": "required_status_checks", "parameters": + {"strict_required_status_checks_policy": false, "required_status_checks": []}}] end + | map(if .type == "required_status_checks" + then .parameters.required_status_checks |= (. + $checks | unique_by(.context)) + else . end)), + bypass_actors: ((.bypass_actors // []) + [{"actor_id": $app, "actor_type": "Integration", "bypass_mode": "always"}] + | unique_by([.actor_id, .actor_type]))}' \ | gh api -X PUT repos/$REPO/rulesets/$id --input - ``` - This assumes a `main` ruleset with a `required_status_checks` rule; create one first - if there is none. + An `Organization` ruleset changes only at `orgs/Algorithmiq/rulesets/$id`, in both + calls, by an organisation owner, and the change covers every repository it targets. + A repository that had its own checks for PR titles or migration notes drops them from + the ruleset now (see [Caveats](caveats.md)). The first release: check that no deprecation is due for removal, then **Actions → Release → Run workflow** on `main` (or From b6f960eebc232ace89b1b24f124ea3ecfa5ba223 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Thu, 5 Nov 2026 10:38:26 +0100 Subject: [PATCH 09/14] =?UTF-8?q?fix(skill):=20=F0=9F=90=9B=20close=20the?= =?UTF-8?q?=20gaps=20an=20adversarial=20review=20found=20across=20model=20?= =?UTF-8?q?tiers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quizzes, paper dry runs and reviews by Claude 5.5 and GPT-5.6 models at several thinking levels showed where executors diverged or would act wrongly. The skill now: forbids opening issues upstream; lists what the agent may run; shows one handed-over command in full; holds back a command that needs an unanswered value; stops for the admin after the plan and in phases 4 to 6; reads the organisation variables' values and asks when an inlined one differs; places jobs of workflows that also run on PRs or main; asks whether moved workflows run for pre-releases; and covers stub name collisions, check integration ids, the index to query, hand-written changelog notes, local release.py and cliff.toml, re-runs, and step 8 on any ruleset of main. Tests pin the upstream rule, the stops, literal commands and the worked example. Assisted-by: Pi:claude-opus-5-5 --- docs/developing.md | 5 +- skills/adopt-release-tools/SKILL.md | 356 +++++++++++------- .../adopt-release-tools/publish-workflow.md | 58 ++- tests/test_skill.py | 48 +++ 4 files changed, 321 insertions(+), 146 deletions(-) diff --git a/docs/developing.md b/docs/developing.md index 634b814..8421d61 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -25,8 +25,9 @@ just test # the release package, cliff.toml, the actions, and that the examp - `tests/test_leaks.py` checks that no tracked file holds an AWS account ID, ARN or endpoint, or names an `Algorithmiq/` repository other than the public ones. - `tests/test_skill.py` checks the agent skill in `skills/adopt-release-tools/`: its - frontmatter, the major version it is written for, and that every release-tools path - and heading it reads exists. + frontmatter, the major version it is written for, that every release-tools path + and heading it reads exists, and that it stops for the admin and hands over only + literal commands. On Nix, `nix develop` (or [direnv](https://direnv.net) with the tracked `.envrc`) gives a shell with Python 3.12, uv, just and git; run the same commands inside it. The tools diff --git a/skills/adopt-release-tools/SKILL.md b/skills/adopt-release-tools/SKILL.md index f9dcc73..c0d8e2f 100644 --- a/skills/adopt-release-tools/SKILL.md +++ b/skills/adopt-release-tools/SKILL.md @@ -15,29 +15,62 @@ two, and stays split even when the admin offers you their token: - **The admin** runs every command that changes GitHub: repository settings, labels, tags, rulesets, the organisation's variable and secret lists, `gh workflow disable`/`enable`, the merge of the adoption PR and the first release. Give each one in - order, and wait until the admin reports it done. Write "you run" for each of them, - never "the commands run" or "I run". - -Beyond the adoption branch and its PR, you run reads only. A command with a write in it -(`gh api -X …`, `gh label create`, `gh variable set`, `git push` of a tag) is text for -the admin: hand it over as text, even when you only want to test it. -**File tool only:** write every file whose content you compose with your file-writing + order, and wait until the admin reports it done. When you hand one over, address the + admin: write "You run:" above it, never "the commands run" or "I run". + +**What you run.** Reads: `gh api` without `-X`/`--method` other than `GET` and without +`-f`, `-F`, `--field`, `--raw-field` or `--input` (any of them makes it a POST); +`gh … list`, `view` and `status`; `git clone`, `fetch`, `log`, `show`, `ls-remote`. Local +work in your clone and in `mktemp -d` directories. And the adoption PR only: +`git push origin `, by name, and `gh pr create`/`gh pr edit` for it. +Everything else that changes GitHub (a `gh api` write, `gh label create`, +`gh variable set`, `gh workflow run`, `gh release …`, `gh pr merge`, `gh issue create`) +is text for the admin: hand it over as text, even when you only want to test it. Never +create a tag, local or pushed, and never push with `--tags`, `--all` or `--follow-tags`. + +**File tool only.** Write every file whose content you compose with your file-writing tool, never a shell heredoc, `echo`, `printf` or `tee`: the adoption PR's files (workflows, PR template, `dependabot.yml`, `justfile`, your edits to `CHANGELOG.md`), the payload files, and every plan, report or document. A tool's own output is fine: git-cliff's `--output`, or a stub copied with `cp` and then edited with the file tool. -A heredoc only feeds a script that reads. A JSON payload goes into -a file outside the clone, so that it never reaches the adoption PR: even in a dry run, -create the directory with `mktemp -d`, write the file there, and put its literal -absolute path in the command's `--input`, never a variable (`${DIR:?}`). Show the -file's content next to the command, and tell the admin to save it at that path, or edit -the path, if they run the command on another machine: no command you hand over holds -a heredoc. Each one names this repository, the App ID and -the ruleset ids literally, or guards each variable it uses (`${REPO:?}`, `${APP_ID:?}`), -so that a missing value stops it before `gh api` runs. A value you cannot read (a role -ARN, an organisation variable's value) you ask the admin for in phase 2 and write in; -until then the command waits with its question. No write command holds a -``. +A heredoc only feeds a script that reads. + +**Handed-over commands.** A JSON payload goes into a file outside the clone, so that it +never reaches the adoption PR: even in a dry run, create the directory with `mktemp -d`, +write the file there, and put its literal absolute path in the command's `--input`, +never a variable (`${DIR:?}`). Where `$rt/docs/repository-setup.md` pipes a heredoc into +`--input -`, the file replaces the heredoc. Show the file's content next to the command, +and tell the admin to save it at that path, or edit the path, if they run the command on +another machine. Each command names this repository, the App ID and the ruleset ids +literally, or guards each variable it uses (`${REPO:?}`, `${APP_ID:?}`) and sets it on +its first line, so that a missing value stops it before `gh api` runs. No command you +hand over holds a heredoc or a ``. A value you cannot read (a role ARN that +no workflow holds, the commit `v0.0.0` goes on) is a question for the admin in phase 2: +until it is answered you hand over no command that needs it, and the plan lists that +command under **Waiting for**, with its question. For example, step 5 of phase 4, after +`mktemp -d` printed `/tmp/tmp.Xa81kQ`: + +````markdown +`/tmp/tmp.Xa81kQ/release-tags.json`: + +```json +{"name": "release tags", "target": "tag", "enforcement": "active", + "conditions": {"ref_name": {"include": ["refs/tags/v*"], "exclude": []}}, + "rules": [{"type": "creation"}, {"type": "update"}, {"type": "deletion"}], + "bypass_actors": [{"actor_id": 1234567, "actor_type": "Integration", "bypass_mode": "always"}]} +``` + +You run (save the file at that path first if you run it on another machine): + +```bash +gh api -X POST repos/Algorithmiq/example/rulesets --input /tmp/tmp.Xa81kQ/release-tags.json +``` + +Reply `done` once it has run, or paste its error. +```` + +**Stop** in a phase means: send what the phase lists, end your turn, and go on only once +the admin has replied as it says. Work through the phases in order. Each ends on a **done** check. A **blocker** is what stops the adoption until someone outside it acts: a default branch other than `main`, a @@ -46,22 +79,27 @@ missing `CODEARTIFACT_*` organisation variables, no admin access. Stop on a bloc exactly what is missing and who can resolve it, and go on once it is resolved. Everything the plan does itself (sharing the bot credentials, installing the app, creating rulesets, the settings, rewriting the publish workflow for the index it -publishes to today) is a **to-do** in the plan. What you cannot read (the package -index, a role ARN) is a **question** for the admin in phase 2. What you cannot check -(an AWS role's trust policy) the plan and the report mark **not verified**, never a -fact. Inspect only reads, so a second run of this skill finds where an interrupted -adoption stopped: pass quickly over every phase whose done check already holds. - -The adopting repository is private or internal, and release-tools is public. Anything -you report upstream (an issue, a PR, a comment) describes the problem in general terms, -without this repository's name, files or contents. +publishes to today) is a **to-do** in the plan. What you cannot read or decide (the +package index, a role ARN, a value that differs from the organisation's) is a +**question** for the admin in phase 2. What you cannot check (an AWS role's trust +policy) the plan and the report mark **not verified**, never a fact. Inspect only reads, +so a second run of this skill finds where an interrupted adoption stopped: pass quickly +over every phase whose done check already holds, continue on the open adoption PR's +branch if a previous run opened one, and hand over no step whose row is already at its +target. + +The adopting repository is usually private or internal, and release-tools is public. +You do not open issues, PRs or comments on release-tools. When something there is +wrong, write the report in general terms, without this repository's name, files or +contents, and hand it to the admin to post. ## Phase 0: The source release-tools' documentation is the source of every stub, command and setting: read it from a clone of one release, and copy stubs and commands from there. If the admin names a release-tools tag (such as a release candidate for a pilot), use that tag: set `tag` -to it instead of the first line below, which reads the latest final release only. +to it instead of the first line below, which reads the latest final release only. A +named tag still goes through the major check below. ```bash tag=$(gh release view -R Algorithmiq/release-tools --json tagName --jq .tagName) @@ -73,47 +111,56 @@ git clone --quiet --depth 1 --branch "$tag" https://github.com/Algorithmiq/relea PR template. - `$rt/docs/repository-setup.md`: the admin's steps, numbered 1 to 8. - `$rt/docs/caveats.md`: what the team needs to know afterwards. -- `$rt/examples/stubs/`: the files to copy. They pin an example tag: write `$tag` - in every `Algorithmiq/release-tools` reference instead. +- `$rt/examples/stubs/`: the files to copy. They pin an example tag: in every + `uses: Algorithmiq/release-tools…@` pin, write the value of `$tag` (such as `v1.3.0`) + instead, never the string `$tag`. + +Shell variables may not outlive one command: write the values of `$tag` and `$rt` at the +top of the facts table, and use them literally from then on. This skill is written for the major version in `release-tools-major` above. If `$tag` -has another major, that is a blocker: the skill is out of date (`npx skills update`). -If a path under `$rt` that this skill names is missing, the release predates the skill: -that is a blocker too; use a newer tag, or ask the admin for one. +has another major, that is a blocker: the skill is out of date (`npx skills update`), or +the admin names a tag of this major. If a path under `$rt` that this skill names is +missing, the release predates the skill: that is a blocker too; use a newer tag, or ask +the admin for one. **Done:** `$tag` is the admin's tag or the latest release, its major matches, `$rt` exists, and every path under `$rt` that this skill names exists in it. ## Phase 1: Inspect -Read only. Fill in a **facts table**, one row per fact below, with the value its command -returned. Set `REPO=Algorithmiq/`, and work in an up-to-date clone of the -repository on its default branch. +Read only. Fill in a **facts table** with the columns `Fact | Value | Target | Status`: +one row per fact below, the value its command returned, and a status of `ok`, `to-do`, +`blocker`, `question` or `not verified`. Set `REPO=Algorithmiq/`, and work in an +up-to-date clone of the repository on its default branch. | Fact | How to read it | Target, or blocker | | --- | --- | --- | -| Admin access | `gh api repos/$REPO --jq .permissions.admin`; `gh auth status` lists the `admin:org` scope | `true`, else a blocker. Without `admin:org`, an organisation owner runs the organisation steps | +| Admin access | `gh api repos/$REPO --jq .permissions.admin`; `gh auth status` lists the `admin:org` scope | `true`, else a blocker. Without `admin:org`, an organisation owner runs the organisation steps: say who, in the plan | | Default branch | `gh api repos/$REPO --jq .default_branch` | `main`. Anything else is a blocker: the admin renames it (`$rt/docs/repository-setup.md`) | -| Visibility | `gh api repos/$REPO --jq .visibility` | As read: `public`, `internal` or `private`. The organisation variables (visibility `private`) reach `internal` and `private` repositories | -| Version source | `pyproject.toml` (`[project] version` or `dynamic`), the build backend, a `VERSION` file | `version-files`: `"pyproject.toml"` for a static version; `"VERSION"` when the build reads it, or when it is kept in step with the tags next to setuptools-scm; `""` when the version comes only from the tag | -| Newest final tag | `git tag --list 'v*' --sort=-v:refname` | The newest `vX.Y.Z` | +| Visibility | `gh api repos/$REPO --jq .visibility` | As read: `public`, `internal` or `private`. The organisation variables (visibility `private`) reach `internal` and `private` repositories only: a `public` one that publishes to CodeArtifact is a blocker | +| Version source | `pyproject.toml` (`[project] version` or `dynamic`), the build backend, a `VERSION` file | `version-files`: `"pyproject.toml"` for a static version; `"VERSION"` when the build reads it; `""` when the version comes only from the tag (setuptools-scm, hatch-vcs); `"VERSION"` too for a `VERSION` file kept in step with the tags next to setuptools-scm (`$rt/docs/adopting.md`). When you cannot tell which file the build reads, it is a question | +| Newest final tag | `git fetch --tags`, then `git tag --list 'v*' --sort=-v:refname`; a final tag matches `vX.Y.Z` exactly | The newest `vX.Y.Z` | | Newest tag | the same list | A pre-release (`v0.4.0rc1`, `v0.4.0b1`) newer than every final tag, without its final, is a blocker: the admin releases that final the old way first | -| Published versions | `gh release list -R $REPO`; PyPI: `curl -s https://pypi.org/pypi//json \| jq -r '.releases \| keys[]'`; CodeArtifact: `aws codeartifact list-package-versions --format pypi --package …`, which needs AWS credentials | A final version newer than the newest final tag is a blocker: the admin tags that version's commit `vX.Y.Z` first. An index you cannot read is a question for phase 2, not a blocker | +| Published versions | `gh release list -R $REPO`, and the index the publish workflow uploads to, only that one: PyPI, `curl -s https://pypi.org/pypi//json \| jq -r '.releases \| keys[]'`; CodeArtifact, `aws codeartifact list-package-versions --format pypi --package ` with the domain, domain owner, repository and region of the CodeArtifact rows, which needs AWS credentials. Never query PyPI for a package published elsewhere: its name is private | A final version newer than the newest final tag is a blocker: the admin tags that version's commit `vX.Y.Z` first. An index you cannot read is a question for phase 2, not a blocker | | Baseline | the rows above | The newest final tag; `v0.0.0` only with no final tags and nothing published | -| Publish workflow | the workflow that uploads (`twine`, `uv publish`, `pypa/gh-action-pypi-publish`, `codeartifact`) | Variant: PyPI, CodeArtifact or none; its triggers; each job, what it does and its `if:` | -| `release` triggers | the `on:` of every workflow; a job named `release:` is not a trigger | Every workflow listed, with what it does on a release: phase 3 moves each one | +| Publish workflow | the workflow that uploads (`twine`, `uv publish`, `pypa/gh-action-pypi-publish`, `codeartifact`) | Variant: PyPI, CodeArtifact, both or none; its file name, its triggers; each job, what it does and its `if:` | +| `release` triggers | the `on:` of every workflow; a job named `release:` is not a trigger | Every workflow listed, with what it does on a release and what else triggers it: phase 3 moves each one | +| Workflow file names | `.github/workflows/release.yml`, `backport.yml`, `pr-checks.yml` | Absent, or already a release-tools stub. Any other file of that name is renamed by the adoption PR before the stub takes its place (publish-workflow.md, "Each existing job") | | Inlined CodeArtifact values | every workflow and job: AWS account, role ARNs, domain, region, repository | Each value with its file, job and the `vars.CODEARTIFACT_*` it becomes | | Tag-push workflows at the `v0.0.0` commit | baseline `v0.0.0` only: `git show :.github/workflows/`, a `tags:` under `push:` | None that publish; otherwise the admin disables that workflow around the push of `v0.0.0` | -| Old process | `release` triggers, `workflow_dispatch` routes to publishing, `.github/release.yml`, docs that say to publish by creating a release on GitHub | Listed; the adoption PR removes them and points those docs to **Actions → Release** | -| Files | the PR template (`.github/pull_request_template.md`, any case), `justfile`, `.github/dependabot.yml`, `CHANGELOG.md` | Present or absent | +| Old process | `release` triggers, `workflow_dispatch` routes to publishing, `.github/release.yml`, local copies of `release.py` and `cliff.toml`, docs that say to publish by creating a release on GitHub | Listed; the adoption PR removes them and points those docs to **Actions → Release** | +| Files | the PR template (`.github/pull_request_template.md`, any case), `justfile`, `.github/dependabot.yml` (or `.yaml`), `CHANGELOG.md` | Present or absent | | Settings | `gh api repos/$REPO --jq '{allow_squash_merge, allow_merge_commit, allow_rebase_merge, allow_auto_merge, delete_branch_on_merge, squash_merge_commit_title, squash_merge_commit_message}'` | As step 3 of `$rt/docs/repository-setup.md` sets them | | Immutable releases | `gh api repos/$REPO/immutable-releases --jq .enabled` | `true` | | `backport` label | `gh label list -R $REPO --search backport` | Present | -| Rulesets | `gh api --paginate repos/$REPO/rulesets --jq '.[].id'`, then for each `gh api repos/$REPO/rulesets/ --jq '{id, name, source_type, enforcement, target, include: .conditions.ref_name.include, rules: [.rules[].type], bypass: .bypass_actors}'` | `release tags` and `release branches` as steps 5 and 6 create them. The default branch's rulesets are those whose `include` holds `~DEFAULT_BRANCH`, `~ALL` or `refs/heads/`, whatever their name (such as `Protect main`): note their ids, `source_type`, `enforcement` and the checks they already require | +| Rulesets | `gh api --paginate repos/$REPO/rulesets --jq '.[].id'`, then for each `gh api repos/$REPO/rulesets/ --jq '{id, name, source_type, enforcement, target, include: .conditions.ref_name.include, rules: [.rules[].type], checks: [.rules[] \| select(.type == "required_status_checks") \| .parameters.required_status_checks[]], bypass: .bypass_actors}'` | `release tags` and `release branches` as steps 5 and 6 create them. Another ruleset on `refs/tags/v*` or `refs/heads/release/*` is a question. The default branch's rulesets are those whose `include` holds `~DEFAULT_BRANCH`, `~ALL` or `refs/heads/`, whatever their name (such as `Protect main`): note their ids, `source_type`, `enforcement`, and the checks they already require with each one's `integration_id` | +| Classic branch protection | `gh api repos/$REPO/branches/main/protection` (404: none) | None. Otherwise a question: the bot's bypass covers rulesets only | +| `release/*` branches | `git ls-remote --heads origin 'release/*'` | None. Otherwise listed: they have no stubs, so the PR checks never report on them | | App ID | `gh api --paginate orgs/Algorithmiq/installations --jq '.installations[] \| select(.app_slug == "algorithmiq-release-bot") \| .app_id'` | A number, for the rulesets | | App installed on the repository | not readable with a user token | The admin confirms it: Organisation settings → GitHub Apps → algorithmiq-release-bot → Configure | | Bot credentials | `gh api --paginate orgs/Algorithmiq/actions/variables/RELEASE_BOT_CLIENT_ID/repositories --jq '.repositories[].name'`; the same for `secrets/RELEASE_BOT_PRIVATE_KEY` | The repository is in both lists. Phase 4 adds it one repository at a time; `--repos` is not used, because it replaces the whole list | -| CodeArtifact variables | when any workflow uses CodeArtifact: `gh api --paginate repos/$REPO/actions/organization-variables --jq '.variables[].name'` | All six of `$rt/docs/organisation-setup.md`, step 5. A missing one is a blocker for an organisation owner. If no workflow assumes a read role, say so: `CODEARTIFACT_READ_ROLE` is an organisation value for an owner to confirm, and you do not ask the admin for its ARN | +| CodeArtifact variables | when any workflow uses CodeArtifact: `gh api --paginate repos/$REPO/actions/organization-variables --jq '.variables[] \| {name, value}'` | All six of `$rt/docs/organisation-setup.md`, step 5. A missing one is a blocker for an organisation owner, except `CODEARTIFACT_READ_ROLE` when no workflow assumes a read role: then note it for an owner. Each inlined value that differs from its variable's is a question: after adoption the workflows read the variable, so packages go where it points | **Done:** every row holds what its command returned; "cannot check" only with the error or the missing credential that stopped it. The blockers are listed apart from the @@ -121,86 +168,120 @@ to-dos. ## Phase 2: Plan -Show the admin the facts table, then the plan: - -1. **The admin's decisions:** the baseline, and for `v0.0.0` its commit (the last one - that should not appear in the changelog); the repository's own CI checks to require - on `release/*` next to the PR checks: start from those the default branch's rulesets - already require, add others only if they run on every PR to `release/*` (a workflow - with `paths` or `paths-ignore` filters, or `branches` that leave out `release/*`, - skips PRs, and its required check would block them), and list those left out and - why; the number of approvals there. -2. **The adoption PR:** every file it adds, changes or removes (phase 3). -3. **The admin's commands,** in order, each filled in for this repository (phases 4 and - 5). -4. **What changes for the team:** squash-merge only; releases start from the Release +Send the admin one message with these headings, in this order: + +1. **Facts:** the facts table. +2. **Blockers:** each one, who resolves it and how; `None` if there are none. +3. **Decisions requested:** numbered, each with the answer you need: + - the baseline, and for `v0.0.0` its commit (the last one that should not appear in + the changelog); + - the repository's own CI checks to require on `release/*` next to the PR checks. + Start from those the default branch's rulesets already require, with the + `integration_id` each has there. Leave out a check whose workflow skips some PRs to + `release/*` (`paths` or `paths-ignore` filters, or `branches` that leave out + `release/*`): its required check would block those PRs. Leave out a PR-title or + migration-notes check of the repository's own: the PR checks replace it. List each + check left out, and why; + - the number of approvals on `release/*` (1 in step 6 of + `$rt/docs/repository-setup.md`); + - for each workflow or job that moves from `release` to tag pushes: whether it should + also run for pre-release tags (alphas, betas, release candidates). If not, the PR + limits it to final tags (publish-workflow.md, "The old triggers"); + - every question of phase 1. For a package index you could not read, the admin runs + the filled-in `list-package-versions` command and reports every version it lists. + Settle the baseline on that answer: `v0.0.0` only once the admin confirms the index + holds no version of the package. +4. **The adoption PR:** every file it adds, changes, renames or removes (phase 3). With + an existing baseline tag, `CHANGELOG.md` gets a section for each final release up to + it, generated in the PR; the admin checks each section there against that release's + GitHub notes. +5. **The admin's commands:** in order, each filled in for this repository (phases 4 and + 5), and under **Waiting for**, each command that needs an answer, with its question. +6. **What changes for the team:** squash-merge only; releases start from the Release workflow instead of the GitHub UI; every pre-release is published to the package - index; workflows with `release` triggers move to tag pushes. + index; workflows with `release` triggers move to tag pushes, and run for pre-release + tags unless the plan says otherwise. -Ask your **questions** with the plan. For a package index you could not read, the admin -runs the filled-in `list-package-versions` command and reports every version it lists. -Settle the baseline on that answer: `v0.0.0` only once the admin confirms the index -holds no version of the package. +End the message with: "Answer each numbered decision, then reply `Plan approved`." +**Stop.** -With an existing baseline tag, add that `CHANGELOG.md` gets the releases before -adoption, generated in the adoption PR, where the admin completes them. - -**Done:** the admin has answered every question, approves the plan explicitly, -and no blocker from phase 1 is open. +**Done:** the admin has answered every decision, replied `Plan approved`, and no +blocker from phase 1 is open. ## Phase 3: The adoption PR -On a new branch off the default branch: +On a new branch `ci/adopt-release-tools` off the default branch, or the branch of the +adoption PR a previous run opened: +- **Renames first:** a workflow that holds the name of a stub (Workflow file names, phase + 1) moves to a free name in the same PR, as publish-workflow.md, "Each existing job" + says. - **The three stubs** from `$rt/examples/stubs/` (`release.yml`, `backport.yml`, - `pr-checks.yml`) into `.github/workflows/`, with `$tag` and the `version-files` of the - facts table. + `pr-checks.yml`) into `.github/workflows/`, with the value of `$tag` and the + `version-files` of the facts table. - **The publish workflow:** follow [publish-workflow.md](publish-workflow.md). - **Every inlined CodeArtifact value** of the facts table, in every workflow and job, not only the upload: mapped as publish-workflow.md, "The CodeArtifact variables" says. -- **`just changelog`:** the recipe for a public release-tools from - `$rt/docs/adopting.md`, in the `justfile` (a new one if there is none). +- **`just changelog`:** release-tools is public, so the recipe for a public release-tools + from `$rt/docs/adopting.md` (not `justfile-internal`), in the `justfile` (a new one if + there is none). - **The PR template:** the Migration section and the backport checklist item from - `$rt/docs/adopting.md`, above any later heading of the existing template. + `$rt/docs/adopting.md`, above any later heading of the existing template. Migration is + never the last section: with no later heading, add the `## Checklist` heading after it, + as the docs' template does. With no template, use the docs' template. - **`CHANGELOG.md`:** with an existing baseline tag, a `## X.Y.Z (YYYY-MM-DD)` section for every final release up to it, generated as step 2 of `$rt/docs/repository-setup.md` shows; an empty file with `v0.0.0` only. An existing - `CHANGELOG.md` stays only if its release headings read `## X.Y.Z (YYYY-MM-DD)`; - otherwise regenerate it and tell the admin. + `CHANGELOG.md` whose release headings read `## X.Y.Z (YYYY-MM-DD)` stays as it is. + Otherwise generate the new file, then carry over by hand every note the old file holds + that the generated sections lack, in its wording, under the section of its release, + and tell the admin. - **Dependabot:** the `release-tools` group from `$rt/docs/versioning.md`, merged into - `.github/dependabot.yml`. + the existing `github-actions` entry of `.github/dependabot.yml`, or added with one. - **The old process removed:** the publishing `workflow_dispatch` routes, - `.github/release.yml`, and the `release` trigger of every workflow (tests, image - builds, documentation), each replaced as publish-workflow.md says so that its work - still runs for every release. Every doc of the old process points to - **Actions → Release** instead. + `.github/release.yml`, local copies of `release.py` and `cliff.toml`, and the + `release` trigger of every workflow (tests, image builds, documentation), each replaced + as publish-workflow.md says so that its work still runs for every release. Every doc + of the old process points to **Actions → Release** instead. Title the PR by the convention it introduces, for example `ci: 👷 adopt release-tools`. -Its description lists, for every release before adoption, the section to compare with -`gh release view vX.Y.Z`, and the stray groups git-cliff made from capitalised types -(such as `### Fix`). Before you push, run actionlint on `.github/workflows/`: -`uvx --from actionlint-py actionlint`, or, when that cannot download its binary, -`docker run --rm -v "$PWD":/repo -w /repo rhysd/actionlint:1.7.12` or -`nix run nixpkgs#actionlint`, and fix what it reports. When none runs, the PR -description says `actionlint not run: `. Then push the branch and open the -PR; the admin merges it after phase 4. - -**Done:** every `Algorithmiq/release-tools` reference uses `$tag` -(`grep -rn 'Algorithmiq/release-tools' .github justfile`); no workflow has a `release` -trigger; in `.github/workflows`, `grep -rnF` finds no role ARN of the facts table, and -no line of `grep -rniE 'codeartifact|--domain-owner'` holds the AWS account (which other -services, such as a container registry, can use; values such as a `pypi` repository or -a region appear in the stub itself, so the check leaves them out); the done check of -publish-workflow.md holds; actionlint passed before the push, or the PR description -says why it did not run; and the PR is open. +Its description lists, for every final release up to the baseline, the section to +compare with `gh release view vX.Y.Z`, the stray groups git-cliff made from capitalised +types (such as `### Fix`), and every renamed file. Before you push, run actionlint on +`.github/workflows/`: `uvx --from actionlint-py actionlint`, or, when that cannot +download its binary, `docker run --rm -v "$PWD":/repo -w /repo rhysd/actionlint:1.7.12` +or `nix run nixpkgs#actionlint`. Fix what it reports in the files you changed, and list +the rest in the PR description. When none runs, the PR description says +`actionlint not run: `. Then push the branch and open the PR. + +**Done:** + +- every `uses: Algorithmiq/release-tools…@` pin ends in the value of `$tag` + (`grep -rn 'Algorithmiq/release-tools[^ ]*@' .github`); +- the `on:` of every workflow holds no `release`, and + `grep -rn 'github.event.release' .github` prints nothing; +- in `.github/workflows`, `grep -rnF` finds no role ARN of the facts table, and no line + of `grep -rniE 'codeartifact|--domain-owner'` holds the AWS account or the domain of + the facts table (the account can also serve other services, such as a container + registry; values such as a `pypi` repository or a region appear in the stub itself, + so the check leaves them out); +- the done check of publish-workflow.md holds; +- actionlint passed before the push, or the PR description says why it did not run; +- the PR is open. + +Tell the admin the PR is open and that phase 4 comes before its merge. ## Phase 4: The admin's steps before the merge -Steps 1 to 7 of `$rt/docs/repository-setup.md`, as filled-in commands, one at a time, -in this order. After each, re-read its row of the facts table. +The admin's part of steps 1 to 7 of `$rt/docs/repository-setup.md` (step 4, the files, +is phase 3), as filled-in commands. The items below are numbered in the order to run +them, which is not the docs' order: hand over one at a time, **stop** until the admin +replies, then re-read its row of the facts table. Skip an item whose row is already at +its target. A ruleset whose name already exists gets a `PUT` on its id, never a second +`POST`. 1. **Bot credentials** (step 1): add this repository to the variable and the secret - one by one: + one by one, with `REPO=Algorithmiq/` written in on the first line: ```bash REPO_ID=$(gh api "repos/${REPO:?}" --jq .id) @@ -212,42 +293,61 @@ in this order. After each, re-read its row of the facts table. 2. **App installation** (step 1, web UI). 3. **Settings, immutable releases, the `backport` label** (step 3). 4. **The baseline** (step 2): `v0.0.0` only when the plan says so, before the tag - ruleset, with a tag-push publishing workflow disabled around it if phase 1 found one. + ruleset: step 2's `git tag` and `git push` with the commit the admin chose written + in, and `gh workflow disable` before and `gh workflow enable` after them for a + tag-push publishing workflow phase 1 found. 5. **The tag ruleset** (step 5): its JSON, with the App ID, in `release-tags.json` in the `mktemp -d` directory, shown in full, and step 5's `gh api -X POST` with - `--input` and that file's literal absolute path. + `--input` and that file's literal absolute path, as in the example at the top. 6. **The `release/*` ruleset** (step 6): the same with `release-branches.json`, with the - App ID, the checks and the approvals the admin chose. -7. **The package index** (step 7): PyPI, the Trusted Publisher (web UI); CodeArtifact - and no package, nothing. + App ID, the checks the admin chose (each with its `integration_id` from the default + branch's ruleset) and the approvals. +7. **The package index** (step 7): PyPI, the Trusted Publisher (web UI), with the + owner, repository, workflow file name and environment of the publish workflow written + in; for a project already on PyPI, the admin checks that its publisher matches them + (a renamed file breaks it). CodeArtifact and no package, nothing. -**Done:** re-running phase 1 shows each of these rows at its target. +**Done:** re-running phase 1 shows each of these rows at its target, and the admin has +confirmed the app installation. ## Phase 5: After the merge -The admin squash-merges the adoption PR. Then step 8 of -`$rt/docs/repository-setup.md`, on the default branch's rulesets from phase 1, whatever -their name: the default branch requires `PR checks / PR title` and +The admin squash-merges the adoption PR. **Stop** until they report it merged. + +Then step 8 of `$rt/docs/repository-setup.md`, on the default branch's rulesets from +phase 1, whatever their name: the default branch requires `PR checks / PR title` and `PR checks / Migration notes`, and the release bot bypasses every one of those rulesets with a `pull_request` or `required_status_checks` rule, without which every changelog -PR waits for an approval. Hand over step 8's command once per ruleset, with its id -written in place of the `id=$(…)` line. An organisation ruleset (`source_type` -`Organization`) cannot change from the repository: hand it to an organisation owner, -saying that it covers every repository it targets. A default branch without a -`required_status_checks` rule gets one first. If a ruleset required other checks for -PR titles, they switch to the new names now (`$rt/docs/caveats.md`, check names). - -**Done:** `gh api repos/$REPO/rulesets/` shows both checks required on the default -branch by a ruleset with `enforcement` `active`, and the App ID among the bypass actors -of each of those rulesets. +PR waits for an approval. Hand over step 8's second command once per ruleset, adapted: + +- the ruleset's id written in place of ``, and the App ID in + `--argjson app`; +- an organisation ruleset (`source_type` `Organization`) reads and writes + `orgs/Algorithmiq/rulesets/` instead of `repos/…`, and goes to an organisation + owner, saying that it covers every repository it targets; +- if a ruleset required other checks for PR titles or migration notes, the same jq + removes them (`$rt/docs/caveats.md`, check names). + +In an older release, step 8 finds the ruleset by name (`id=$(…)`) and appends without +looking: write the id in place of that line, leave out of the jq a check or the bypass +actor the ruleset already holds, and give a ruleset without a `required_status_checks` +rule one first. + +**Done:** `gh api repos/$REPO/rulesets/` (`orgs/Algorithmiq/rulesets/` for an +organisation ruleset) shows both checks required on the default branch by a ruleset +with `enforcement` `active`, and the App ID among the bypass actors of each of those +rulesets. ## Phase 6: The first release, and the report -Hand over the first release: check that no deprecation is due for removal, then -**Actions → Release → Run workflow** on `main` with a bump (`minor` or `major`) and a -stage (`rc` by default; `beta` or `alpha` to start earlier), or +Ask the admin whether a deprecation is due for removal (`$rt/docs/process.md`: a +`DeprecationWarning` names the earliest version that removes it). Then hand over the +first release: **Actions → Release → Run workflow** on `main` with a bump (`minor` or +`major`) and a stage (`rc` by default; `beta` or `alpha` to start earlier), or `gh workflow run release.yml -R --ref main -f bump=minor -f stage=rc` with the repository written in. Every pre-release is published to the package index. +**Stop** until the admin reports the run's result, then read it: +`gh run list -R $REPO -w release.yml -L 1`, and `gh release view` of the tag it made. End with a report: @@ -256,8 +356,8 @@ End with a report: - from `$rt/docs/caveats.md`, what matters to the team from now on: the changelog PR merges itself only with the bypass of phase 5; an older release line runs the release-tools version its branch pins; workflows on `release` events no longer run; -- last, as in any dry-run report: "Commands run that were not reads: none", or their - list. +- last: "Commands run that were not reads, beyond the adoption branch and its PR: none", + or their list. **Done:** the report is written. The adoption is complete when its waiting list is empty. diff --git a/skills/adopt-release-tools/publish-workflow.md b/skills/adopt-release-tools/publish-workflow.md index 7453ac0..8d43706 100644 --- a/skills/adopt-release-tools/publish-workflow.md +++ b/skills/adopt-release-tools/publish-workflow.md @@ -18,11 +18,20 @@ Read with `SKILL.md`, phase 3. `$rt` is the clone of release-tools at `$tag`. | --- | --- | --- | | PyPI | `$rt/examples/stubs/deploy.yml` | Trusted Publishing; the admin adds the publisher (step 7) | | AWS CodeArtifact | `$rt/examples/stubs/deploy-codeartifact.yml`, see `$rt/docs/adopting.md`, "Publishing to AWS CodeArtifact" | attests the files, assumes `vars.CODEARTIFACT_WRITE_ROLE`, runs `uv publish` | -| nothing | `$rt/examples/stubs/deploy.yml` without `upload_pypi`, and without `build` when nothing is attached | none: `publish_release` needs `draft_release` | +| Both | `$rt/examples/stubs/deploy.yml`, with the `upload_codeartifact` job of `deploy-codeartifact.yml` added | both jobs need `draft_release`, and `publish_release` needs both | +| No package index | `$rt/examples/stubs/deploy.yml` without `upload_pypi` | none: `publish_release` needs `draft_release`. Keep `build` while the release attaches files (wheels, a bundle). With nothing to attach, drop `build` too, keep `draft_release` without its download step and `files`, and keep the version check in a job that `draft_release` needs, if the repository has one | + +Anything else is a question for the admin, not a guess: a publish workflow that another +workflow calls (`workflow_call`, `workflow_run`) or that a shared workflow provides, an +index other than these, or a package that is not Python. Keep the existing file name (usually `deploy.yml`) and the repository's build: its steps, runners, Python versions, wheels, and the authentication a build with private -dependencies needs. A build step that assumes a CodeArtifact read role stays. +dependencies needs. A build step that assumes a CodeArtifact read role stays, with the +role read from `vars.CODEARTIFACT_READ_ROLE` (below). The one +exception to the name: a publish workflow named `release.yml`, `backport.yml` or +`pr-checks.yml` moves to `deploy.yml` (or another free name), so that the stub can take +its name. A PyPI Trusted Publisher names the file, so the admin updates it (step 7). ## The CodeArtifact variables @@ -38,11 +47,14 @@ documentation jobs, the upload), becomes the organisation variable that holds it | the role ARN a build or an image job assumes to read packages | `vars.CODEARTIFACT_READ_ROLE` | | the role ARN the upload assumes | `vars.CODEARTIFACT_WRITE_ROLE` | -A role ARN maps whole to its role variable, never split into account and name. +A role ARN maps whole to its role variable, never split into account and name. The +account and region of another service (a container registry's login, say) stay as they +are, even when they equal the CodeArtifact ones. -When `gh variable list --org Algorithmiq` shows a variable's value and it differs from -the inlined one (another repository name, say), the PR description says so for the admin -to confirm. +An inlined value that differs from its variable's (another repository name, say) moves +the packages once the workflow reads the variable: it is a question of phase 2 (SKILL.md, +phase 1, "CodeArtifact variables"). Write the workflow only once the admin has answered +it, and record the answer in the PR description. ## Each existing job @@ -52,32 +64,46 @@ Place every job of the existing workflow: to attach is uploaded as an artifact; `draft_release` and the upload download them (`pattern` and `merge-multiple: true` for several artifacts), and every glob of `draft-release`'s `files` matches. -- **The version check stays.** When a `VERSION` file is kept in step with the tags, the - bot now writes it (`version-files`), and the check compares it with the tag. +- **The version check stays,** reading the tag from `GITHUB_REF_NAME` instead of + `github.event.release`. With no check in the repository, add the stub's. When a + `VERSION` file is kept in step with the tags, the bot now writes it (`version-files`), + and the check compares it with the tag. - **The upload** becomes the variant's upload job, as the stub has it: its `twine` or `uv publish` step is replaced, not kept, and `pyproject.toml` stays as it is. - **Jobs that ship something for the tag** (container images, documentation) stay, and every one of them joins the `needs` of `publish_release`: the release is published only once everything it ships exists. - **Jobs that also run for pull requests or pushes to the default branch** (test builds, - preview images) move to a separate workflow, for example `build.yml`, with their own - triggers and `cancel-in-progress: true`: a publish run is never cancelled, a PR build - should be. + preview images) are copied: the publish workflow keeps its tag-only job, and the + copy moves to a separate workflow, for example `build.yml`, with their other triggers + and `cancel-in-progress: true`. A publish run is never cancelled; a PR build should + be. A check that a ruleset requires keeps its name (the workflow's and the job's). ## The old triggers - `release` triggers leave the publish workflow, and so does any `workflow_dispatch` input or job that publishes ("force upload"): only the bot's tags publish. -- **Other workflows with a `release` trigger** (tests, image builds) get - `push: tags: ["v*"]` instead, or move into the publish workflow when they ship - something: release-tools publishes with `GITHUB_TOKEN`, which starts no workflows. +- **Other workflows with a `release` trigger** (release-tools publishes with + `GITHUB_TOKEN`, which starts no workflows): + - the jobs that ship something for the tag (an image push, a documentation deploy) + move into the publish workflow, as above; + - the jobs that ship nothing (tests) get `push: tags: ["v*"]` in place of the + `release` trigger; + - the workflow's other triggers (pull requests, pushes to the default branch) and + their jobs stay in its file. +- **Final tags only:** a moved job or workflow runs for every pre-release tag too. Where + the admin chose finals only (phase 2), a workflow gets + `tags: ["v[0-9]+.[0-9]+.[0-9]+"]`, and a job in the publish workflow + `if: ${{ !contains(github.ref_name, 'a') && !contains(github.ref_name, 'b') && !contains(github.ref_name, 'rc') }}` + (the bot's pre-release tags end in `aN`, `bN` or `rcN`). `publish_release` still needs + a skipped job: add `if: ${{ !cancelled() && !failure() }}` to it. - `.github/release.yml`, the configuration of GitHub's generated notes, goes: the `release-notes` action writes the notes. ## What stays as it is - The repository's pins of third-party actions; only `Algorithmiq/release-tools` - references move to `$tag`. + references move to `$tag`. A job copied from a stub keeps the stub's pins. - Permissions per job, as in the stub: `contents: write` for `draft_release` and `publish_release`; `id-token: write` where a job assumes an AWS role or uses Trusted Publishing. @@ -85,4 +111,4 @@ Place every job of the existing workflow: **Done:** the workflow triggers on `v*` tags only; `publish_release` needs every job that ships something; no job reads `github.event.release`; no workflow has a `release` trigger; every CodeArtifact value reads a `vars.CODEARTIFACT_*` variable; actionlint -passes. +passes, or the PR description says why it did not run (SKILL.md, phase 3). diff --git a/tests/test_skill.py b/tests/test_skill.py index 273ccdf..632abfe 100644 --- a/tests/test_skill.py +++ b/tests/test_skill.py @@ -20,9 +20,11 @@ from __future__ import annotations +import json import re from typing import Any +import pytest import yaml from conftest import ROOT @@ -86,3 +88,49 @@ def test_links_between_skill_files_resolve() -> None: def test_no_pinned_release_tools_reference() -> None: # The skill pins the latest release at run time; a tag written here would go stale. assert not RELEASE_TOOLS_REF.search(skill_text()) + + +def phase(number: int) -> str: + """Return the text of SKILL.md's phase NUMBER, up to the next phase.""" + text = (SKILL / "SKILL.md").read_text(encoding="utf-8") + match = re.search( + rf"^## Phase {number}:.*?(?=^## |\Z)", text, re.MULTILINE | re.DOTALL + ) + assert match, number + return match.group(0) + + +def test_upstream_reports_go_through_the_admin() -> None: + # release-tools is public: an agent that opens an issue there itself can leak. + text = " ".join(skill_text().split()) + assert "You do not open issues, PRs or comments on release-tools" in text + + +@pytest.mark.parametrize("number", [2, 4, 5, 6]) +def test_phases_that_wait_for_the_admin_say_stop(number: int) -> None: + assert re.search(r"\*\*stop\b", phase(number), re.IGNORECASE) + + +def test_handed_over_commands_hold_no_placeholder_or_heredoc() -> None: + blocks = re.findall(r"```bash\n(.*?)```", skill_text(), re.DOTALL) + assert blocks + for block in blocks: + assert "<<" not in block, block + assert not re.search(r"<[\w -]+>", block), block + + +def test_the_handed_over_example_is_the_docs_tag_ruleset() -> None: + skill = (SKILL / "SKILL.md").read_text(encoding="utf-8") + example = re.search(r"```json\n(.*?)```", skill, re.DOTALL) + assert example + setup = (ROOT / "docs" / "repository-setup.md").read_text(encoding="utf-8") + docs = re.search( + r"rulesets --input - < Date: Thu, 5 Nov 2026 11:05:12 +0100 Subject: [PATCH 10/14] =?UTF-8?q?docs(setup):=20=F0=9F=93=9D=20make=20step?= =?UTF-8?q?=208=20add=20only=20what=20is=20missing,=20and=20test=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unique_by reordered the required checks, so a second run still sent a changed ruleset. Step 8 now appends each PR check and the bot's bypass only when absent, keeping the order: on release-tools' own main ruleset it is a no-op. Checked against the API on a scratch repository: the PUT of the added required_status_checks rule and of an unchanged ruleset are accepted. tests/test_repository_setup.py runs the docs' jq on sample rulesets. Assisted-by: Pi:claude-opus-5-5 --- docs/developing.md | 2 + docs/repository-setup.md | 10 +-- tests/test_repository_setup.py | 109 +++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 tests/test_repository_setup.py diff --git a/docs/developing.md b/docs/developing.md index 8421d61..9d62bb7 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -24,6 +24,8 @@ just test # the release package, cliff.toml, the actions, and that the examp reference uses the same release-tools tag and git-cliff version. - `tests/test_leaks.py` checks that no tracked file holds an AWS account ID, ARN or endpoint, or names an `Algorithmiq/` repository other than the public ones. +- `tests/test_repository_setup.py` runs the jq of docs/repository-setup.md, step 8, on + sample rulesets: it adds only what is missing, so a second run changes nothing. - `tests/test_skill.py` checks the agent skill in `skills/adopt-release-tools/`: its frontmatter, the major version it is written for, that every release-tools path and heading it reads exists, and that it stops for the admin and hands over only diff --git a/docs/repository-setup.md b/docs/repository-setup.md index 8af423f..529e7cb 100644 --- a/docs/repository-setup.md +++ b/docs/repository-setup.md @@ -136,7 +136,7 @@ which retargets the open PRs; everyone then updates their clones. Run the following once for each `Repository` one with a `pull_request` or `required_status_checks` rule, with its id. It adds the two checks, and a `required_status_checks` rule if the ruleset has none, and the bot to the bypass - list; what is already there stays once, so running it again changes nothing. + list, each only if it is missing: running it again changes nothing. ```bash id= @@ -148,10 +148,12 @@ which retargets the open PRs; everyone then updates their clones. else .rules + [{"type": "required_status_checks", "parameters": {"strict_required_status_checks_policy": false, "required_status_checks": []}}] end | map(if .type == "required_status_checks" - then .parameters.required_status_checks |= (. + $checks | unique_by(.context)) + then .parameters.required_status_checks |= (. as $have + | . + [$checks[] | select(.context as $c | all($have[]; .context != $c))]) else . end)), - bypass_actors: ((.bypass_actors // []) + [{"actor_id": $app, "actor_type": "Integration", "bypass_mode": "always"}] - | unique_by([.actor_id, .actor_type]))}' \ + bypass_actors: (.bypass_actors // [] + | if any(.[]; .actor_id == $app and .actor_type == "Integration") then . + else . + [{"actor_id": $app, "actor_type": "Integration", "bypass_mode": "always"}] end)}' \ | gh api -X PUT repos/$REPO/rulesets/$id --input - ``` diff --git a/tests/test_repository_setup.py b/tests/test_repository_setup.py new file mode 100644 index 0000000..8b646b3 --- /dev/null +++ b/tests/test_repository_setup.py @@ -0,0 +1,109 @@ +# Copyright 2026 Algorithmiq +# +# Licensed 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 of the commands in docs/repository-setup.md that can run without GitHub. + +Step 8 rewrites a live ruleset of `main` with jq before a PUT. It must add only what is +missing, so that running it again, or on a ruleset that already has the checks and the +bypass, sends the ruleset back unchanged. +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from typing import Any + +import pytest +from conftest import ROOT + +APP_ID = 1234567 +CHECKS = ["PR checks / PR title", "PR checks / Migration notes"] + +pytestmark = pytest.mark.skipif( + shutil.which("jq") is None, reason="jq is not installed" +) + + +def step_8(ruleset: dict[str, Any]) -> dict[str, Any]: + """Run step 8's jq program on RULESET, as `gh api …/rulesets/$id` returns it.""" + setup = (ROOT / "docs" / "repository-setup.md").read_text(encoding="utf-8") + match = re.search(r"""jq --argjson app "\$APP_ID" '(.*?)' \\\n""", setup, re.DOTALL) + assert match, "step 8's jq program" + result = subprocess.run( + ["jq", "--argjson", "app", str(APP_ID), match.group(1)], + input=json.dumps(ruleset), + capture_output=True, + text=True, + check=True, + ) + return json.loads(result.stdout) + + +def as_put(ruleset: dict[str, Any]) -> dict[str, Any]: + """Return the fields of RULESET that a PUT sends back.""" + keys = ("name", "target", "enforcement", "conditions", "rules", "bypass_actors") + return {key: ruleset[key] for key in keys} + + +def required(ruleset: dict[str, Any]) -> list[str]: + """Return the contexts that RULESET's required_status_checks rule requires.""" + (rule,) = [r for r in ruleset["rules"] if r["type"] == "required_status_checks"] + return [check["context"] for check in rule["parameters"]["required_status_checks"]] + + +def ruleset( + rules: list[dict[str, Any]], bypass: list[dict[str, Any]] +) -> dict[str, Any]: + """Return a ruleset of main, with fields that a GET returns and a PUT drops.""" + return { + "id": 7, + "name": "Protect main", + "source_type": "Repository", + "target": "branch", + "enforcement": "active", + "conditions": {"ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}}, + "rules": rules, + "bypass_actors": bypass, + } + + +def test_adds_the_checks_the_rule_and_the_bypass() -> None: + result = step_8(ruleset([{"type": "deletion"}, {"type": "pull_request"}], [])) + assert required(result) == CHECKS + assert result["bypass_actors"] == [ + {"actor_id": APP_ID, "actor_type": "Integration", "bypass_mode": "always"} + ] + assert "id" not in result + + +def test_keeps_the_existing_checks_first() -> None: + lint = {"context": "lint", "integration_id": 15368} + rule = { + "type": "required_status_checks", + "parameters": {"required_status_checks": [lint]}, + } + assert required(step_8(ruleset([rule], []))) == ["lint", *CHECKS] + + +def test_a_second_run_changes_nothing() -> None: + once = step_8( + ruleset( + [{"type": "pull_request"}], + [{"actor_id": 2, "actor_type": "RepositoryRole"}], + ) + ) + assert step_8(ruleset(once["rules"], once["bypass_actors"])) == as_put(once) From 25ddf2151426684be5f9f103ba7600c799671f88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Thu, 5 Nov 2026 11:12:28 +0100 Subject: [PATCH 11/14] =?UTF-8?q?fix(skill):=20=F0=9F=90=9B=20ask=20an=20o?= =?UTF-8?q?wner=20for=20the=20CodeArtifact=20values,=20then=20hand=20over?= =?UTF-8?q?=20the=20commands=20that=20set=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No CODEARTIFACT_* organisation variable exists yet, so every CodeArtifact adoption stopped at a blocker with no way out. Phase 2 now shows the values phase 1 found inlined, several where workflows disagree, and asks which ones the organisation uses; only then does it hand an owner step 5's gh variable set commands with the answers written in, and ask the admin about every inlined value that differs. Dry runs with gpt-5.6-luna and claude-haiku-5-5 at low thinking first handed over commands before the answer and stated a mismatch instead of asking it; both now wait. A test checks that the six variables the skill maps are the six that organisation-setup.md, step 5, sets. Assisted-by: Pi:claude-opus-5-5 --- skills/adopt-release-tools/SKILL.md | 18 ++++++++++++++++-- tests/test_skill.py | 10 ++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/skills/adopt-release-tools/SKILL.md b/skills/adopt-release-tools/SKILL.md index c0d8e2f..6e0f16c 100644 --- a/skills/adopt-release-tools/SKILL.md +++ b/skills/adopt-release-tools/SKILL.md @@ -75,7 +75,8 @@ the admin has replied as it says. Work through the phases in order. Each ends on a **done** check. A **blocker** is what stops the adoption until someone outside it acts: a default branch other than `main`, a pre-release tag without its final, a published version above the newest final tag, -missing `CODEARTIFACT_*` organisation variables, no admin access. Stop on a blocker, say +missing `CODEARTIFACT_*` organisation variables (phase 2 hands an owner the commands +that set them), no admin access. Stop on a blocker, say exactly what is missing and who can resolve it, and go on once it is resolved. Everything the plan does itself (sharing the bot credentials, installing the app, creating rulesets, the settings, rewriting the publish workflow for the index it @@ -160,7 +161,7 @@ up-to-date clone of the repository on its default branch. | App ID | `gh api --paginate orgs/Algorithmiq/installations --jq '.installations[] \| select(.app_slug == "algorithmiq-release-bot") \| .app_id'` | A number, for the rulesets | | App installed on the repository | not readable with a user token | The admin confirms it: Organisation settings → GitHub Apps → algorithmiq-release-bot → Configure | | Bot credentials | `gh api --paginate orgs/Algorithmiq/actions/variables/RELEASE_BOT_CLIENT_ID/repositories --jq '.repositories[].name'`; the same for `secrets/RELEASE_BOT_PRIVATE_KEY` | The repository is in both lists. Phase 4 adds it one repository at a time; `--repos` is not used, because it replaces the whole list | -| CodeArtifact variables | when any workflow uses CodeArtifact: `gh api --paginate repos/$REPO/actions/organization-variables --jq '.variables[] \| {name, value}'` | All six of `$rt/docs/organisation-setup.md`, step 5. A missing one is a blocker for an organisation owner, except `CODEARTIFACT_READ_ROLE` when no workflow assumes a read role: then note it for an owner. Each inlined value that differs from its variable's is a question: after adoption the workflows read the variable, so packages go where it points | +| CodeArtifact variables | when any workflow uses CodeArtifact: `gh api --paginate repos/$REPO/actions/organization-variables --jq '.variables[] \| {name, value}'` | All six of `$rt/docs/organisation-setup.md`, step 5. A missing one is a blocker for an organisation owner, except `CODEARTIFACT_READ_ROLE` when no workflow assumes a read role: then note it for an owner. Phase 2 hands the owner the commands that set the missing ones. Each inlined value that differs from its variable's is a question: after adoption the workflows read the variable, so packages go where it points | **Done:** every row holds what its command returned; "cannot check" only with the error or the missing credential that stopped it. The blockers are listed apart from the @@ -172,6 +173,19 @@ Send the admin one message with these headings, in this order: 1. **Facts:** the facts table. 2. **Blockers:** each one, who resolves it and how; `None` if there are none. + **Missing `CODEARTIFACT_*` variables** are set by an organisation owner, with step 5 + of `$rt/docs/organisation-setup.md`. They serve every private and internal + repository, so this message hands over no `gh variable set` yet. Show, for each + missing variable, every value phase 1 found inlined and where (several when + workflows disagree, none when no workflow holds it), and ask, as a decision: which + value does the organisation use? Under **Waiting for**, name the variables only, + with no command and no value. Once the owner has answered every variable, hand over + one `gh variable set` per variable, with the answered value written in, to the owner + only (never into the adoption PR or a report upstream: they name the AWS account and + the roles). In the same message, ask the admin about each inlined value that + differs from the answer: once the workflows read the variable, that job's packages + go elsewhere (phase 1). Phase 3 waits for that answer too. The blocker stays open + until the owner reports the commands run and the row reads at its target. 3. **Decisions requested:** numbered, each with the answer you need: - the baseline, and for `v0.0.0` its commit (the last one that should not appear in the changelog); diff --git a/tests/test_skill.py b/tests/test_skill.py index 632abfe..32adb05 100644 --- a/tests/test_skill.py +++ b/tests/test_skill.py @@ -134,3 +134,13 @@ def test_the_handed_over_example_is_the_docs_tag_ruleset() -> None: assert json.loads(example.group(1)) == json.loads( docs.group(1).replace("$APP_ID", str(app_id)) ) + + +def test_the_codeartifact_variables_are_the_ones_the_owner_sets() -> None: + # Phase 2 hands an owner step 5's commands for the variables the workflows read. + mapped = re.findall( + r"vars\.(CODEARTIFACT_\w+)", (SKILL / "publish-workflow.md").read_text() + ) + setup = (ROOT / "docs" / "organisation-setup.md").read_text(encoding="utf-8") + assert set(re.findall(r"gh variable set (CODEARTIFACT_\w+)", setup)) == set(mapped) + assert len(set(mapped)) == 6 From 54d94d245fea204908ebbdaa5e21bf360fedd361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Thu, 5 Nov 2026 12:35:54 +0100 Subject: [PATCH 12/14] =?UTF-8?q?fix(skill):=20=F0=9F=90=9B=20ask=20before?= =?UTF-8?q?=20swapping=20a=20role=20that=20serves=20more=20than=20CodeArti?= =?UTF-8?q?fact?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A role that a job also uses for something else, or whose name says it does (an S3 or a deploy role), may hold permissions the CodeArtifact read role lacks, used by code the workflow does not show. It is now a question of phase 2 and stays unless the admin says the CodeArtifact role covers the job; the phase 3 done check allows a role the admin kept. Assisted-by: Pi:claude-opus-5-5 --- skills/adopt-release-tools/SKILL.md | 13 +++++++------ skills/adopt-release-tools/publish-workflow.md | 7 +++++++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/skills/adopt-release-tools/SKILL.md b/skills/adopt-release-tools/SKILL.md index 6e0f16c..41dda63 100644 --- a/skills/adopt-release-tools/SKILL.md +++ b/skills/adopt-release-tools/SKILL.md @@ -148,7 +148,7 @@ up-to-date clone of the repository on its default branch. | Publish workflow | the workflow that uploads (`twine`, `uv publish`, `pypa/gh-action-pypi-publish`, `codeartifact`) | Variant: PyPI, CodeArtifact, both or none; its file name, its triggers; each job, what it does and its `if:` | | `release` triggers | the `on:` of every workflow; a job named `release:` is not a trigger | Every workflow listed, with what it does on a release and what else triggers it: phase 3 moves each one | | Workflow file names | `.github/workflows/release.yml`, `backport.yml`, `pr-checks.yml` | Absent, or already a release-tools stub. Any other file of that name is renamed by the adoption PR before the stub takes its place (publish-workflow.md, "Each existing job") | -| Inlined CodeArtifact values | every workflow and job: AWS account, role ARNs, domain, region, repository | Each value with its file, job and the `vars.CODEARTIFACT_*` it becomes | +| Inlined CodeArtifact values | every workflow and job: AWS account, role ARNs, domain, region, repository | Each value with its file, job and the `vars.CODEARTIFACT_*` it becomes. A role that serves more than CodeArtifact is a question (publish-workflow.md, "The CodeArtifact variables") | | Tag-push workflows at the `v0.0.0` commit | baseline `v0.0.0` only: `git show :.github/workflows/`, a `tags:` under `push:` | None that publish; otherwise the admin disables that workflow around the push of `v0.0.0` | | Old process | `release` triggers, `workflow_dispatch` routes to publishing, `.github/release.yml`, local copies of `release.py` and `cliff.toml`, docs that say to publish by creating a release on GitHub | Listed; the adoption PR removes them and points those docs to **Actions → Release** | | Files | the PR template (`.github/pull_request_template.md`, any case), `justfile`, `.github/dependabot.yml` (or `.yaml`), `CHANGELOG.md` | Present or absent | @@ -274,11 +274,12 @@ the rest in the PR description. When none runs, the PR description says (`grep -rn 'Algorithmiq/release-tools[^ ]*@' .github`); - the `on:` of every workflow holds no `release`, and `grep -rn 'github.event.release' .github` prints nothing; -- in `.github/workflows`, `grep -rnF` finds no role ARN of the facts table, and no line - of `grep -rniE 'codeartifact|--domain-owner'` holds the AWS account or the domain of - the facts table (the account can also serve other services, such as a container - registry; values such as a `pypi` repository or a region appear in the stub itself, - so the check leaves them out); +- in `.github/workflows`, `grep -rnF` finds no role ARN of the facts table that became + a variable, and no line of `grep -rniE 'codeartifact|--domain-owner'` holds the AWS + account or the domain of the facts table, except a role the admin kept (the account + can also serve other services, such as a container registry; values such as a + `pypi` repository or a region appear in the stub itself, so the check leaves them + out); - the done check of publish-workflow.md holds; - actionlint passed before the push, or the PR description says why it did not run; - the PR is open. diff --git a/skills/adopt-release-tools/publish-workflow.md b/skills/adopt-release-tools/publish-workflow.md index 8d43706..2e619e2 100644 --- a/skills/adopt-release-tools/publish-workflow.md +++ b/skills/adopt-release-tools/publish-workflow.md @@ -51,6 +51,13 @@ A role ARN maps whole to its role variable, never split into account and name. T account and region of another service (a container registry's login, say) stay as they are, even when they equal the CodeArtifact ones. +A role is a CodeArtifact value only when its job uses it for CodeArtifact alone (a +token, a login). A role that the job also uses for something else (S3, a deployment), +or whose name says it serves more (`…_S3`, `…-deploy`), may hold permissions that the +CodeArtifact role lacks, used by code the workflow does not show. Swap it for nothing +on your own: it is a question of phase 2, and it stays as it is unless the admin says +the CodeArtifact role covers that job. + An inlined value that differs from its variable's (another repository name, say) moves the packages once the workflow reads the variable: it is a question of phase 2 (SKILL.md, phase 1, "CodeArtifact variables"). Write the workflow only once the admin has answered From 760499a89a73a0e92dbbdf8629e6840a6d82cc8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Thu, 5 Nov 2026 13:03:43 +0100 Subject: [PATCH 13/14] =?UTF-8?q?docs(setup):=20=F0=9F=93=9D=20require=20t?= =?UTF-8?q?he=20PR=20checks=20in=20a=20repository=20ruleset=20only,=20and?= =?UTF-8?q?=20let=20organisation=20rulesets=20get=20the=20bypass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 8 added the PR checks to every ruleset of main, an organisation ruleset included: that would require them in every repository it targets, where they never report without the stubs, and hold every PR there. Its jq now takes the checks as an argument: a repository ruleset gets them, an organisation ruleset '[]', the bot's bypass only, read and written at orgs/. Without a repository ruleset on main, a new one requires the checks. Tests cover both cases and the new ruleset's payload. Assisted-by: Pi:claude-opus-5-5 --- docs/repository-setup.md | 34 +++++++++++++++++++------ tests/test_repository_setup.py | 45 +++++++++++++++++++++++++++++++--- 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/docs/repository-setup.md b/docs/repository-setup.md index 529e7cb..20421ea 100644 --- a/docs/repository-setup.md +++ b/docs/repository-setup.md @@ -140,11 +140,12 @@ which retargets the open PRs; everyone then updates their clones. ```bash id= - gh api repos/$REPO/rulesets/$id | jq --argjson app "$APP_ID" ' - [{"context": "PR checks / PR title", "integration_id": 15368}, - {"context": "PR checks / Migration notes", "integration_id": 15368}] as $checks - | {name, target, enforcement, conditions, - rules: (if any(.rules[]; .type == "required_status_checks") then .rules + gh api repos/$REPO/rulesets/$id | jq --argjson app "$APP_ID" --argjson checks '[ + {"context": "PR checks / PR title", "integration_id": 15368}, + {"context": "PR checks / Migration notes", "integration_id": 15368}]' ' + {name, target, enforcement, conditions, + rules: (if ($checks | length) == 0 or any(.rules[]; .type == "required_status_checks") + then .rules else .rules + [{"type": "required_status_checks", "parameters": {"strict_required_status_checks_policy": false, "required_status_checks": []}}] end | map(if .type == "required_status_checks" @@ -157,10 +158,27 @@ which retargets the open PRs; everyone then updates their clones. | gh api -X PUT repos/$REPO/rulesets/$id --input - ``` - An `Organization` ruleset changes only at `orgs/Algorithmiq/rulesets/$id`, in both - calls, by an organisation owner, and the change covers every repository it targets. + An `Organization` ruleset with such a rule gets the bypass only: the same command + with `--argjson checks '[]'`, at `orgs/Algorithmiq/rulesets/$id` in both calls, the + read too, run by an organisation owner. Never the checks: an organisation ruleset would require + them in every repository it targets, and they never report where the stubs are not, + so every PR there would wait. + + If no `Repository` ruleset applies to `main`, create one that requires the checks: + + ```bash + gh api -X POST repos/$REPO/rulesets --input - < dict[str, Any]: - """Run step 8's jq program on RULESET, as `gh api …/rulesets/$id` returns it.""" +def step_8(ruleset: dict[str, Any], *, organisation: bool = False) -> dict[str, Any]: + """Run step 8's jq program on RULESET, as `gh api …/rulesets/$id` returns it. + + A Repository ruleset gets the docs' checks, an Organization ruleset `[]`. + """ setup = (ROOT / "docs" / "repository-setup.md").read_text(encoding="utf-8") - match = re.search(r"""jq --argjson app "\$APP_ID" '(.*?)' \\\n""", setup, re.DOTALL) + match = re.search( + r"""jq --argjson app "\$APP_ID" --argjson checks '(\[.*?\])' '(.*?)' \\\n""", + setup, + re.DOTALL, + ) assert match, "step 8's jq program" + checks, program = match.groups() + assert [c["context"] for c in json.loads(checks)] == CHECKS result = subprocess.run( - ["jq", "--argjson", "app", str(APP_ID), match.group(1)], + [ + "jq", + "--argjson", + "app", + str(APP_ID), + "--argjson", + "checks", + "[]" if organisation else checks, + program, + ], input=json.dumps(ruleset), capture_output=True, text=True, @@ -107,3 +125,22 @@ def test_a_second_run_changes_nothing() -> None: ) ) assert step_8(ruleset(once["rules"], once["bypass_actors"])) == as_put(once) + + +def test_an_organisation_ruleset_gets_the_bypass_only() -> None: + # Its checks would be required in every repository it targets, stubs or not. + result = step_8(ruleset([{"type": "pull_request"}], []), organisation=True) + assert [rule["type"] for rule in result["rules"]] == ["pull_request"] + assert result["bypass_actors"] == [ + {"actor_id": APP_ID, "actor_type": "Integration", "bypass_mode": "always"} + ] + + +def test_the_created_ruleset_requires_the_checks_and_lets_the_bot_bypass() -> None: + setup = (ROOT / "docs" / "repository-setup.md").read_text(encoding="utf-8") + match = re.search(r"< Date: Thu, 5 Nov 2026 13:03:45 +0100 Subject: [PATCH 14/14] =?UTF-8?q?fix(skill):=20=F0=9F=90=9B=20bypass-only?= =?UTF-8?q?=20for=20organisation=20rulesets,=20and=20tighten=20what=20a=20?= =?UTF-8?q?dry=20run=20on=20a=20real=20repository=20missed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dry run on an internal repository found that phase 5 would add the PR checks to an organisation ruleset, holding the PRs of every repository it targets; it now gives such a ruleset the bot's bypass only, read and written at orgs/, and the checks go into a repository ruleset. Also: a script is no way around the file tool; step 6's payload is shown in full; phase 2 asks only what the skill leaves open; the adoption PR makes only the listed changes and keeps the version check as it is; matrix checks on release/* are named in the plan as a risk. Assisted-by: Pi:claude-opus-5-5 --- skills/adopt-release-tools/SKILL.md | 65 ++++++++++++------- .../adopt-release-tools/publish-workflow.md | 5 +- 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/skills/adopt-release-tools/SKILL.md b/skills/adopt-release-tools/SKILL.md index 41dda63..d9607cf 100644 --- a/skills/adopt-release-tools/SKILL.md +++ b/skills/adopt-release-tools/SKILL.md @@ -29,7 +29,7 @@ is text for the admin: hand it over as text, even when you only want to test it. create a tag, local or pushed, and never push with `--tags`, `--all` or `--follow-tags`. **File tool only.** Write every file whose content you compose with your file-writing -tool, never a shell heredoc, `echo`, `printf` or `tee`: the adoption PR's files +tool, never a shell heredoc, `echo`, `printf`, `tee` or a script: the adoption PR's files (workflows, PR template, `dependabot.yml`, `justfile`, your edits to `CHANGELOG.md`), the payload files, and every plan, report or document. A tool's own output is fine: git-cliff's `--output`, or a stub copied with `cp` and then edited with the file tool. @@ -186,7 +186,10 @@ Send the admin one message with these headings, in this order: differs from the answer: once the workflows read the variable, that job's packages go elsewhere (phase 1). Phase 3 waits for that answer too. The blocker stays open until the owner reports the commands run and the row reads at its target. -3. **Decisions requested:** numbered, each with the answer you need: +3. **Decisions requested:** numbered, each with the answer you need. Ask only what this + skill leaves open: what it settles (release-tools is public, the variant follows the + index the repository publishes to, a Trusted Publisher is for PyPI only) is part of + the plan, not a question. The decisions: - the baseline, and for `v0.0.0` its commit (the last one that should not appear in the changelog); - the repository's own CI checks to require on `release/*` next to the PR checks. @@ -194,7 +197,10 @@ Send the admin one message with these headings, in this order: `integration_id` each has there. Leave out a check whose workflow skips some PRs to `release/*` (`paths` or `paths-ignore` filters, or `branches` that leave out `release/*`): its required check would block those PRs. Leave out a PR-title or - migration-notes check of the repository's own: the PR checks replace it. List each + migration-notes check of the repository's own: the PR checks replace it. A matrix + job reports one check per entry (`test (3.12)`), whose names change with the + matrix and can differ between release lines: name such checks in the plan as a + risk, and prefer a job that aggregates them if the repository has one. List each check left out, and why; - the number of approvals on `release/*` (1 in step 6 of `$rt/docs/repository-setup.md`); @@ -225,7 +231,9 @@ blocker from phase 1 is open. ## Phase 3: The adoption PR On a new branch `ci/adopt-release-tools` off the default branch, or the branch of the -adoption PR a previous run opened: +adoption PR a previous run opened, the changes below and no others. An improvement you +notice on the way (a stricter check, a newer pin) goes in the report, for a PR of its +own: - **Renames first:** a workflow that holds the name of a stub (Workflow file names, phase 1) moves to a free name in the same PR, as publish-workflow.md, "Each existing job" @@ -314,9 +322,10 @@ its target. A ruleset whose name already exists gets a `PUT` on its id, never a 5. **The tag ruleset** (step 5): its JSON, with the App ID, in `release-tags.json` in the `mktemp -d` directory, shown in full, and step 5's `gh api -X POST` with `--input` and that file's literal absolute path, as in the example at the top. -6. **The `release/*` ruleset** (step 6): the same with `release-branches.json`, with the - App ID, the checks the admin chose (each with its `integration_id` from the default - branch's ruleset) and the approvals. +6. **The `release/*` ruleset** (step 6): its JSON, with the App ID, the checks the + admin chose (each with its `integration_id` from the default branch's ruleset, or + `15368` for a GitHub Actions check) and the approvals, in `release-branches.json` in + the same directory, shown in full like step 5's, and step 6's `gh api -X POST`. 7. **The package index** (step 7): PyPI, the Trusted Publisher (web UI), with the owner, repository, workflow file name and environment of the publish workflow written in; for a project already on PyPI, the admin checks that its publisher matches them @@ -330,28 +339,36 @@ confirmed the app installation. The admin squash-merges the adoption PR. **Stop** until they report it merged. Then step 8 of `$rt/docs/repository-setup.md`, on the default branch's rulesets from -phase 1, whatever their name: the default branch requires `PR checks / PR title` and -`PR checks / Migration notes`, and the release bot bypasses every one of those rulesets -with a `pull_request` or `required_status_checks` rule, without which every changelog -PR waits for an approval. Hand over step 8's second command once per ruleset, adapted: - -- the ruleset's id written in place of ``, and the App ID in - `--argjson app`; -- an organisation ruleset (`source_type` `Organization`) reads and writes - `orgs/Algorithmiq/rulesets/` instead of `repos/…`, and goes to an organisation - owner, saying that it covers every repository it targets; +phase 1, whatever their name, that have a `pull_request` or `required_status_checks` +rule. A repository ruleset requires `PR checks / PR title` and +`PR checks / Migration notes` on the default branch, and the release bot bypasses every +one of those rulesets, without which every changelog PR waits for an approval. Hand +over step 8's command once per ruleset, with its id written in place of +`` and the App ID in `--argjson app`: + +- a repository ruleset (`source_type` `Repository`) as step 8 has it, with the checks; +- an organisation ruleset (`source_type` `Organization`) gets the bypass only: + `--argjson checks '[]'`, and `orgs/Algorithmiq/rulesets/` instead of `repos/…` + in both calls, the read too, so that the `PUT` starts from the ruleset as the + organisation holds it. It goes to an organisation owner, saying that the bot's + bypass then covers every repository the ruleset targets. Never the checks: the + ruleset would require them in repositories without the PR-checks stub, where they + never report, and block every PR there; +- with no repository ruleset on the default branch, step 8's `PR checks` ruleset, its + JSON in a file as in phase 4, item 5; - if a ruleset required other checks for PR titles or migration notes, the same jq removes them (`$rt/docs/caveats.md`, check names). In an older release, step 8 finds the ruleset by name (`id=$(…)`) and appends without looking: write the id in place of that line, leave out of the jq a check or the bypass -actor the ruleset already holds, and give a ruleset without a `required_status_checks` -rule one first. - -**Done:** `gh api repos/$REPO/rulesets/` (`orgs/Algorithmiq/rulesets/` for an -organisation ruleset) shows both checks required on the default branch by a ruleset -with `enforcement` `active`, and the App ID among the bypass actors of each of those -rulesets. +actor the ruleset already holds, give a repository ruleset without a +`required_status_checks` rule one first, and give an organisation ruleset the bypass +only. + +**Done:** `gh api repos/$REPO/rulesets/` shows both checks required on the default +branch by a repository ruleset with `enforcement` `active`, and the App ID is among the +bypass actors of each of those rulesets (`orgs/Algorithmiq/rulesets/` for an +organisation one). ## Phase 6: The first release, and the report diff --git a/skills/adopt-release-tools/publish-workflow.md b/skills/adopt-release-tools/publish-workflow.md index 2e619e2..2faddef 100644 --- a/skills/adopt-release-tools/publish-workflow.md +++ b/skills/adopt-release-tools/publish-workflow.md @@ -71,8 +71,9 @@ Place every job of the existing workflow: to attach is uploaded as an artifact; `draft_release` and the upload download them (`pattern` and `merge-multiple: true` for several artifacts), and every glob of `draft-release`'s `files` matches. -- **The version check stays,** reading the tag from `GITHUB_REF_NAME` instead of - `github.event.release`. With no check in the repository, add the stub's. When a +- **The version check stays as it is,** reading the tag from `GITHUB_REF_NAME` instead + of `github.event.release`: extending it (to the sdist, say) is a PR of its own. With + no check in the repository, add the stub's. When a `VERSION` file is kept in step with the tags, the bot now writes it (`version-files`), and the check compares it with the tag. - **The upload** becomes the variant's upload job, as the stub has it: its `twine` or