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..3c0aa9c 100644 --- a/docs/adopting.md +++ b/docs/adopting.md @@ -7,11 +7,28 @@ 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 `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/developing.md b/docs/developing.md index 81e37a9..9d62bb7 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -24,6 +24,12 @@ 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 + 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/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..20421ea 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,65 @@ 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, each only if it is missing: 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" --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" + then .parameters.required_status_checks |= (. as $have + | . + [$checks[] | select(.context as $c | all($have[]; .context != $c))]) + else . end)), + 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 - ``` - This assumes a `main` ruleset with a `required_status_checks` rule; create one first - if there is none. + 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 - <`, 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`, `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. +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 +pre-release tag without its final, a published version above the newest final tag, +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 +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. A +named tag still goes through the major check below. + +```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: 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`), 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** 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: 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 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`, 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, 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. 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 | +| 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], 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, 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 +to-dos. + +## Phase 2: Plan + +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. 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. + 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. 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`); + - 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, and run for pre-release + tags unless the plan says otherwise. + +End the message with: "Answer each numbered decision, then reply `Plan approved`." +**Stop.** + +**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 `ci/adopt-release-tools` off the default branch, or the branch of the +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" + says. +- **The three stubs** from `$rt/examples/stubs/` (`release.yml`, `backport.yml`, + `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`:** 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. 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` 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 + 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`, 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 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 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. + +Tell the admin the PR is open and that phase 4 comes before its merge. + +## Phase 4: The admin's steps before the merge + +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, with `REPO=Algorithmiq/` written in on the first line: + + ```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: 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, as in the example at the top. +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 + (a renamed file breaks it). CodeArtifact and no package, nothing. + +**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. **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, 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, 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 + +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: + +- 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; +- 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 new file mode 100644 index 0000000..2faddef --- /dev/null +++ b/skills/adopt-release-tools/publish-workflow.md @@ -0,0 +1,122 @@ +# 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" | attests the files, assumes `vars.CODEARTIFACT_WRITE_ROLE`, runs `uv publish` | +| 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, 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 + +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`) | `vars.CODEARTIFACT_DOMAIN_OWNER` | +| `--domain` | `vars.CODEARTIFACT_DOMAIN` | +| `--region`, `aws-region` | `vars.CODEARTIFACT_REGION` | +| `--repository` | `vars.CODEARTIFACT_REPOSITORY` | +| 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. The +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 +it, and record the answer in the PR description. + +## 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 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 + `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) 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** (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`. 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. + +**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, or the PR description says why it did not run (SKILL.md, phase 3). diff --git a/tests/test_repository_setup.py b/tests/test_repository_setup.py new file mode 100644 index 0000000..73f3723 --- /dev/null +++ b/tests/test_repository_setup.py @@ -0,0 +1,146 @@ +# 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], *, 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" --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), + "--argjson", + "checks", + "[]" if organisation else checks, + program, + ], + 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) + + +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"< 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()) + + +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 - < 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