Git-aware AWS resource tagging in 370 lines of Python
A small Python library that tags every Pulumi-managed AWS resource automatically with git and Pulumi metadata. One line of integration per project; no boilerplate, no remembering.
A few years ago I worked on a small Python library that automated AWS resource tagging across a team’s Pulumi projects. Tagging on AWS is the kind of operational concern most teams converge on the hard way — a manual Environment tag here, a tagging policy document there, eventually some form of automation. This article is about that automation step: a Python library of around 370 lines that hooked into Pulumi’s resource pipeline and tagged every taggable AWS resource with metadata derived from git and from Pulumi itself. The integration cost for any project that adopted it was one line.
Why automate the tagging
Tags are the only mechanism AWS gives you to slice cost and ownership across accounts. Tagged resources flow into Cost Explorer, into Cost and Usage Reports, into incident forensics (“what changed in this account in the hour before the alert?”), into compliance audits, and into routine orphan-resource cleanups.
If the tagging is left to each author of each Pulumi program, the data ends up inconsistent — different keys, different values, different formatting — or missing. The right place to apply it is downstream of every author, in a place they don’t have to think about. Pulumi provides that place: a stack-level transformation.
Decision: stack transformations, not resource subclasses
Pulumi exposes a hook called register_stack_transformation that fires before every resource is created. The transformation function receives the resource type, the proposed properties, the options — and returns whatever it wants instead. It’s the cleanest place to inject cross-cutting concerns, because it runs for every resource, including resources created by libraries you don’t own.
The library’s entire integration looks like this:
from pulumi_autotag import register_auto_tags
register_auto_tags()
That goes once at the top of __main__.py in any Pulumi project. Every resource declared in the program — direct or indirect — gets tagged. Authors don’t change anything else.
The hook itself is small:
def register_auto_tags(auto_tags: dict[str, str] = {}) -> None:
git_info = get_git_info()
pulumi.runtime.register_stack_transformation(
lambda args: auto_tag(
args,
auto_tags,
project=pulumi.get_project(),
stack=pulumi.get_stack(),
git_info=git_info,
)
)
(The mutable default is the production code as written — a small Python footgun in this position because auto_tags is only read from, never assigned to. The form I’d ship today is auto_tags: dict[str, str] | None = None with a auto_tags = auto_tags or {} on the next line.)
The alternative I considered was a base resource class that every resource subclasses. That route forces every author to remember to use the base class — which is exactly the failure mode we’re trying to avoid. Stack transformations are unconditional: opt out is explicit, opt in is implicit.
Decision: trace detached HEAD back to its parent branches
CI runners typically check out a specific commit rather than a branch, leaving you in detached HEAD state — which makes repo.active_branch raise and the naive code path fail. The library walks back from the detached commit to find which branches contain it as an ancestor:
def get_git_info() -> tuple[str, str, str, str, str]:
repo = git.Repo(search_parent_directories=True)
commit = repo.head.commit
repo_name = repo.remotes.origin.url.split("/")[-1].split(".")[0]
if repo.head.is_detached:
# CI: detached at a specific commit. Find branches that contain it.
branches = [b for b in repo.branches if commit in b.commit.iter_parents()]
branch = "+".join(b.name for b in branches)
commit_sha = "+".join(c.hexsha[:7] for c in commit.parents)
commit_date = datetime.now(tz=timezone.utc).isoformat()
dirty = True
else:
branch = repo.active_branch.name
commit_sha = commit.hexsha[:7]
commit_date = commit.committed_datetime.isoformat()
dirty = repo.is_dirty()
return repo_name, branch, commit_sha, str(dirty), commit_date
The function works when the CI checkout has fetched the local branch refs (typically GIT_DEPTH: 0); in shallow clones the ancestor lookup has nothing to walk and the branch tag comes out as an empty string — not useful, but the deploy still goes through.
There’s also a bug in the detached branch worth flagging: commit_sha is built from commit.parents, not from commit itself, so for a normal CI build it returns the SHA of the parent commit rather than the deployed one. The fix is one line:
commit_sha = commit.hexsha[:7]
A future iteration of this code would also probably read the branch name from the CI provider’s environment variables (CI_COMMIT_REF_NAME, GITHUB_REF_NAME) rather than reconstructing it from the local clone, which sidesteps the shallow-clone problem entirely.
Decision: a curated whitelist, not a try-everything heuristic
AWS has hundreds of resource types and a confusing relationship with tags. EC2 instances have tags. S3 buckets have tags. IAM users have tags. IAM policy documents don’t. Resource policies don’t. Some Lambda permissions don’t. Trying to add a tags property to a resource that doesn’t accept one is a deploy-time error.
The library maintains a curated whitelist of around 230 taggable AWS resource types — built once, by hand, by reading the Pulumi AWS provider docs. The hook checks the resource type against the whitelist and silently skips anything that isn’t on it:
def auto_tag(args, auto_tags, project, stack, git_info):
if not is_taggable(args.type_) or "tags" not in args.props:
return pulumi.ResourceTransformationResult(args.props, args.opts)
repo, branch, sha, dirty, _ = git_info
args.props["tags"] = {
**(args.props["tags"] or {}), # respect user-provided tags
**auto_tags,
"Name": args.props.get("name")
or args.props.get("resource_name")
or args.name,
"pulumi:project": project,
"pulumi:stack": stack,
"git:repo": repo,
"git:branch": branch,
"git:commit": sha,
"git:dirty": dirty,
}
return pulumi.ResourceTransformationResult(args.props, args.opts)
The Name precedence chain prefers the AWS-visible name (the name prop most resources accept), falls back to a resource_name prop where it exists, and only as a last resort uses the Pulumi logical name (args.name) — which is the internal identifier and not always pleasant to read in the AWS console, but better than no Name at all.
The dict-merge order matters: user-provided tags win, then any extras passed at registration, then the auto-derived ones. If an author has a reason to override git:branch for some specific resource (rare but real), they can.
I considered the heuristic alternative — try to add tags, catch the error, skip on failure. The whitelist is more code but better behaviour: it gives a debug log on resources that are silently skipped, which has caught at least one real bug (a new resource type the AWS provider added but we hadn’t whitelisted yet).
What it buys you
Three things, in order of importance:
- Cost slicing. Cost Explorer grouped by
git:repoproduces a per-team breakdown that’s accurate by construction, because the tags come from git rather than from anyone’s memory. - Incident forensics. Every resource carries the SHA of the deploy that created it; CloudTrail tells you what changed, the tag tells you which deploy did it.
- Orphan cleanup. A
git:dirty=Truetag in production is a signal that someone deployed from a dirty working tree; that resource warrants investigation.
The library went through most of its real activity over a few months in early 2024 — sixteen commits in total, mostly handling edge cases as they surfaced (a detached-HEAD bug, a new resource type that needed adding to the whitelist, a committed_datetime that some grafted commits don’t have). After that the maintenance flatlined, which is what I’d want from this kind of code: cross-cutting infrastructure that quietly works.
What I’d do differently next time
- Tag key namespace. I used
git:,pulumi:, and bare keys. AWS tag keys allow these characters but some downstream tools treat the colon as a path separator and splinter the key. Underscores (git_branch,pulumi_stack) would have been the safer choice; migrating after the fact is a real cost. - Optional resource policy emission. AWS Organizations can enforce tag policies — refuse resources that don’t carry required tags. The library could emit the schema for that policy automatically; we never wired it up, and an org-level enforcement would have caught a couple of misconfigured projects earlier than we did.
What we could have used
The most obvious alternative is the AWS provider’s built-in default_tags, available in both Pulumi and Terraform: declare the tags on the provider once, every resource created through it gets them merged in, no library needed. AWS Organizations tag policies sit at the other end — they don’t add tags, they enforce that the tags you wanted are present, refusing resources that lack them. Pulumi’s CrossGuard policies offer the same enforcement at preview time inside a single project.
The library covered something default_tags doesn’t quite reach: a single line of integration regardless of how many provider instances or alias providers were in play, plus the derivation of git metadata in one place rather than duplicated across every provider declaration. The trade-off is the maintenance of the resource whitelist, which default_tags sidesteps by leaning on the provider’s own knowledge of which resources accept tags. For a single-provider project with no git-derived tags, default_tags is the right answer; for the multi-provider, git-aware case the library still earns its keep.
Why automation in this shape is worth doing
A tagging policy document is advisory; a tagging linter in CI catches violations only on the paths it sees; a library inside the deploy pipeline applies unconditionally by virtue of being in the path. With the library in place, every resource carried the right tags because the deploy applied them — not because an author had remembered, or a reviewer had spotted the omission. The cost reports, incident-forensics queries, and orphan cleanups that relied on those tags became reliable as a side effect, without anyone having to be more disciplined — the right shape for any operational rule whose value comes from being applied uniformly, and whose cost-of-getting-wrong is paid by people who weren’t in the room when the rule was written.