From 1d0e47a868c65bab2a654e705c4cd632b3778858 Mon Sep 17 00:00:00 2001 From: ChrisRackauckas-Claude Date: Fri, 10 Jul 2026 03:23:40 -0400 Subject: [PATCH 1/2] ci: route monorepo TagBot to registered package Co-Authored-By: Chris Rackauckas --- .../resolve-monorepo-tagbot/action.yml | 40 ++++ .../resolve-monorepo-tagbot/resolve.py | 193 ++++++++++++++++++ .github/workflows/ci.yml | 8 + .github/workflows/monorepo-tagbot.yml | 55 +++++ .github/workflows/tagbot.yml | 8 +- Monorepo.md | 58 ++---- README.md | 50 +++-- test/test_resolve_monorepo_tagbot.py | 129 ++++++++++++ 8 files changed, 485 insertions(+), 56 deletions(-) create mode 100644 .github/actions/resolve-monorepo-tagbot/action.yml create mode 100644 .github/actions/resolve-monorepo-tagbot/resolve.py create mode 100644 .github/workflows/monorepo-tagbot.yml create mode 100644 test/test_resolve_monorepo_tagbot.py diff --git a/.github/actions/resolve-monorepo-tagbot/action.yml b/.github/actions/resolve-monorepo-tagbot/action.yml new file mode 100644 index 0000000..694788f --- /dev/null +++ b/.github/actions/resolve-monorepo-tagbot/action.yml @@ -0,0 +1,40 @@ +name: "Resolve monorepo TagBot target" +description: "Route a JuliaTagBot registry notification to the matching package in a Julia monorepo" + +inputs: + token: + description: "GitHub token used to read the referenced General registry pull request" + required: true + package: + description: "Package name for a manual targeted run; empty runs a full audit" + default: "" + required: false + comment-body: + description: "JuliaTagBot issue comment body" + default: "" + required: false + +outputs: + subdirs: + description: "JSON array of package subdirectories; the root package is represented by an empty string" + value: "${{ steps.resolve.outputs.subdirs }}" + mode: + description: "Resolution mode: registry-pr, manual, or full-audit" + value: "${{ steps.resolve.outputs.mode }}" + package: + description: "Resolved package name for a targeted run" + value: "${{ steps.resolve.outputs.package }}" + version: + description: "Registered version parsed from the General pull request" + value: "${{ steps.resolve.outputs.version }}" + +runs: + using: "composite" + steps: + - id: resolve + shell: bash + env: + TAGBOT_COMMENT_BODY: "${{ inputs.comment-body }}" + TAGBOT_MANUAL_PACKAGE: "${{ inputs.package }}" + TAGBOT_GITHUB_TOKEN: "${{ inputs.token }}" + run: python3 "$GITHUB_ACTION_PATH/resolve.py" diff --git a/.github/actions/resolve-monorepo-tagbot/resolve.py b/.github/actions/resolve-monorepo-tagbot/resolve.py new file mode 100644 index 0000000..5396ebb --- /dev/null +++ b/.github/actions/resolve-monorepo-tagbot/resolve.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 + +import json +import os +import re +import sys +import urllib.error +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + + +GENERAL_PR_PATTERN = re.compile( + r"https://github\.com/JuliaRegistries/General/pull/(?P[0-9]+)" +) +VERSION_TITLE_PATTERN = re.compile( + r"^New version: (?P[^ ]+) v(?P[^ ]+)$" +) +PROJECT_NAME_PATTERN = re.compile( + r"^\s*name\s*=\s*(?P['\"])(?P[A-Za-z][A-Za-z0-9_]*)" + r"(?P=quote)\s*(?:#.*)?$" +) + + +class ResolutionError(RuntimeError): + pass + + +@dataclass(frozen=True) +class Resolution: + subdirs: list[str] + mode: str + package: str = "" + version: str = "" + + +def discover_packages(workspace: Path) -> dict[str, str]: + projects = [workspace / "Project.toml"] + lib = workspace / "lib" + if lib.is_dir(): + projects.extend(sorted(lib.glob("*/Project.toml"))) + + packages: dict[str, str] = {} + for project in projects: + if not project.is_file(): + continue + name = None + for line in project.read_text(encoding="utf-8").splitlines(): + if line.lstrip().startswith("["): + break + match = PROJECT_NAME_PATTERN.fullmatch(line) + if match is not None: + name = match.group("name") + break + if name is None: + continue + subdir = ( + "" + if project.parent == workspace + else project.parent.relative_to(workspace).as_posix() + ) + if name in packages: + raise ResolutionError(f"duplicate package name {name!r} in the monorepo") + packages[name] = subdir + + if not packages: + raise ResolutionError( + "no Julia packages were found at the repository root or under lib/*" + ) + return packages + + +def general_pr_number(comment_body: str) -> int | None: + matches = { + int(match.group("number")) + for match in GENERAL_PR_PATTERN.finditer(comment_body) + } + if not matches: + return None + if len(matches) != 1: + raise ResolutionError( + "the JuliaTagBot comment references multiple General pull requests" + ) + return matches.pop() + + +def package_version(title: str) -> tuple[str, str]: + match = VERSION_TITLE_PATTERN.fullmatch(title) + if match is None: + raise ResolutionError(f"unexpected General pull request title: {title!r}") + return match.group("package"), match.group("version") + + +def fetch_general_pr(number: int, token: str) -> dict[str, object]: + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "SciML-monorepo-TagBot", + "X-GitHub-Api-Version": "2022-11-28", + } + if token: + headers["Authorization"] = f"Bearer {token}" + request = urllib.request.Request( + f"https://api.github.com/repos/JuliaRegistries/General/pulls/{number}", + headers=headers, + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + return json.load(response) + except urllib.error.HTTPError as error: + raise ResolutionError( + f"General pull request {number} lookup failed with HTTP {error.code}" + ) from error + except urllib.error.URLError as error: + raise ResolutionError( + f"General pull request {number} lookup failed: {error.reason}" + ) from error + + +def resolve( + workspace: Path, + comment_body: str, + manual_package: str, + token: str, + fetch_pr: Callable[[int, str], dict[str, object]] = fetch_general_pr, +) -> Resolution: + packages = discover_packages(workspace) + + if manual_package: + if manual_package not in packages: + raise ResolutionError( + f"manual package {manual_package!r} was not found in the monorepo" + ) + return Resolution([packages[manual_package]], "manual", manual_package) + + pr_number = general_pr_number(comment_body) + if pr_number is None: + ordered = sorted(packages.items(), key=lambda item: (item[1] != "", item[1])) + return Resolution([subdir for _, subdir in ordered], "full-audit") + + pull_request = fetch_pr(pr_number, token) + title = pull_request.get("title") + if not isinstance(title, str): + raise ResolutionError(f"General pull request {pr_number} has no title") + package, version = package_version(title) + if package not in packages: + raise ResolutionError( + f"registered package {package!r} from General pull request {pr_number} " + "was not found in the monorepo" + ) + return Resolution([packages[package]], "registry-pr", package, version) + + +def write_output(name: str, value: str) -> None: + with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as stream: + stream.write(f"{name}={value}\n") + + +def write_summary(result: Resolution) -> None: + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary: + return + if result.mode == "full-audit": + detail = f"full audit of {len(result.subdirs)} packages" + elif result.version: + detail = f"{result.package} v{result.version}" + else: + detail = result.package + with Path(summary).open("a", encoding="utf-8") as stream: + stream.write(f"Resolved monorepo TagBot target: {detail} ({result.mode}).\n") + + +def main() -> int: + try: + result = resolve( + Path(os.environ.get("GITHUB_WORKSPACE", ".")).resolve(), + os.environ.get("TAGBOT_COMMENT_BODY", ""), + os.environ.get("TAGBOT_MANUAL_PACKAGE", "").strip(), + os.environ.get("TAGBOT_GITHUB_TOKEN", ""), + ) + write_output("subdirs", json.dumps(result.subdirs, separators=(",", ":"))) + write_output("mode", result.mode) + write_output("package", result.package) + write_output("version", result.version) + write_summary(result) + return 0 + except (OSError, ResolutionError) as error: + print(f"error: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92b2c54..2578662 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,3 +44,11 @@ jobs: - uses: julia-actions/cache@v3 - name: "Run detection-script tests" run: julia --color=yes test/runtests.jl + + tagbot-resolver-tests: + name: "monorepo TagBot resolver tests" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: "Run resolver tests" + run: python3 -m unittest discover -s test -p 'test_resolve_monorepo_tagbot.py' -v diff --git a/.github/workflows/monorepo-tagbot.yml b/.github/workflows/monorepo-tagbot.yml new file mode 100644 index 0000000..6cb4023 --- /dev/null +++ b/.github/workflows/monorepo-tagbot.yml @@ -0,0 +1,55 @@ +name: "Reusable Monorepo TagBot Workflow" + +on: + workflow_call: + inputs: + package: + description: "Package name for a manual targeted run; empty runs a full audit. Ignored for JuliaTagBot registry notifications." + default: "" + required: false + type: string + +permissions: + actions: read + checks: read + contents: write + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + security-events: read + statuses: read + +jobs: + resolve: + if: github.event_name == 'workflow_dispatch' || github.actor == 'JuliaTagBot' + runs-on: ubuntu-latest + outputs: + subdirs: "${{ steps.target.outputs.subdirs }}" + steps: + - uses: actions/checkout@v7 + - id: target + uses: SciML/.github/.github/actions/resolve-monorepo-tagbot@v1 + with: + token: "${{ secrets.GITHUB_TOKEN }}" + package: "${{ github.event_name == 'workflow_dispatch' && inputs.package || '' }}" + comment-body: "${{ github.event.comment.body }}" + + tagbot: + needs: resolve + if: needs.resolve.outputs.subdirs != '' && needs.resolve.outputs.subdirs != '[]' + strategy: + fail-fast: false + max-parallel: 1 + matrix: + subdir: "${{ fromJSON(needs.resolve.outputs.subdirs) }}" + concurrency: + group: "tagbot-${{ github.repository }}-${{ matrix.subdir != '' && matrix.subdir || 'root' }}" + cancel-in-progress: false + uses: SciML/.github/.github/workflows/tagbot.yml@v1 + with: + subdir: "${{ matrix.subdir }}" + secrets: inherit diff --git a/.github/workflows/tagbot.yml b/.github/workflows/tagbot.yml index e5ac361..5ee24ef 100644 --- a/.github/workflows/tagbot.yml +++ b/.github/workflows/tagbot.yml @@ -2,9 +2,8 @@ name: "Reusable TagBot Workflow" # Creates GitHub releases/tags when a package is registered, via # JuliaRegistries/TagBot. Tags one package: the root, or a monorepo -# sublibrary when `subdir` is set. The caller keeps the issue_comment / -# workflow_dispatch triggers (TagBot is driven by the registrator comment); -# a monorepo caller matrixes over its lib/* and calls this once per subdir. +# sublibrary when `subdir` is set. Single-package repos call this workflow +# directly; monorepos use monorepo-tagbot.yml to route each registry notification. on: workflow_call: @@ -15,7 +14,7 @@ on: required: false type: string lookback: - description: "TagBot lookback window in days (used on manual workflow_dispatch runs)." + description: "Deprecated and ignored; retained for v1 caller compatibility." default: "3" required: false type: string @@ -45,4 +44,3 @@ jobs: token: "${{ secrets.GITHUB_TOKEN }}" ssh: "${{ secrets.DOCUMENTER_KEY }}" subdir: "${{ inputs.subdir }}" - lookback: "${{ inputs.lookback }}" diff --git a/Monorepo.md b/Monorepo.md index 8042bd2..c708bee 100644 --- a/Monorepo.md +++ b/Monorepo.md @@ -31,9 +31,9 @@ the target conventions below, not the transitional state. The in-flight work: matrices move to the standard sets in §5 (`["lts","1","pre"]` for base/`Core` groups; `["lts","1"]` for `QA`; `["1"]` for `GPU`). OrdinaryDiffEq is being updated to these. -- **The fleet TagBot thin-caller conversion** — every repo's `TagBot.yml` - becomes a thin caller of `tagbot.yml@v1` (root + a subpackages matrix for - monorepos), replacing the inlined `JuliaRegistries/TagBot@v1` steps (§6). +- **The fleet TagBot thin-caller conversion** — every monorepo's `TagBot.yml` + becomes a thin caller of `monorepo-tagbot.yml@v1`, replacing inlined + `JuliaRegistries/TagBot@v1` matrices with targeted package routing (§6). Where a value below differs from what you currently see in OrdinaryDiffEq.jl `master`, the value below is the one to adopt. @@ -637,18 +637,15 @@ jobs: secrets: "inherit" ``` -### `TagBot.yml` → thin caller of `tagbot.yml@v1` (root + subpackages matrix) +### `TagBot.yml` → targeted `monorepo-tagbot.yml@v1` caller -TagBot is a **thin caller** of the reusable `tagbot.yml@v1`, exactly like every -other workflow. Do **not** inline `permissions`, the `if`-guard, or -`JuliaRegistries/TagBot@v1` steps — those all live in the reusable workflow. The -caller only keeps the `issue_comment` / `workflow_dispatch` triggers (TagBot is -driven by the JuliaRegistrator comment) and forwards `secrets: "inherit"` -(`token` / `ssh` `DOCUMENTER_KEY` come through inherit). - -A **monorepo** caller has two jobs: one `tagbot` job for the umbrella root, plus -a `tagbot-subpackages` matrix job that calls the same reusable workflow once per -**registered** `lib/`, passing the path through the `subdir` input: +TagBot is a **thin caller** of the reusable `monorepo-tagbot.yml@v1`. Do **not** +inline `permissions`, matrices, the `if`-guard, or `JuliaRegistries/TagBot@v1` +steps. The reusable workflow resolves the General registry pull request from a +JuliaTagBot comment, validates its package name against the root and +`lib/*/Project.toml`, and calls TagBot only for that package. Retry comments +without a General pull-request URL and manual runs without `package` fall back +to a serialized full audit. ```yaml # .github/workflows/TagBot.yml @@ -658,32 +655,21 @@ on: types: [created] workflow_dispatch: inputs: - lookback: - default: "3" + package: + description: "Package name to tag; empty audits every package" + required: false + type: string jobs: tagbot: - uses: "SciML/.github/.github/workflows/tagbot.yml@v1" - secrets: "inherit" - - tagbot-subpackages: - strategy: - fail-fast: false - matrix: - package: - - OrdinaryDiffEqCore - - OrdinaryDiffEqBDF - # ... every registered lib/ - uses: "SciML/.github/.github/workflows/tagbot.yml@v1" + uses: "SciML/.github/.github/workflows/monorepo-tagbot.yml@v1" with: - subdir: "lib/${{ matrix.package }}" + package: "${{ inputs.package }}" secrets: "inherit" ``` -A **single-package** repo has only the first job (the `tagbot` caller with -`secrets: "inherit"`, no `subdir`); there is no subpackages matrix. The whole -fleet, OrdinaryDiffEq included, is being converted from the previous inlined -form (per-package `JuliaRegistries/TagBot@v1` steps) to this thin-caller form. -See the [README `tagbot.yml`](README.md#tagbotyml) entry for the input table. +A **single-package** repo calls `tagbot.yml@v1` directly. The monorepo resolver +is unnecessary when there is only one package to scan. See the +[README TagBot entries](README.md#tagbotyml) for both input tables. ### `DependabotAutoMerge.yml` → `dependabot-automerge.yml@v1` @@ -809,8 +795,8 @@ should use **Runic**; only these two legacy repos are on JuliaFormatter. auto-populated), `CI` (group-dispatched), `Downgrade` (`julia-version: "lts"`, auto-`skip`), `Documentation`, `Downstream`, `FormatCheck`/`RunicSuggestions`, `SpellCheck`, `TagBot` (thin caller of - `tagbot.yml@v1`: root `tagbot` job + `tagbot-subpackages` matrix with - `subdir: lib/`; single-package repos have only the root job), + `monorepo-tagbot.yml@v1` for targeted monorepo routing; single-package + repos call `tagbot.yml@v1` directly), `DependabotAutoMerge`, `DocPreviewCleanup`, `benchmark`. - [ ] Repo files: `.codecov.yml` (`comment: false`), `.typos.toml`, `.gitignore`, per-package `LICENSE.md`. diff --git a/README.md b/README.md index 4654f39..7b57c11 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ Two things live here: [`downgrade.yml`](#downgradeyml) · [`documentation.yml`](#documentationyml) · [`runic.yml`](#runicyml) · [`format-check.yml` / `format-suggestions-on-pr.yml`](#format-checkyml--format-suggestions-on-pryml) · [`spellcheck.yml`](#spellcheckyml) · [`benchmark.yml`](#benchmarkyml) · - [`tagbot.yml`](#tagbotyml) · [`dependabot-automerge.yml`](#dependabot-automergeyml) · + [`tagbot.yml`](#tagbotyml) · [`monorepo-tagbot.yml`](#monorepo-tagbotyml) · + [`dependabot-automerge.yml`](#dependabot-automergeyml) · [`docs-preview-cleanup.yml`](#docs-preview-cleanupyml) · [`major-version-tag.yml`](#major-version-tagyml) - [Monorepos: sublibrary CI](#monorepos-sublibrary-ci) @@ -114,7 +115,7 @@ lines: | `Documentation.yml` | `documentation.yml@v1` | Build & deploy docs | | `FormatCheck.yml` | `runic.yml@v1` | Runic format check | | `SpellCheck.yml` | `spellcheck.yml@v1` | Spell-check with `typos` | -| `TagBot.yml` | `tagbot.yml@v1` | Create releases/tags on registration | +| `TagBot.yml` | `tagbot.yml@v1` | Create releases/tags for a single-package repository | | `DocPreviewCleanup.yml` | `docs-preview-cleanup.yml@v1` | Delete closed-PR doc previews | Monorepos (a package with `lib//` sub-packages) add one more — @@ -332,16 +333,17 @@ and reports results on PRs. Input: `julia-version` (`"1"`). Creates GitHub releases/tags when a package is registered, via [JuliaRegistries/TagBot](https://github.com/JuliaRegistries/TagBot). Tags one -package — the root, or a monorepo sublibrary when `subdir` is set. The +package — the repository root, or a package at `subdir`. The if-guard (`workflow_dispatch` or `JuliaTagBot` actor) and the permissions block live in the reusable workflow; the caller keeps the `issue_comment` / `workflow_dispatch` triggers. `token` and `ssh` (`DOCUMENTER_KEY`) come from -`secrets: inherit`. +`secrets: inherit`. Monorepos should use [`monorepo-tagbot.yml`](#monorepo-tagbotyml) +instead of calling this workflow once per package. | Input | Type | Default | Description | |---|---|---|---| | `subdir` | string | `""` | Package subdirectory to tag (e.g. `lib/Foo` for a sublibrary); empty for the root. | -| `lookback` | string | `"3"` | TagBot lookback window in days (manual `workflow_dispatch` runs). | +| `lookback` | string | `"3"` | Deprecated and ignored; retained so existing v1 callers remain valid. | ```yaml # .github/workflows/TagBot.yml @@ -356,21 +358,39 @@ jobs: secrets: "inherit" ``` -Monorepo (tag the root + each sublibrary): +### `monorepo-tagbot.yml` + +Routes a monorepo registration to one TagBot invocation. For a JuliaTagBot +issue comment, the resolver reads the referenced General registry pull request, +extracts the package name, validates it against the root and +`lib/*/Project.toml`, and runs `tagbot.yml` only for that package. A retry +notification without a General pull-request URL, or a manual run without a +`package`, performs a full root-plus-sublibraries audit with `max-parallel: 1`. +Runs targeting the same package share a concurrency group, so duplicate +notifications do not execute that package concurrently. + +| Input | Type | Default | Description | +|---|---|---|---| +| `package` | string | `""` | Package name for a manual targeted run; empty performs a full audit. Ignored for JuliaTagBot registry notifications. | + +Monorepo caller: ```yaml +name: TagBot +on: + issue_comment: + types: [created] + workflow_dispatch: + inputs: + package: + description: "Package name to tag; empty audits every package" + required: false + type: string jobs: tagbot: - uses: "SciML/.github/.github/workflows/tagbot.yml@v1" - secrets: "inherit" - tagbot-sublibraries: - strategy: - fail-fast: false - matrix: - package: [lib/Foo, lib/Bar] - uses: "SciML/.github/.github/workflows/tagbot.yml@v1" + uses: "SciML/.github/.github/workflows/monorepo-tagbot.yml@v1" with: - subdir: "${{ matrix.package }}" + package: "${{ inputs.package }}" secrets: "inherit" ``` diff --git a/test/test_resolve_monorepo_tagbot.py b/test/test_resolve_monorepo_tagbot.py new file mode 100644 index 0000000..134e18d --- /dev/null +++ b/test/test_resolve_monorepo_tagbot.py @@ -0,0 +1,129 @@ +import importlib.util +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = ( + Path(__file__).parents[1] + / ".github" + / "actions" + / "resolve-monorepo-tagbot" + / "resolve.py" +) +SPEC = importlib.util.spec_from_file_location("resolve_monorepo_tagbot", SCRIPT) +MODULE = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +sys.modules[SPEC.name] = MODULE +SPEC.loader.exec_module(MODULE) + + +class ResolverTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.workspace = Path(self.tempdir.name) + self.write_project("Project.toml", "Umbrella") + self.write_project("lib/Foo/Project.toml", "Foo") + self.write_project("lib/Bar/Project.toml", "Bar") + + def tearDown(self): + self.tempdir.cleanup() + + def write_project(self, relative_path, name): + path = self.workspace / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f'name = "{name}"\n', encoding="utf-8") + + def test_registry_pr_routes_only_matching_subpackage(self): + body = ( + "Triggering TagBot for merged registry pull request: " + "https://github.com/JuliaRegistries/General/pull/123" + ) + seen = [] + + def fetch_pr(number, token): + seen.append((number, token)) + return {"title": "New version: Foo v1.2.3"} + + result = MODULE.resolve(self.workspace, body, "", "token", fetch_pr) + + self.assertEqual(result.subdirs, ["lib/Foo"]) + self.assertEqual(result.mode, "registry-pr") + self.assertEqual(result.package, "Foo") + self.assertEqual(result.version, "1.2.3") + self.assertEqual(seen, [(123, "token")]) + + def test_registry_pr_routes_root_package(self): + result = MODULE.resolve( + self.workspace, + "https://github.com/JuliaRegistries/General/pull/42", + "", + "token", + lambda number, token: {"title": "New version: Umbrella v2.0.0"}, + ) + + self.assertEqual(result.subdirs, [""]) + self.assertEqual(result.package, "Umbrella") + + def test_retry_comment_without_pr_runs_ordered_full_audit(self): + result = MODULE.resolve( + self.workspace, + "This extra notification is being sent because I expected a tag to exist by now.", + "", + "token", + ) + + self.assertEqual(result.mode, "full-audit") + self.assertEqual(result.subdirs, ["", "lib/Bar", "lib/Foo"]) + + def test_manual_package_routes_one_package(self): + result = MODULE.resolve(self.workspace, "", "Bar", "token") + + self.assertEqual(result.mode, "manual") + self.assertEqual(result.package, "Bar") + self.assertEqual(result.subdirs, ["lib/Bar"]) + + def test_empty_manual_package_runs_full_audit(self): + result = MODULE.resolve(self.workspace, "", "", "token") + + self.assertEqual(result.mode, "full-audit") + self.assertEqual(len(result.subdirs), 3) + + def test_unknown_registered_package_fails(self): + with self.assertRaisesRegex(MODULE.ResolutionError, "was not found"): + MODULE.resolve( + self.workspace, + "https://github.com/JuliaRegistries/General/pull/42", + "", + "token", + lambda number, token: {"title": "New version: Missing v1.0.0"}, + ) + + def test_unknown_manual_package_fails(self): + with self.assertRaisesRegex(MODULE.ResolutionError, "manual package"): + MODULE.resolve(self.workspace, "", "Missing", "token") + + def test_unexpected_general_pr_title_fails(self): + with self.assertRaisesRegex(MODULE.ResolutionError, "unexpected General"): + MODULE.resolve( + self.workspace, + "https://github.com/JuliaRegistries/General/pull/42", + "", + "token", + lambda number, token: {"title": "Update Versions.toml"}, + ) + + def test_multiple_general_pr_links_fail(self): + with self.assertRaisesRegex(MODULE.ResolutionError, "multiple General"): + MODULE.resolve( + self.workspace, + "https://github.com/JuliaRegistries/General/pull/1 " + "https://github.com/JuliaRegistries/General/pull/2", + "", + "token", + ) + + +if __name__ == "__main__": + unittest.main() From d043414767db48bedb7deb4905eed89e2bae34e7 Mon Sep 17 00:00:00 2001 From: ChrisRackauckas-Claude Date: Fri, 10 Jul 2026 06:29:45 -0400 Subject: [PATCH 2/2] docs: preserve TagBot caller permissions Co-Authored-By: Chris Rackauckas --- Monorepo.md | 28 +++++++++++++++++++++------- README.md | 38 +++++++++++++++++++++++++++++++++----- 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/Monorepo.md b/Monorepo.md index c708bee..0b05907 100644 --- a/Monorepo.md +++ b/Monorepo.md @@ -639,13 +639,14 @@ jobs: ### `TagBot.yml` → targeted `monorepo-tagbot.yml@v1` caller -TagBot is a **thin caller** of the reusable `monorepo-tagbot.yml@v1`. Do **not** -inline `permissions`, matrices, the `if`-guard, or `JuliaRegistries/TagBot@v1` -steps. The reusable workflow resolves the General registry pull request from a -JuliaTagBot comment, validates its package name against the root and -`lib/*/Project.toml`, and calls TagBot only for that package. Retry comments -without a General pull-request URL and manual runs without `package` fall back -to a serialized full audit. +TagBot is a **thin caller** of the reusable `monorepo-tagbot.yml@v1`. Keep the +caller permissions shown below because a reusable workflow cannot elevate its +caller's token. Do **not** inline matrices, the `if`-guard, or +`JuliaRegistries/TagBot@v1` steps. The reusable workflow resolves the General +registry pull request from a JuliaTagBot comment, validates its package name +against the root and `lib/*/Project.toml`, and calls TagBot only for that +package. Retry comments without a General pull-request URL and manual runs +without `package` fall back to a serialized full audit. ```yaml # .github/workflows/TagBot.yml @@ -659,6 +660,19 @@ on: description: "Package name to tag; empty audits every package" required: false type: string +permissions: + actions: read + checks: read + contents: write + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + security-events: read + statuses: read jobs: tagbot: uses: "SciML/.github/.github/workflows/monorepo-tagbot.yml@v1" diff --git a/README.md b/README.md index 7b57c11..882a16e 100644 --- a/README.md +++ b/README.md @@ -334,11 +334,13 @@ and reports results on PRs. Input: `julia-version` (`"1"`). Creates GitHub releases/tags when a package is registered, via [JuliaRegistries/TagBot](https://github.com/JuliaRegistries/TagBot). Tags one package — the repository root, or a package at `subdir`. The -if-guard (`workflow_dispatch` or `JuliaTagBot` actor) and the permissions block -live in the reusable workflow; the caller keeps the `issue_comment` / -`workflow_dispatch` triggers. `token` and `ssh` (`DOCUMENTER_KEY`) come from -`secrets: inherit`. Monorepos should use [`monorepo-tagbot.yml`](#monorepo-tagbotyml) -instead of calling this workflow once per package. +if-guard (`workflow_dispatch` or `JuliaTagBot` actor) lives in the reusable +workflow; the caller keeps the `issue_comment` / `workflow_dispatch` triggers +and grants the token permissions shown below. A called workflow cannot elevate +the caller's token permissions. `token` and `ssh` (`DOCUMENTER_KEY`) come from +`secrets: inherit`. Monorepos should use +[`monorepo-tagbot.yml`](#monorepo-tagbotyml) instead of calling this workflow +once per package. | Input | Type | Default | Description | |---|---|---|---| @@ -352,6 +354,19 @@ on: issue_comment: types: [created] workflow_dispatch: +permissions: + actions: read + checks: read + contents: write + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + security-events: read + statuses: read jobs: tagbot: uses: "SciML/.github/.github/workflows/tagbot.yml@v1" @@ -386,6 +401,19 @@ on: description: "Package name to tag; empty audits every package" required: false type: string +permissions: + actions: read + checks: read + contents: write + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + security-events: read + statuses: read jobs: tagbot: uses: "SciML/.github/.github/workflows/monorepo-tagbot.yml@v1"