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
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: CI

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- run: uv sync --locked
- run: uv run python -m py_compile create_tag.py
- run: uv run ruff check
- run: uv run ruff format --check
- run: uv run pyright
- run: uv run pytest
- run: ./smoke-test.sh
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.venv/
__pycache__/
.pytest_cache/
.ruff_cache/
7 changes: 7 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.4
hooks:
- id: ruff-check
args: [--fix]
- id: ruff-format
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.11
2 changes: 2 additions & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
uv 0.10.9
nodejs 20.19.4
43 changes: 33 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,41 @@
# README
# production-commit-tagger

This action does three things:
GitHub composite action that runs after a production deployment and:

- Discovers the most recent production deployment (if one exists)
- Builds a changelog between the previous and new commits
- Creates a git tag corresponding to the current deployment
- Discovers the most recent production deployment tag (if one exists)
- Builds a conventional-commit changelog between that tag and the deploying SHA
- Creates a new annotated git tag of the form `<prefix><timestamp>.<deployment_id>`
- Exposes `tag_name`, `release_body_path`, and `commit_authors` as step outputs

## Development Setup
## Usage

Create a virtual environment and install dependencies using [uv](https://github.com/astral-sh/uv):
```yaml
- uses: actions/checkout@v4
with: { fetch-depth: 0, fetch-tags: true }
- id: tag
uses: instrumentl/production-commit-tagger@main
with:
timestamp: ${{ github.event.deployment.created_at }}
deployment_id: ${{ github.event.deployment.id }}
token: ${{ secrets.GITHUB_TOKEN }} # optional; enables GitHub-user lookup for commit_authors
```

## Development

Requires [uv](https://github.com/astral-sh/uv) and Node (pyright ships as a node
binary). Both are pinned in `.tool-versions`.

```sh
uv venv .venv
source .venv/bin/activate
uv pip install -r requirements.txt pytest
uv sync # install runtime + dev deps into .venv
uv run ruff check # lint
uv run ruff format # format
uv run pyright # type check
uv run pytest # run tests
./smoke-test.sh # end-to-end: exercise the action against a throwaway repo
```

`smoke-test.sh` builds its own throwaway venv, so it leaves `./.venv` alone.

Pre-commit hooks are available via `pre-commit install`.

CI (`.github/workflows/ci.yml`) runs all of the above on every push and PR.
13 changes: 9 additions & 4 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,19 @@ outputs:
runs:
using: 'composite'
steps:
- uses: actions/setup-python@v4
- uses: astral-sh/setup-uv@v7
with:
python-version: '3.11'
- run: pip install -r ${{ github.action_path }}/requirements.txt
enable-cache: true
cache-dependency-glob: ${{ github.action_path }}/uv.lock
- name: Install dependencies
shell: bash
- run: ${{github.action_path}}/create-tag --verbose --prefix ${{ inputs.prefix }} --timestamp-format ${{ inputs.timestamp_format }} --token ${{ inputs.token }} --repository ${{ github.repository }} ${{ inputs.timestamp }} ${{ inputs.deployment_id }}
working-directory: ${{ github.action_path }}
run: uv sync --frozen --no-dev
- name: Create tag
id: create-tag
shell: bash
working-directory: ${{ github.action_path }}
run: uv run --frozen --no-dev python create_tag.py --verbose --prefix ${{ inputs.prefix }} --timestamp-format ${{ inputs.timestamp_format }} --token ${{ inputs.token }} --repository ${{ github.repository }} ${{ inputs.timestamp }} ${{ inputs.deployment_id }}
env:
GIT_AUTHOR_NAME: "Instrumentl GitHub Actions Bot"
GIT_COMMITTER_NAME: "Instrumentl GitHub Actions Bot"
Expand Down
49 changes: 20 additions & 29 deletions create-tag → create_tag.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,16 @@
import dateutil.parser
import git
import github
from git.exc import GitCommandError


def get_existing_tags(repo, prefix):
for tag in repo.tags:
if not tag.name.startswith(prefix):
continue
date = getattr(
tag.object, "tagged_date", getattr(tag.object, "committed_date", None)
)
date = getattr(tag.object, "tagged_date", getattr(tag.object, "committed_date", None))
if date is None:
continue
date = datetime.datetime.fromtimestamp(date, tz=datetime.timezone.utc)
yield tag, date

Expand All @@ -39,9 +40,7 @@ def get_existing_tags(repo, prefix):
@dataclass
class CommitMessage(object):
BREAKING_CHANGE_RE = re.compile(r"^BREAKING CHANGES?: (.*)$", re.M)
SUMMARY_RE = re.compile(
r"^(?P<type>[a-z]+)(?P<scope>\([^)]+\))?:\s+(?P<description>.*)"
)
SUMMARY_RE = re.compile(r"^(?P<type>[a-z]+)(?P<scope>\([^)]+\))?:\s+(?P<description>.*)")

type: str
scope: Optional[str]
Expand Down Expand Up @@ -70,17 +69,19 @@ def parse(cls, commit):
return None


def enumerate_changes(repo, latest_tag, commit, max_commits=50):
def enumerate_changes(repo, latest_tag, head_commit, max_commits=50):
try:
merge_base = repo.git.merge_base(latest_tag, commit)
except git.exc.GitCommandError:
merge_base = repo.git.merge_base(latest_tag, head_commit)
except GitCommandError:
# no merge base; treat as none
return None
for commit in itertools.islice(
repo.iter_commits(f"{merge_base}..{commit.hexsha}"), max_commits
# NB: the revision range is bounded by head_commit (the commit being deployed),
# not by change_commit -- the range is evaluated once, before the loop starts.
for change_commit in itertools.islice(
repo.iter_commits(f"{merge_base}..{head_commit.hexsha}"), max_commits
):
logging.debug(f"examining commit {commit}")
parsed = CommitMessage.parse(commit)
logging.debug(f"examining commit {change_commit}")
parsed = CommitMessage.parse(change_commit)
if parsed is not None:
yield parsed

Expand All @@ -93,9 +94,7 @@ def main():
required="GITHUB_WORKSPACE" not in os.environ,
help="Checkout directory (default %(default)s)",
)
parser.add_argument(
"--prefix", default="v2.", help="Tag prefix (default %(default)s)"
)
parser.add_argument("--prefix", default="v2.", help="Tag prefix (default %(default)s)")
parser.add_argument(
"--sha",
default=os.environ.get("GITHUB_SHA", ""),
Expand All @@ -119,9 +118,7 @@ def main():

timestamp = args.timestamp.replace(tzinfo=datetime.timezone.utc)

logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.WARNING, stream=sys.stderr
)
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.WARNING, stream=sys.stderr)

actor = os.environ.get("GITHUB_ACTOR", "unknown")

Expand All @@ -135,9 +132,7 @@ def main():

commit = repo.commit(args.sha)

new_name = (
f"{args.prefix}{timestamp.strftime(args.timestamp_format)}.{args.deployment_id}"
)
new_name = f"{args.prefix}{timestamp.strftime(args.timestamp_format)}.{args.deployment_id}"

logging.debug("scanning all existing tags")
existing_tags = list(get_existing_tags(repo, args.prefix))
Expand Down Expand Up @@ -174,13 +169,9 @@ def main():
by_type["BREAKING CHANGES"].append(f"{breaker} ({change.author})")
delta = timestamp - last_tag_date
if any(v for v in by_type.values()):
message_lines.extend(
["", f"changes since {last_tag.name} ({delta} ago):", ""]
)
message_lines.extend(["", f"changes since {last_tag.name} ({delta} ago):", ""])
else:
message_lines.extend(
["", f"no parseable changes since {last_tag.name} ({delta} ago)"]
)
message_lines.extend(["", f"no parseable changes since {last_tag.name} ({delta} ago)"])
for type, changes in by_type.items():
if not changes:
continue
Expand Down Expand Up @@ -212,7 +203,7 @@ def main():
output = {
"tag_name": new_name,
"release_body_path": release_body_path,
"commit_authors": ",".join(commit_authors)
"commit_authors": ",".join(commit_authors),
}

output = "\n".join(f"{k}={v}".format(k, v) for k, v in output.items())
Expand Down
111 changes: 111 additions & 0 deletions create_tag_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import pathlib
from unittest import mock

import git
import pytest

from create_tag import PRETTY_TYPES, CommitMessage, enumerate_changes


def _commit(message, sha="abc123", email="dev@example.com"):
"""A constrained stand-in for the git.Commit attributes the parser touches."""
commit = mock.create_autospec(git.Commit, instance=True)
commit.message = message
commit.hexsha = sha
commit.author = mock.create_autospec(git.Actor, instance=True)
commit.author.email = email
return commit


def test_parse_feat_with_scope():
parsed = CommitMessage.parse(_commit("feat(api): add new endpoint"))
assert parsed is not None
assert parsed.type == "feat"
assert parsed.scope == "(api)"
assert parsed.description == "add new endpoint"
assert parsed.breaking_changes == []


def test_parse_breaking_change_in_body():
parsed = CommitMessage.parse(
_commit("fix: handle nil case\n\nBREAKING CHANGE: response shape changed")
)
assert parsed is not None
assert parsed.type == "fix"
assert parsed.breaking_changes == ["response shape changed"]


def test_parse_non_conventional_returns_none():
assert CommitMessage.parse(_commit("not a conventional commit")) is None


def test_pretty_types_covers_all_emitted_types():
assert set(PRETTY_TYPES) >= {"feat", "fix", "perf"}


TAG = "v2.202604010000.1"


@pytest.fixture
def repo(tmp_path):
"""Throwaway repo: one tagged commit, then feat -> fix -> perf on top."""
repo = git.Repo.init(tmp_path, initial_branch="main")
with repo.config_writer() as cw:
cw.set_value("user", "email", "test@example.com")
cw.set_value("user", "name", "Test User")

def commit(filename, message):
(pathlib.Path(tmp_path) / filename).write_text(filename)
repo.index.add([filename])
return repo.index.commit(message)

commit("a.txt", "chore: initial")
repo.create_tag(TAG, message="first deploy")
commit("b.txt", "feat(api): add new endpoint")
commit("c.txt", "fix: handle nil case")
commit("d.txt", "perf: speed up parser")
return repo


def test_enumerate_changes_yields_commits_since_tag(repo):
changes = list(enumerate_changes(repo, TAG, repo.head.commit))
assert [c.description for c in changes] == [
"speed up parser",
"handle nil case",
"add new endpoint",
]


def test_enumerate_changes_range_is_bounded_by_head_commit(repo):
"""Regression guard: the range must end at head_commit, not at HEAD.

Deploying an older SHA must not pick up commits landed after it. This fails
if the revision range is built from anything other than the head_commit
argument.
"""
fix_commit = repo.commit("HEAD~1")
changes = list(enumerate_changes(repo, TAG, fix_commit))
assert [c.description for c in changes] == ["handle nil case", "add new endpoint"]


def test_enumerate_changes_skips_non_conventional_commits(repo):
(pathlib.Path(repo.working_tree_dir) / "e.txt").write_text("e")
repo.index.add(["e.txt"])
repo.index.commit("wip nonsense")
changes = list(enumerate_changes(repo, TAG, repo.head.commit))
assert [c.description for c in changes] == [
"speed up parser",
"handle nil case",
"add new endpoint",
]


def test_enumerate_changes_respects_max_commits(repo):
changes = list(enumerate_changes(repo, TAG, repo.head.commit, max_commits=2))
assert [c.description for c in changes] == ["speed up parser", "handle nil case"]


def test_enumerate_changes_without_merge_base_yields_nothing(repo):
"""No merge base (e.g. an orphan history) is swallowed, not raised."""
orphan = repo.git.commit_tree(repo.head.commit.tree.hexsha, m="feat: orphan")
assert list(enumerate_changes(repo, TAG, repo.commit(orphan))) == []
40 changes: 40 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
[project]
name = "production-commit-tagger"
version = "0.1.0"
description = "Create tags after a production deployment"
requires-python = ">=3.11"
dependencies = [
"python-dateutil>=2.8,<3",
"GitPython>=3.1.40,<4",
"PyGitHub>=2,<3",
]

[dependency-groups]
dev = [
"ruff>=0.8",
"pyright>=1.1",
"pytest>=8",
]

[tool.uv]
package = false

[tool.ruff]
line-length = 100
target-version = "py311"

[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"F", # pyflakes (catches duplicate kwargs, unused imports, undefined names)
"I", # isort
"B", # flake8-bugbear
]

[tool.pyright]
pythonVersion = "3.11"
typeCheckingMode = "standard"

[tool.pytest.ini_options]
testpaths = ["."]
python_files = ["*_test.py"]
3 changes: 0 additions & 3 deletions requirements.txt

This file was deleted.

Loading