From 8ed64d111a0e3c8b14af4e1e2813f10c542b610e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Thu, 5 Nov 2026 15:05:05 +0100 Subject: [PATCH 1/4] =?UTF-8?q?fix(hooks):=20=F0=9F=90=9B=20report=20quote?= =?UTF-8?q?d=20paths=20as=20named,=20and=20tell=20an=20empty=20listing=20f?= =?UTF-8?q?rom=20no=20access?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A path git quotes (a double quote, a backslash, a control character) is unquoted, non-ASCII paths are no longer escaped, and the tab git appends to a path with a space in a diff header is dropped. - Output of gh that is not the expected listing skips the check, like a gh that fails, instead of crashing the hook. - When gh lists no non-public repositories the hook says so, rather than that it was skipped. Refs #31 Assisted-by: Pi:claude-opus-5-5 --- scripts/check_internal_names.py | 67 +++++++++++++++++++++++++----- tests/test_check_internal_names.py | 39 +++++++++++++++++ 2 files changed, 95 insertions(+), 11 deletions(-) diff --git a/scripts/check_internal_names.py b/scripts/check_internal_names.py index 6d6bf44..f607771 100644 --- a/scripts/check_internal_names.py +++ b/scripts/check_internal_names.py @@ -48,8 +48,8 @@ def git(*args: str) -> str: ).stdout -def non_public_repositories() -> set[str]: - """Return the organisation's non-public repositories; none if gh can't list them.""" +def non_public_repositories() -> set[str] | None: + """Return the organisation's non-public repositories; None if gh can't list them.""" try: listing = subprocess.run( [ @@ -66,11 +66,13 @@ def non_public_repositories() -> set[str]: capture_output=True, text=True, ).stdout - except (OSError, subprocess.CalledProcessError): - return set() - return { - repo["name"] for repo in json.loads(listing) if repo["visibility"] != "PUBLIC" - } + return { + repo["name"] + for repo in json.loads(listing) + if repo["visibility"] != "PUBLIC" + } + except (OSError, subprocess.CalledProcessError, ValueError, KeyError, TypeError): + return None def allowed_names() -> set[str]: @@ -92,8 +94,11 @@ def pushed_range() -> str: # Each commit is diffed against its first parent, merges included (a conflict # resolution can add lines of its own), rather than the range as a whole, where what one # commit adds and a later one removes cancels out. %x00 starts the line of each commit: -# no line of a diff or of --name-only starts with NUL. +# no line of a diff or of --name-only starts with NUL. Paths are written out as they +# are, except those git still quotes (see unquote). LOG_PER_COMMIT = ( + "-c", + "core.quotePath=false", "log", "--format=%x00%H", "--diff-merges=first-parent", @@ -105,6 +110,36 @@ def pushed_range() -> str: ) +# An escape in a path git quotes: three octal digits, a letter, a quote or a backslash. +GIT_ESCAPE = re.compile(rb"\\([0-7]{3}|.)") +GIT_ESCAPES = { + b"a": b"\a", + b"b": b"\b", + b"t": b"\t", + b"n": b"\n", + b"v": b"\v", + b"f": b"\f", + b"r": b"\r", +} + + +def unquote(path: str) -> str: + """Return PATH as it is named, when git wrote it quoted. + + With core.quotePath=false git still quotes a path that holds a double quote, a + backslash or a control character: in double quotes, with C-style escapes. + """ + quoted = re.fullmatch(r'"(.+)"', path) + if not quoted: + return path + + def unescape(match: re.Match[bytes]) -> bytes: + code = match[1] + return bytes([int(code, 8)]) if code.isdigit() else GIT_ESCAPES.get(code, code) + + return GIT_ESCAPE.sub(unescape, quoted[1].encode()).decode(errors="replace") + + def added_lines(revisions: str) -> list[tuple[str, str]]: """Return (where, line) for every line a commit of REVISIONS adds.""" found: list[tuple[str, str]] = [] @@ -119,8 +154,10 @@ def added_lines(revisions: str) -> list[tuple[str, str]]: in_header = False elif in_header: # Only here: an added line "++ x" also reads "+++ x" in the hunk. + # A path with a space ends in a tab there, for patch(1). if line.startswith("+++ "): - path = line.removeprefix("+++ ").removeprefix("b/") + name = line.removeprefix("+++ ").removesuffix("\t") + path = unquote(name).removeprefix("b/") elif line.startswith("+"): found.append((f"commit {commit} {path}", line[1:])) return found @@ -134,7 +171,8 @@ def changed_paths(revisions: str) -> list[tuple[str, str]]: if line.startswith("\0"): commit = line[1:11] elif line: - found.append((f"commit {commit} {line}", line)) + path = unquote(line) + found.append((f"commit {commit} {path}", path)) return found @@ -163,13 +201,20 @@ def leaks(texts: list[tuple[str, str]], names: set[str]) -> list[str]: def main() -> int: """Check the push; return the exit status.""" names = non_public_repositories() - if not names: + if names is None: print( "check-internal-names: gh can't list the organisation's non-public " "repositories; skipped.", file=sys.stderr, ) return 0 + if not names: + print( + "check-internal-names: gh lists no non-public repositories of the " + "organisation; nothing to check.", + file=sys.stderr, + ) + return 0 allowed = allowed_names() found = leaks( pushed_text(pushed_range()), {n for n in names if n.lower() not in allowed} diff --git a/tests/test_check_internal_names.py b/tests/test_check_internal_names.py index 6bf8d33..442da97 100644 --- a/tests/test_check_internal_names.py +++ b/tests/test_check_internal_names.py @@ -136,6 +136,21 @@ def test_a_line_only_a_merge_adds_is_refused(repo: Repo) -> None: assert f"commit {merge[:10]} notes.md: secret-solver" in result.stderr +@pytest.mark.usefixtures("gh") +@pytest.mark.parametrize( + "name", + ["a secret-solver note.md", 'a "secret-solver" note.md', "ünï secret-solver.md"], +) +def test_paths_git_quotes_are_reported_as_named(repo: Repo, name: str) -> None: + base = repo.commit("chore: start") + added = repo.commit("docs: notes", {name: "See lab.\n"})[:10] + result = check(repo, base) + assert result.returncode == 1 + # The path from --name-only, and the path of the added line from the patch. + assert f"commit {added} {name}: secret-solver\n" in result.stderr + assert f"commit {added} {name}: lab\n" in result.stderr + + @pytest.mark.usefixtures("gh") def test_an_added_line_that_looks_like_a_diff_header_is_checked(repo: Repo) -> None: base = repo.commit("chore: start") @@ -181,3 +196,27 @@ def test_without_access_to_the_organisation_the_check_is_skipped( result = check(repo, base) assert result.returncode == 0 assert "skipped" in result.stderr + + +def test_gh_output_that_is_not_the_listing_skips_the_check( + repo: Repo, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + fake_gh(tmp_path, monkeypatch, "echo 'A new release of gh is available'") + base = repo.commit("chore: start") + result = check(repo, base) + assert result.returncode == 0 + assert "skipped" in result.stderr + assert "Traceback" not in result.stderr + + +def test_no_non_public_repositories_is_not_reported_as_skipped( + repo: Repo, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + listing = tmp_path / "repositories.json" + listing.write_text(json.dumps(REPOSITORIES[2:]), encoding="utf-8") + fake_gh(tmp_path, monkeypatch, f'cat "{listing}"') + base = repo.commit("chore: start") + result = check(repo, base) + assert result.returncode == 0 + assert "skipped" not in result.stderr + assert "nothing to check" in result.stderr From 6354056291046848b6920852b14fccb54a76bf84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Thu, 5 Nov 2026 15:05:10 +0100 Subject: [PATCH 2/4] =?UTF-8?q?test:=20=E2=9C=85=20read=20each=20tracked?= =?UTF-8?q?=20file=20once,=20and=20skip=20those=20deleted=20in=20the=20wor?= =?UTF-8?q?king=20tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #31 Assisted-by: Pi:claude-opus-5-5 --- tests/test_leaks.py | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/tests/test_leaks.py b/tests/test_leaks.py index e2b785b..f764791 100644 --- a/tests/test_leaks.py +++ b/tests/test_leaks.py @@ -24,14 +24,10 @@ import re import subprocess -from typing import TYPE_CHECKING import pytest from conftest import ROOT -if TYPE_CHECKING: - from pathlib import Path - # A 12-digit AWS account ID on its own, an ARN, or an AWS endpoint. # Written so that this file doesn't match itself. AWS_IDENTIFIERS = re.compile(r"(? list[Path]: - """Return the files git tracks here, except the lock file.""" +@pytest.fixture(scope="module") +def tracked_texts() -> dict[str, str]: + """Return the text of each file git tracks here, by path, except the lock file. + + A tracked file deleted in the working tree, and not yet staged, is left out. + """ result = subprocess.run( ["git", "ls-files", "-z"], cwd=ROOT, capture_output=True, text=True, check=True ) - return [ - ROOT / name for name in result.stdout.split("\0") if name not in {"", "uv.lock"} - ] + return { + name: (ROOT / name).read_text(encoding="utf-8", errors="replace") + for name in result.stdout.split("\0") + if name not in {"", "uv.lock"} and (ROOT / name).is_file() + } def referenced_repositories(text: str) -> set[str]: @@ -97,25 +99,23 @@ def test_repository_references() -> None: assert referenced_repositories(text) == {"secret-solver", "lab", "release-tools"} -def test_no_tracked_file_has_an_aws_identifier() -> None: +def test_no_tracked_file_has_an_aws_identifier(tracked_texts: dict[str, str]) -> None: found = [ - f"{path.relative_to(ROOT)}:{number}" - for path in tracked_files() - for number, line in enumerate( - path.read_text(encoding="utf-8", errors="replace").splitlines(), start=1 - ) + f"{path}:{number}" + for path, text in tracked_texts.items() + for number, line in enumerate(text.splitlines(), start=1) if AWS_IDENTIFIERS.search(line) ] assert found == [] -def test_tracked_files_reference_only_public_repositories() -> None: +def test_tracked_files_reference_only_public_repositories( + tracked_texts: dict[str, str], +) -> None: found = { - f"{path.relative_to(ROOT)}: Algorithmiq/{name}" - for path in tracked_files() - for name in referenced_repositories( - path.read_text(encoding="utf-8", errors="replace") - ) + f"{path}: Algorithmiq/{name}" + for path, text in tracked_texts.items() + for name in referenced_repositories(text) if name not in PUBLIC_REPOSITORIES } assert found == set() From ca18ee21dd771124fd37a6bff58fe216d13f5d59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Thu, 5 Nov 2026 15:05:12 +0100 Subject: [PATCH 3/4] =?UTF-8?q?docs(setup):=20=F0=9F=93=9D=20warn=20about?= =?UTF-8?q?=20tag-push=20workflows=20for=20every=20tag,=20and=20clean=20up?= =?UTF-8?q?=20after=20the=20history=20snippet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The warning about workflows that publish on tag pushes covers tagging a published version too, not only v0.0.0. - The snippet that generates the sections before the baseline runs in a subshell that removes its temporary directory and the worktree even when git-cliff fails. Refs #31 Assisted-by: Pi:claude-opus-5-5 --- docs/repository-setup.md | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/docs/repository-setup.md b/docs/repository-setup.md index 20421ea..8825b87 100644 --- a/docs/repository-setup.md +++ b/docs/repository-setup.md @@ -30,9 +30,7 @@ which retargets the open PRs; everyone then updates their clones. `vX.Y.Z` first. - **A repository with no final tags and nothing published** tags `v0.0.0` on the last commit that should not appear in the changelog. The first release is then `0.1.0` - (or `1.0.0` when started with **major**). Pushing a tag runs the workflows of the - tagged commit: if one of them publishes on tag pushes, disable it while you push - (`gh workflow disable`, then `gh workflow enable`). + (or `1.0.0` when started with **major**). ```bash git tag -a v0.0.0 -m "Start of the changelog" @@ -40,20 +38,28 @@ which retargets the open PRs; everyone then updates their clones. just changelog # what the first release will contain ``` + Pushing a tag, `v0.0.0` or a published version's, runs the workflows of the tagged + commit: if one of them publishes on tag pushes, disable it while you push + (`gh workflow disable`, then `gh workflow enable`). + **The releases before the baseline** belong in `CHANGELOG.md` too. Generate their sections in the PR that adds the stubs (step 4), with the `cliff.toml` of the release-tools tag the stubs pin, from a worktree at the baseline tag so that later commits stay out. The command replaces an existing `CHANGELOG.md`: keep or merge a hand-written one first. + The subshell removes the worktree and the temporary directory however it ends: + ```bash - tmp=$(mktemp -d) - git worktree add "$tmp/base" vX.Y.Z # the baseline tag - gh api -H "Accept: application/vnd.github.raw" \ - "repos/Algorithmiq/release-tools/contents/cliff.toml?ref=" > "$tmp/cliff.toml" - GITHUB_REPOSITORY=$REPO uvx --from git-cliff==2.14.2 git-cliff \ - --config "$tmp/cliff.toml" --repository "$tmp/base" --output CHANGELOG.md - git worktree remove "$tmp/base" + ( + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"; git worktree prune' EXIT + git worktree add "$tmp/base" vX.Y.Z # the baseline tag + gh api -H "Accept: application/vnd.github.raw" \ + "repos/Algorithmiq/release-tools/contents/cliff.toml?ref=" > "$tmp/cliff.toml" + GITHUB_REPOSITORY=$REPO uvx --from git-cliff==2.14.2 git-cliff \ + --config "$tmp/cliff.toml" --repository "$tmp/base" --output CHANGELOG.md + ) ``` Each final tag gets a `## X.Y.Z (date)` section; pre-release tags are skipped. Titles From e2ebad455b76ae93fb666c7f037e0d461075b2fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Thu, 5 Nov 2026 15:05:13 +0100 Subject: [PATCH 4/4] =?UTF-8?q?fix(skill):=20=F0=9F=90=9B=20close=20the=20?= =?UTF-8?q?small=20gaps=20the=20reviews=20of=20the=20skill=20found?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Read every release, not the first 30. - Compare each inlined CodeArtifact value with its variable in the facts table already. - Widen the old-process docs to any manual step, and the tag-push check to tagging a published version. - Leave rulesets with enforcement disabled or evaluate out of step 8, and give an organisation ruleset handed to an owner a done state. - Name branches-ignore for the release/* checks, set REPO in the handed-over credentials command, count command substitution as a heredoc, and end every report with the commands that were not reads. Refs #31 Assisted-by: Pi:claude-opus-5-5 --- skills/adopt-release-tools/SKILL.md | 52 ++++++++++++++++++----------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/skills/adopt-release-tools/SKILL.md b/skills/adopt-release-tools/SKILL.md index d9607cf..038fa01 100644 --- a/skills/adopt-release-tools/SKILL.md +++ b/skills/adopt-release-tools/SKILL.md @@ -27,13 +27,18 @@ 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`. +Every report you write, the one of phase 6 and one that ends the adoption early (on a +blocker, or because the admin stops it), ends with "Commands run that were not reads, +beyond the adoption branch and its PR: none", or their list. **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. +the payload files, and every plan, report or document. A heredoc inside a command +substitution (`$(cat <<'EOF' …)`, as an argument of `--body` or `-m`, say) is a heredoc +too: write the text to a file, and pass the file (`--body-file`, `-F`). 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`, @@ -143,19 +148,19 @@ up-to-date clone of the repository on its default branch. | 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 | +| Published versions | `gh release list -R $REPO --limit 1000` (30 without `--limit`), 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, with any tag-push workflow that publishes disabled around the push (Tag-push workflows row). 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** | +| Inlined CodeArtifact values | every workflow and job: AWS account, role ARNs, domain, region, repository | Each value with its file, job, the `vars.CODEARTIFACT_*` it becomes, and whether it equals that variable's value (the CodeArtifact variables row): one that differs is a question. A role that serves more than CodeArtifact is a question (publish-workflow.md, "The CodeArtifact variables") | +| Tag-push workflows at a commit the admin tags | `v0.0.0`, or the commit of a published version above the newest final tag: `git show :.github/workflows/`, a `tags:` under `push:` | None that publish; otherwise the admin disables that workflow around the push of the tag | +| Old process | `release` triggers, `workflow_dispatch` routes to publishing, `.github/release.yml`, local copies of `release.py` and `cliff.toml`, docs (README, `CONTRIBUTING.md`, `docs/`) that describe any step of the old process: creating a release on GitHub, bumping the version by hand, tagging by hand, a forced upload, editing the changelog at release time | 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` | +| 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`. A ruleset with `enforcement` `disabled` or `evaluate` enforces nothing: list it, and leave it out of phase 5 | | 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 | @@ -193,11 +198,12 @@ Send the admin one message with these headings, in this order: - 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 + Start from those the default branch's active 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 + `release/*` (`paths` or `paths-ignore` filters, `branches` that leave out + `release/*`, or `branches-ignore` that matches it): 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 @@ -304,9 +310,11 @@ its target. A ruleset whose name already exists gets a `PUT` on its id, never a `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: + one by one, with this repository written in on the first line (`example` here), so + that the command does not rely on the admin's shell: ```bash + REPO=Algorithmiq/example 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:?}" @@ -340,7 +348,9 @@ 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 +rule and `enforcement` `active`. Leave a `disabled` or `evaluate` one as it is, and say +so in the report: it requires nothing today, and if someone activates it, it needs the +bot's bypass. 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 @@ -354,8 +364,8 @@ over step 8's command once per ruleset, with its id written in place of 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; +- with no active 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). @@ -367,8 +377,11 @@ 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). +bypass actors of each of those rulesets. An organisation ruleset is done when +`gh api orgs/Algorithmiq/rulesets/` lists the App ID among its bypass actors; until +the owner has run its command, it is under **Waiting for** in the report, with the +command and the owner it went to, and phase 6 goes ahead: the changelog PR then waits +for an approval. ## Phase 6: The first release, and the report @@ -388,8 +401,7 @@ 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: "Commands run that were not reads, beyond the adoption branch and its PR: none", - or their list. +- last, the line on commands run that were not reads (see "What you run"). **Done:** the report is written. The adoption is complete when its waiting list is empty.