Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 30 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -287,8 +287,8 @@ jobs:
with:
dotnet-workloads: android

- name: Run CI (Compile + Coverage + Pack)
run: ./build.ps1 -Target Ci -Configuration Release -VersionSuffix "${{ needs.version.outputs.version_suffix }}"
- name: Run matrix CI validation
run: ./build.ps1 -Target CiMatrix -Configuration Release -VersionSuffix "${{ needs.version.outputs.version_suffix }}"

- name: Upload test results
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
Expand Down Expand Up @@ -336,8 +336,8 @@ jobs:
- name: Check code format
run: ./build.sh --target Format --configuration Release

- name: Run CI (Compile + Coverage + Pack)
run: ./build.sh --target Ci --configuration Release --version-suffix "${{ needs.version.outputs.version_suffix }}"
- name: Run matrix CI validation
run: ./build.sh --target CiMatrix --configuration Release --version-suffix "${{ needs.version.outputs.version_suffix }}"

- name: Validate NativeAOT publish
run: |
Expand Down Expand Up @@ -422,7 +422,7 @@ jobs:

update-badges:
name: Update Quality Badges
needs: [build-macos]
needs: [build-macos, merge-coverage]
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
runs-on: ubuntu-latest
permissions:
Expand All @@ -434,7 +434,7 @@ jobs:
- name: Download coverage report
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: coverage-report-macos
name: merged-coverage-report
path: coverage-report

- name: Download test results
Expand Down Expand Up @@ -520,14 +520,26 @@ jobs:
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch origin badges:badges || true

if git show-ref --verify --quiet refs/heads/badges; then
git checkout badges
else
git checkout --orphan badges
git rm -rf .
fi

git checkout --orphan badges-tmp
git rm -rf .
rm -f ./*.json
cp badge-output/*.json .
git add *.json
git commit -m "Update quality badges [skip ci]"

git push origin badges-tmp:badges --force
if git diff --cached --quiet; then
echo "No badge changes detected."
exit 0
fi

git commit -m "Update quality badges [skip ci]"
git push origin badges

build-docs:
name: Build Documentation
Expand Down Expand Up @@ -616,6 +628,14 @@ jobs:
if manifest.get("version") != expected_version:
print(f"Version mismatch: expected {expected_version}, actual {manifest.get('version')}")
sys.exit(1)
expected_commit_sha = "${{ needs.version.outputs.sha }}"
if manifest.get("commitSha") != expected_commit_sha:
print(f"Commit SHA mismatch: expected {expected_commit_sha}, actual {manifest.get('commitSha')}")
sys.exit(1)
expected_workflow_run_id = "${{ github.run_id }}"
if str(manifest.get("workflowRunId")) != expected_workflow_run_id:
print(f"Workflow run id mismatch: expected {expected_workflow_run_id}, actual {manifest.get('workflowRunId')}")
sys.exit(1)

def digest(path: Path) -> str:
h = hashlib.sha256()
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ jobs:
- uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json', '.config/dotnet-tools.json') }}
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }}
restore-keys: ${{ runner.os }}-nuget-

- name: Install .NET workloads
Expand Down
1 change: 1 addition & 0 deletions .nuke/build.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"Build",
"BuildAll",
"Ci",
"CiMatrix",
"CiPublish",
"Clean",
"ContinuousTransitionGateGovernance",
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,12 +255,17 @@ Unlike wrapper-only solutions, Fulora provides typed host/web contracts, policy-

## Quality Signals

Quality badges at the top of the page are updated automatically by CI on every successful build to `main`.
Quality badges at the top of the page are updated automatically by CI on every successful build to `main` from these CI gates:

```bash
nuke Test # Unit + Integration
nuke Coverage # Coverage report + threshold enforcement
nuke NugetPackageTest # Pack → install → run smoke test
```

For local template validation (not required by the default CI badge pipeline):

```bash
nuke TemplateE2E # Template end-to-end test
```

Expand Down
164 changes: 96 additions & 68 deletions build/Build.Governance.Release.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,6 @@ internal partial class BuildTask

private sealed record TransitionGateParityRule(string Group, string CiDependency, string CiPublishDependency);

private sealed record TransitionGateDiagnosticEntry(
string InvariantId,
string Lane,
string ArtifactPath,
string Expected,
string Actual,
string Group);

private static readonly string[] CompletedPhaseCloseoutChangeIds =
[
"sentry-crash-reporting",
Expand All @@ -56,7 +48,7 @@ private sealed record TransitionGateDiagnosticEntry(
];

internal Target ContinuousTransitionGateGovernance => _ => _
.Description("Validates closeout transition-gate governance targets are present in Ci with lane-aware diagnostics.")
.Description("Validates closeout transition-gate governance targets are reachable in Ci with lane-aware failures.")
.DependsOn(ReleaseCloseoutSnapshot)
.Executes(() =>
{
Expand All @@ -65,43 +57,31 @@ private sealed record TransitionGateDiagnosticEntry(
TransitionGateGovernanceReportFile,
() =>
{
var diagnostics = new List<TransitionGateDiagnosticEntry>();
var failures = new List<GovernanceFailure>();
const string buildArtifactPath = "build/Build.cs";
const string buildArtifactPath = "build/Build*.cs";

var buildSource = File.ReadAllText(RootDirectory / "build" / "Build.cs");
var ciDependsOnBlock = ExtractDependsOnBlock(buildSource, LaneContextCi);
var ciPublishDependsOnBlock = ExtractDependsOnBlock(buildSource, LaneContextCiPublish);
var buildSource = ReadCombinedBuildSource();
var dependencyGraph = BuildTargetDependencyGraph(buildSource);
var ciDependencyClosure = ExpandTargetDependencies(LaneContextCi, dependencyGraph);
var ciPublishDirectDependencies = dependencyGraph.TryGetValue(LaneContextCiPublish, out var ciPublishDeps)
? ciPublishDeps
: new HashSet<string>(StringComparer.Ordinal);

foreach (var rule in CloseoutCriticalTransitionGateParityRules)
{
if (!ciDependsOnBlock.Contains(rule.CiDependency, StringComparison.Ordinal))
if (!ciDependencyClosure.Contains(rule.CiDependency))
{
diagnostics.Add(new TransitionGateDiagnosticEntry(
TransitionGateParityInvariantId,
Lane: LaneContextCi,
ArtifactPath: buildArtifactPath,
Expected: rule.CiDependency,
Actual: "missing",
Group: rule.Group));
failures.Add(new GovernanceFailure(
Category: rule.Group,
InvariantId: TransitionGateParityInvariantId,
SourceArtifact: buildArtifactPath,
Expected: $"{LaneContextCi}: {rule.CiDependency}",
Expected: $"{LaneContextCi}: dependency closure contains {rule.CiDependency}",
Actual: $"{LaneContextCi}: missing"));
}
}

if (!ciPublishDependsOnBlock.Contains(LaneContextCi, StringComparison.Ordinal))
if (!ciPublishDirectDependencies.Contains(LaneContextCi, StringComparer.Ordinal))
{
diagnostics.Add(new TransitionGateDiagnosticEntry(
TransitionGateParityInvariantId,
Lane: LaneContextCiPublish,
ArtifactPath: buildArtifactPath,
Expected: LaneContextCi,
Actual: "missing",
Group: "ci-inheritance"));
failures.Add(new GovernanceFailure(
Category: "ci-inheritance",
InvariantId: TransitionGateParityInvariantId,
Expand All @@ -116,13 +96,6 @@ private sealed record TransitionGateDiagnosticEntry(

if (!File.Exists(CloseoutSnapshotFile))
{
diagnostics.Add(new TransitionGateDiagnosticEntry(
TransitionLaneProvenanceInvariantId,
Lane: LaneContextCi,
ArtifactPath: closeoutArtifactPath,
Expected: "closeout snapshot exists",
Actual: "file missing",
Group: "transition-continuity"));
failures.Add(new GovernanceFailure(
Category: "transition-continuity",
InvariantId: TransitionLaneProvenanceInvariantId,
Expand All @@ -138,35 +111,32 @@ private sealed record TransitionGateDiagnosticEntry(
var transition = root.GetProperty("transition");
var continuity = root.GetProperty("transitionContinuity");

ValidateTransitionField(diagnostics, failures, LaneContextCi, closeoutArtifactPath, LaneContextCi, provenance.GetProperty("laneContext").GetString(), "transition-continuity", "provenance.laneContext");
ValidateTransitionField(diagnostics, failures, LaneContextCi, closeoutArtifactPath, "ReleaseCloseoutSnapshot", provenance.GetProperty("producerTarget").GetString(), "transition-continuity", "provenance.producerTarget");
ValidateTransitionField(diagnostics, failures, LaneContextCi, closeoutArtifactPath, roadmapCompletedPhase, transition.GetProperty("completedPhase").GetString(), "transition-continuity", "transition.completedPhase");
ValidateTransitionField(diagnostics, failures, LaneContextCi, closeoutArtifactPath, roadmapActivePhase, transition.GetProperty("activePhase").GetString(), "transition-continuity", "transition.activePhase");
ValidateTransitionField(diagnostics, failures, LaneContextCi, closeoutArtifactPath, LaneContextCi, continuity.GetProperty("laneContext").GetString(), "transition-continuity", "transitionContinuity.laneContext");
ValidateTransitionField(diagnostics, failures, LaneContextCi, closeoutArtifactPath, "ReleaseCloseoutSnapshot", continuity.GetProperty("producerTarget").GetString(), "transition-continuity", "transitionContinuity.producerTarget");
ValidateTransitionField(diagnostics, failures, LaneContextCi, closeoutArtifactPath, roadmapCompletedPhase, continuity.GetProperty("completedPhase").GetString(), "transition-continuity", "transitionContinuity.completedPhase");
ValidateTransitionField(diagnostics, failures, LaneContextCi, closeoutArtifactPath, roadmapActivePhase, continuity.GetProperty("activePhase").GetString(), "transition-continuity", "transitionContinuity.activePhase");
ValidateTransitionField(failures, LaneContextCi, closeoutArtifactPath, LaneContextCi, provenance.GetProperty("laneContext").GetString(), "transition-continuity", "provenance.laneContext");
ValidateTransitionField(failures, LaneContextCi, closeoutArtifactPath, "ReleaseCloseoutSnapshot", provenance.GetProperty("producerTarget").GetString(), "transition-continuity", "provenance.producerTarget");
ValidateTransitionField(failures, LaneContextCi, closeoutArtifactPath, roadmapCompletedPhase, transition.GetProperty("completedPhase").GetString(), "transition-continuity", "transition.completedPhase");
ValidateTransitionField(failures, LaneContextCi, closeoutArtifactPath, roadmapActivePhase, transition.GetProperty("activePhase").GetString(), "transition-continuity", "transition.activePhase");
ValidateTransitionField(failures, LaneContextCi, closeoutArtifactPath, LaneContextCi, continuity.GetProperty("laneContext").GetString(), "transition-continuity", "transitionContinuity.laneContext");
ValidateTransitionField(failures, LaneContextCi, closeoutArtifactPath, "ReleaseCloseoutSnapshot", continuity.GetProperty("producerTarget").GetString(), "transition-continuity", "transitionContinuity.producerTarget");
ValidateTransitionField(failures, LaneContextCi, closeoutArtifactPath, roadmapCompletedPhase, continuity.GetProperty("completedPhase").GetString(), "transition-continuity", "transitionContinuity.completedPhase");
ValidateTransitionField(failures, LaneContextCi, closeoutArtifactPath, roadmapActivePhase, continuity.GetProperty("activePhase").GetString(), "transition-continuity", "transitionContinuity.activePhase");
}

var reportPayload = new
{
schemaVersion = 1,
generatedAtUtc = DateTime.UtcNow,
dependencyResolution = "transitive-closure",
parityRules = CloseoutCriticalTransitionGateParityRules.Select(rule => new
{
group = rule.Group,
ciDependency = rule.CiDependency,
ciPublishDependency = rule.CiPublishDependency
}),
diagnostics = diagnostics.Select(x => new
laneDependencyClosure = new
{
invariantId = x.InvariantId,
lane = x.Lane,
artifactPath = x.ArtifactPath,
expected = x.Expected,
actual = x.Actual,
group = x.Group
}),
ci = ciDependencyClosure.OrderBy(x => x, StringComparer.Ordinal).ToArray(),
ciPublish = ciPublishDirectDependencies.OrderBy(x => x, StringComparer.Ordinal).ToArray()
},
failureCount = failures.Count,
failures
};
Expand All @@ -175,16 +145,82 @@ private sealed record TransitionGateDiagnosticEntry(
});
});

private static string ExtractDependsOnBlock(string buildSource, string targetName)
private static string ReadCombinedBuildSource()
{
var match = Regex.Match(
var buildFiles = Directory.GetFiles(RootDirectory / "build", "Build*.cs", SearchOption.TopDirectoryOnly)
.OrderBy(path => path, StringComparer.Ordinal)
.ToArray();
if (buildFiles.Length == 0)
Assert.Fail("Unable to locate any Build*.cs files under build/.");

return string.Join(Environment.NewLine, buildFiles.Select(File.ReadAllText));
}

private static IReadOnlyDictionary<string, IReadOnlySet<string>> BuildTargetDependencyGraph(string buildSource)
{
var matches = Regex.Matches(
buildSource,
$@"Target\s+{Regex.Escape(targetName)}\s*=>[\s\S]*?\.DependsOn\((?<deps>[\s\S]*?)\);",
@"Target\s+(?<target>[A-Za-z_][A-Za-z0-9_]*)\s*=>[\s\S]*?\.DependsOn\((?<deps>[\s\S]*?)\);",
RegexOptions.Multiline);
if (!match.Success)
Assert.Fail($"Unable to locate DependsOn block for target '{targetName}' in build/Build.cs.");

return match.Groups["deps"].Value;
var graph = new Dictionary<string, IReadOnlySet<string>>(StringComparer.Ordinal);
foreach (Match match in matches)
{
var targetName = match.Groups["target"].Value;
var depsBlock = match.Groups["deps"].Value;
var deps = ParseDependencyNames(depsBlock);
graph[targetName] = deps;
}

if (graph.Count == 0)
Assert.Fail("Unable to build target dependency graph from Build*.cs sources.");

return graph;
}

private static IReadOnlySet<string> ParseDependencyNames(string dependsOnBlock)
{
var dependencies = new HashSet<string>(StringComparer.Ordinal);
var segments = dependsOnBlock.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);

foreach (var segment in segments)
{
var token = segment.Trim();
if (token.StartsWith("nameof(", StringComparison.Ordinal) && token.EndsWith(')'))
token = token[7..^1];

var identifierMatch = Regex.Match(token, @"([A-Za-z_][A-Za-z0-9_]*)$");
if (identifierMatch.Success)
dependencies.Add(identifierMatch.Groups[1].Value);
}

return dependencies;
}

private static IReadOnlySet<string> ExpandTargetDependencies(
string targetName,
IReadOnlyDictionary<string, IReadOnlySet<string>> dependencyGraph)
{
var reachable = new HashSet<string>(StringComparer.Ordinal);
var queue = new Queue<string>();
queue.Enqueue(targetName);

while (queue.Count > 0)
{
var current = queue.Dequeue();
if (!dependencyGraph.TryGetValue(current, out var dependencies))
continue;

foreach (var dependency in dependencies)
{
if (!reachable.Add(dependency))
continue;

queue.Enqueue(dependency);
}
}

return reachable;
}

private static (string CompletedPhase, string ActivePhase) ReadRoadmapTransitionState(string roadmap)
Expand All @@ -200,7 +236,6 @@ private static (string CompletedPhase, string ActivePhase) ReadRoadmapTransition
}

private static void ValidateTransitionField(
IList<TransitionGateDiagnosticEntry> diagnostics,
List<GovernanceFailure> failures,
string lane,
string artifactPath,
Expand All @@ -212,13 +247,6 @@ private static void ValidateTransitionField(
if (string.Equals(expected, actual, StringComparison.Ordinal))
return;

diagnostics.Add(new TransitionGateDiagnosticEntry(
TransitionLaneProvenanceInvariantId,
lane,
artifactPath,
Expected: $"{fieldName} = {expected}",
Actual: $"{fieldName} = {actual ?? "<null>"}",
Group: group));
failures.Add(new GovernanceFailure(
Category: group,
InvariantId: TransitionLaneProvenanceInvariantId,
Expand Down
Loading
Loading