Skip to content

Commit ba45e40

Browse files
authored
Add v2 modularization CI gates, release workflow, and runbook (#1856)
* 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. * address review: harden v2 CI gates and release workflow - v2-release.yml: drop to least-privilege (read default, write only on the tag-push job) and run the release-mode build in pre-flight so an unbuildable commit cannot be tagged. - check-acyclic-deps.sh: fail closed when a module or imported module is missing from the tier map, instead of defaulting to a permissive top tier. - check-no-replaces.sh: catch block-form replace directives, not just the single-line form. * address second review pass on v2 CI scaffolding - check-release-mode.sh: split the cleanup checkout so a missing go.work.sum can no longer abort the whole revert and leave modules/*/go.mod dirty (git checkout treats multi-pathspec atomically). - v2-release.yml: validate the version input looks like v2.x before pushing immutable tags; drop a stray whitespace line in the ORDER array. - runbook: point step 4 at check-no-replaces.sh instead of a single-line grep that misses block-form replaces. * address review comments on v2 CI scaffolding - v2-checks.yml: run on pull_request + push to main only (was every branch push, duplicating PR runs). - Pin actions/checkout and jdx/mise-action to the SHAs already used elsewhere in the repo (both workflows). - Drop the executable bit on the gate scripts; they are invoked via 'bash scripts/...'. - check-release-mode.sh: rename B to MODULE_BASE; move function declarations ahead of procedural code; scope the repo-root cd inside main(); keep all scratch files under a single mktemp -d instead of polluting /tmp; derive the consumer import list from the ORDER list instead of duplicating it. * address overlooked issues from second review pass Shell (from independent review): - check-siv-placement.sh: grep -o so the /v2 filter inspects the import token, not the whole source line (a comment or second import could mask a real SIV bug); add the pre-split skip-guard; drop echo|sed. - check-release-mode.sh: guard the repo-root cd; note core leaf packages must stay in sync. - check-no-replaces.sh: replace echo|sed with a here-string. - Document the intentional lack of -e in the failure-accumulating gates. Workflows/docs (from independent review): - Fix the checkout pin comment: the SHA is v6, not v4.2.2. - v2-release.yml: add job timeouts; single-source the module list via a job-level env used by both the tag and proxy steps; tighten the version regex to full semver; make the post-push proxy check a warning so a slow proxy does not fail an already-pushed release. - v2-checks.yml: add a cancel-in-progress concurrency group. - Runbook: point at the automated release workflow and clarify the external consumer is generated by check-release-mode.sh, not a committed test-external dir. * drop the success echo in check-no-replaces * address CodeRabbit re-review: worktree guard and read-only checkout creds - check-release-mode.sh: refuse to run against a dirty modules/ or go.work.sum tree, so the cleanup trap cannot discard a developer's uncommitted work on a local run. - v2-checks.yml and the v2-release verify job: set persist-credentials: false on the read-only checkouts (zizmor artipacked). The tag-push checkout keeps credentials since it pushes tags. * drop the no-replaces gate Remove check-no-replaces.sh, the release-prep-guard job in v2-checks, and the pre-flight call in v2-release. go.work keeps go.mods clean during development and replaces are dropped at release-prep, so the separate gate is not needed. The runbook keeps a manual reminder to confirm no replace directives remain before tagging. * release-mode gate: derive module list from disk to support a partial split Replaces the hardcoded 16-module ORDER with one derived from which modules have a go.mod, so the gate validates a single-tier split (e.g. core only) as well as the full split. Also exercises all core leaf packages in the consumer and drops the hardcoded '16 modules' wording. Inert until the first submodule lands (the pre-split guard still skips).
1 parent f2ca1f4 commit ba45e40

7 files changed

Lines changed: 537 additions & 0 deletions

File tree

.github/workflows/v2-checks.yml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
name: v2 Modularization Checks
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
# Cancel superseded runs on the same ref; these gates are cheap and idempotent.
9+
concurrency:
10+
group: v2-checks-${{ github.ref }}
11+
cancel-in-progress: true
12+
13+
permissions:
14+
contents: read
15+
16+
jobs:
17+
acyclic-deps:
18+
name: Acyclic dependency graph
19+
runs-on: ubuntu-latest
20+
steps:
21+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
22+
with:
23+
persist-credentials: false
24+
- run: bash scripts/check-acyclic-deps.sh
25+
26+
single-source:
27+
name: Single source of truth per package path
28+
runs-on: ubuntu-latest
29+
steps:
30+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
31+
with:
32+
persist-credentials: false
33+
- run: bash scripts/check-single-source.sh
34+
35+
siv-placement:
36+
name: /v2 SIV placement
37+
runs-on: ubuntu-latest
38+
steps:
39+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
40+
with:
41+
persist-credentials: false
42+
- run: bash scripts/check-siv-placement.sh
43+
44+
consumer-simulation:
45+
name: External consumer simulation (GOWORK=off)
46+
runs-on: ubuntu-latest
47+
steps:
48+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
49+
with:
50+
persist-credentials: false
51+
52+
- name: Set up mise
53+
uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3
54+
env:
55+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
56+
57+
- name: Release-mode build (each module pins at root versions + external consumer, GOWORK=off)
58+
run: bash scripts/check-release-mode.sh

.github/workflows/v2-release.yml

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
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+
10+
on:
11+
workflow_dispatch:
12+
inputs:
13+
version:
14+
description: 'Version to tag (e.g. v2.0.0-beta.1). Applied to every submodule.'
15+
required: true
16+
type: string
17+
commit_sha:
18+
description: 'SHA of the release-prep commit to tag.'
19+
required: true
20+
type: string
21+
dry_run:
22+
description: 'If true, print the tag commands without pushing.'
23+
required: false
24+
type: boolean
25+
default: true
26+
27+
# Default to read-only. Only the tag-pushing job is granted write, so the
28+
# pre-flight job cannot push even though it runs scripts from a dispatched SHA.
29+
permissions:
30+
contents: read
31+
32+
jobs:
33+
verify:
34+
name: Pre-flight checks
35+
runs-on: ubuntu-latest
36+
timeout-minutes: 20
37+
steps:
38+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
39+
with:
40+
ref: ${{ inputs.commit_sha }}
41+
persist-credentials: false
42+
- run: bash scripts/check-acyclic-deps.sh
43+
- run: bash scripts/check-single-source.sh
44+
- run: bash scripts/check-siv-placement.sh
45+
46+
- name: Set up mise
47+
uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3
48+
env:
49+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
50+
- name: Release-mode build (GOWORK=off external consumer)
51+
run: bash scripts/check-release-mode.sh
52+
53+
tag-and-push:
54+
name: Push lockstep tags
55+
needs: verify
56+
runs-on: ubuntu-latest
57+
timeout-minutes: 10
58+
permissions:
59+
contents: write
60+
env:
61+
VERSION: ${{ inputs.version }}
62+
# Single source of truth for the module list and tag order (mirrors ORDER
63+
# in scripts/check-release-mode.sh and the runbook tag sequence).
64+
MODULE_PATHS: >-
65+
modules/core
66+
modules/ssh modules/httphelper modules/dnshelper
67+
modules/docker modules/packer modules/database modules/opa
68+
modules/aws modules/azure modules/gcp modules/k8s modules/helm
69+
modules/terraform modules/terragrunt modules/teststructure
70+
steps:
71+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
72+
with:
73+
ref: ${{ inputs.commit_sha }}
74+
fetch-depth: 0
75+
76+
- name: Configure git
77+
run: |
78+
git config user.name "github-actions[bot]"
79+
git config user.email "github-actions[bot]@users.noreply.github.com"
80+
81+
- name: Tag in dependency order
82+
env:
83+
DRY_RUN: ${{ inputs.dry_run }}
84+
run: |
85+
set -e
86+
87+
if [[ ! "$VERSION" =~ ^v2\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
88+
echo "::error::version '$VERSION' must look like v2.<minor>.<patch>[-suffix] (e.g. v2.0.0-beta.1)"
89+
exit 1
90+
fi
91+
92+
for path in $MODULE_PATHS; do
93+
tag="${path}/v2/${VERSION}"
94+
if [ "$DRY_RUN" = "true" ]; then
95+
echo "DRY-RUN would tag: $tag"
96+
else
97+
git tag -a "$tag" -m "v2 lockstep release $VERSION"
98+
git push origin "$tag"
99+
echo "pushed $tag"
100+
fi
101+
done
102+
103+
- name: Verify proxy serves each tag
104+
if: ${{ inputs.dry_run == false }}
105+
run: |
106+
# Allow the proxy a moment to populate after push.
107+
sleep 30
108+
fail=0
109+
for path in $MODULE_PATHS; do
110+
url="https://proxy.golang.org/github.com/gruntwork-io/terratest/${path}/v2/@v/${VERSION}.info"
111+
code=$(curl -s -o /dev/null -w '%{http_code}' "$url")
112+
if [ "$code" = "200" ]; then
113+
echo " $path: 200"
114+
else
115+
echo "::warning::$path: $code from $url"
116+
fail=1
117+
fi
118+
done
119+
if [ "$fail" -ne 0 ]; then
120+
echo "::warning::Proxy has not served every tag yet. The tags are already pushed and immutable; the proxy usually populates within a few minutes. Re-run this check if needed."
121+
fi

docs/v2-release-runbook.md

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

scripts/check-acyclic-deps.sh

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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+
# No -e: we accumulate every tier violation and report them all, rather than
11+
# aborting on the first one.
12+
set -uo pipefail
13+
14+
# Tier assignment. Lower number = lower layer.
15+
declare -A TIER=(
16+
[core]=0
17+
[ssh]=1
18+
[httphelper]=1
19+
[dnshelper]=1
20+
[docker]=2
21+
[packer]=2
22+
[database]=2
23+
[opa]=2
24+
[aws]=3
25+
[azure]=3
26+
[gcp]=3
27+
[k8s]=3
28+
[helm]=3
29+
[terraform]=4
30+
[terragrunt]=4
31+
[teststructure]=4
32+
)
33+
34+
# Pre-split guard: the tier rule only holds once packages are relocated into
35+
# their /v2 submodules. Until then the flat tree has no tiers to enforce.
36+
if ! ls modules/*/go.mod >/dev/null 2>&1; then
37+
echo "acyclic-deps check: skipped (no /v2 submodules present yet)"
38+
exit 0
39+
fi
40+
41+
fail=0
42+
43+
for dir in modules/*/; do
44+
importer=$(basename "$dir")
45+
# Only real submodules carry a go.mod and a tier; skip anything else.
46+
[ -f "$dir/go.mod" ] || continue
47+
if [ -z "${TIER[$importer]+set}" ]; then
48+
echo "::error file=${dir}::module '$importer' has no tier; add it to the TIER map in check-acyclic-deps.sh"
49+
fail=1
50+
continue
51+
fi
52+
importer_tier="${TIER[$importer]}"
53+
54+
# Scan all .go files in the submodule recursively, excluding test files.
55+
while IFS= read -r gofile; do
56+
while IFS= read -r importee; do
57+
[ -z "$importee" ] && continue
58+
if [ -z "${TIER[$importee]+set}" ]; then
59+
echo "::error file=${gofile}::imports unknown module '$importee'; add it to the TIER map in check-acyclic-deps.sh"
60+
fail=1
61+
continue
62+
fi
63+
importee_tier="${TIER[$importee]}"
64+
if [ "$importee_tier" -gt "$importer_tier" ]; then
65+
echo "::error file=${gofile}::tier violation — $importer (tier $importer_tier) imports $importee (tier $importee_tier)"
66+
fail=1
67+
fi
68+
done < <(grep -oE '"github\.com/gruntwork-io/terratest/modules/[a-z][a-z0-9-]*' "$gofile" 2>/dev/null \
69+
| awk -F'/' '{print $NF}' \
70+
| sort -u)
71+
done < <(find "$dir" -name '*.go' -not -name '*_test.go' 2>/dev/null)
72+
done
73+
74+
if [ "$fail" -ne 0 ]; then
75+
echo "::error::Tier-violation imports detected. Imports must flow downward only."
76+
exit 1
77+
fi
78+
79+
echo "acyclic-deps check: OK"

0 commit comments

Comments
 (0)