Skip to content

Commit 4d4495c

Browse files
committed
feat: add tagging strategy documentation for Terraform modules
1 parent e481d6d commit 4d4495c

1 file changed

Lines changed: 205 additions & 0 deletions

File tree

docs/tagging.md

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
# Tagging Strategy
2+
3+
This document describes how `terraform-module-releaser` creates, manages, and pushes Git tags and GitHub Releases for
4+
Terraform modules in a monorepo. It is intended to give AI agents and contributors a precise mental model before
5+
touching `src/releases.ts`, `src/tags.ts`, or anything related to the release pipeline.
6+
7+
## Overview
8+
9+
Every Terraform module in the monorepo gets its own **namespaced tag** (e.g., `aws/vpc/v1.2.0`) and a corresponding
10+
**GitHub Release**. Tags are deliberately **tag-only** — they do not land on any branch — because each module release
11+
contains only the files inside that module's directory. Putting these commits on a long-lived branch would pollute the
12+
repo's branching model and mix unrelated module histories together.
13+
14+
## Key Files
15+
16+
| File | Role |
17+
| ------------------------- | ---------------------------------------------------------------------- |
18+
| `src/releases.ts` | `createTaggedReleases()` — the main release engine |
19+
| `src/tags.ts` | `getAllTags()`, `deleteTags()` — read/clean operations |
20+
| `src/terraform-module.ts` | `TerraformModule` domain model, computes next tag/version |
21+
| `src/utils/github.ts` | `configureGitAuthentication()`, `getGitHubActionsBotEmail()` |
22+
| `src/utils/constants.ts` | `GITHUB_ACTIONS_BOT_NAME`, `MODULE_TAG_REGEX`, tag separator constants |
23+
| `src/utils/file.ts` | `copyModuleContents()` — excludes patterns before release commit |
24+
| `src/changelog.ts` | `createTerraformModuleChangelog()` — release body generation |
25+
26+
## Tag Naming Convention
27+
28+
A release tag takes the form:
29+
30+
```
31+
<module-path><separator><version-prefix><semver>
32+
```
33+
34+
Examples with default separator (`/`) and prefix (`v`):
35+
36+
```
37+
aws/vpc/v1.0.0
38+
aws/s3-bucket/v2.3.1
39+
kms/v0.1.0
40+
kms/examples/complete/v1.0.0
41+
```
42+
43+
The separator is configurable via the `tag-directory-separator` input (`/`, `-`, `_`, or `.`). Version prefix is toggled
44+
by `use-version-prefix`. Both affect `TerraformModule.getReleaseTag()` and `TerraformModule.getReleaseTagVersion()`.
45+
46+
### Tag-to-Module Matching (Normalization)
47+
48+
`TerraformModule.isModuleAssociatedWithTag()` normalizes **all** valid separator characters (`-`, `_`, `/`, `.`) to a
49+
single canonical form before comparing a tag to a module path. This means tags created with one separator scheme are
50+
still correctly attributed to their module even if the action's separator setting later changes.
51+
52+
The regular expression used is `MODULE_TAG_REGEX`:
53+
54+
```
55+
/^(.+)([-_/.])(v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*))$/
56+
```
57+
58+
## Release Creation Flow (`createTaggedReleases`)
59+
60+
For each module that `needsRelease()` returns `true`, the action:
61+
62+
1. **Creates a temporary directory** (`mkdtempSync`) named after the module.
63+
2. **Copies module files** into the temp dir using `copyModuleContents()`, respecting `module-asset-exclude-patterns`.
64+
3. **Copies the primary `.git` directory** (`cpSync`) so the temp dir is a valid local Git repository with its own
65+
independent copy of the object database, separate from the checked-out workspace.
66+
4. **Configures Git identity** (GitHub Actions bot name + dynamically fetched bot email via API).
67+
5. **Configures HTTPS authentication** via `http.extraheader` (base64-encoded token) — same method used by the official
68+
`actions/checkout` action.
69+
6. **Runs a sequence of Git commands** in the temp dir:
70+
```
71+
git config --local user.name "GitHub Actions"
72+
git config --local user.email "<id>+github-actions[bot]@users.noreply.github.com"
73+
git add .
74+
git commit -m "<releaseTag>\n\n<prTitle>\n\n<prBody>"
75+
git tag <releaseTag>
76+
git push origin <releaseTag>
77+
```
78+
7. **Reads the commit SHA** via `git rev-parse HEAD` immediately after the push (the GitHub API for `createRelease` does
79+
not return the underlying commit SHA).
80+
8. **Creates a GitHub Release** via `octokit.rest.repos.createRelease()` using the tag name and a fully rendered
81+
changelog body.
82+
9. **Updates the in-memory `TerraformModule`** with the new release and tag objects, then calls `clearCommits()` to
83+
prevent re-releasing the same module in the same run.
84+
85+
### Why a Temp Dir?
86+
87+
The temp dir approach allows each module's release commit to contain **only that module's files**, not the entire
88+
monorepo. This has real performance benefits for Terraform consumers — `terraform init` only downloads the tag's tree,
89+
which is small and module-scoped, rather than a full monorepo snapshot.
90+
91+
### Why Branchless Release Commits?
92+
93+
The release commits are created on a detached `HEAD` in the temp clone. They have a parent commit (the workspace `HEAD`
94+
at the time the `.git` directory is copied) but are reachable only via the pushed tag — no long-lived branch pointer is
95+
ever created on the remote. This is intentional:
96+
97+
- **No branch pollution**: branches represent active development lines; release snapshots are not development.
98+
- **Minimal trees**: each tag points to a commit whose tree contains only that module's files.
99+
- **Git stores them correctly**: even though these commits are not reachable from any branch, Git object storage keeps
100+
the commit/tree/blob objects reachable from the tag ref; the tag itself is sufficient to fetch the full content.
101+
- **Terraform works perfectly**: Terraform's Git source protocol resolves `?ref=<tag>` directly against the remote's
102+
advertised ref list. GitHub serves tags over the standard Git smart HTTP/SSH protocol. The Terraform CLI runs
103+
`git fetch` followed by `git checkout`, referencing the tag — this is entirely standard and functions without any
104+
branch being present.
105+
106+
## How Terraform Consumers Reference Tags
107+
108+
The action generates wiki usage blocks (customizable via `wiki-usage-template`) in the format:
109+
110+
```hcl
111+
module "my_module" {
112+
source = "git::https://github.com/<owner>/<repo>.git//<module-path>?ref=<module-path>/v1.2.3"
113+
}
114+
```
115+
116+
Or, when `use-ssh-source-format: true`:
117+
118+
```hcl
119+
module "my_module" {
120+
source = "git::ssh://git@github.com/<owner>/<repo>.git//<module-path>?ref=<module-path>/v1.2.3"
121+
}
122+
```
123+
124+
The `ref=` query parameter is the released tag name. Terraform's `git` source type passes this directly to
125+
`git fetch --tags origin <ref>`, which resolves correctly as long as the tag exists in the remote. **The commit does not
126+
need to be on any branch.**
127+
128+
An alternate `module-ref-mode: sha` is supported: the `ref=` value becomes the commit SHA instead of the tag name, which
129+
further guarantees immutability even if someone force-pushes a tag.
130+
131+
## Tag Lifecycle: Orphan Cleanup
132+
133+
When modules are **deleted** from the monorepo, their orphaned tags and releases become stale. If
134+
`delete-legacy-tags: true` is configured, the action:
135+
136+
1. Calls `TerraformModule.getTagsToDelete(allTags, terraformModules)` — finds tags that match `MODULE_TAG_REGEX` but
137+
have no corresponding module directory in the workspace.
138+
2. Calls `TerraformModule.getReleasesToDelete(allReleases, terraformModules)` — finds releases whose tag names have no
139+
corresponding module directory in the workspace.
140+
3. Calls `deleteTags()` (`src/tags.ts`) which issues `DELETE /repos/{owner}/{repo}/git/refs/tags/{tag}` per tag.
141+
4. Calls `deleteReleases()` (`src/releases.ts`) which issues `DELETE /repos/{owner}/{repo}/releases/{id}` per release.
142+
143+
## Tag Fetching (`getAllTags`)
144+
145+
Uses `octokit.paginate.iterator` against `repos.listTags` with `per_page: 100`. Returns `GitHubTag[]` (name +
146+
commitSHA). Tags are not sorted at fetch time — sorting happens in `TerraformModule` when associating tags to a module
147+
(sorted by SemVer: major → minor → patch descending).
148+
149+
## Error Handling
150+
151+
- **403 on push**: A missing `contents: write` permission in the workflow YAML is detected by the error message and
152+
re-thrown with an actionable fix suggestion.
153+
- **Other push failures**: Re-thrown with full status and message context.
154+
- **Tag deletion 403**: Same pattern — detected by status code and re-thrown with the required permissions block.
155+
156+
## GitHub Actions Bot Identity
157+
158+
The bot email is resolved at runtime to handle both GitHub.com and GitHub Enterprise Server:
159+
160+
```
161+
<user_id>+github-actions[bot]@users.noreply.github.com
162+
```
163+
164+
`getGitHubActionsBotEmail()` calls `octokit.rest.users.getByUsername({ username: 'github-actions[bot]' })` to get the
165+
numeric user ID, making this compatible with any GitHub server.
166+
167+
## Concurrency and Idempotency
168+
169+
- Releases are created **sequentially** (one module at a time) to avoid concurrent `git push` and GitHub API calls
170+
across modules. Each module gets its own temp dir and `.git` copy, but running releases in parallel would increase the
171+
risk of API rate limiting and add significant error-handling complexity with no meaningful throughput benefit.
172+
- **Idempotency on re-runs**: If the workflow re-runs after a partial success, the action checks for a hidden HTML
173+
marker in the PR's release comment (`PR_RELEASE_MARKER`) via `hasReleaseComment()`. If the marker exists in any
174+
comment, the entire merge handler exits early to avoid duplicate releases.
175+
176+
## Relationship to `TerraformModule`
177+
178+
`createTaggedReleases()` consumes `TerraformModule` instances but also **mutates** them:
179+
180+
- `module.setReleases([newRelease, ...module.releases])` — prepends the new release.
181+
- `module.setTags([newTag, ...module.tags])` — prepends the new tag.
182+
- `module.clearCommits()` — marks the module as no longer needing a release within the same run.
183+
184+
These mutations allow downstream code (e.g., action outputs, PR comment generation) to see the freshly released state
185+
immediately, without re-fetching from the API.
186+
187+
## Relevant Tests
188+
189+
| Test file | Coverage focus |
190+
| ------------------------------------ | --------------------------------------------------------------------------------------- |
191+
| `__tests__/releases.test.ts` | `createTaggedReleases`, `getAllReleases`, `deleteReleases`, pagination, 403 error paths |
192+
| `__tests__/tags.test.ts` | `getAllTags`, `deleteTags`, pagination, 403 error paths |
193+
| `__tests__/terraform-module.test.ts` | `getReleaseTag`, `getReleaseTagVersion`, `isModuleAssociatedWithTag`, tag normalization |
194+
195+
## Design Decisions and Trade-offs
196+
197+
| Decision | Rationale |
198+
| ---------------------------------- | ------------------------------------------------------------------------------------ |
199+
| Branchless release commits | Avoids polluting repository branches; each release tree is module-scoped and minimal |
200+
| Temp dir with `.git` copy | Isolates module files per release without touching the workspace checkout |
201+
| Sequential releases (not parallel) | Shared `.git` object store in temp dirs; avoids race conditions |
202+
| Runtime bot email resolution | Compatible with GitHub.com and GHES without hardcoding IDs |
203+
| HTTPS extraheader auth | Same mechanism as `actions/checkout`; avoids SSH key management |
204+
| Tag-only push (no branch push) | Keeps repository branches clean; standard Git tag resolution works for all consumers |
205+
| Tag name normalization on match | Handles repositories that changed separator schemes over time |

0 commit comments

Comments
 (0)