Skip to content

Commit

Permalink
Support packages in subdirs (#252)
Browse files Browse the repository at this point in the history
  • Loading branch information
hannahilea authored Aug 25, 2023
1 parent 0d4181f commit 9dadb95
Show file tree
Hide file tree
Showing 8 changed files with 289 additions and 33 deletions.
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ Read on for a full description of all of the available configuration options.
- [Pre-Release Hooks](#pre-release-hooks)
- [Release Branch Selection](#release-branch-selection)
- [Release Branch Management](#release-branch-management)
- [Subpackage Configuration](#subpackage-configuration)
- [Local Usage](#local-usage)

## Basic Configuration Options
Expand Down Expand Up @@ -374,6 +375,69 @@ with:
branches: true
```

### Subpackage Configuration

If your package is not at the top-level of your repository, you should set the `subdir` input:

```yml
with:
token: ${{ secrets.GITHUB_TOKEN }}
subdir: path/to/SubpackageName.jl
```

Version tags will then be prefixed with the subpackage's name: `{PACKAGE}-v{VERSION}`, e.g., `SubpackageName-v0.2.3`. (For top-level packages, the default tag is simply `v{VERSION}`.)

To tag releases from a monorepo containing multiple subpackages and an optional top-level package, set up a separate step for each package you want to tag. For example, to tag all three packages in the following repository,

```
.
├── SubpackageA.jl
│   ├── Package.toml
│   └── src/...
├── path
│ └── to
│ └── SubpackageB.jl
│ ├── Package.toml
│ └── src/...
├── Package.toml
└── src/...
```
the action configuration should look something like
```yml
steps:
- name: Tag top-level package
uses: JuliaRegistries/TagBot@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
# Edit the following line to reflect the actual name of the GitHub Secret containing your private key
ssh: ${{ secrets.DOCUMENTER_KEY }}
# ssh: ${{ secrets.NAME_OF_MY_SSH_PRIVATE_KEY_SECRET }}
- name: Tag subpackage A
uses: JuliaRegistries/TagBot@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
# Edit the following line to reflect the actual name of the GitHub Secret containing your private key
ssh: ${{ secrets.DOCUMENTER_KEY }}
# ssh: ${{ secrets.NAME_OF_MY_SSH_PRIVATE_KEY_SECRET }}
subdir: SubpackageA.jl
- name: Tag subpackage B
uses: JuliaRegistries/TagBot@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
# Edit the following line to reflect the actual name of the GitHub Secret containing your private key
ssh: ${{ secrets.DOCUMENTER_KEY }}
# ssh: ${{ secrets.NAME_OF_MY_SSH_PRIVATE_KEY_SECRET }}
subdir: path/to/SubpackageB.jl
```

Generated tags will then be `v0.1.2` (top-level), `SubpackageA-v0.0.3`, and `SubpackageB-v2.3.1`.

**:information_source: Monorepo-specific changelog behavior**

Each subpackage will include all issues and pull requests in its changelogs, such that a single issue will be duplicated up in all of the repository's subpackages' release notes. Careful [`changelog_ignore` and/or custom changelog settings](#changelogs) on a per-subpackage basis can mitigate this duplication.

## Local Usage

There are some scenarios in which you want to manually run TagBot.
Expand All @@ -392,6 +456,7 @@ Options:
--github-api TEXT GitHub API URL
--changelog TEXT Changelog template
--registry TEXT Registry to search
--subdir TEXT Subdirectory path in repo
--help Show this message and exit.

$ docker run --rm ghcr.io/juliaregistries/tagbot python -m tagbot.local \
Expand Down
3 changes: 3 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ inputs:
branch:
description: Branch to create releases against when possible
required: false
subdir:
description: Subdirectory of package in repo, if not at top level
required: false
changelog:
description: Changelog template
required: false
Expand Down
1 change: 1 addition & 0 deletions tagbot/action/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ def get_input(key: str, default: str = "") -> str:
email=get_input("email"),
lookback=int(get_input("lookback")),
branch=get_input("branch"),
subdir=get_input("subdir"),
)

if not repo.is_registered():
Expand Down
36 changes: 21 additions & 15 deletions tagbot/action/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,19 @@ def _slug(self, s: str) -> str:
"""Return a version of the string that's easy to compare."""
return re.sub(r"[\s_-]", "", s.casefold())

def _previous_release(self, version: str) -> Optional[GitRelease]:
def _previous_release(self, version_tag: str) -> Optional[GitRelease]:
"""Get the release previous to the current one (according to SemVer)."""
cur_ver = VersionInfo.parse(version[1:])
tag_prefix = self._repo._tag_prefix()
i_start = len(tag_prefix)
cur_ver = VersionInfo.parse(version_tag[i_start:])
prev_ver = VersionInfo(0)
prev_rel = None
tag_prefix = self._repo._tag_prefix()
for r in self._repo._repo.get_releases():
if not r.tag_name.startswith("v"):
if not r.tag_name.startswith(tag_prefix):
continue
try:
ver = VersionInfo.parse(r.tag_name[1:])
ver = VersionInfo.parse(r.tag_name[i_start:])
except ValueError:
continue
if ver.prerelease or ver.build:
Expand Down Expand Up @@ -103,10 +106,13 @@ def _pulls(self, start: datetime, end: datetime) -> List[PullRequest]:
p for p in self._issues_and_pulls(start, end) if isinstance(p, PullRequest)
]

def _custom_release_notes(self, version: str) -> Optional[str]:
def _custom_release_notes(self, version_tag: str) -> Optional[str]:
"""Look up a version's custom release notes."""
logger.debug("Looking up custom release notes")
pr = self._repo._registry_pr(version)
tag_prefix = self._repo._tag_prefix()
i_start = len(tag_prefix) - 1
package_version = version_tag[i_start:]
pr = self._repo._registry_pr(package_version)
if not pr:
logger.warning("No registry pull request was found for this version")
return None
Expand Down Expand Up @@ -153,16 +159,16 @@ def _format_pull(self, pull: PullRequest) -> Dict[str, object]:
"url": pull.html_url,
}

def _collect_data(self, version: str, sha: str) -> Dict[str, object]:
def _collect_data(self, version_tag: str, sha: str) -> Dict[str, object]:
"""Collect data needed to create the changelog."""
previous = self._previous_release(version)
previous = self._previous_release(version_tag)
start = datetime.fromtimestamp(0)
prev_tag = None
compare = None
if previous:
start = previous.created_at
prev_tag = previous.tag_name
compare = f"{self._repo._repo.html_url}/compare/{prev_tag}...{version}"
compare = f"{self._repo._repo.html_url}/compare/{prev_tag}...{version_tag}"
# When the last commit is a PR merge, the commit happens a second or two before
# the PR and associated issues are closed.
end = self._repo._git.time_of_commit(sha) + timedelta(minutes=1)
Expand All @@ -173,23 +179,23 @@ def _collect_data(self, version: str, sha: str) -> Dict[str, object]:
pulls = self._pulls(start, end)
return {
"compare_url": compare,
"custom": self._custom_release_notes(version),
"custom": self._custom_release_notes(version_tag),
"issues": [self._format_issue(i) for i in issues],
"package": self._repo._project("name"),
"previous_release": prev_tag,
"pulls": [self._format_pull(p) for p in pulls],
"sha": sha,
"version": version,
"version_url": f"{self._repo._repo.html_url}/tree/{version}",
"version": version_tag,
"version_url": f"{self._repo._repo.html_url}/tree/{version_tag}",
}

def _render(self, data: Dict[str, object]) -> str:
"""Render the template."""
return self._template.render(data).strip()

def get(self, version: str, sha: str) -> str:
def get(self, version_tag: str, sha: str) -> str:
"""Get the changelog for a specific version."""
logger.info(f"Generating changelog for version {version} ({sha})")
data = self._collect_data(version, sha)
logger.info(f"Generating changelog for version {version_tag} ({sha})")
data = self._collect_data(version_tag, sha)
logger.debug(f"Changelog data: {json.dumps(data, indent=2)}")
return self._render(data)
52 changes: 35 additions & 17 deletions tagbot/action/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ def __init__(
email: str,
lookback: int,
branch: Optional[str],
subdir: Optional[str] = None,
github_kwargs: Optional[Dict[str, object]] = None,
) -> None:
if github_kwargs is None:
Expand Down Expand Up @@ -99,6 +100,7 @@ def __init__(
self._lookback = timedelta(days=lookback, hours=1)
self.__registry_clone_dir: Optional[str] = None
self.__release_branch = branch
self.__subdir = subdir
self.__project: Optional[MutableMapping[str, object]] = None
self.__registry_path: Optional[str] = None
self.__registry_url: Optional[str] = None
Expand All @@ -109,7 +111,8 @@ def _project(self, k: str) -> str:
return str(self.__project[k])
for name in ["Project.toml", "JuliaProject.toml"]:
try:
contents = self._only(self._repo.get_contents(name))
filepath = os.path.join(self.__subdir, name) if self.__subdir else name
contents = self._only(self._repo.get_contents(filepath))
break
except UnknownObjectException:
pass
Expand Down Expand Up @@ -179,15 +182,25 @@ def _maybe_decode_private_key(self, key: str) -> str:
"""Return a decoded value if it is Base64-encoded, or the original value."""
return key if "PRIVATE KEY" in key else b64decode(key).decode()

def _create_release_branch_pr(self, version: str, branch: str) -> None:
def _create_release_branch_pr(self, version_tag: str, branch: str) -> None:
"""Create a pull request for the release branch."""
self._repo.create_pull(
title=f"Merge release branch for {version}",
title=f"Merge release branch for {version_tag}",
body="",
head=branch,
base=self._repo.default_branch,
)

def _tag_prefix(self) -> str:
"""Return the package's tag prefix."""
return self._project("name") + "-v" if self.__subdir else "v"

def _get_version_tag(self, package_version: str) -> str:
"""Return the package-prefixed version tag."""
if package_version.startswith("v"):
package_version = package_version[1:]
return self._tag_prefix() + package_version

def _registry_pr(self, version: str) -> Optional[PullRequest]:
"""Look up a merged registry pull request for this version."""
if self._clone_registry:
Expand Down Expand Up @@ -266,10 +279,10 @@ def _commit_sha_of_tree(self, tree: str) -> Optional[str]:
# Fall back to cloning the repo in that case.
return self._git.commit_sha_of_tree(tree)

def _commit_sha_of_tag(self, version: str) -> Optional[str]:
def _commit_sha_of_tag(self, version_tag: str) -> Optional[str]:
"""Look up the commit SHA of a given tag."""
try:
ref = self._repo.get_git_ref(f"tags/{version}")
ref = self._repo.get_git_ref(f"tags/{version_tag}")
except UnknownObjectException:
return None
ref_type = getattr(ref.object, "type", None)
Expand Down Expand Up @@ -299,13 +312,14 @@ def _filter_map_versions(self, versions: Dict[str, str]) -> Dict[str, str]:
f"No matching commit was found for version {version} ({tree})"
)
continue
sha = self._commit_sha_of_tag(version)
version_tag = self._get_version_tag(version)
sha = self._commit_sha_of_tag(version_tag)
if sha:
if sha != expected:
msg = f"Existing tag {version} points at the wrong commit (expected {expected})" # noqa: E501
msg = f"Existing tag {version_tag} points at the wrong commit (expected {expected})" # noqa: E501
logger.error(msg)
else:
logger.info(f"Tag {version} already exists")
logger.info(f"Tag {version_tag} already exists")
continue
valid[version] = expected
return valid
Expand Down Expand Up @@ -525,7 +539,9 @@ def configure_gpg(self, key: str, password: Optional[str]) -> None:

def handle_release_branch(self, version: str) -> None:
"""Merge an existing release branch or create a PR to merge it."""
branch = f"release-{version[1:]}"
# Exclude "v" from version: `0.0.0` or `SubPackage-0.0.0`
branch_version = self._tag_prefix()[:-1] + version[1:]
branch = f"release-{branch_version}"
if not self._git.fetch_branch(branch):
logger.info(f"Release branch {branch} does not exist")
elif self._git.is_merged(branch):
Expand All @@ -539,7 +555,8 @@ def handle_release_branch(self, version: str) -> None:
logger.info(
"Release branch cannot be fast-forwarded, creating pull request"
)
self._create_release_branch_pr(version, branch)
version_tag = self._get_version_tag(version)
self._create_release_branch_pr(version_tag, branch)

def create_release(self, version: str, sha: str) -> None:
"""Create a GitHub release."""
Expand All @@ -548,25 +565,26 @@ def create_release(self, version: str, sha: str) -> None:
# If we use <branch> as the target, GitHub will show
# "<n> commits to <branch> since this release" on the release page.
target = self._release_branch
logger.debug(f"Release {version} target: {target}")
log = self._changelog.get(version, sha)
version_tag = self._get_version_tag(version)
logger.debug(f"Release {version_tag} target: {target}")
log = self._changelog.get(version_tag, sha)
if not self._draft:
if self._ssh or self._gpg:
logger.debug("Creating tag via Git CLI")
self._git.create_tag(version, sha, log)
self._git.create_tag(version_tag, sha, log)
else:
logger.debug("Creating tag via GitHub API")
tag = self._repo.create_git_tag(
version,
version_tag,
log,
sha,
"commit",
tagger=InputGitAuthor(self._user, self._email),
)
self._repo.create_git_ref(f"refs/tags/{version}", tag.sha)
logger.info(f"Creating release {version} at {sha}")
self._repo.create_git_ref(f"refs/tags/{version_tag}", tag.sha)
logger.info(f"Creating release {version_tag} at {sha}")
self._repo.create_git_release(
version, version, log, target_commitish=target, draft=self._draft
version_tag, version_tag, log, target_commitish=target, draft=self._draft
)

def handle_error(self, e: Exception) -> None:
Expand Down
3 changes: 3 additions & 0 deletions tagbot/local/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
@click.option("--changelog", default=CHANGELOG, help="Changelog template")
@click.option("--registry", default=REGISTRY, help="Registry to search")
@click.option("--draft", default=DRAFT, help="Create a draft release", is_flag=True)
@click.option("--subdir", default=None, help="Subdirectory path in repo")
def main(
repo: str,
version: str,
Expand All @@ -34,6 +35,7 @@ def main(
changelog: str,
registry: str,
draft: bool,
subdir: str,
) -> None:
r = Repo(
repo=repo,
Expand All @@ -51,6 +53,7 @@ def main(
email=EMAIL,
lookback=0,
branch=None,
subdir=subdir,
)
version = version if version.startswith("v") else f"v{version}"
sha = r.commit_sha_of_version(version)
Expand Down
Loading

0 comments on commit 9dadb95

Please sign in to comment.