blob: 4d30eab6eb9dc9a2faba5c49be13e3cd7aab4847 [file]
#!/usr/bin/env python3
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
#
"""Cut per-action, path-prefixed releases for infrastructure-actions.
This repository serves several independently-consumed actions from a single
repo (a monorepo of actions). To let downstream projects pin a specific
version -- and let Dependabot propose bumps -- each action is released under
its own *path-prefixed* tag, e.g. ``allowlist-check/v1.2.3`` or
``restore/v1.4.0``. Consumers then pin::
- uses: apache/infrastructure-actions/stash/restore@<sha> # restore/v1.4.0
Dependabot's ``github_actions`` ecosystem understands this leaf-name prefix
scheme (dependabot/dependabot-core#11286, added specifically for this repo),
matching candidate tags by the ``restore/`` prefix so each action bumps
independently.
The module keeps its decision logic (which actions changed, what the next
version is, which bump to apply) as pure functions so they can be unit
tested; all git/GitHub side effects live in ``main`` and the small ``_run``
helpers and only fire with ``--apply``.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Action:
"""A releasable action served from this repo.
``tag_prefix`` is the *leaf* directory name used as the tag/ref prefix
(the format Dependabot matches). ``consumed_path`` is how downstream
workflows reference the action. ``watch_paths`` are the repo-relative
path prefixes whose changes warrant a new release of this action --
including any shared code the action depends on at runtime.
"""
tag_prefix: str
consumed_path: str
watch_paths: tuple[str, ...] = field(default_factory=tuple)
# The actions this repo publishes. ``stash/save`` and ``stash/restore`` both
# import ``stash/shared/mung.py`` at runtime (PYTHONPATH -> ../shared), so a
# change there releases both.
ACTIONS: tuple[Action, ...] = (
Action("allowlist-check", "allowlist-check", ("allowlist-check/",)),
Action("pelican", "pelican", ("pelican/",)),
Action("save", "stash/save", ("stash/save/", "stash/shared/")),
Action("restore", "stash/restore", ("stash/restore/", "stash/shared/")),
)
VALID_BUMPS = ("major", "minor", "patch")
# --------------------------------------------------------------------------- #
# Pure logic (unit tested)
# --------------------------------------------------------------------------- #
def affected_actions(
changed_files: list[str], actions: tuple[Action, ...] = ACTIONS
) -> list[Action]:
"""Return the actions whose ``watch_paths`` match any changed file."""
hit: list[Action] = []
for action in actions:
if any(
f.startswith(prefix)
for f in changed_files
for prefix in action.watch_paths
):
hit.append(action)
return hit
def parse_version(tag: str, prefix: str) -> tuple[int, int, int] | None:
"""Parse ``<prefix>/vX.Y.Z`` -> ``(X, Y, Z)``; ``None`` if it doesn't match.
Only fully-specified ``X.Y.Z`` tags are treated as releases; the moving
major tag ``<prefix>/vX`` is intentionally ignored here.
"""
m = re.fullmatch(rf"{re.escape(prefix)}/v(\d+)\.(\d+)\.(\d+)", tag)
if not m:
return None
return int(m.group(1)), int(m.group(2)), int(m.group(3))
def latest_version(
tags: list[str], prefix: str
) -> tuple[int, int, int] | None:
"""Highest ``X.Y.Z`` among ``tags`` for ``prefix``; ``None`` if none."""
versions = [v for v in (parse_version(t, prefix) for t in tags) if v]
return max(versions) if versions else None
def bump_version(
current: tuple[int, int, int] | None, bump: str
) -> tuple[int, int, int]:
"""Apply ``bump`` to ``current`` (``None`` => first release ``1.0.0``)."""
if bump not in VALID_BUMPS:
raise ValueError(f"invalid bump {bump!r}; expected one of {VALID_BUMPS}")
if current is None:
# Seed the first release at v1.0.0 so the moving major tag is vN>=1.
return (1, 0, 0)
major, minor, patch = current
if bump == "major":
return (major + 1, 0, 0)
if bump == "minor":
return (major, minor + 1, 0)
return (major, minor, patch + 1)
def format_tag(prefix: str, version: tuple[int, int, int]) -> str:
return f"{prefix}/v{version[0]}.{version[1]}.{version[2]}"
def format_major_tag(prefix: str, version: tuple[int, int, int]) -> str:
return f"{prefix}/v{version[0]}"
def determine_bump(
labels: list[str], commit_message: str = ""
) -> str | None:
"""Decide the bump for a merge from PR labels / commit message.
Precedence (highest first):
* a skip request -> ``None`` (no release)
* ``release:major`` label or ``[major]`` in the message
* ``release:minor`` label or ``[minor]`` in the message
* ``release:patch`` label or ``[patch]`` in the message
* default -> ``"patch"``
"""
label_set = {label.strip().lower() for label in labels}
msg = commit_message.lower()
if "release:skip" in label_set or "[skip release]" in msg or "[no release]" in msg:
return None
if "release:major" in label_set or "[major]" in msg:
return "major"
if "release:minor" in label_set or "[minor]" in msg:
return "minor"
if "release:patch" in label_set or "[patch]" in msg:
return "patch"
return "patch"
# --------------------------------------------------------------------------- #
# git / GitHub side effects
# --------------------------------------------------------------------------- #
def _run(cmd: list[str], *, check: bool = True, capture: bool = True) -> str:
result = subprocess.run(
cmd,
check=check,
text=True,
stdout=subprocess.PIPE if capture else None,
stderr=subprocess.PIPE if capture else None,
)
if capture and result.returncode != 0 and not check:
return ""
return (result.stdout or "").strip()
def changed_files_in_range(before: str, after: str) -> list[str]:
"""Files changed between two commits.
Falls back to the diff of ``after`` against its first parent when
``before`` is unusable (all-zero sha of a brand-new ref, or missing).
"""
zero = re.fullmatch(r"0+", before or "")
if not before or zero:
out = _run(["git", "diff", "--name-only", f"{after}^", after], check=False)
else:
out = _run(["git", "diff", "--name-only", f"{before}..{after}"], check=False)
return [line for line in out.splitlines() if line.strip()]
def existing_tags() -> list[str]:
return [t for t in _run(["git", "tag", "--list"]).splitlines() if t.strip()]
def pr_labels_for_commit(sha: str, repo: str) -> tuple[list[str], str]:
"""Return ``(labels, title)`` of the PR that produced ``sha`` (best effort)."""
out = _run(
[
"gh",
"api",
f"repos/{repo}/commits/{sha}/pulls",
"--jq",
"[.[] | {labels: [.labels[].name], title: .title}]",
],
check=False,
)
if not out:
return [], ""
try:
prs = json.loads(out)
except json.JSONDecodeError:
return [], ""
if not prs:
return [], ""
first = prs[0]
return list(first.get("labels", [])), str(first.get("title", ""))
def create_release(
action: Action,
version: tuple[int, int, int],
sha: str,
*,
apply: bool,
) -> None:
"""Create the ``X.Y.Z`` tag and move the ``<prefix>/vN`` major tag.
Tags only: no GitHub Release is published. Dependabot's ``github_actions``
ecosystem resolves versions from the tags themselves, so a Release adds
nothing a consumer or a bump PR depends on.
"""
version_tag = format_tag(action.tag_prefix, version)
major_tag = format_major_tag(action.tag_prefix, version)
title = f"{action.consumed_path} {version_tag.split('/', 1)[1]}"
print(f" -> {version_tag} (major {major_tag}) @ {sha[:12]}")
if not apply:
print(" [dry-run] skipping tag/push")
return
# Annotated version tag (immutable), pushed once.
_run(["git", "tag", "-a", version_tag, "-m", title, sha])
_run(["git", "push", "origin", version_tag])
# Moving major tag -- force-updated to the newest release of this action.
_run(["git", "tag", "-f", "-a", major_tag, "-m", f"{action.consumed_path} {major_tag.split('/', 1)[1]}", sha])
_run(["git", "push", "--force", "origin", major_tag])
print(f" tagged {version_tag} and {major_tag}")
# --------------------------------------------------------------------------- #
# Orchestration
# --------------------------------------------------------------------------- #
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--before", default="", help="previous commit sha (github.event.before)")
parser.add_argument("--after", default="", help="pushed commit sha (github.sha)")
parser.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY", ""), help="owner/name")
parser.add_argument(
"--bump",
choices=VALID_BUMPS,
default=None,
help="force a bump type instead of inferring from PR labels/commit message",
)
parser.add_argument(
"--action",
action="append",
default=None,
help="restrict to a specific action tag_prefix (repeatable); default: auto-detect changed",
)
parser.add_argument("--apply", action="store_true", help="actually create tags/releases")
args = parser.parse_args(argv)
after = args.after or _run(["git", "rev-parse", "HEAD"])
if not args.repo:
print("error: --repo or GITHUB_REPOSITORY is required", file=sys.stderr)
return 2
# 1. Which actions changed?
if args.action:
selected = {a.lower() for a in args.action}
actions = [a for a in ACTIONS if a.tag_prefix.lower() in selected]
print(f"Releasing explicitly requested actions: {sorted(selected)}")
else:
changed = changed_files_in_range(args.before, after)
actions = affected_actions(changed)
print(f"{len(changed)} file(s) changed; affected actions: "
f"{[a.tag_prefix for a in actions] or 'none'}")
if not actions:
print("No releasable actions changed; nothing to do.")
return 0
# 2. What bump?
if args.bump:
bump = args.bump
print(f"Bump forced to: {bump}")
else:
labels, title = pr_labels_for_commit(after, args.repo)
commit_msg = _run(["git", "log", "-1", "--pretty=%B", after], check=False)
bump = determine_bump(labels, f"{title}\n{commit_msg}")
print(f"PR labels: {labels or 'none'} -> bump: {bump or 'SKIP'}")
if bump is None:
print("Release skipped by request (release:skip / [skip release]).")
return 0
# 3. Cut a release per affected action.
tags = existing_tags()
for action in actions:
current = latest_version(tags, action.tag_prefix)
new_version = bump_version(current, bump)
cur_str = format_tag(action.tag_prefix, current) if current else "(none)"
print(f"{action.consumed_path}: {cur_str} --{bump}--> "
f"{format_tag(action.tag_prefix, new_version)}")
create_release(action, new_version, after, apply=args.apply)
return 0
if __name__ == "__main__":
raise SystemExit(main())