Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/actions/resolve-monorepo-tagbot/action.yml
Original file line number Diff line number Diff line change
@@ -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"
193 changes: 193 additions & 0 deletions .github/actions/resolve-monorepo-tagbot/resolve.py
Original file line number Diff line number Diff line change
@@ -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<number>[0-9]+)"
)
VERSION_TITLE_PATTERN = re.compile(
r"^New version: (?P<package>[^ ]+) v(?P<version>[^ ]+)$"
)
PROJECT_NAME_PATTERN = re.compile(
r"^\s*name\s*=\s*(?P<quote>['\"])(?P<name>[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())
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
55 changes: 55 additions & 0 deletions .github/workflows/monorepo-tagbot.yml
Original file line number Diff line number Diff line change
@@ -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
8 changes: 3 additions & 5 deletions .github/workflows/tagbot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -45,4 +44,3 @@ jobs:
token: "${{ secrets.GITHUB_TOKEN }}"
ssh: "${{ secrets.DOCUMENTER_KEY }}"
subdir: "${{ inputs.subdir }}"
lookback: "${{ inputs.lookback }}"
Loading
Loading