Skip to content

Commit ade94c9

Browse files
committed
add v2 modularization CI gates, release workflow, and runbook
Five gate scripts (acyclic deps, single source, SIV placement, no replaces, release-mode build), a checks workflow, a manual lockstep release workflow, and the release runbook. Structural gates skip until the /v2 submodules exist, so they land green now and enforce automatically once the split arrives.
1 parent b9650d0 commit ade94c9

8 files changed

Lines changed: 462 additions & 0 deletions

File tree

.github/workflows/v2-checks.yml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
name: v2 Modularization Checks
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
acyclic-deps:
12+
name: Acyclic dependency graph
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
- run: bash scripts/check-acyclic-deps.sh
17+
18+
single-source:
19+
name: Single source of truth per package path
20+
runs-on: ubuntu-latest
21+
steps:
22+
- uses: actions/checkout@v4
23+
- run: bash scripts/check-single-source.sh
24+
25+
siv-placement:
26+
name: /v2 SIV placement
27+
runs-on: ubuntu-latest
28+
steps:
29+
- uses: actions/checkout@v4
30+
- run: bash scripts/check-siv-placement.sh
31+
32+
consumer-simulation:
33+
name: External consumer simulation (GOWORK=off)
34+
runs-on: ubuntu-latest
35+
steps:
36+
- uses: actions/checkout@v4
37+
38+
- name: Set up mise
39+
uses: jdx/mise-action@v3
40+
env:
41+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
42+
43+
- name: Release-mode build (all 16 modules pin + external consumer, GOWORK=off)
44+
run: bash scripts/check-release-mode.sh
45+
46+
release-prep-guard:
47+
name: No local terratest replaces (release commits only)
48+
runs-on: ubuntu-latest
49+
if: |
50+
startsWith(github.ref, 'refs/tags/modules/') ||
51+
contains(github.event.head_commit.message, '[release-prep]')
52+
steps:
53+
- uses: actions/checkout@v4
54+
- run: bash scripts/check-no-replaces.sh

.github/workflows/v2-release.yml

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
name: v2 Release (lockstep tag push)
2+
3+
# Manual-trigger workflow that pushes the full v2 tag set from a release-prep commit.
4+
# Procedure documented in docs/v2-release-runbook.md.
5+
#
6+
# Use this AFTER:
7+
# - scripts/release-prep.sh ran on the release-prep branch
8+
# - The release-prep commit was reviewed and approved
9+
# - check-no-replaces.sh passes on that commit
10+
11+
on:
12+
workflow_dispatch:
13+
inputs:
14+
version:
15+
description: 'Version to tag (e.g. v2.0.0-beta.1). Applied to every submodule.'
16+
required: true
17+
type: string
18+
commit_sha:
19+
description: 'SHA of the release-prep commit to tag.'
20+
required: true
21+
type: string
22+
dry_run:
23+
description: 'If true, print the tag commands without pushing.'
24+
required: false
25+
type: boolean
26+
default: true
27+
28+
permissions:
29+
contents: write
30+
31+
jobs:
32+
verify:
33+
name: Pre-flight checks
34+
runs-on: ubuntu-latest
35+
steps:
36+
- uses: actions/checkout@v4
37+
with:
38+
ref: ${{ inputs.commit_sha }}
39+
- run: bash scripts/check-no-replaces.sh
40+
- run: bash scripts/check-acyclic-deps.sh
41+
- run: bash scripts/check-single-source.sh
42+
- run: bash scripts/check-siv-placement.sh
43+
44+
tag-and-push:
45+
name: Push lockstep tags
46+
needs: verify
47+
runs-on: ubuntu-latest
48+
steps:
49+
- uses: actions/checkout@v4
50+
with:
51+
ref: ${{ inputs.commit_sha }}
52+
fetch-depth: 0
53+
54+
- name: Configure git
55+
run: |
56+
git config user.name "github-actions[bot]"
57+
git config user.email "github-actions[bot]@users.noreply.github.com"
58+
59+
- name: Tag in dependency order
60+
env:
61+
VERSION: ${{ inputs.version }}
62+
DRY_RUN: ${{ inputs.dry_run }}
63+
run: |
64+
set -e
65+
66+
# Same order as docs/v2-release-runbook.md.
67+
ORDER=(
68+
modules/core
69+
modules/ssh modules/httphelper modules/dnshelper
70+
modules/docker modules/packer modules/database modules/opa
71+
modules/aws modules/azure modules/gcp modules/k8s modules/helm
72+
modules/terraform modules/terragrunt modules/teststructure
73+
74+
)
75+
76+
for path in "${ORDER[@]}"; do
77+
tag="${path}/v2/${VERSION}"
78+
if [ "$DRY_RUN" = "true" ]; then
79+
echo "DRY-RUN would tag: $tag"
80+
else
81+
git tag -a "$tag" -m "v2 lockstep release $VERSION"
82+
git push origin "$tag"
83+
echo "pushed $tag"
84+
fi
85+
done
86+
87+
- name: Verify proxy serves each tag
88+
if: ${{ inputs.dry_run == false }}
89+
env:
90+
VERSION: ${{ inputs.version }}
91+
run: |
92+
# Allow the proxy a moment to populate after push.
93+
sleep 30
94+
fail=0
95+
for path in modules/core modules/ssh modules/httphelper modules/dnshelper modules/docker modules/packer modules/database modules/opa modules/aws modules/azure modules/gcp modules/k8s modules/helm modules/terraform modules/terragrunt modules/teststructure; do
96+
url="https://proxy.golang.org/github.com/gruntwork-io/terratest/${path}/v2/@v/${VERSION}.info"
97+
code=$(curl -s -o /dev/null -w '%{http_code}' "$url")
98+
if [ "$code" = "200" ]; then
99+
echo " $path: 200"
100+
else
101+
echo "::error::$path: $code from $url"
102+
fail=1
103+
fi
104+
done
105+
if [ "$fail" -ne 0 ]; then
106+
echo "::error::Proxy verification failed. Some tags may need a few minutes; retry the verify step manually if so."
107+
exit 1
108+
fi

docs/v2-release-runbook.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Terratest v2 Release Runbook
2+
3+
How to cut a coordinated release of the v2 submodules. Read this before tagging.
4+
5+
## Layout
6+
7+
v2 is split into per-domain modules under `modules/<name>/`, each declaring
8+
`module github.com/gruntwork-io/terratest/modules/<name>/v2`. Local development
9+
uses the root `go.work`, which resolves every submodule to its local tree, so the
10+
submodule `go.mod` files do not need internal `require` lines or `replace`
11+
directives during normal development.
12+
13+
## Why pinning is a release-time step (do not commit it early)
14+
15+
The submodules' `go.mod` files are deliberately left without cross-module
16+
`require` lines on `main`. Pinning a sibling `require` to the to-be-published
17+
version (e.g. `core/v2 v2.0.0-beta.1`) BREAKS the workspace build until that tag
18+
actually exists: `go.work` does not shadow an unpublished required version, so
19+
`go build` and `go work sync` try to fetch the missing revision and fail. The pin
20+
must therefore happen on a short-lived release-prep branch, immediately before the
21+
tags are pushed, never on the modularization PR.
22+
23+
CI validates the release-mode build continuously without committing the pin: the
24+
`GOWORK=off` check generates the pinned state with throwaway `replace` directives,
25+
builds a consumer, and discards it (see `scripts/`).
26+
27+
## Pre-flight (on a release-prep branch)
28+
29+
1. Choose the version, e.g. `v2.0.0-beta.1`.
30+
2. Pin each module, in dependency order (core first, then helpers, tooling,
31+
platforms, k8s/helm, IaC). For each module:
32+
- Add a temporary `replace` for EVERY sibling it transitively needs, not just
33+
its direct imports. Tidy follows transitive edges, so a partial replace set
34+
fails with `unknown revision` on a deeper sibling.
35+
- Seed the module with the root `go.mod`'s exact dependency versions before
36+
tidying (copy its `require` lines in). Otherwise tidy floats deps to the
37+
latest compatible release and you get silent version drift, e.g. the Azure
38+
SDK advancing to a release that drops a symbol the code uses
39+
(`undefined: armmonitor.DiagnosticSettingsClient`). The code is tested
40+
against the root's pinned versions; the submodules must inherit them.
41+
- `GOWORK=off go mod tidy` to populate external `require`s and `go.sum`.
42+
43+
`scripts/check-release-mode.sh` performs this exact pin (transitive replaces +
44+
root-version seeding + an all-module external consumer build under `GOWORK=off`)
45+
in a throwaway and reverts it, and runs in CI on every PR so the release-mode
46+
build is validated continuously without committing the pin or needing tags.
47+
3. Set every internal `require` to the exact version being tagged, then DROP all
48+
internal `replace` directives. Do not run `go work sync` against the unpinned
49+
tree.
50+
4. CI guard: `grep -nH '^replace github.com/gruntwork-io/terratest' modules/*/go.mod`
51+
must return zero before tagging.
52+
5. Move `test/` to its own module here too if not already done, and pin it the
53+
same way (it is test-only, so committed `replace`s are acceptable for it).
54+
55+
## Tag push order
56+
57+
All tags point at the same release commit. Each tag name puts the `/v2` SIV in the
58+
tag itself: `modules/<name>/v2/<version>` (NOT `modules/<name>/<version>`, which
59+
the proxy cannot associate with the `/v2` module path). Push in dependency order:
60+
61+
1. `modules/core/v2/v2.0.0-beta.1`
62+
2. helpers: `ssh`, `httphelper`, `dnshelper`
63+
3. tooling: `docker`, `packer`, `database`, `opa`
64+
4. platforms: `aws`, `azure`, `gcp`, then `k8s`, `helm`
65+
5. IaC: `terraform`, `terragrunt`, `teststructure`
66+
67+
After each tier, probe the proxy before continuing:
68+
`curl -o /dev/null -w '%{http_code}' https://proxy.golang.org/github.com/gruntwork-io/terratest/modules/<name>/v2/@v/<version>.info`
69+
should return 200.
70+
71+
## Verify
72+
73+
Run the `test-external/` consumer against the published tags with `GOWORK=off`. A
74+
clean external consumer should resolve, build, and test green with zero local
75+
references.
76+
77+
## If a tag is wrong
78+
79+
Proxy tags are immutable. Recover by cutting the next patch (`v2.0.0-beta.2`),
80+
never by editing in place. The pre-flight checks exist to keep this rare.
81+
82+
## Beta to GA
83+
84+
After the beta soaks (suggested two weeks minimum), repeat the same procedure at
85+
`v2.0.0` with no suffix: same release commit shape, same tag sequence, same proxy
86+
verification, then announce.

scripts/check-acyclic-deps.sh

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#!/usr/bin/env bash
2+
# check-acyclic-deps.sh — fails CI if any submodule's production code imports a
3+
# module from a strictly higher tier. Enforces the v2 layering rule:
4+
# core → helpers → tooling → platforms → IaC, downward-only.
5+
#
6+
# Test files (*_test.go) are excluded; cross-module test-only imports are allowed
7+
# (e.g. modules/core/logger/parser_test imports modules/shell/v2 — legal per the
8+
# RFC's external _test package rule).
9+
10+
set -uo pipefail
11+
12+
# Tier assignment. Lower number = lower layer.
13+
declare -A TIER=(
14+
[core]=0
15+
[ssh]=1
16+
[httphelper]=1
17+
[dnshelper]=1
18+
[docker]=2
19+
[packer]=2
20+
[database]=2
21+
[opa]=2
22+
[aws]=3
23+
[azure]=3
24+
[gcp]=3
25+
[k8s]=3
26+
[helm]=3
27+
[terraform]=4
28+
[terragrunt]=4
29+
[teststructure]=4
30+
)
31+
32+
# Pre-split guard: the tier rule only holds once packages are relocated into
33+
# their /v2 submodules. Until then the flat tree has no tiers to enforce.
34+
if ! ls modules/*/go.mod >/dev/null 2>&1; then
35+
echo "acyclic-deps check: skipped (no /v2 submodules present yet)"
36+
exit 0
37+
fi
38+
39+
fail=0
40+
41+
for dir in modules/*/; do
42+
importer=$(basename "$dir")
43+
importer_tier="${TIER[$importer]:-99}"
44+
45+
# Scan all .go files in the submodule recursively, excluding test files.
46+
while IFS= read -r gofile; do
47+
while IFS= read -r importee; do
48+
[ -z "$importee" ] && continue
49+
importee_tier="${TIER[$importee]:-99}"
50+
if [ "$importee_tier" -gt "$importer_tier" ]; then
51+
echo "::error file=${gofile}::tier violation — $importer (tier $importer_tier) imports $importee (tier $importee_tier)"
52+
fail=1
53+
fi
54+
done < <(grep -oE '"github\.com/gruntwork-io/terratest/modules/[a-z][a-z0-9-]*' "$gofile" 2>/dev/null \
55+
| awk -F'/' '{print $NF}' \
56+
| sort -u)
57+
done < <(find "$dir" -name '*.go' -not -name '*_test.go' 2>/dev/null)
58+
done
59+
60+
if [ "$fail" -ne 0 ]; then
61+
echo "::error::Tier-violation imports detected. Imports must flow downward only."
62+
exit 1
63+
fi
64+
65+
echo "acyclic-deps check: OK"

scripts/check-no-replaces.sh

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/usr/bin/env bash
2+
# check-no-replaces.sh — fails the build if a release commit still contains local
3+
# `replace github.com/gruntwork-io/terratest/...` directives. Required before
4+
# tagging the lockstep release.
5+
#
6+
# Invoked only when the workflow runs in "release-prep" mode (controlled by a
7+
# CI env var or a manual workflow_dispatch). Dev branches keep their replaces.
8+
9+
set -euo pipefail
10+
11+
matches=$(grep -nH '^replace github.com/gruntwork-io/terratest' modules/*/go.mod cmd/*/go.mod 2>/dev/null || true)
12+
13+
if [ -n "$matches" ]; then
14+
echo "::error::Local terratest replace directives present in release commit:"
15+
echo "$matches" | sed 's/^/ /'
16+
echo "::error::Strip them before tagging (see docs/v2-release-runbook.md)."
17+
exit 1
18+
fi
19+
20+
echo "no-replaces check: OK"

scripts/check-release-mode.sh

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
#!/usr/bin/env bash
2+
# check-release-mode.sh — validates the v2 modules build in release mode
3+
# (GOWORK=off) WITHOUT needing published tags.
4+
#
5+
# For each module it: (1) adds throwaway internal `replace`s for every sibling,
6+
# (2) seeds the module with the ROOT go.mod's exact dependency versions so naive
7+
# tidy cannot float a dep to an incompatible newer release, then (3) tidies.
8+
# Finally it builds an external consumer that imports every module. All changes
9+
# are reverted on exit; nothing is committed.
10+
set -uo pipefail
11+
cd "$(git rev-parse --show-toplevel)"
12+
export GOFLAGS="${GOFLAGS:--tags=aws,azure,azure_ci_excluded,azureslim,compute,gcp,helm,kubeall,kubernetes,network}"
13+
B="github.com/gruntwork-io/terratest/modules"
14+
ORDER="core ssh httphelper dnshelper docker packer database opa aws azure gcp k8s helm terraform terragrunt teststructure"
15+
16+
# Pre-split guard: until the /v2 submodules exist, there is nothing to validate.
17+
# This lets the gate land and run green before the modularization commit, and do
18+
# the real release-mode build afterward.
19+
if ! ls modules/*/go.mod >/dev/null 2>&1; then
20+
echo "release-mode check: skipped (no /v2 submodules present yet)"
21+
exit 0
22+
fi
23+
24+
cleanup() {
25+
git checkout -- modules/ go.work.sum >/dev/null 2>&1 || true
26+
git status --porcelain 2>/dev/null | awk '/^\?\?.*modules\/.*\/go\.sum$/{print $2}' | xargs -r rm -f
27+
[ -n "${TMP:-}" ] && rm -rf "$TMP"
28+
}
29+
trap cleanup EXIT
30+
31+
# Exact dependency versions the code is tested against, from the root module.
32+
REQARGS=$(go mod edit -json go.mod | jq -r '.Require[]? | "-require=\(.Path)@\(.Version)"' | tr '\n' ' ')
33+
34+
fail=0
35+
for m in $ORDER; do
36+
( cd "modules/$m"
37+
for s in $ORDER; do [ "$s" = "$m" ] || go mod edit -replace="$B/$s/v2=../$s"; done
38+
# shellcheck disable=SC2086
39+
go mod edit $REQARGS
40+
GOWORK=off go mod tidy ) 2>/tmp/cr_$m.err || { echo "::error::release-mode tidy failed: modules/$m"; tail -4 /tmp/cr_$m.err; fail=1; }
41+
done
42+
[ "$fail" -ne 0 ] && { echo "release-mode check: FAILED (per-module pin)"; exit 1; }
43+
44+
TMP=$(mktemp -d)
45+
{ echo "module releasecheckconsumer"; echo "go 1.26"; } > "$TMP/go.mod"
46+
for s in $ORDER; do
47+
go mod edit -modfile="$TMP/go.mod" -require="$B/$s/v2@v2.0.0" -replace="$B/$s/v2=$(pwd)/modules/$s"
48+
done
49+
{
50+
echo "package main"; echo "import ("
51+
echo " _ \"$B/core/v2/random\""; echo " _ \"$B/core/v2/files\""; echo " _ \"$B/core/v2/collections\""; echo " _ \"$B/core/v2/formatting\""
52+
for s in ssh httphelper dnshelper docker packer database opa aws azure gcp k8s helm terraform terragrunt teststructure; do echo " _ \"$B/$s/v2\""; done
53+
echo ")"; echo "func main() {}"
54+
} > "$TMP/main.go"
55+
( cd "$TMP" && GOWORK=off go mod tidy && GOWORK=off go build ./... ) 2>/tmp/cr_consumer.err \
56+
|| { echo "::error::external consumer failed to build in release mode"; tail -10 /tmp/cr_consumer.err; echo "release-mode check: FAILED (consumer)"; exit 1; }
57+
58+
echo "release-mode check: OK (all 16 modules pin at root versions + external consumer builds with GOWORK=off)"

0 commit comments

Comments
 (0)