Skip to content
Merged
Show file tree
Hide file tree
Changes from 25 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
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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "tagbot"
version = "1.14.1"
version = "1.14.2"
description = "Creates tags, releases, and changelogs for your Julia packages when they're registered"
authors = ["Chris de Graaf <[email protected]>"]
license = "MIT"
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
38 changes: 23 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,15 @@ 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 +161,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 +181,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)
59 changes: 42 additions & 17 deletions tagbot/action/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,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 @@ -98,6 +99,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

Expand All @@ -107,7 +109,11 @@ 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))
if self.__subdir is not None:
filepath = os.path.join(self.__subdir, name)
else:
filepath = name
contents = self._only(self._repo.get_contents(filepath))
break
except UnknownObjectException:
pass
Expand Down Expand Up @@ -163,15 +169,29 @@ 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."""
if self.__subdir is None or self.__subdir == "":
prefix = "v"
else:
prefix = self._project("name") + "-v"
return prefix

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 @@ -243,10 +263,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 @@ -276,13 +296,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 @@ -502,7 +523,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 @@ -516,7 +539,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 @@ -525,25 +549,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