Skip to content

The CI guard tests hand-parse workflow YAML, and now do it twice #255

Closed

Description

@phmatray

Problem / motivation

FormCraft.UnitTests/Ci/ now holds two guard suites that read the same files the same way, with their
own copies of the code that does it.

TrustedPublishingWorkflowTests.cs (#173, #198, #226) and TestReportingTests.cs (added in #252) each
declare their own:

  • RepoRoot / LocateRepoRoot() — walk up from AppContext.BaseDirectory until FormCraft.sln
  • WorkflowsDirectory, ReadWorkflow(), ReadBuildScript()
  • WithoutComments(text, marker) — drop whole-line comments so a "not referenced" assertion fires on
    wiring rather than on prose
  • an independent enumeration of .yml/.yaml under .github/workflows

These are byte-identical including their explanatory comments — both carry the same
dir.Parent is DirectoryInfo?, so the variable has to be too note and the same
a trailing-comment strip would also mangle the "https://" in a URL justification. And
TestReportingTests.StepNamed() is a one-line variant of TrustedPublishingWorkflowTests.StepWithId():
both find a step by scanning from its header line to the next line whose trimmed text starts with
- , differing only in whether the header is matched on - name: or on id:.

This is not a tidiness complaint. That step-boundary scan is an approximation of YAML, shared by
both files and owned by neither. A fix to it now has to be made twice or the copies drift — and this
codebase has already paid that price: #205 records a copied test comment that propagated a factual
error (AddCommonFieldAttributes described as "shared by the text and numeric paths") and hid the date
path from coverage until review caught it. Duplicated scaffolding duplicates its errors too.

The gap the duplication is holding open

Both suites' assertions are file-scoped, not job-scoped, and closing that is the same piece of work.

WorkflowsThatRunTests() matches on file content, and StepNamed() scans the whole file. So a
workflow that ran ./build.cmd Test in job A and uploaded the test-results artifact from job B would
satisfy every assertion in TestReportingTests while uploading nothing at all — job B gets a fresh
workspace, so test-results/ never exists there. Combined with if-no-files-found: ignore (which
#252 pinned on all three uploads), that failure is completely silent.

This is not hypothetical: release-please.yml is already a two-job workflow (release-please and
nupkg). It is correct today only because the build and the upload happen to sit in the same job.
Structurally it is the same "declared but inert" shape #231 was filed about, moved from the artifact
path to the job boundary.

Catching it needs the workflow split into jobs before the step scan runs — which is exactly the kind of
thing that should exist once, in a shared reader, rather than being added to one copy and forgotten in
the other. The limitation is currently recorded only as a <remarks> block on TestReportingTests.

This was raised in #252's code review and deliberately deferred there, to keep a refactor of a passing
guard suite out of a build/CI fix.

Proposed solution

Extract a single FormCraft.UnitTests/Ci/WorkflowSource helper owning repo-root location, workflow
enumeration and reading (cached — the files cannot change during a run), comment stripping, and step
extraction. Move both existing classes onto it. Then give it job awareness and scope
TestReportingTests' step assertions to the job that actually runs the build.

Job splitting is done by indentation rather than by adding a YAML parser: jobs: is a top-level key
and each job is its own two-space-indented key, which is a small, testable rule. release-please.yml
is the fixture that proves it — a real two-job workflow already in the repo.

Alternatives considered

  • Leave it. The duplication is only 2× today and both suites pass. But Ci/ has grown a file per
    CI investigation (Pin the version-tag publish rule: IsOnVersionTag is unanchored by design but untested #227 added VersionTagRuleTests.cs, ci: emit test reports natively under Microsoft.Testing.Platform (#231) #252 added this one) and the third copy is the
    natural next step. Leaving it also leaves the job-scoping hole open, which is the part that can go
    silently wrong.
  • Add a real YAML parser (YamlDotNet) and drop the hand-rolled scanning entirely. Genuinely more
    correct, and worth doing if the assertions get much more structural. Rejected for now: packages are
    centrally pinned in Directory.Packages.props, and adding a dependency to assert on five files the
    repo controls is a poor trade — especially as the tests deliberately assert on text in places
    (WithoutComments exists so a documentation edit cannot redden the suite, a distinction a parsed
    model discards).
  • Merge the two suites into one class. Removes the duplication trivially, but loses the
    one-suite-per-concern layout that makes it clear which tests guard which issue — the same reasoning
    Extract a shared collection-item test fixture instead of copying models per suite #205 gives for keeping its suites separate.

Area

Test infrastructure — FormCraft.UnitTests/Ci/. No library code, no workflow or Build.cs behaviour
change.

Related: #231, #252 (introduced the second copy), #198 and #226 (own the other suite), #205 (the
same duplicated-scaffolding pattern in the MudBlazor test project), #227.

🧠 Brainstorm

Problem / context

FormCraft.UnitTests/Ci/ is a small but growing family of tests that assert on repository files
rather than on library behaviour — workflows, Build.cs. They exist because the things they guard are
exercised a handful of times a year (a release, a publish) or fail silently (#231's inert reporting), so
a regression is invisible until it is expensive.

Three files live there now: TrustedPublishingWorkflowTests.cs, VersionTagRuleTests.cs,
TestReportingTests.cs. Two of them read workflows, and both do it with their own copy of the same
~60 lines.

The shared code is not incidental — it encodes two real decisions:

  1. Comment stripping. These workflow files carry unusually long explanatory comment blocks (ci.yml
    has a ~25-line block about the artifact alone). Without stripping, a documentation-only edit reddens
    an assertion, and the natural repair under time pressure is to delete the assertion.
  2. Step boundaries. An assertion about a step's if: must not be satisfiable by another step's
    if:. Both classes solve this by slicing the file from a step header to the next - line.

Both decisions are load-bearing and both are currently duplicated.

Approaches

A. Extract a shared helper, keep line-based scanning. One WorkflowSource type: repo root, cached
file reads, comment stripping, step extraction. Both classes use it. Add job splitting by indentation
and scope the step assertions to a job.
Trade-offs: small, no new dependency, incremental. Job splitting by indentation is an approximation —
but a much smaller one than the step scan already in use, and release-please.yml provides a real
two-job fixture to test it against.

B. Extract a shared helper backed by a YAML parser. Same extraction, but WorkflowSource parses
into a model and exposes jobs and steps structurally.
Trade-offs: correct by construction, and job scoping falls out for free. Costs a centrally-pinned
dependency in the test project, and the parsed model throws away the raw text that WithoutComments
and the "is this option enabled" assertions deliberately work on — so some assertions would need to
keep raw text anyway, leaving both models in play.

C. Do nothing structural; just copy the job-splitting logic into both files too.
Trade-offs: smallest diff now, third copy of the problem. Explicitly rejected.

Recommendation

A. It removes the duplication that exists today, closes the job-scoping gap that the duplication is
holding open, and does not spend a dependency on a problem the repo can solve in a dozen lines against
a fixture it already has. B stays available and becomes an easier call once the reader is a single type
with a single set of callers — which is itself an argument for doing A first.

Assumptions: the extraction is behaviour-preserving for TrustedPublishingWorkflowTests (its
assertions do not change, only where its helpers live); no new package is added.

📋 Spec

Goal

One reader for the Ci/ guard suites, and step assertions that cannot be satisfied by a step in a
different job.

Scope

  • New FormCraft.UnitTests/Ci/WorkflowSource.cs (internal static).
  • TestReportingTests.cs and TrustedPublishingWorkflowTests.cs move onto it.
  • TestReportingTests' step assertions become job-scoped; its <remarks> limitation note goes away.

Non-goals

  • No change to any workflow, to build/Build.cs, or to library code.
  • No change to what either suite asserts, beyond adding the job scoping.
  • No new NuGet dependency.

Surface

internal static class WorkflowSource
    string  RepoRoot                                  // located once, cached
    string  WorkflowsDirectory
    IReadOnlyDictionary<string,string> All            // filename -> raw text, read once
    string  Read(string fileName)
    string  ReadBuildScript()
    string  WithoutComments(string text, string marker)
    IReadOnlyList<string> Matching(string pattern)    // workflow files whose stripped text matches
    IReadOnlyDictionary<string,string> JobsOf(string fileName)   // job name -> that job's text
    string  StepNamed(string scope, string stepName)  // scope = a whole file's text, or one job's
    string  StepWithId(string scope, string stepId)

StepNamed/StepWithId take the text to search rather than a filename, so the same primitive
serves a whole-file assertion and a job-scoped one.

Job splitting

jobs: is a top-level key; each job is a key indented two spaces beneath it. Split on that:

flowchart TD
    A[workflow text] --> B{line == 'jobs:' at col 0}
    B -->|no| A
    B -->|yes| C[scan forward]
    C --> D{line matches '^  [A-Za-z0-9_-]+:'}
    D -->|yes| E[close previous job, open new one]
    D -->|no| F{indent == 0 and non-blank}
    F -->|yes| G[end of jobs block]
    F -->|no| C
Loading

Fixture: release-please.yml must split into exactly release-please and nupkg; the single-job
workflows into one each.

Job-scoped assertions

TestReportingTests currently asks "does this file invoke the build, and does this file have a
correct upload step". It becomes: for each workflow, for each job whose text invokes
./build.[cmd|sh|ps1] (Test|Pack|PublishIfNeeded|Publish|Continuous|Release), that same job must
contain the Publish: test-results step, and that step must satisfy the existing four assertions
(if: always(), path: test-results, no **/TestResults/ glob, if-no-files-found: ignore).

The existing vacuity guard carries over: the set of (workflow, job) pairs that run the build must be
non-empty, or every assertion passes trivially.

Edge cases

  • A workflow with no jobs: key (none today) → empty job map; contributes nothing, and the vacuity
    guard is what stops that from silently emptying the set.
  • A job whose steps use run: | blocks → already handled, since ci: emit test reports natively under Microsoft.Testing.Platform (#231) #252 matches the invocation anywhere
    in the stripped text rather than on a run: prefix.
  • WithoutComments must be applied before job splitting, or a commented-out ./build.cmd Test inside
    a comment block would mark a job as test-running.

Assumptions

Both suites stay green throughout — this is a refactor plus one added assertion, and the added
assertion passes against the repo as it stands today (release-please.yml builds and uploads in the
same nupkg job).

🛠️ Implementation plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: one shared workflow reader for the Ci/ guard suites, then job-scoped step assertions.

Architecture: test-infrastructure only, inside FormCraft.UnitTests/Ci/. These suites assert on
repository files (.github/workflows/*.yml, build/Build.cs) rather than on library behaviour.

Tech stack: net10.0, xUnit v3 on Microsoft.Testing.Platform, Shouldly, Arrange-Act-Assert.

Global Constraints

  • Base branch is dev, not main. The PR targets dev.
  • Commit identity: Philippe Matray <phmatray@gmail.com>.
  • TreatWarningsAsErrors=true is deliberate — never relax it to make a build pass. Watch nullability:
    DirectoryInfo? dir in LocateRepoRoot is annotated the way it is for this reason.
  • dotnet test --filter is inert under MTP (warning MTP0001) — always run the full suite
    (dotnet test -c Release) and never report a filtered result.
  • No new NuGet package. Versions are centrally pinned in Directory.Packages.props; adding a YAML
    parser is explicitly out of scope here (see Alternatives considered).
  • No change to any workflow, to build/Build.cs, or to library code. If an assertion would require
    editing a workflow to pass, stop — that is a finding, not a refactor.
  • CHANGELOG.md is owned by release-please — never hand-edit it.

Task 1: Extract WorkflowSource and move TestReportingTests onto it

Files: create FormCraft.UnitTests/Ci/WorkflowSource.cs; modify
FormCraft.UnitTests/Ci/TestReportingTests.cs.

Interfaces: internal static class WorkflowSource exposing RepoRoot, WorkflowsDirectory, All,
Read(string), ReadBuildScript(), WithoutComments(string,string), Matching(string),
StepNamed(string scope, string stepName), StepWithId(string scope, string stepId).

  • Step 1: Write FormCraft.UnitTests/Ci/WorkflowSourceTests.cs asserting the primitives against
    the real repo: All contains every .yml under .github/workflows, WithoutComments drops a
    leading-# line but keeps a URL containing //, and StepNamed returns only the named step's
    own lines (assert it excludes the following step's - name:). Run dotnet test -c Release and
    confirm RED (type does not exist).
  • Step 2: Implement WorkflowSource, reading all workflow files once into a static dictionary.
    Move the bodies of LocateRepoRoot, WithoutComments and the step scan across unchanged.
  • Step 3: Run dotnet test -c Release; confirm the new suite is GREEN.
  • Step 4: Rewrite TestReportingTests to delegate to WorkflowSource, deleting its private
    copies. Do not change any assertion.
  • Step 5: Run dotnet test -c Release; confirm GREEN with the same test count as before
    plus the new WorkflowSourceTests.
  • Step 6: Commit: test(ci): extract a shared workflow reader for the CI guard suites

Task 2: Move TrustedPublishingWorkflowTests onto the shared reader

Files: modify FormCraft.UnitTests/Ci/TrustedPublishingWorkflowTests.cs.

Interfaces: no new surface — StepWithId and StepCondition become thin wrappers over
WorkflowSource.

  • Step 1: Confirm the suite is green before touching it, so any later red is attributable:
    run dotnet test -c Release and record the pass count.
  • Step 2: Delete this class's copies of RepoRoot/LocateRepoRoot(), WorkflowsDirectory,
    ReadWorkflow(), ReadBuildScript() and WithoutComments(), delegating to WorkflowSource.
    Keep StepCondition — it is specific to this suite's if:-vs-with: distinction (Decide whether a missing NUGET_USER should silently skip the release publish #226).
  • Step 3: Run dotnet test -c Release; confirm GREEN with an unchanged pass count.
  • Step 4: Verify no copy survives: grep -rn "LocateRepoRoot\|WithoutComments" FormCraft.UnitTests/Ci/
    should show definitions only in WorkflowSource.cs.
  • Step 5: Commit: test(ci): move the trusted-publishing guards onto the shared workflow reader

Task 3: Split workflows into jobs and scope the test-results assertions to one

Files: modify FormCraft.UnitTests/Ci/WorkflowSource.cs,
FormCraft.UnitTests/Ci/WorkflowSourceTests.cs, FormCraft.UnitTests/Ci/TestReportingTests.cs.

Interfaces: add IReadOnlyDictionary<string,string> JobsOf(string fileName).

  • Step 1: Write the failing test for JobsOf: release-please.yml splits into exactly
    release-please and nupkg, the nupkg job's text contains ./build.cmd Pack, and the
    release-please job's text does not. Run dotnet test -c Release → RED.
  • Step 2: Implement JobsOf by indentation, applying WithoutComments first so a commented-out
    invocation cannot mark a job as test-running.
  • Step 3: Run dotnet test -c Release → GREEN.
  • Step 4: Rework TestReportingTests' five workflow assertions to iterate (workflow, job) pairs
    whose job text invokes ./build.[cmd|sh|ps1] (Test|Pack|PublishIfNeeded|Publish|Continuous|Release),
    asserting the Publish: test-results step exists in that same job and satisfies the existing
    four checks. Keep the vacuity guard on the pair set.
  • Step 5: Run dotnet test -c Release → GREEN (the repo satisfies this today: the build and
    the upload both live in release-please.yml's nupkg job).
  • Step 6: Prove the new scoping is not vacuous — temporarily move ci.yml's
    Publish: test-results step into a second job, confirm the suite turns RED, then revert.
  • Step 7: Delete the <remarks> paragraph on TestReportingTests recording the file-scoped
    limitation, which no longer applies.
  • Step 8: Commit: test(ci): scope the test-results assertions to the job that runs the build

Metadata

Metadata

Assignees

No one assigned

    Labels

    priority:lowNice to havetype:refactorCode refactoring without behavior change

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions