Second remediation round from end-to-end testing released 0.1.6 against the
canary repo Azure/terraform-azurerm-avm-ptn-example-repo (F23–F48).
Two numbering sequences collided during review, so the F tags below are
branch-local. Where a reviewer used the same number for a different defect, the
mapping is:
| Reviewer ID | Tag used here | Defect |
|---|---|---|
| F39 | F41 | Three unanchored ::error:: annotations per failure, worst-first |
| F40 | F42 | format and docs auto-fix the CI working copy and report pass |
Both are fixed in this release. The F39 and F40 tags below refer to the
shell-hook guard and the empty-tier status respectively.
Breaking
-
avm pr-checknow fails on formatting and documentation drift.format
anddocspreviously rewrote your files in the CI working copy and reported
pass, so the fix was thrown away with the runner and the defect merged. Both
steps now check rather than fix, and reportfailwith one issue per file. A
repository with unformatted sources or a staleREADME.mdthat passed on
0.1.6will fail on0.1.7. Action required: runavm pre-commit
locally and commit the result.pre-commitstill auto-fixes — onlypr-check
gates. (F42) -
The end-to-end check name has changed. Fanning e2e out across a per-example
matrix renames the check fromEnd-to-end teststo
End-to-end tests (<example>), one per example. A branch protection rule that
requires the old literal name will not error — it will simply never be
satisfied, and pull requests will wait on a check that no longer reports.
Action required: in Settings → Branches → your protection rule, remove the
required checkEnd-to-end testsand add eachEnd-to-end tests (<example>)
entry that now appears. Do this in the same change that picks up0.1.7. -
avmno longer writes result objects to the success stream. Anything that
captured a command's output as an object needs--passthruto get it back.
(F23) -
avm test e2e --example <name>treats an unknown name, or one carrying a
.e2eignoremarker, as a hard error rather than a silent skip. This is
deliberate — it is what stops a typo in a CI matrix from quietly passing — but
a matrix generated by hand rather than by--listmay now fail. -
The round-robin subscription fan-out reads
TEST_SUBSCRIPTION_IDS. Repos
configured with onlyARM_SUBSCRIPTION_IDstill work through the preserved
fallback path, but every parallel matrix leg then deploys into the same
subscription and may hit quota where a sequential run did not. -
avm pr-checknow runs the unit test tier, and itsteststep is renamed
tovalidate. The step calledtestnever ran a test: it routes to
terraform validate/bicep build, reportsFilesProcessedand carries no
run counts, so a module whose tests were broken — or entirely absent — still
produced an all-green gauntlet in a few seconds. The step is now named for
what it does, and a realunit teststep runs after it.Two consequences.
pr-checkgets slower by the cost of your unit tier
(~20s on a typical module). And it can newly fail: if your unit tests were
never actually running locally, this is the run that tells you. Both are the
point —pr-checkdocuments itself as running every check that runs in CI,
and that claim was not true while the unit tier was absent. Only the unit
tier is included, because it is the one tier that is credential-free by
design;integrationande2eneed a live subscription and stay out.
avm pre-commitis unchanged and still offline. (F38) -
A repo misconfiguration now fails the gauntlet instead of skipping it.
avm pr-checkandavm pre-commitmapped everyAvmConfigurationException
toskipped, which does not flip the overall status. That type is thrown
both for "this verb does not apply to this ecosystem" and for real
misconfigurations — an unresolvable tflint or mapotf config bundle, an
unresolvable managed-files repo id, an invalid.avmcontext override,
AVM_OFFLINE=1, an invalidAVM_MIRROR, an unknown or.e2eignored e2e
example. Any of those inside a gauntlet step rendered as a benign green run.
Ecosystem gates now throw the newAvmNotSupportedException(which derives
fromAvmConfigurationException, so nothing else changes), and only that
type still skips. Action required: none, unless one of your repos was
quietly misconfigured — in which case the gauntlet will now say so. The step
fails rather than errors, so the chain still runs to the end and reports
every problem instead of stopping at the first. (F39) -
A test tier with no test files now reports
skipped, notpass.
avm test unit,avm test integrationandavm test e2ereturned
Status: passwithFilesProcessed = 0when the module shipped no
tests/<tier>/*.tftest.hcl(or no runnableexamples/). Once F38 put the
unit tier insidepr-check, that rendered asunit test -> passfor a module
with no tests at all — indistinguishable from a real pass, and exactly the
vacuous-green failure mode the F33 run counts exist to prevent, one level up.
skippeddoes not flip the overall status and the CLI still exits0, so a
module with no tier is reported rather than broken. Action required: none,
unless you parseStatusfrom these verbs — an absent tier now yields
skipped. A tier whose files exist but execute norunblocks still reports
pass;RunsTotal = 0is the signal there. (F40)
Upgrade notes
tflint --initneeds an authenticated token outside the reusable workflow.
TFLint resolves its ruleset plugins through the GitHub releases API, which
allows 60 unauthenticated requests per hour per source IP. Hosted runners
share an egress IP, so a busy period surfaces as an intermittent, platform-
correlated lint failure that looks like a flake rather than a rate limit.
.github/workflows/terraform-module.ymland this repo's own CI now both set
GITHUB_TOKENfor the 5,000/hour authenticated budget, and a workflow-contract
test keeps the two from drifting apart again. If you self-host runners, or call
tflintoutside the reusable workflow, setGITHUB_TOKENin that environment
too.
Added
avm test e2e --example <name>targets a single example. It accepts either
the folder leaf (example-a) or a repo-relative path (examples/example-a)
and may be repeated. Omitting it keeps the existing behaviour of running every
runnable example sequentially. A name that does not exist, or that carries a
.e2eignoremarker, is a hard error listing the valid names rather than a
silent pass, so a typo in a CI matrix cannot skip a real test. (F26)avm test e2e --listemits a compact JSON array of runnable example names and
nothing else, so a workflow can build a matrix withfromJson()and no
post-processing. A module with no runnable examples emits[]. (F27)- The Terraform reusable workflow fans end-to-end tests out across a per-example
matrix withfail-fast: false, restoring the governance round-robin
subscription fan-out so parallel legs do not collide on quota. The matrix is
skipped cleanly when a module has no runnable examples. - Every step and sub-step now carries
StartTime,EndTimeand a duration, both
on the result objects and in the rendered output. (F29) RUNNER_DEBUG=1(set when a workflow is re-run with debug logging) turns
verbose on, and verbose now cascades from the entry point to engines,
sub-cmdlets andInvoke-AvmProcess.AVM_VERBOSE=1does the same outside
GitHub Actions. (F30)avm test unitandavm test integrationreportRunsTotal,RunsPassedand
RunsFailedalongsideFilesProcessed, and render the tally. A file count is a
poor coverage signal; a run count exposes an empty suite immediately. (F33)
Changed
- All module output routes through a single writer with levels, which is GitHub
Actions aware (::group::,::error::,::warning::,::debug::) instead of
mixingWrite-Host,Write-InformationandWrite-Verbose. (F31) - Subprocess output is quiet by default and replayed in full at the point of
failure. Verbose or debug streams it live; GitHub Actions wraps it in a
collapsed::group::so the log is present but not noisy. Long-running
sub-steps emit a periodic elapsed-time heartbeat so a slow deploy is
distinguishable from a hang. (F28) avmrenders one summary per invocation instead of one per emitted result
object, and labels per-item rows with the item's own identity, so
avm tool listno longer reads as six conflicting statuses for one command.
(F25)avmno longer writes result objects to the success stream by default; pass
--passthruto get them back for scripting. (F23)- Both test tiers now invoke
terraform testidentically and render a progress
line per test run with its duration, instead of the unit tier running silently
and the integration tier dumping raw-jsonNDJSON on the human channel. The
terraform initsub-step follows the same quiet-by-default rule as everything
else. (F33) - Caller-rendered progress is no longer collapsed behind a GitHub Actions
::group::. Grouping exists to fold away raw child output, and a sub-step that
supplies its own line renderer emits no raw output, so the fold hid exactly the
curated per-run progress it was meant to reveal. (F33)
Fixed
- The release workflow no longer fails at the point of publishing. The
build
task stamps the tag's whole CHANGELOG section into the staged manifest's
ReleaseNotes, and the PowerShell Gallery rejects any package whose
ReleaseNotesexceeds 10600 characters — 0.1.7's section is 23987, so the
first attempt was rejected with400 (The package is invalid)after the tag
and the GitHub Release already existed.Get-AvmReleaseNotes.ps1gained
-MaxLength, which truncates on a line boundary from the bottom (a section
leads with### Breaking, so the tail is the safe end to lose) and appends a
link to the full notes on the GitHub Release, which has no such limit. Newlines
are measured at their CRLF worst case, because the packer may rewrite them on
the way into the nuspec. Thebuildtask then re-reads the stamped manifest
and fails locally if the value is still over the limit, so the cap is verified
against what actually ships rather than trusted from the generator. (F49) -CheckDriftis now genuinely read-only fordocsandtransform.format
already checked without writing, butterraform-docsandmapotfhave no
dry-run mode, so those two engines detected drift by writing first and
comparing hashes afterwards — leaving the caller's working copy rewritten by a
command whose whole job is to report rather than fix. Both now snapshot the
managed files before the tool runs and restore them from afinallyblock, so
the tree is byte-identical even when the tool throws part-way through. On an
ephemeral CI runner this was invisible; locally,avm pr-checksilently
rewrote two of the three managed-content areas. Drift detection itself is
unchanged — the tool still runs and the changed-file list is still computed
from real hashes. (F44)avm pr-checkgates formatting and documentation instead of silently fixing
them.formatanddocshad no-CheckDriftparameter — onlysyncand
transformdid — so two of the four managed-content steps could not gate at
all. Both cmdlets gained the switch and all four steps now use it. Issues
carry relative, forward-slashed paths, so a drifted file is annotated inline
in the pull request.avm pre-commitis unchanged and still auto-fixes.
(F42)- A shell hook is now only a misconfiguration when it has no PowerShell
counterpart. The guard rejected the mere presence ofsetup.sh,
teardown.sh,pre.shorpost.sh, but upstream AVM governance ships both
a.ps1and a.shside by side — soavm test unit,avm test integrationandavm test e2ethrew on every governance-compliant module.
A.shonly matters when there is no.ps1beside it, because only then
does the hook silently never run. (F39) - The test suite result object now carries
RunsTotal/RunsPassed/
RunsFailedeven when a tier ships no.tftest.hclfiles at all. That case
previously returnedStatus='pass'withFilesProcessed=0and no run-count
members, so the one shape that most needs to be conspicuous — a module with no
tests — was the one shape indistinguishable from a real result. The tier also
now says so on the console. (F38) - That same empty tier now reports
Status='skipped'rather thanpass, in the
suite engine and the e2e engine alike. Run counts made the gap legible on the
object, but inside a gauntlet the rendered status is what an author reads, and
passthere is indistinguishable from a real one. Fixed in the engines rather
than the gauntlet, soavm test uniton its own is honest too. (F40) - A failing
avmcommand reports a clean one-line failure summary and exits
non-zero instead of surfacing a rawOperationStoppedstack trace pointing at
module source. The remainder of a calling script no longer stops running, so
cleanup lines after anavmcall execute as written. (F24) - The failure headline now prefers a diagnostic that carries a file position.
A failing terraform test records a baretest run '<name>' failissue ahead of
the diagnostic naming the file, line and cause, so the console summary, the
GitHub Actions error annotation and the terminating error message all carried
the least useful line while the actionable one sat in the body. (F24) - A failed command now emits exactly one GitHub Actions annotation, anchored on
the failing file, line and column so it renders inline on the offending line in
the PR Files-changed view. A single failing test run previously produced three
::error::annotations — the positionless progress line first, the actionable
diagnostic last — and none carried a position. Annotations are capped at ten
per step, so a badly broken PR could push the useful ones past the cap and show
a reviewer nothing butrun … -> fail. Narration (the per-run progress line,
the subprocessFAILED:/TIMEOUT:lines and the gauntlet step error line)
is now plain text and can no longer crowd out the diagnostic. Paths are
normalised to forward slashes and rebased onGITHUB_WORKSPACE, because
GitHub only anchors repo-relative paths; a diagnostic with no position still
produces an unanchored annotation rather than none. Console output outside
Actions is unchanged. (F41) - A gauntlet step that fails or errors now narrates its error message at the
point of failure, so callers that invokeInvoke-AvmPrCheckor
Invoke-AvmPreCommitdirectly (bypassing the dispatcher's renderer) get a
diagnosis instead of a bare status. terraform initnow receives the same-test-directoryasterraform test.
Init only scans the defaulttests/directory when resolving modules declared
inside.tftest.hclrun blocks, so with AVM tiers undertests/unit/and
tests/integration/arun { module { source = "./tests/integration/setup" } }
helper was never installed and the tier failed immediately with
Module not installed. This affected a cold checkout identically, so CI was
hit as well as local runs, and it made the standard HashiCorp pattern for
prerequisite infrastructure unusable. (F34)terraform initnow always runs beforeterraform validateand
terraform testunless-NoInitwas passed. The previous gate on the
existence of.terraform/only proved init had run at some point, against a
possibly different set of requirements, so bumping or adding a module
dependency was not picked up on a warm working directory. CI never saw this
(every job is a fresh checkout); it only bit locally.-backend=falsekeeps
the warm path cheap. (F32)avm check policyreportedpasswithout evaluating a single policy, and
now reportsskipped. conftest was invoked with no namespace selector, so
it evaluated only its defaultmainnamespace — and none of the 266 bundled
APRL/AVMSEC.regofiles declarespackage main. Zero policies ran, exit was
0, and the step passed in ~320ms, which is about what doing nothing costs. One
of ninepr-checkgates had never been able to fail on any AVM module. The
engine now counts what conftest actually evaluated, exposes it asEvaluated,
and reportsskippedwith anavm.tf.policy-not-evaluateddiagnostic when
nothing could have been checked.skippeddoes not flip a gauntlet's overall
status, so no module breaks; under GitHub Actions it surfaces as a single
::warning::, which makes the gap visible in every run instead of invisible.
Deliberately not fixed by adding--all-namespaces: every input accessor in
the bundles destructuresterraform show -jsonshapes, so that flag alone
would evaluate all 260 rules against an input none of them can read and count
every one as a success — slower, more convincing, still no gate. The second
skip reason exists precisely to catch that state, and both reasons key off the
parser mode used to build the argv, so they retire themselves when the
plan-JSON input path lands. Policy enforcement is therefore still a follow-up
slice — this release stops it claiming otherwise. (F46)
Tests
-
Mock-invocation assertions are pinned to exact counts. The original claim
under this heading was wrong:Should -Invoke -Times 0is not vacuous on
Pester 5, which special-cases zero as exact — that behaviour is Pester 4's,
and the repo pins[5.5.0,)everywhere. The genuinely loose assertions were
the positives:-Times 1without-Exactlymeans "at least once", and 15 of
them sat in the two gauntlet suites. A double-invoke injected into
Invoke-AvmPrCheckwas caught by exactly one test — the F42 drift gate, the
only one already using-Exactly; the eight-assertion compose test passed
with every step invoked twice. All 15 are now-Exactly, and
Invoke-AvmTestUnitgained the compose assertion it never had. (F43, F45) -
The
conftestcomponent-tier stub gained anAVM_STUB_CONFTEST_OUTPUThatch,
so the component tier provescheck policycan go red end-to-end rather
than only proving it stays quiet. The previous stub emitted[]and the
fixture assertedpasson it — the vacuous case encoded as the expectation,
which is how F46 survived a green suite. The real-binary integration tier had
encoded the same false expectation against genuine conftest and the
genuine pinned bundles, and now asserts the honestskipped/
Evaluated = 0/avm.tf.policy-not-evaluatedtriple instead. Pinning the
evaluated count doubles as a canary: if a future bundle re-packages into
conftest's default namespace, that assertion fails loudly rather than the gate
silently going quiet again. (F46) -
Anchored every negative-only assertion with a positive control. A negative
matcher (Should -Not -Match,-Not -Contain,-Not -Be) passes on an empty
or$nullsubject, andShould -Not -Throwpasses when the operation was
never reached — so anItwhose only assertion is negative cannot distinguish
"the bad thing did not happen" from "nothing happened at all". Six genuine
holes were found by forcing each captured value to empty and seeing which
tests still passed: theavm doctor --jsonrouting test ('' | ConvertFrom-Jsondoes not throw), theGet-AvmVersionPSVersion cast
([version]$nulldoes not throw), theavm test e2e -Listdiscovery surface
(an empty array would collapse the CI matrix to zero legs), the module-version
encoding test (a zero-byte manifest satisfied both BOM and CRLF negatives), the
dispatcher's non-zero-exit test, and aWrite-AvmLogverbose test that was
worse than vacuous — it captured-InformationVariablefor output that only
ever reaches stream 4, so it could never fail in either direction. Each fix is
proven load-bearing by mutating the source and confirming the new anchor
catches. (F47) -
Pinned the
avm test e2e -Listdiscovery contract under GitHub Actions. The
reusable workflow feeds that string straight tofromJson()to build the
per-example e2e matrix, so a single::group::marker leaking onto the same
channel collapses every e2e leg — and the F31 logging overhaul emits those
markers only whenGITHUB_ACTIONSis set, so no existing test ran under the
condition that enables them. Proven decisive by mutation: an
Actions-conditional leak is caught by this test and by nothing else in the
suite (31 passed / 1 failed), where the same leak made unconditional is caught
by four. (F47) -
Documented the output-capture discard trap and the sweep that finds it
(Appendix L.10).| Out-Stringyields nothing when a terminating error unwinds
the pipeline, which turns any negative matcher on the capture into a vacuous
pass; redirect to a file instead. The sweep pattern is\d?\*?>&1— an earlier
audit that grepped only*>&1and6>&1missed two live2>&1sites. Each of
the four capture sites in the suite is safe for a different reason, so the
reason is now recorded alongside what would break it. (F47) -
avm check policynow stages the module's*.tf/*.tfvarsinto a temporary
directory and runs conftest there, instead of pointing--parser hcl2 .at the
repository root. conftest walks every file under its working directory, so on a
real module it hit.gitignore(or.editorconfig, or.github/**/*.yml),
died inparse configurationsbefore loading a single policy, and exited 1
with no output. That is on every AVM repository in existence: measured on the
integration fixture, conftest reported
parse config: [:1,1-2: Argument or block definition required], path: .gitignore.
After the fix the same fixture runs to completion and reports
namespaces seen: main. Naming the Terraform files as arguments — the obvious
alternative — does not work: conftest 0.68.2 on Windows then resolves--policy
paths relative to the named input and loses the drive letter.The staging directory lives under the AVM cache rather than the system temp
directory, and that is load-bearing for the same reason. conftest strips the
drive letter from--policyregardless of the input form, so an absolute bundle
path only resolves when the working directory is on the same volume. With
the bundles onC:, staging onC:exits 0 and staging onQ:exits 1 with
GetFileAttributesEx \Users\…\avm-policy-aprl\…: The system cannot find the path specified.The bundles live under the cache root, so staging there shares a
volume by construction on every OS. This was latent until the parse abort above
was fixed — that crash happens first and masked it — and it surfaced on Windows
CI, where the checkout andAVM_HOMEare onD:while the temp directory is on
C:. Reproduced locally by pointingAVM_HOMEat a second volume: the released
arrangement reportserror/avm.tf.policy-run-failed/loading policies,
and the fixed one reportsnamespaces seen: main. (F48) -
avm check policyno longer reports a conftest crash as the F46 vacuity skip.
conftest reuses exit 1 for "a policy failed" and "I aborted before evaluating
anything", and the engine only treated codes other than 0 and 1 as a
malfunction — so a crash fell through to the normal path, produced zero records,
and was reported with the diagnostic "the bundles declare no rules in the
default 'main' namespace". True about the bundles, false as the reason. A
non-zero exit with no parseable output is nowStatus='error'carrying
conftest's stderr underavm.tf.policy-run-failed. This outlives F46: that
guard self-cancels once the plan-JSON slice lands, this one does not. A module
holding no Terraform sources at all is detected before conftest is launched and
stays a skip, because nothing to check is not a failure. (F48) -
Stopped the test suite ratifying the crash above. The integration and component
tiers both assertedEvaluated -eq 0— a value both causes produce, so the
assertions passed for the crash for as long as it existed. They now pin the
cause (namespaces seen: main), which only a completed run can report. The
component stub's default output was[]with a comment claiming that was what
real conftest emits; it is not — that is the crash shape. The stub now
enumerates its working directory, emits onemain-namespace record per input,
and reproduces the parse abort if a non-Terraform file reaches it, so the
component tier witnesses the staging too. (F48)
What's Changed
- fix: e2e per-example matrix, logging overhaul, and gauntlet honesty (F23-F48) by @jaredfholgate in #17
Full Changelog: v0.1.6...v0.1.7