From 631dfa4c43c6e87d1787c78b6cc03cdf161558e5 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sat, 11 Jul 2026 22:07:46 +0200 Subject: [PATCH] Add tag-based, per-action releases (closes #1007) This repo serves several independently-consumed actions from one repo, but consumers could only track `@main`, which drifts from any pinned SHA and which Dependabot cannot bump. Introduce path-prefixed, per-action releases: each action is tagged under its leaf-directory prefix (allowlist-check/vX.Y.Z, pelican/vX.Y.Z, save/vX.Y.Z, restore/vX.Y.Z) with a moving major tag. This is the monorepo scheme Dependabot's github_actions ecosystem understands (dependabot/dependabot-core#11286, contributed for this repo), so downstream projects can pin a SHA with a '# /vX.Y.Z' comment and get automatic bump PRs. - scripts/release_actions.py: pure version/bump/changed-action logic plus a thin git/gh layer; auto-detects affected actions (a stash/shared change releases both stash actions), seeds first release at v1.0.0. - scripts/test_release_actions.py: 25 unit tests for the pure logic. - .github/workflows/release-actions.yml: on push to main, cut releases for changed actions; bump defaults to patch, raised via release:minor / release:major labels (or [minor]/[major] tokens) and suppressed with release:skip. workflow_dispatch allows manual/seed releases. - README.md + RELEASING.md: pinning guidance and the release process. Generated-by: Claude Opus 4.8 (1M context) via Claude Code --- .github/workflows/release-actions.yml | 107 ++++++++ README.md | 31 +++ RELEASING.md | 105 ++++++++ scripts/release_actions.py | 349 ++++++++++++++++++++++++++ scripts/test_release_actions.py | 167 ++++++++++++ 5 files changed, 759 insertions(+) create mode 100644 .github/workflows/release-actions.yml create mode 100644 RELEASING.md create mode 100644 scripts/release_actions.py create mode 100644 scripts/test_release_actions.py diff --git a/.github/workflows/release-actions.yml b/.github/workflows/release-actions.yml new file mode 100644 index 00000000..fe8d2725 --- /dev/null +++ b/.github/workflows/release-actions.yml @@ -0,0 +1,107 @@ +# +# 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. +# +# Cuts path-prefixed, per-action releases so downstream projects can pin a +# version (and Dependabot can propose bumps). On every push to main the +# release_actions.py script works out which action(s) changed, computes the +# next version from the newest existing /vX.Y.Z tag, creates the tag, +# moves the /vX major tag and publishes a GitHub Release. The bump type +# defaults to patch and can be raised per PR with a release:minor / +# release:major label (or suppressed with release:skip). See RELEASING.md. +name: Release actions + +on: + push: + branches: + - main + paths: + - "allowlist-check/**" + - "pelican/**" + - "stash/**" + workflow_dispatch: + inputs: + action: + description: "Tag prefix to release (allowlist-check, pelican, save, restore). Leave empty to auto-detect from the latest commit." + required: false + default: "" + bump: + description: "Version bump to apply" + required: true + type: choice + default: patch + options: + - patch + - minor + - major + +permissions: + contents: read + +# Serialize releases so concurrent pushes to main can't race on tag creation. +concurrency: + group: release-actions + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + # Credentials are persisted so the script can push tags; full + # history + tags are needed to diff the pushed range and to find + # the newest existing version tag per action. + persist-credentials: true + token: ${{ secrets.ALLOWLIST_WORKFLOW_TOKEN || github.token }} # zizmor: ignore[secrets-outside-env] + fetch-depth: 0 + fetch-tags: true + + - run: pipx install uv + + - name: Configure git identity + env: + GH_TOKEN: ${{ secrets.ALLOWLIST_WORKFLOW_TOKEN || github.token }} # zizmor: ignore[secrets-outside-env] + run: | + AUTHOR_NAME=$(gh api /user --jq '.login' 2>/dev/null || echo "asfgit") + AUTHOR_EMAIL=$(gh api /user --jq '.email // "\(.login)@users.noreply.github.com"' 2>/dev/null || echo "asfgit@users.noreply.github.com") + git config --local user.name "${AUTHOR_NAME}" + git config --local user.email "${AUTHOR_EMAIL}" + + - name: Release changed actions + env: + GH_TOKEN: ${{ secrets.ALLOWLIST_WORKFLOW_TOKEN || github.token }} # zizmor: ignore[secrets-outside-env] + EVENT_NAME: ${{ github.event_name }} + REPO: ${{ github.repository }} + BEFORE: ${{ github.event.before }} + AFTER: ${{ github.sha }} + INPUT_ACTION: ${{ github.event.inputs.action }} + INPUT_BUMP: ${{ github.event.inputs.bump }} + run: | + set -euo pipefail + args=(--repo "${REPO}" --after "${AFTER}" --apply) + if [ "${EVENT_NAME}" = "workflow_dispatch" ]; then + args+=(--bump "${INPUT_BUMP}") + if [ -n "${INPUT_ACTION}" ]; then + args+=(--action "${INPUT_ACTION}") + fi + else + args+=(--before "${BEFORE}") + fi + uv run python scripts/release_actions.py "${args[@]}" diff --git a/README.md b/README.md index 5f9697d4..236ebf01 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ This repository hosts GitHub Actions developed by the ASF community and approved - [Checking the Action Usage in an ASF Project](#checking-the-action-usage-in-an-asf-project) - [Submitting an Action](#submitting-an-action) - [Available GitHub Actions](#available-github-actions) +- [Versioning and Pinning Actions](#versioning-and-pinning-actions) - [Organization-wide GitHub Actions Allow List](#management-of-organization-wide-github-actions-allow-list) - [Pipeline Overview](#pipeline-overview) - [Adding a New Action](#adding-a-new-action-to-the-allow-list) @@ -56,6 +57,9 @@ jobs: When calling the `check-project-actions` workflow from a `push` or `pull_request` event, it should work automatically against the "right" reference. See the sample workflow linked above for more details. +To pin to an immutable, Dependabot-trackable version instead of `@main`, see +[Versioning and Pinning Actions](#versioning-and-pinning-actions). + ## Submitting an Action To contribute a GitHub Action to this repository: @@ -93,6 +97,33 @@ correctness of the action. - [Stash Action](/stash/README.md): Manage large build caches - [ASF Allowlist Check](/allowlist-check/README.md): Verify workflow action refs are on the ASF allowlist +## Versioning and Pinning Actions + +The actions in this repo are a *monorepo of actions*, and each one is released +under its own **path-prefixed tag** so you can pin a specific version and let +Dependabot propose bumps. The tag prefix is the action's leaf directory name, +which you repeat after the `@`: + +| Action | Pin it like this | +| --------------------- | ----------------------------------------------------------------------------- | +| `allowlist-check` | `apache/infrastructure-actions/allowlist-check@ # allowlist-check/v1.2.3`| +| `pelican` | `apache/infrastructure-actions/pelican@ # pelican/v1.2.3` | +| `stash/save` | `apache/infrastructure-actions/stash/save@ # save/v1.2.3` | +| `stash/restore` | `apache/infrastructure-actions/stash/restore@ # restore/v1.2.3` | + +Pinning to a commit SHA with the version in a trailing comment is the +recommended, [Zizmor](https://zizmor.sh/)-friendly form: the SHA is immutable, +and Dependabot's `github_actions` ecosystem recognises the `# /vX.Y.Z` +comment and opens a PR (updating both the SHA and the comment) when a newer +tag for that prefix is published. Support for this monorepo leaf-prefix scheme +was added to Dependabot in +[dependabot/dependabot-core#11286](https://github.com/dependabot/dependabot-core/pull/11286), +contributed specifically for this repository. + +Tracking `@main` (as in the quick-start above) also works and always gives you +the latest code, but it drifts from any pinned SHA and Zizmor will flag the +unpinned ref. See [RELEASING.md](RELEASING.md) for how releases are cut. + ## Management of Organization-wide GitHub Actions Allow List As stated in the [ASF GitHub Actions Policy](https://infra.apache.org/github-actions-policy.html), GitHub Actions from external sources are blocked by default in all `apache/*` repositories. Only actions from the following namespaces are automatically allowed: diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 00000000..c2fc4466 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,105 @@ + + +# Releasing the actions + +This repository is a *monorepo of actions*: several independently-consumed +actions are served from one repo. To let downstream projects pin a specific +version — and let Dependabot propose bumps — each action is released under its +own **path-prefixed tag**. + +## Tag scheme + +The tag prefix is the action's **leaf directory name**, which is also the +prefix a consumer repeats in the `@ref`: + +| Action (consumed path) | Tag prefix | Example tag | +| ----------------------------------------------- | ---------------- | ------------------------ | +| `allowlist-check` | `allowlist-check`| `allowlist-check/v1.2.3` | +| `pelican` | `pelican` | `pelican/v1.2.3` | +| `stash/save` | `save` | `save/v1.2.3` | +| `stash/restore` | `restore` | `restore/v1.2.3` | + +For every `X.Y.Z` release we also move a **major** tag (`/vN`) to the +same commit, so consumers can track a major line if they prefer. + +This leaf-name prefix scheme is the format Dependabot's `github_actions` +ecosystem understands for monorepos +([dependabot/dependabot-core#11286][11286], added specifically for this repo). +Dependabot filters candidate tags by the prefix, so `save/*` and `restore/*` +bump independently. + +[11286]: https://github.com/dependabot/dependabot-core/pull/11286 + +## How a release happens (automatic) + +Releases are cut automatically by +[`.github/workflows/release-actions.yml`](.github/workflows/release-actions.yml) +on every push to `main` that touches an action's files. The workflow runs +[`scripts/release_actions.py`](scripts/release_actions.py), which: + +1. Diffs the pushed range and maps changed files to the affected action(s). A + change under `stash/shared/` releases **both** stash actions, since they + import that shared code at runtime. +2. Picks the bump type (see below). +3. For each affected action, computes the next version from the newest + existing `/vX.Y.Z` tag (the first release seeds `v1.0.0`), creates + the annotated tag, moves the `/vN` major tag and publishes a GitHub + Release with auto-generated notes. + +### Choosing the bump + +The bump defaults to **patch**. Raise or suppress it per PR: + +| Signal (PR label or commit-message token) | Effect | +| ----------------------------------------- | ------------------ | +| _none_ | `patch` (default) | +| `release:minor` / `[minor]` | `minor` | +| `release:major` / `[major]` | `major` | +| `release:skip` / `[skip release]` | no release cut | + +Labels take precedence over commit-message tokens; `release:major` wins over +`release:minor`. + +## Cutting a release manually + +Use the **Run workflow** button on the *Release actions* workflow +(`workflow_dispatch`) to release out-of-band — for example to seed the very +first tags or to force a specific bump: + +- **action** — a single tag prefix (`allowlist-check`, `pelican`, `save`, + `restore`). Leave empty to auto-detect from the latest commit. +- **bump** — `patch`, `minor` or `major`. + +## Testing the release logic + +The version math and changed-action detection are pure functions with unit +tests: + +```bash +uv run pytest scripts/test_release_actions.py +``` + +A dry run (no `--apply`, so nothing is tagged or pushed) prints what *would* +be released: + +```bash +python3 scripts/release_actions.py \ + --repo apache/infrastructure-actions --action restore --bump minor +``` diff --git a/scripts/release_actions.py b/scripts/release_actions.py new file mode 100644 index 00000000..c3917b89 --- /dev/null +++ b/scripts/release_actions.py @@ -0,0 +1,349 @@ +#!/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@ # 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 ``/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 ``/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, + repo: str, + *, + apply: bool, +) -> None: + """Create the ``X.Y.Z`` tag, move the major tag, publish a GitHub release.""" + 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/release") + 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]) + + # GitHub Release with auto-generated notes for the version tag. + _run( + [ + "gh", + "release", + "create", + version_tag, + "--repo", + repo, + "--title", + title, + "--generate-notes", + "--target", + sha, + ], + check=False, + ) + + +# --------------------------------------------------------------------------- # +# 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, args.repo, apply=args.apply) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_release_actions.py b/scripts/test_release_actions.py new file mode 100644 index 00000000..c3b532f3 --- /dev/null +++ b/scripts/test_release_actions.py @@ -0,0 +1,167 @@ +# +# 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. +# +"""Unit tests for the pure logic in ``release_actions.py``.""" + +import importlib.util +import sys +from pathlib import Path + +import pytest + +_spec = importlib.util.spec_from_file_location( + "release_actions", Path(__file__).with_name("release_actions.py") +) +ra = importlib.util.module_from_spec(_spec) +assert _spec and _spec.loader +# Register before exec so dataclass string-annotation resolution can find the +# module in sys.modules (needed under `from __future__ import annotations`). +sys.modules["release_actions"] = ra +_spec.loader.exec_module(ra) + + +# --------------------------------------------------------------------------- # +# affected_actions +# --------------------------------------------------------------------------- # +def _prefixes(actions): + return sorted(a.tag_prefix for a in actions) + + +def test_affected_single_action(): + changed = ["allowlist-check/check_asf_allowlist.py"] + assert _prefixes(ra.affected_actions(changed)) == ["allowlist-check"] + + +def test_affected_ignores_unrelated_paths(): + changed = ["README.md", "actions.yml", ".github/workflows/pytest.yml"] + assert ra.affected_actions(changed) == [] + + +def test_shared_stash_change_bumps_both_stash_actions(): + changed = ["stash/shared/mung.py"] + assert _prefixes(ra.affected_actions(changed)) == ["restore", "save"] + + +def test_save_only_change_bumps_only_save(): + changed = ["stash/save/action.yml"] + assert _prefixes(ra.affected_actions(changed)) == ["save"] + + +def test_multiple_actions_changed_at_once(): + changed = ["pelican/entrypoint.sh", "allowlist-check/action.yml"] + assert _prefixes(ra.affected_actions(changed)) == ["allowlist-check", "pelican"] + + +def test_stash_root_dep_change_does_not_falsely_match_leaf_prefixes(): + # A change to stash/pyproject.toml is not in any watch_path -> no release + # (deliberately conservative; deps bumps ride along with code changes). + assert ra.affected_actions(["stash/pyproject.toml"]) == [] + + +# --------------------------------------------------------------------------- # +# parse_version / latest_version +# --------------------------------------------------------------------------- # +def test_parse_version_matches_full_semver(): + assert ra.parse_version("restore/v1.4.2", "restore") == (1, 4, 2) + + +def test_parse_version_ignores_major_only_tag(): + assert ra.parse_version("restore/v1", "restore") is None + + +def test_parse_version_rejects_other_prefix(): + assert ra.parse_version("save/v1.0.0", "restore") is None + + +def test_parse_version_rejects_leaf_collision_lookalike(): + # "check/v1.0.0" must not be parsed as the "allowlist-check" prefix. + assert ra.parse_version("check/v1.0.0", "allowlist-check") is None + + +def test_latest_version_picks_highest(): + tags = ["save/v1.0.0", "save/v1.2.0", "save/v1.1.5", "restore/v9.9.9", "save/v1"] + assert ra.latest_version(tags, "save") == (1, 2, 0) + + +def test_latest_version_none_when_no_tags(): + assert ra.latest_version(["restore/v1.0.0"], "save") is None + + +# --------------------------------------------------------------------------- # +# bump_version +# --------------------------------------------------------------------------- # +def test_first_release_seeds_v1_0_0(): + assert ra.bump_version(None, "patch") == (1, 0, 0) + assert ra.bump_version(None, "minor") == (1, 0, 0) + assert ra.bump_version(None, "major") == (1, 0, 0) + + +def test_bump_patch(): + assert ra.bump_version((1, 2, 3), "patch") == (1, 2, 4) + + +def test_bump_minor_resets_patch(): + assert ra.bump_version((1, 2, 3), "minor") == (1, 3, 0) + + +def test_bump_major_resets_minor_and_patch(): + assert ra.bump_version((1, 2, 3), "major") == (2, 0, 0) + + +def test_bump_invalid_raises(): + with pytest.raises(ValueError): + ra.bump_version((1, 0, 0), "nope") + + +# --------------------------------------------------------------------------- # +# format helpers +# --------------------------------------------------------------------------- # +def test_format_tag_and_major_tag(): + assert ra.format_tag("restore", (1, 4, 2)) == "restore/v1.4.2" + assert ra.format_major_tag("restore", (1, 4, 2)) == "restore/v1" + + +# --------------------------------------------------------------------------- # +# determine_bump +# --------------------------------------------------------------------------- # +def test_default_bump_is_patch(): + assert ra.determine_bump([], "Fix a typo") == "patch" + + +def test_label_minor(): + assert ra.determine_bump(["release:minor"], "add feature") == "minor" + + +def test_label_major_wins_over_minor(): + assert ra.determine_bump(["release:minor", "release:major"], "") == "major" + + +def test_skip_label_returns_none(): + assert ra.determine_bump(["release:skip", "release:major"], "") is None + + +def test_commit_token_minor(): + assert ra.determine_bump([], "Add input [minor]") == "minor" + + +def test_commit_token_skip(): + assert ra.determine_bump([], "docs only [skip release]") is None + + +def test_labels_are_case_insensitive(): + assert ra.determine_bump(["Release:Major"], "") == "major"