From 0421792e8a586f99c3a890dd8e4ad61753cd3f50 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 4 Jul 2026 12:43:22 -0700 Subject: [PATCH 01/17] Remove sample projects and build infra; extract cspell.json Strip the buildable sample apps (CodeGen, Console, NuGetLibrary, PyPiLibrary, Benchmarks, Tests), the Docker image sources, and the .NET build infra (Directory.Build.props/.Packages.props, .slnx, dotnet-tools, .dockerignore). The repo becomes a governance + orchestration + audit-catalog repo. Extract the cSpell word list from the deleted workspace into a standalone cspell.json so the CLI and CI read the same config as the editor extension (linter parity). Co-Authored-By: Claude Opus 4.8 (1M context) --- .config/dotnet-tools.json | 20 -- .dockerignore | 41 ---- .vscode/launch.json | 55 ----- Benchmarks/.editorconfig | 7 - Benchmarks/Benchmarks.csproj | 12 - Benchmarks/GlobalUsings.cs | 2 - Benchmarks/Program.cs | 6 - Benchmarks/SampleBenchmark.cs | 41 ---- CodeGen/.editorconfig | 7 - CodeGen/ApiNinjas.cs | 58 ----- CodeGen/AssemblyInfo.cs | 44 ---- CodeGen/CodeGen.cs | 14 -- CodeGen/CodeGen.csproj | 21 -- CodeGen/CodeGenBuilder.cs | 85 ------- CodeGen/CommandLine.cs | 90 ------- CodeGen/Extensions.cs | 27 --- CodeGen/GlobalUsings.cs | 3 - CodeGen/HttpClientFactory.cs | 118 --------- CodeGen/Program.cs | 77 ------ Console/.editorconfig | 7 - Console/CommandLine.cs | 122 ---------- Console/Console.csproj | 26 -- Console/Extensions.cs | 64 ----- Console/GlobalUsings.cs | 5 - Console/LoggerFactory.cs | 64 ----- Console/Program.cs | 76 ------ Directory.Build.props | 14 -- Directory.Packages.props | 19 -- Docker/Build.sh | 48 ---- Docker/Dockerfile | 137 ----------- Docker/InstallDebugTools.sh | 67 ----- Docker/Version.sh | 12 - NuGetLibrary/.editorconfig | 7 - NuGetLibrary/Extensions.cs | 37 --- NuGetLibrary/GlobalUsings.cs | 2 - NuGetLibrary/Library.cs | 31 --- NuGetLibrary/LogOptions.cs | 138 ----------- NuGetLibrary/NuGetLibrary.csproj | 43 ---- NuGetLibrary/Options.cs | 17 -- ProjectTemplate.code-workspace | 140 ----------- ProjectTemplate.slnx | 36 --- PyPiLibrary/README.md | 94 -------- PyPiLibrary/pyproject.toml | 82 ------- .../__init__.py | 6 - .../_version.py | 12 - .../ptr727_projecttemplate_library/example.py | 6 - .../ptr727_projecttemplate_library/py.typed | 0 PyPiLibrary/tests/__init__.py | 0 PyPiLibrary/tests/test_example.py | 16 -- PyPiLibrary/uv.lock | 140 ----------- Tests/.editorconfig | 19 -- Tests/Fixture.cs | 18 -- Tests/GlobalUsings.cs | 2 - Tests/LoggingTests.cs | 228 ------------------ Tests/SampleTest.cs | 28 --- Tests/Tests.csproj | 19 -- cspell.json | 94 ++++++++ 57 files changed, 94 insertions(+), 2510 deletions(-) delete mode 100644 .config/dotnet-tools.json delete mode 100644 .dockerignore delete mode 100644 .vscode/launch.json delete mode 100644 Benchmarks/.editorconfig delete mode 100644 Benchmarks/Benchmarks.csproj delete mode 100644 Benchmarks/GlobalUsings.cs delete mode 100644 Benchmarks/Program.cs delete mode 100644 Benchmarks/SampleBenchmark.cs delete mode 100644 CodeGen/.editorconfig delete mode 100644 CodeGen/ApiNinjas.cs delete mode 100644 CodeGen/AssemblyInfo.cs delete mode 100644 CodeGen/CodeGen.cs delete mode 100644 CodeGen/CodeGen.csproj delete mode 100644 CodeGen/CodeGenBuilder.cs delete mode 100644 CodeGen/CommandLine.cs delete mode 100644 CodeGen/Extensions.cs delete mode 100644 CodeGen/GlobalUsings.cs delete mode 100644 CodeGen/HttpClientFactory.cs delete mode 100644 CodeGen/Program.cs delete mode 100644 Console/.editorconfig delete mode 100644 Console/CommandLine.cs delete mode 100644 Console/Console.csproj delete mode 100644 Console/Extensions.cs delete mode 100644 Console/GlobalUsings.cs delete mode 100644 Console/LoggerFactory.cs delete mode 100644 Console/Program.cs delete mode 100644 Directory.Build.props delete mode 100644 Directory.Packages.props delete mode 100755 Docker/Build.sh delete mode 100644 Docker/Dockerfile delete mode 100755 Docker/InstallDebugTools.sh delete mode 100755 Docker/Version.sh delete mode 100644 NuGetLibrary/.editorconfig delete mode 100644 NuGetLibrary/Extensions.cs delete mode 100644 NuGetLibrary/GlobalUsings.cs delete mode 100644 NuGetLibrary/Library.cs delete mode 100644 NuGetLibrary/LogOptions.cs delete mode 100644 NuGetLibrary/NuGetLibrary.csproj delete mode 100644 NuGetLibrary/Options.cs delete mode 100644 ProjectTemplate.code-workspace delete mode 100644 ProjectTemplate.slnx delete mode 100644 PyPiLibrary/README.md delete mode 100644 PyPiLibrary/pyproject.toml delete mode 100644 PyPiLibrary/src/ptr727_projecttemplate_library/__init__.py delete mode 100644 PyPiLibrary/src/ptr727_projecttemplate_library/_version.py delete mode 100644 PyPiLibrary/src/ptr727_projecttemplate_library/example.py delete mode 100644 PyPiLibrary/src/ptr727_projecttemplate_library/py.typed delete mode 100644 PyPiLibrary/tests/__init__.py delete mode 100644 PyPiLibrary/tests/test_example.py delete mode 100644 PyPiLibrary/uv.lock delete mode 100644 Tests/.editorconfig delete mode 100644 Tests/Fixture.cs delete mode 100644 Tests/GlobalUsings.cs delete mode 100644 Tests/LoggingTests.cs delete mode 100644 Tests/SampleTest.cs delete mode 100644 Tests/Tests.csproj create mode 100644 cspell.json diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json deleted file mode 100644 index fd29f5f6..00000000 --- a/.config/dotnet-tools.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": 1, - "isRoot": true, - "tools": { - "csharpier": { - "version": "1.2.6", - "commands": [ - "csharpier" - ], - "rollForward": false - }, - "dotnet-outdated-tool": { - "version": "4.7.1", - "commands": [ - "dotnet-outdated" - ], - "rollForward": false - } - } -} \ No newline at end of file diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 704624fa..00000000 --- a/.dockerignore +++ /dev/null @@ -1,41 +0,0 @@ -# .NET build artifacts -**/bin/ -**/obj/ -**/out/ -.artifacts/ -**/.artifacts/ - -# Visual Studio / VS Code -.vs/ -.vscode/ -*.user -*.suo -*.userosscache -*.sln.docstates - -# Test results -TestResults/ -**/TestResults/ - -# Benchmark results -BenchmarkDotNet.Artifacts/ -**/BenchmarkDotNet.Artifacts/ - -# NuGet packages -*.nupkg -*.snupkg -packages/ - -# Git -.git/ -.gitignore -.gitattributes - -# CI/CD -.github/ -.azuredevops/ - -# Other -*.log -*.tmp -*.cache diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 37c28be9..00000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "name": "Console Root", - "type": "coreclr", - "request": "launch", - "preLaunchTask": ".NET Build", - "program": "${workspaceFolder}/.artifacts/bin/Console/debug/Console.dll", - "args": [ - "--loglevel=Debug", - "--logfile=console.log", - "--logfile-clear=true" - ], - "cwd": "${workspaceFolder}/.artifacts/bin/Console/debug", - "console": "internalConsole", - "stopAtEntry": false - }, - { - "name": "Console Test", - "type": "coreclr", - "request": "launch", - "preLaunchTask": ".NET Build", - "program": "${workspaceFolder}/.artifacts/bin/Console/debug/Console.dll", - "args": [ - "--loglevel=Debug", - "--logfile=console.log", - "--logfile-clear=true", - "test", - "--test=test" - ], - "cwd": "${workspaceFolder}/.artifacts/bin/Console/debug", - "console": "internalConsole", - "stopAtEntry": false - }, - { - "name": "CodeGen", - "type": "coreclr", - "request": "launch", - "preLaunchTask": ".NET Build", - "program": "${workspaceFolder}/.artifacts/bin/CodeGen/debug/CodeGen.dll", - "args": [ - "--codepath", - "${workspaceFolder}/CodeGen", - "--apikey", - "${env:NINJA_API_KEY}", - "--runtime", - "now" - ], - "cwd": "${workspaceFolder}/.artifacts/bin/CodeGen/debug", - "console": "internalConsole", - "stopAtEntry": false - } - ] -} diff --git a/Benchmarks/.editorconfig b/Benchmarks/.editorconfig deleted file mode 100644 index 8537609f..00000000 --- a/Benchmarks/.editorconfig +++ /dev/null @@ -1,7 +0,0 @@ -root = false - -# C# files -[*.cs] - -# Ignore missing XML docs for public benchmark APIs -dotnet_diagnostic.CS1591.severity = none diff --git a/Benchmarks/Benchmarks.csproj b/Benchmarks/Benchmarks.csproj deleted file mode 100644 index fbf90517..00000000 --- a/Benchmarks/Benchmarks.csproj +++ /dev/null @@ -1,12 +0,0 @@ - - - Exe - ptr727.ProjectTemplate.Benchmarks - - - - - - - - diff --git a/Benchmarks/GlobalUsings.cs b/Benchmarks/GlobalUsings.cs deleted file mode 100644 index 15ff6fb5..00000000 --- a/Benchmarks/GlobalUsings.cs +++ /dev/null @@ -1,2 +0,0 @@ -global using BenchmarkDotNet.Attributes; -global using BenchmarkDotNet.Running; diff --git a/Benchmarks/Program.cs b/Benchmarks/Program.cs deleted file mode 100644 index 4742e0fe..00000000 --- a/Benchmarks/Program.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace ptr727.ProjectTemplate.Benchmarks; - -internal static class Program -{ - public static void Main(string[] args) => BenchmarkRunner.Run(args: args); -} diff --git a/Benchmarks/SampleBenchmark.cs b/Benchmarks/SampleBenchmark.cs deleted file mode 100644 index 1f13d919..00000000 --- a/Benchmarks/SampleBenchmark.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Security.Cryptography; - -namespace ptr727.ProjectTemplate.Benchmarks; - -[MemoryDiagnoser] -[System.Diagnostics.CodeAnalysis.SuppressMessage( - "Performance", - "CA1515:Make types declared in an executable internal", - Justification = "Benchmark classes must be public for BenchmarkDotNet" -)] -public class SampleBenchmark -{ - private const int N = 10000; - private readonly byte[] _data; - - private readonly SHA256 _sha256 = SHA256.Create(); - - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Security", - "CA5351:Do Not Use Broken Cryptographic Algorithms", - Justification = "This is sample benchmark code." - )] - private readonly MD5 _md5 = MD5.Create(); - - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Security", - "CA5394:Do not use insecure randomness", - Justification = "This is sample benchmark code." - )] - public SampleBenchmark() - { - _data = new byte[N]; - new Random(42).NextBytes(_data); - } - - [Benchmark] - public byte[] Sha256() => _sha256.ComputeHash(_data); - - [Benchmark] - public byte[] Md5() => _md5.ComputeHash(_data); -} diff --git a/CodeGen/.editorconfig b/CodeGen/.editorconfig deleted file mode 100644 index 797656b4..00000000 --- a/CodeGen/.editorconfig +++ /dev/null @@ -1,7 +0,0 @@ -root = false - -# C# files -[*.cs] - -# Ignore types can be made internal -dotnet_diagnostic.CA1515.severity = none diff --git a/CodeGen/ApiNinjas.cs b/CodeGen/ApiNinjas.cs deleted file mode 100644 index 98b665c6..00000000 --- a/CodeGen/ApiNinjas.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Net.Http.Headers; -using System.Net.Mime; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace ptr727.ProjectTemplate.CodeGen; - -internal sealed class ApiNinjas(string apiKey, CancellationToken cancellationToken) -{ - [SuppressMessage( - "Usage", - "CA2007:Consider calling ConfigureAwait on the awaited task", - Justification = "https://github.com/dotnet/roslyn-analyzers/issues/7185" - )] - internal async Task GetQuoteOfTheDayAsync() - { - // https://api-ninjas.com/api/quotes#v2-quoteoftheday - using HttpRequestMessage request = new( - HttpMethod.Get, - "https://api.api-ninjas.com/v2/quotes?categories=philosophy" - ); - request.Headers.Accept.Add( - new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json) - ); - request.Headers.Add("X-Api-Key", apiKey); - - using HttpResponseMessage response = await HttpClientFactory - .GetHttpClient() - .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) - .ConfigureAwait(false); - _ = response.EnsureSuccessStatusCode(); - - await using Stream responseStream = await response - .Content.ReadAsStreamAsync(cancellationToken) - .ConfigureAwait(false); - - QuoteOfTheDayItem[]? items = await JsonSerializer - .DeserializeAsync( - responseStream, - QuoteOfTheDayJsonContext.Default.QuoteOfTheDayItemArray, - cancellationToken - ) - .ConfigureAwait(false); - - string? quote = items?.FirstOrDefault()?.Quote; - return string.IsNullOrWhiteSpace(quote) - ? throw new InvalidOperationException( - "Quote of the day response did not include a quote." - ) - : quote; - } -} - -internal sealed record QuoteOfTheDayItem([property: JsonPropertyName("quote")] string Quote); - -[JsonSerializable(typeof(QuoteOfTheDayItem[]))] -internal sealed partial class QuoteOfTheDayJsonContext : JsonSerializerContext; diff --git a/CodeGen/AssemblyInfo.cs b/CodeGen/AssemblyInfo.cs deleted file mode 100644 index a2054d10..00000000 --- a/CodeGen/AssemblyInfo.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -namespace ptr727.ProjectTemplate.CodeGen; - -internal static class AssemblyInfo -{ - internal static string AppVersion => $"{AppName} : {FileVersion} ({BuildType})"; - - internal static string RuntimeVersion => - $"{RuntimeInformation.FrameworkDescription} : {RuntimeInformation.RuntimeIdentifier}"; - - internal static string BuildType => -#if DEBUG - "Debug"; -#else - "Release"; -#endif - - internal static string AppName => GetAssembly().GetName().Name ?? string.Empty; - - internal static string InformationalVersion => - // E.g. 1.2.3+abc123.abc123 - GetAssembly() - .GetCustomAttribute() - ?.InformationalVersion - ?? string.Empty; - - internal static string FileVersion => - // E.g. 1.2.3.4 - GetAssembly().GetCustomAttribute()?.Version - ?? string.Empty; - - internal static string ReleaseVersion => - // Core only: strip prerelease and build metadata, e.g. 1.2.3 from 1.2.3-pre+abc123 - InformationalVersion.Split('+', '-')[0]; - - private static Assembly GetAssembly() - { - Assembly? assembly = Assembly.GetEntryAssembly(); - assembly ??= Assembly.GetExecutingAssembly(); - return assembly; - } -} diff --git a/CodeGen/CodeGen.cs b/CodeGen/CodeGen.cs deleted file mode 100644 index 065ab063..00000000 --- a/CodeGen/CodeGen.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace ptr727.ProjectTemplate.CodeGen; - -[System.CodeDom.Compiler.GeneratedCode("ptr727.ProjectTemplate.CodeGen", "1.0")] -internal static class CodeGen -{ - private const string QuoteOfTheDay = - "Don't accept a life that has been molded for you by others because eventually you'll succumb to its falseness."; - - internal static void Quote() - { - Console.WriteLine(QuoteOfTheDay); - Log.Logger.Information("Quote of the Day: {Quote}", QuoteOfTheDay); - } -} diff --git a/CodeGen/CodeGen.csproj b/CodeGen/CodeGen.csproj deleted file mode 100644 index dc3bf7db..00000000 --- a/CodeGen/CodeGen.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - 1.0.0-pre - Exe - false - ptr727.ProjectTemplate.CodeGen - 1.0.0 - - - true - true - true - - - - - - - - - diff --git a/CodeGen/CodeGenBuilder.cs b/CodeGen/CodeGenBuilder.cs deleted file mode 100644 index 043adf2b..00000000 --- a/CodeGen/CodeGenBuilder.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Text; - -namespace ptr727.ProjectTemplate.CodeGen; - -internal sealed class CodeGenBuilder(string outputPath, CancellationToken cancellationToken) -{ - // No runtime -> deterministic output from the quote alone (CI). A runtime ("now" -> UtcNow, else the literal value) - // embeds a timestamp; that per-run state is a local-only demo of why a generator's output then diverges per run. - internal async Task CodeGenAsync(string quote, string runtime) - { - string codeGen = string.IsNullOrEmpty(runtime) - ? GenerateDeterministic(quote) - : GenerateWithTimestamp(quote, ResolveRuntime(runtime)); - - // Write code to file - await File.WriteAllTextAsync(outputPath, codeGen, cancellationToken).ConfigureAwait(false); - } - - private static string ResolveRuntime(string runtime) => - runtime.Equals("now", StringComparison.OrdinalIgnoreCase) - ? DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture) - : runtime; - - private static string GenerateDeterministic(string quote) => - $$""" - namespace ptr727.ProjectTemplate.CodeGen; - - [System.CodeDom.Compiler.GeneratedCode("ptr727.ProjectTemplate.CodeGen", "1.0")] - internal static class CodeGen - { - private const string QuoteOfTheDay = {{ToCSharpStringLiteral(quote)}}; - - internal static void Quote() - { - Console.WriteLine(QuoteOfTheDay); - Log.Logger.Information("Quote of the Day: {Quote}", QuoteOfTheDay); - } - } - """; - - private static string GenerateWithTimestamp(string quote, string dateTime) => - $$""" - namespace ptr727.ProjectTemplate.CodeGen; - - [System.CodeDom.Compiler.GeneratedCode("ptr727.ProjectTemplate.CodeGen", "1.0")] - internal static class CodeGen - { - private const string QuoteOfTheDay = {{ToCSharpStringLiteral(quote)}}; - - internal static void Quote() - { - const string dateTime = {{ToCSharpStringLiteral(dateTime)}}; - Console.WriteLine($"{dateTime} : {QuoteOfTheDay}"); - Log.Logger.Information("Quote of the Day: {DateTime} : {Quote}", dateTime, QuoteOfTheDay); - } - } - """; - - private static string ToCSharpStringLiteral(string value) - { - StringBuilder sb = new(value.Length + 2); - _ = sb.Append('"'); - foreach (char c in value) - { - _ = sb.Append( - c switch - { - '\\' => "\\\\", - '\"' => "\\\"", - '\r' => "\\r", - '\n' => "\\n", - '\t' => "\\t", - '\0' => "\\0", - '\b' => "\\b", - '\f' => "\\f", - '\u2019' => "'", - _ when char.IsControl(c) => $"\\u{(int)c:X4}", - _ => c.ToString(), - } - ); - } - _ = sb.Append('"'); - return sb.ToString(); - } -} diff --git a/CodeGen/CommandLine.cs b/CodeGen/CommandLine.cs deleted file mode 100644 index f933a9c3..00000000 --- a/CodeGen/CommandLine.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System.CommandLine; -using System.CommandLine.Parsing; - -namespace ptr727.ProjectTemplate.CodeGen; - -internal sealed class CommandLine -{ - private readonly Option _codePathOption = CreateCodePathOption(); - private readonly Option _apiKeyOption = CreateApiKeyOption(); - private readonly Option _runtimeOption = CreateRuntimeOption(); - - private static readonly FrozenSet s_cliBypassList = FrozenSet.Create( - StringComparer.OrdinalIgnoreCase, - "--help", - "--version" - ); - - internal CommandLine(string[] args) - { - Root = CreateRootCommand(); - Result = Root.Parse(args); - } - - internal RootCommand Root { get; } - internal ParseResult Result { get; } - - internal RootCommand CreateRootCommand() - { - RootCommand rootCommand = new("C# .NET codegen project") - { - _codePathOption, - _apiKeyOption, - _runtimeOption, - }; - rootCommand.SetAction( - (parseResult, cancellationToken) => - { - Program program = new(CreateOptions(parseResult), cancellationToken); - return program.ExecuteAsync(); - } - ); - - return rootCommand; - } - - internal Options CreateOptions(ParseResult parseResult) => - new() - { - CodePath = parseResult.GetValue(_codePathOption)!, - ApiKey = parseResult.GetValue(_apiKeyOption) ?? string.Empty, - Runtime = parseResult.GetValue(_runtimeOption) ?? string.Empty, - }; - - private static Option CreateCodePathOption() - { - Option option = new("--codepath", "-p") - { - Description = "The path to the code generation output directory.", - Required = true, - }; - return option.AcceptExistingOnly(); - } - - private static Option CreateApiKeyOption() => - new("--apikey", "-a") { Description = "The API key to use (optional).", Required = false }; - - // Local-only demo of per-run state; omit for deterministic CI output (the quote alone). - private static Option CreateRuntimeOption() => - new("--runtime", "-r") - { - Description = - "Embed a timestamp in generated content to demonstrate per-run divergence " - + "('now' for current UtcNow, or an ISO 8601 value); omit for deterministic output.", - Required = false, - }; - - internal static bool BypassStartup(ParseResult parseResult) => - parseResult.Errors.Count > 0 - || parseResult.CommandResult.Children.Any(symbolResult => - symbolResult is OptionResult optionResult - && s_cliBypassList.Contains(optionResult.Option.Name) - ); - - internal sealed class Options - { - internal required DirectoryInfo CodePath { get; init; } - internal required string ApiKey { get; init; } - internal required string Runtime { get; init; } - } -} diff --git a/CodeGen/Extensions.cs b/CodeGen/Extensions.cs deleted file mode 100644 index ff6b83e1..00000000 --- a/CodeGen/Extensions.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Runtime.CompilerServices; - -namespace ptr727.ProjectTemplate.CodeGen; - -internal static class LogExtensions -{ - extension(ILogger logger) - { - internal bool LogAndPropagate( - Exception exception, - [CallerMemberName] string function = "unknown" - ) - { - logger.Error(exception, "{Function}", function); - return false; - } - - internal bool LogAndHandle( - Exception exception, - [CallerMemberName] string function = "unknown" - ) - { - logger.Error(exception, "{Function}", function); - return true; - } - } -} diff --git a/CodeGen/GlobalUsings.cs b/CodeGen/GlobalUsings.cs deleted file mode 100644 index 85c42612..00000000 --- a/CodeGen/GlobalUsings.cs +++ /dev/null @@ -1,3 +0,0 @@ -global using System.Collections.Frozen; -global using System.Globalization; -global using Serilog; diff --git a/CodeGen/HttpClientFactory.cs b/CodeGen/HttpClientFactory.cs deleted file mode 100644 index b6ff37e9..00000000 --- a/CodeGen/HttpClientFactory.cs +++ /dev/null @@ -1,118 +0,0 @@ -using System.Net.Http.Headers; -using Microsoft.Extensions.Http.Resilience; -using Polly; -using Polly.CircuitBreaker; -using Polly.Retry; - -namespace ptr727.ProjectTemplate.CodeGen; - -internal static class HttpClientFactory -{ - // Retry - private const int RetryMaxAttempts = 3; - private static readonly TimeSpan s_retryBaseDelay = TimeSpan.FromSeconds(1); - private static readonly TimeSpan s_retryMaxDelay = TimeSpan.FromSeconds(30); - - // Circuit breaker - private const double CircuitBreakerFailureRatio = 0.1; - private const int CircuitBreakerMinimumThroughput = 10; - private static readonly TimeSpan s_circuitBreakerSamplingDuration = TimeSpan.FromSeconds(60); - private static readonly TimeSpan s_circuitBreakerBreakDuration = TimeSpan.FromSeconds(30); - - // Connection pool - private static readonly TimeSpan s_connectionLifetime = TimeSpan.FromMinutes(15); - private static readonly TimeSpan s_connectionIdleTimeout = TimeSpan.FromMinutes(2); - - // HttpClient - private static readonly TimeSpan s_httpClientTimeout = TimeSpan.FromSeconds(120); - - private static readonly Lazy s_httpClient = new(CreateHttpClient); - - internal static HttpClient GetHttpClient() => s_httpClient.Value; - - private static ResilienceHandler CreateResilienceHandler() => - new( - new ResiliencePipelineBuilder() - .AddRetry( - new RetryStrategyOptions - { - MaxRetryAttempts = RetryMaxAttempts, - BackoffType = DelayBackoffType.Exponential, - UseJitter = true, - Delay = s_retryBaseDelay, - MaxDelay = s_retryMaxDelay, - ShouldHandle = args => - ValueTask.FromResult(IsTransientFailure(args.Outcome)), - OnRetry = args => - { - Log.Logger.Warning( - "HTTP retry attempt {Attempt} after {Delay}ms: {Outcome}", - args.AttemptNumber, - args.RetryDelay.TotalMilliseconds, - args.Outcome - ); - return ValueTask.CompletedTask; - }, - } - ) - .AddCircuitBreaker( - new CircuitBreakerStrategyOptions - { - FailureRatio = CircuitBreakerFailureRatio, - MinimumThroughput = CircuitBreakerMinimumThroughput, - SamplingDuration = s_circuitBreakerSamplingDuration, - BreakDuration = s_circuitBreakerBreakDuration, - ShouldHandle = args => - ValueTask.FromResult(IsTransientFailure(args.Outcome)), - OnOpened = args => - { - Log.Logger.Warning( - "Circuit breaker opened for {Duration}s: {Outcome}", - args.BreakDuration.TotalSeconds, - args.Outcome - ); - return ValueTask.CompletedTask; - }, - OnClosed = _ => - { - Log.Logger.Information("Circuit breaker closed."); - return ValueTask.CompletedTask; - }, - OnHalfOpened = _ => - { - Log.Logger.Debug("Circuit breaker half-opened."); - return ValueTask.CompletedTask; - }, - } - ) - .Build() - ) - { - InnerHandler = new SocketsHttpHandler - { - PooledConnectionLifetime = s_connectionLifetime, - PooledConnectionIdleTimeout = s_connectionIdleTimeout, - AutomaticDecompression = System.Net.DecompressionMethods.All, - }, - }; - - private static bool IsTransientFailure(Outcome outcome) => - outcome.Exception is not null - ? outcome.Exception is not (OperationCanceledException or BrokenCircuitException) - : outcome.Result is not null && (int)outcome.Result.StatusCode is 408 or 429 or >= 500; - - // Each call yields independent circuit-breaker state; callers should cache the instance. - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Reliability", - "CA2000:Dispose objects before losing scope", - Justification = "HttpClient takes ownership of the handler and disposes it when the client is disposed." - )] - internal static HttpClient CreateHttpClient() - { - HttpClient httpClient = new(CreateResilienceHandler()) { Timeout = s_httpClientTimeout }; - httpClient.DefaultRequestHeaders.UserAgent.Add( - new ProductInfoHeaderValue(AssemblyInfo.AppName, AssemblyInfo.ReleaseVersion) - ); - return httpClient; - } -} diff --git a/CodeGen/Program.cs b/CodeGen/Program.cs deleted file mode 100644 index 9ea4efe3..00000000 --- a/CodeGen/Program.cs +++ /dev/null @@ -1,77 +0,0 @@ -using Serilog.Sinks.SystemConsole.Themes; - -namespace ptr727.ProjectTemplate.CodeGen; - -internal sealed class Program( - CommandLine.Options commandLineOptions, - CancellationToken cancellationToken -) -{ - internal static async Task Main(string[] args) - { - try - { - // Parse commandline - CommandLine commandLine = new(args); - commandLine.Result.InvocationConfiguration.EnableDefaultExceptionHandler = false; - commandLine.Result.InvocationConfiguration.ProcessTerminationTimeout = null; - - // Bypass startup for errors or help and version commands - if (CommandLine.BypassStartup(commandLine.Result)) - { - return await commandLine.Result.InvokeAsync().ConfigureAwait(false); - } - - // Log to the console - LoggerConfiguration loggerConfiguration = new LoggerConfiguration() - .Enrich.WithThreadId() - .WriteTo.Console( - theme: AnsiConsoleTheme.Code, - formatProvider: CultureInfo.InvariantCulture, - outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] [t:{ThreadId}{ThreadName}] {Message:lj}{NewLine}{Exception}" - ); - Log.Logger = loggerConfiguration.CreateLogger(); - - // Invoke command - return await commandLine.Result.InvokeAsync().ConfigureAwait(false); - } - catch (Exception ex) when (Log.Logger.LogAndHandle(ex)) - { - return 1; - } - finally - { - await Log.CloseAndFlushAsync().ConfigureAwait(false); - } - } - - internal async Task ExecuteAsync() - { - try - { - Log.Information("Executing codegen command..."); - - string quoteoftheday = "No API key provided."; - if (!string.IsNullOrEmpty(commandLineOptions.ApiKey)) - { - Log.Information("Retrieving quote from API Ninjas..."); - ApiNinjas apiNinjas = new(commandLineOptions.ApiKey, cancellationToken); - quoteoftheday = await apiNinjas.GetQuoteOfTheDayAsync().ConfigureAwait(false); - } - Log.Information("Quote: {Quote}", quoteoftheday); - - string outputPath = Path.Combine(commandLineOptions.CodePath.FullName, "CodeGen.cs"); - Log.Information("Writing quote to {OutputPath}", outputPath); - CodeGenBuilder codegenBuilder = new(outputPath, cancellationToken); - await codegenBuilder - .CodeGenAsync(quoteoftheday, commandLineOptions.Runtime) - .ConfigureAwait(false); - - return 0; - } - catch (Exception ex) when (Log.Logger.LogAndHandle(ex)) - { - return 1; - } - } -} diff --git a/Console/.editorconfig b/Console/.editorconfig deleted file mode 100644 index 797656b4..00000000 --- a/Console/.editorconfig +++ /dev/null @@ -1,7 +0,0 @@ -root = false - -# C# files -[*.cs] - -# Ignore types can be made internal -dotnet_diagnostic.CA1515.severity = none diff --git a/Console/CommandLine.cs b/Console/CommandLine.cs deleted file mode 100644 index d665db24..00000000 --- a/Console/CommandLine.cs +++ /dev/null @@ -1,122 +0,0 @@ -using System.CommandLine; -using System.CommandLine.Parsing; - -namespace ptr727.ProjectTemplate.Console; - -internal sealed class CommandLine -{ - private readonly Option _logLevelOption = CreateLogLevelOption(); - private readonly Option _logFileOption = CreateLogFileOption(); - private readonly Option _logFileClearOption = CreateLogFileClearOption(); - - private readonly Option _testOption = CreateTestOption(); - - private static readonly FrozenSet s_cliBypassList = FrozenSet.Create( - StringComparer.OrdinalIgnoreCase, - "--help", - "--version" - ); - - internal CommandLine(string[] args) - { - Root = CreateRootCommand(); - Result = Root.Parse(args); - } - - internal RootCommand Root { get; } - internal ParseResult Result { get; } - - internal RootCommand CreateRootCommand() - { - // Default root command - RootCommand rootCommand = new("C# .NET console project") - { - // Global options (set Recursive to true to apply to subcommands) - _logLevelOption, - _logFileOption, - _logFileClearOption, - }; - rootCommand.SetAction( - (parseResult, cancellationToken) => - { - Program program = new(CreateOptions(parseResult), cancellationToken); - return program.ExecuteAsync(); - } - ); - - // Sub commands - rootCommand.Subcommands.Add(CreateTestCommand()); - - return rootCommand; - } - - internal Command CreateTestCommand() - { - Command testCommand = new("test", "Test command") - { - // Test command options - _testOption, - }; - testCommand.SetAction( - (parseResult, cancellationToken) => - { - Program program = new(CreateOptions(parseResult), cancellationToken); - return program.ExecuteTestAsync(); - } - ); - return testCommand; - } - - internal Options CreateOptions(ParseResult parseResult) => - new() - { - LogOptions = new LoggerFactory.Options - { - Level = parseResult.GetValue(_logLevelOption), - File = parseResult.GetValue(_logFileOption) ?? string.Empty, - FileClear = parseResult.GetValue(_logFileClearOption), - }, - TestOption = parseResult.GetValue(_testOption) ?? string.Empty, - }; - - private static Option CreateLogFileClearOption() => - new("--logfile-clear", "-c") - { - Description = "Clear the log file before writing (default: false).", - Recursive = true, - }; - - private static Option CreateLogLevelOption() => - new("--loglevel", "-l") - { - Description = "Set the log level (default: Information).", - DefaultValueFactory = _ => LogEventLevel.Information, - Recursive = true, - }; - - private static Option CreateLogFileOption() - { - Option option = new("--logfile", "-f") - { - Description = "Write logs to the specified file (optional).", - Recursive = true, - }; - return option.AcceptLegalFileNamesOnly(); - } - - private static Option CreateTestOption() => - new("--test", "-t") { Description = "Test command option (optional)." }; - - internal static bool BypassStartup(ParseResult parseResult) => - parseResult.Errors.Count > 0 - || parseResult.CommandResult.Children.Any(symbolResult => - symbolResult is OptionResult optionResult - && s_cliBypassList.Contains(optionResult.Option.Name) - ); - - internal sealed class Options - { - internal required LoggerFactory.Options LogOptions { get; init; } - internal required string TestOption { get; init; } - } -} diff --git a/Console/Console.csproj b/Console/Console.csproj deleted file mode 100644 index ae14c9a1..00000000 --- a/Console/Console.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - 1.0.0-pre - Exe - false - ptr727.ProjectTemplate.Console - 1.0.0 - - - true - true - true - - - - - - - - - - - - - - diff --git a/Console/Extensions.cs b/Console/Extensions.cs deleted file mode 100644 index f7dac81c..00000000 --- a/Console/Extensions.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System.Runtime.CompilerServices; - -namespace ptr727.ProjectTemplate.Console; - -internal static partial class LogExtensions -{ - extension(Serilog.ILogger logger) - { - internal bool LogAndPropagate( - Exception exception, - [CallerMemberName] string function = "unknown" - ) - { - logger.Error(exception, "{Function}", function); - return false; - } - - internal bool LogAndHandle( - Exception exception, - [CallerMemberName] string function = "unknown" - ) - { - logger.Error(exception, "{Function}", function); - return true; - } - - internal Serilog.ILogger LogOverrideContext() => logger.ForContext(); - } - - extension(Microsoft.Extensions.Logging.ILogger logger) - { - internal bool LogAndPropagate( - Exception exception, - [CallerMemberName] string function = "unknown" - ) - { - logger.LogCatchException(function, exception); - return false; - } - - internal bool LogAndHandle( - Exception exception, - [CallerMemberName] string function = "unknown" - ) - { - logger.LogCatchException(function, exception); - return true; - } - } - - [LoggerMessage(Message = "Exception in {Function}", Level = LogLevel.Error)] - internal static partial void LogCatchException( - this Microsoft.Extensions.Logging.ILogger logger, - string function, - Exception exception - ); - - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Design", - "CA1812:Avoid uninstantiated internal classes", - Justification = "Used as a type marker for Serilog context filtering" - )] - internal sealed class LogOverride; -} diff --git a/Console/GlobalUsings.cs b/Console/GlobalUsings.cs deleted file mode 100644 index 17a0a025..00000000 --- a/Console/GlobalUsings.cs +++ /dev/null @@ -1,5 +0,0 @@ -global using System.Collections.Frozen; -global using System.Globalization; -global using Microsoft.Extensions.Logging; -global using Serilog; -global using Serilog.Events; diff --git a/Console/LoggerFactory.cs b/Console/LoggerFactory.cs deleted file mode 100644 index 64916635..00000000 --- a/Console/LoggerFactory.cs +++ /dev/null @@ -1,64 +0,0 @@ -using Serilog.Extensions.Logging; -using Serilog.Sinks.SystemConsole.Themes; - -namespace ptr727.ProjectTemplate.Console; - -internal static class LoggerFactory -{ - private static readonly Lazy s_serilogLoggerFactory = new(() => - { - // Use already configured Log.Logger if set, else create a new logger factory - Serilog.ILogger logger = ReferenceEquals(Log.Logger, Serilog.Core.Logger.None) - ? Create() - : Log.Logger; - bool disposeLogger = !ReferenceEquals(logger, Log.Logger); - return new SerilogLoggerFactory(logger, dispose: disposeLogger); - }); - - internal static Serilog.ILogger Create(Options? options = null) - { - // Log to the console - LoggerConfiguration loggerConfiguration = new LoggerConfiguration() - .MinimumLevel.Is(options?.Level ?? LogEventLevel.Information) - .MinimumLevel.Override( - typeof(LogExtensions.LogOverride).FullName!, - LogEventLevel.Verbose - ) - .Enrich.WithThreadId() - .Enrich.WithThreadName() - .WriteTo.Console( - theme: AnsiConsoleTheme.Code, - formatProvider: CultureInfo.InvariantCulture, - outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] [t:{ThreadId}{ThreadName}] {Message:lj}{NewLine}{Exception}" - ); - - // Log to file - if (!string.IsNullOrEmpty(options?.File)) - { - if (options.FileClear && File.Exists(options.File)) - { - File.Delete(options.File); - } - _ = loggerConfiguration.WriteTo.File( - options.File, - formatProvider: CultureInfo.InvariantCulture, - outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [t:{ThreadId}{ThreadName}] {Message:lj}{NewLine}{Exception}" - ); - } - - // Create logger - return loggerConfiguration.CreateLogger(); - } - - internal static ILoggerFactory CreateLoggerFactory() => s_serilogLoggerFactory.Value; - - internal static Microsoft.Extensions.Logging.ILogger CreateLogger(string categoryName) => - s_serilogLoggerFactory.Value.CreateLogger(categoryName); - - internal sealed class Options - { - internal required LogEventLevel Level { get; init; } - internal required string File { get; init; } - internal required bool FileClear { get; init; } - } -} diff --git a/Console/Program.cs b/Console/Program.cs deleted file mode 100644 index 5df60863..00000000 --- a/Console/Program.cs +++ /dev/null @@ -1,76 +0,0 @@ -using ptr727.ProjectTemplate.NuGetLibrary; - -namespace ptr727.ProjectTemplate.Console; - -internal sealed class Program( - CommandLine.Options commandLineOptions, - CancellationToken cancellationToken -) -{ - internal CommandLine.Options GetCommandLineOptions() => commandLineOptions; - - internal CancellationToken GetCancellationToken() => cancellationToken; - - internal static async Task Main(string[] args) - { - try - { - // Parse commandline - CommandLine commandLine = new(args); - commandLine.Result.InvocationConfiguration.EnableDefaultExceptionHandler = false; - commandLine.Result.InvocationConfiguration.ProcessTerminationTimeout = null; - - // Bypass startup for errors or help and version commands - if (CommandLine.BypassStartup(commandLine.Result)) - { - return await commandLine.Result.InvokeAsync().ConfigureAwait(false); - } - - // Create logger - Log.Logger = LoggerFactory.Create( - commandLine.CreateOptions(commandLine.Result).LogOptions - ); - LogOptions.SetFactory(LoggerFactory.CreateLoggerFactory()); - - // Invoke command - Log.Logger.LogOverrideContext().Information("Starting: {Args}", args); - return await commandLine.Result.InvokeAsync().ConfigureAwait(false); - } - catch (Exception ex) when (Log.Logger.LogAndHandle(ex)) - { - return 1; - } - finally - { - await Log.CloseAndFlushAsync().ConfigureAwait(false); - } - } - - internal async Task ExecuteAsync() - { - try - { - Log.Information("Executing root command..."); - await Task.Delay(1000, cancellationToken).ConfigureAwait(false); - return 0; - } - catch (Exception ex) when (Log.Logger.LogAndHandle(ex)) - { - return 1; - } - } - - internal async Task ExecuteTestAsync() - { - try - { - Log.Information("Executing test command..."); - await Task.Delay(1000, cancellationToken).ConfigureAwait(false); - return 0; - } - catch (Exception ex) when (Log.Logger.LogAndHandle(ex)) - { - return 1; - } - } -} diff --git a/Directory.Build.props b/Directory.Build.props deleted file mode 100644 index da26fe38..00000000 --- a/Directory.Build.props +++ /dev/null @@ -1,14 +0,0 @@ - - - net10.0 - enable - enable - latest-all - All - true - true - $(MSBuildThisFileDirectory).artifacts - false - true - - diff --git a/Directory.Packages.props b/Directory.Packages.props deleted file mode 100644 index 2f77ae1b..00000000 --- a/Directory.Packages.props +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/Docker/Build.sh b/Docker/Build.sh deleted file mode 100755 index dd27c4cc..00000000 --- a/Docker/Build.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/bin/sh - -# Echo commands -set -x - -# Exit on error -set -e - -# Build the solution -dotnet build ./ProjectTemplate/ProjectTemplate.slnx - -# Test the solution -dotnet test ./ProjectTemplate/ProjectTemplate.slnx - -# Build publish output -dotnet publish ./ProjectTemplate/Console/Console.csproj \ - --arch $TARGETARCH \ - --output ./Build/Release \ - --configuration release \ - -property:PublishAot=false \ - -property:Version=$BUILD_VERSION \ - -property:FileVersion=$BUILD_FILE_VERSION \ - -property:AssemblyVersion=$BUILD_ASSEMBLY_VERSION \ - -property:InformationalVersion=$BUILD_INFORMATION_VERSION \ - -property:PackageVersion=$BUILD_PACKAGE_VERSION -dotnet publish ./ProjectTemplate/Console/Console.csproj \ - --arch $TARGETARCH \ - --output ./Build/Debug \ - --configuration debug \ - -property:PublishAot=false \ - -property:Version=$BUILD_VERSION \ - -property:FileVersion=$BUILD_FILE_VERSION \ - -property:AssemblyVersion=$BUILD_ASSEMBLY_VERSION \ - -property:InformationalVersion=$BUILD_INFORMATION_VERSION \ - -property:PackageVersion=$BUILD_PACKAGE_VERSION - -# Copy configured build target as default output -mkdir -p ./Publish/ProjectTemplate/Debug -mkdir -p ./Publish/ProjectTemplate/Release -if [ "$BUILD_CONFIGURATION" = "Debug" ] || [ "$BUILD_CONFIGURATION" = "debug" ] -then - cp -r ./Build/Debug/* ./Publish/ProjectTemplate -else - cp -r ./Build/Release/* ./Publish/ProjectTemplate -fi -cp -r ./Build/Debug/* ./Publish/ProjectTemplate/Debug -cp -r ./Build/Release/* ./Publish/ProjectTemplate/Release -ls -la ./Publish/ProjectTemplate diff --git a/Docker/Dockerfile b/Docker/Dockerfile deleted file mode 100644 index 0b93c6b2..00000000 --- a/Docker/Dockerfile +++ /dev/null @@ -1,137 +0,0 @@ -# Description: Ubuntu latest release -# Based on: ubuntu:rolling -# .NET install: Ubuntu repository -# Platforms: linux/amd64, linux/arm64 -# Tag: ptr727/projecttemplate:latest - -# Docker build debugging: -# --progress=plain -# --no-cache - -# Test image in shell: -# docker run -it --rm --pull always --name Testing ubuntu:rolling /bin/bash -# docker run -it --rm --pull always --name Testing ptr727/projecttemplate:latest /bin/bash -# export DEBIAN_FRONTEND=noninteractive - -# Build Dockerfile -# docker buildx create --name "projecttemplate" --use -# docker buildx build --platform linux/amd64,linux/arm64 --file ./Docker/Dockerfile . - -# Build and log output -# docker buildx build --no-cache --progress=plain --platform linux/amd64 --file ./Docker/Dockerfile . 2>&1 | tee build.log - -# Test linux/amd64 target -# docker buildx build --load --platform linux/amd64 --tag projecttemplate:latest --file ./Docker/Dockerfile . -# docker run -it --rm --name ProjectTemplate-Test projecttemplate:latest /bin/bash - - -# Builder layer -FROM --platform=$BUILDPLATFORM ubuntu:rolling AS builder - -# Layer workdir -WORKDIR /Builder - -ARG \ - # Build platform args - TARGETPLATFORM \ - TARGETARCH \ - BUILDPLATFORM \ - # Build attributes - BUILD_CONFIGURATION="Debug" \ - BUILD_VERSION="1.0.0.0" \ - BUILD_FILE_VERSION="1.0.0.0" \ - BUILD_ASSEMBLY_VERSION="1.0.0.0" \ - BUILD_INFORMATION_VERSION="1.0.0.0" \ - BUILD_PACKAGE_VERSION="1.0.0.0" - -# Prevent EULA and confirmation prompts in installers -ENV DEBIAN_FRONTEND=noninteractive - -RUN \ - # Upgrade - apt update \ - && apt upgrade -y \ - # Install .NET SDK (for AOT add clang and zlib1g-dev) - # https://documentation.ubuntu.com/ubuntu-for-developers/howto/dotnet-setup - # https://learn.microsoft.com/en-us/dotnet/core/install/linux-ubuntu-install - # https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/ - && apt install -y \ - dotnet-sdk-10.0 \ - # Cleanup - && apt autoremove -y \ - && apt clean \ - && rm -rf /var/lib/apt/lists/* - -# Copy source -COPY . ./ProjectTemplate/. - -# Build project -COPY --chmod=ug=rwx,o=rx ./Docker/Build.sh ./ProjectTemplate -RUN ./ProjectTemplate/Build.sh - - -# Final layer -FROM ubuntu:rolling AS final - -ARG \ - # Build platform args - TARGETPLATFORM \ - TARGETARCH \ - BUILDPLATFORM \ - # Image label - LABEL_VERSION="1.0.0.0" - -# Label -LABEL name="ProjectTemplate" \ - version=${LABEL_VERSION} \ - description="C# .NET template project." \ - maintainer="Pieter Viljoen " - -# Prevent EULA and confirmation prompts in installers -ENV DEBIAN_FRONTEND=noninteractive - -RUN \ - # Upgrade - apt update \ - && apt upgrade -y \ - # Install dependencies - && apt install -y \ - ca-certificates \ - locales \ - locales-all \ - p7zip-full \ - tzdata \ - wget \ - && locale-gen --no-purge en_US en_US.UTF-8 \ - # Install .NET Runtime - && apt install -y \ - dotnet-runtime-10.0 \ - # Cleanup - && apt autoremove -y \ - && apt clean \ - && rm -rf /var/lib/apt/lists/* - -# Set locale to UTF-8 after running locale-gen -# https://github.com/dotnet/dotnet-docker/blob/main/samples/enable-globalization.md -ENV TZ=Etc/UTC \ - LANG=en_US.UTF-8 \ - LANGUAGE=en_US:en \ - LC_ALL=en_US.UTF-8 - -# Copy build output from builder layer -COPY --from=builder /Builder/Publish/ProjectTemplate/. /ProjectTemplate - -# Install debug tools -COPY --chmod=ug=rwx,o=rx ./Docker/InstallDebugTools.sh ./ProjectTemplate -RUN ./ProjectTemplate/InstallDebugTools.sh \ - && rm -rf ./ProjectTemplate/InstallDebugTools.sh - -# Print environment information -COPY --chmod=ug=rwx,o=rx ./Docker/Version.sh ./ProjectTemplate -RUN if [ "$BUILDPLATFORM" = "$TARGETPLATFORM" ]; then \ - /ProjectTemplate/Version.sh; \ - fi \ - && rm -rf ./ProjectTemplate/Version.sh - -# Set workdir -WORKDIR /ProjectTemplate diff --git a/Docker/InstallDebugTools.sh b/Docker/InstallDebugTools.sh deleted file mode 100755 index 1c2fea0f..00000000 --- a/Docker/InstallDebugTools.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/bin/sh - -# Echo commands -set -x - -# Exit on error -set -e - -# Install VS debug tools to /vsdbg -# https://learn.microsoft.com/en-us/visualstudio/debugger/remote-debugging-dotnet-core-linux-with-ssh -# https://github.com/OmniSharp/omnisharp-vscode/wiki/Attaching-to-remote-processes -echo "Installing VS debug tools to /vsdbg" -wget -O ./getvsdbg.sh https://aka.ms/getvsdbgsh -chmod ug=rwx,o=rx getvsdbg.sh -./getvsdbg.sh -v latest -l /vsdbg -rm -f getvsdbg.sh - -# Get the RID for this OS using the same technique as used by getvsdbg.sh get_dotnet_runtime_id() -__RuntimeID= -get_dotnet_runtime_id() -{ - if [ "$(uname)" = "Darwin" ]; then - if [ "$(uname -m)" = "arm64" ]; then - __RuntimeID=osx-arm64 - else - __RuntimeID=osx-x64 - fi - elif [ "$(uname -m)" = "x86_64" ]; then - __RuntimeID=linux-x64 - if [ -e /etc/os-release ]; then - # '.' is the same as 'source' but is POSIX compliant - . /etc/os-release - if [ "$ID" = "alpine" ]; then - __RuntimeID=linux-musl-x64 - fi - fi - elif [ "$(uname -m)" = "armv7l" ]; then - __RuntimeID=linux-arm - elif [ "$(uname -m)" = "aarch64" ]; then - __RuntimeID=linux-arm64 - if [ -e /etc/os-release ]; then - # '.' is the same as 'source' but is POSIX compliant - . /etc/os-release - if [ "$ID" = "alpine" ]; then - __RuntimeID=linux-musl-arm64 - # Check to see if we have dpkg to get the real architecture on debian based linux OS. - elif hash dpkg 2>/dev/null; then - # Raspbian 32-bit will return aarch64 in 'uname -m', but it can only use the linux-arm debugger - if [ "$(dpkg --print-architecture)" = "armhf" ]; then - echo 'Info: Overriding Runtime ID from linux-arm64 to linux-arm' - __RuntimeID=linux-arm - fi - fi - fi - fi -} -get_dotnet_runtime_id - -# Install .NET diagnostic tools to /dotnet-tools -# https://learn.microsoft.com/en-us/dotnet/core/diagnostics/tools-overview -# https://github.com/dotnet/diagnostics/blob/main/documentation/single-file-tools.md -echo "Installing .NET diagnostic tools for $__RuntimeID to /dotnet-tools" -mkdir -p /dotnet-tools -wget -O /dotnet-tools/dotnet-counters https://aka.ms/dotnet-counters/$__RuntimeID -chmod ug=rwx,o=rx /dotnet-tools/dotnet-counters -wget -O /dotnet-tools/dotnet-dump https://aka.ms/dotnet-dump/$__RuntimeID -chmod ug=rwx,o=rx /dotnet-tools/dotnet-dump diff --git a/Docker/Version.sh b/Docker/Version.sh deleted file mode 100755 index 13ab46de..00000000 --- a/Docker/Version.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/sh - -# Echo commands -set -x - -# Exit on error -set -e - -# Print version information -. /etc/os-release; echo $PRETTY_NAME -dotnet --info -/ProjectTemplate/Console --version diff --git a/NuGetLibrary/.editorconfig b/NuGetLibrary/.editorconfig deleted file mode 100644 index f1906f4a..00000000 --- a/NuGetLibrary/.editorconfig +++ /dev/null @@ -1,7 +0,0 @@ -root = false - -# C# files -[*.cs] - -# Ignore missing XML comment warnings -dotnet_diagnostic.CS1591.severity = none diff --git a/NuGetLibrary/Extensions.cs b/NuGetLibrary/Extensions.cs deleted file mode 100644 index bdf0e4cd..00000000 --- a/NuGetLibrary/Extensions.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Runtime.CompilerServices; - -namespace ptr727.ProjectTemplate.NuGetLibrary; - -internal static partial class LogExtensions -{ - extension(ILogger logger) - { - internal bool LogAndPropagate( - Exception exception, - [CallerMemberName] string function = "unknown" - ) - { - logger.LogCatchException(function, exception); - return false; - } - - internal bool LogAndHandle( - Exception exception, - [CallerMemberName] string function = "unknown" - ) - { - logger.LogCatchException(function, exception); - return true; - } - } - - [LoggerMessage(Message = "Exception in {Function}", Level = LogLevel.Error)] - internal static partial void LogCatchException( - this ILogger logger, - string function, - Exception exception - ); - - [LoggerMessage(Message = "{Message}", Level = LogLevel.Information)] - internal static partial void LogInformation(this ILogger logger, string message); -} diff --git a/NuGetLibrary/GlobalUsings.cs b/NuGetLibrary/GlobalUsings.cs deleted file mode 100644 index f52bcbcf..00000000 --- a/NuGetLibrary/GlobalUsings.cs +++ /dev/null @@ -1,2 +0,0 @@ -global using Microsoft.Extensions.Logging; -global using Microsoft.Extensions.Logging.Abstractions; diff --git a/NuGetLibrary/Library.cs b/NuGetLibrary/Library.cs deleted file mode 100644 index 6f6cdd45..00000000 --- a/NuGetLibrary/Library.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace ptr727.ProjectTemplate.NuGetLibrary; - -/// -/// Provides the primary library functionality. -/// -public sealed class TemplateLibrary(Options? options = null) -{ - private readonly Lazy _logger = new(() => - LogOptions.CreateLogger(options) - ); - internal ILogger Log => _logger.Value; - - /// - /// Writes a test log entry to the configured logger. - /// - public void Test() => Log.LogInformation("Test"); -} - -public static class StaticTemplateLibrary -{ - // Used for naming the logger category - private sealed class LogCategory; - - private static readonly Lazy s_logger = new(LogOptions.CreateLogger); - private static ILogger Log => s_logger.Value; - - /// - /// Writes a test log entry to the configured logger. - /// - public static void Test() => Log.LogInformation("Test"); -} diff --git a/NuGetLibrary/LogOptions.cs b/NuGetLibrary/LogOptions.cs deleted file mode 100644 index 382fc04a..00000000 --- a/NuGetLibrary/LogOptions.cs +++ /dev/null @@ -1,138 +0,0 @@ -namespace ptr727.ProjectTemplate.NuGetLibrary; - -/// -/// Provides global logging configuration for the library. -/// -public static class LogOptions -{ - private static ILoggerFactory s_loggerFactory = NullLoggerFactory.Instance; - private static ILogger s_logger = NullLogger.Instance; - - /// - /// Gets or sets the logger factory used to create category loggers. - /// - /// - /// Changes to this property after loggers have been created will not affect existing cached loggers. - /// - public static ILoggerFactory LoggerFactory - { - get => Volatile.Read(ref s_loggerFactory); - set => _ = Interlocked.Exchange(ref s_loggerFactory, value ?? NullLoggerFactory.Instance); - } - - /// - /// Gets or sets the global fallback logger used when no factory is configured. - /// - /// - /// Changes to this property after loggers have been created will not affect existing cached loggers. - /// - public static ILogger Logger - { - get => Volatile.Read(ref s_logger); - set => _ = Interlocked.Exchange(ref s_logger, value ?? NullLogger.Instance); - } - - /// - /// Creates a logger for the specified type using the current factory or fallback logger. - /// - /// The type used to derive the logger category. - /// The configured logger for the category. - public static ILogger CreateLogger() => CreateLogger(typeof(T).FullName ?? typeof(T).Name); - - /// - /// Creates a logger for the specified type using the provided options or global configuration. - /// - /// The type used to derive the logger category. - /// The options used to configure logging. - /// The configured logger for the category. - public static ILogger CreateLogger(Options? options) => - CreateLogger(typeof(T).FullName ?? typeof(T).Name, options); - - /// - /// Creates a logger for the specified category using the current factory or fallback logger. - /// - /// The category name for the logger. - /// The configured logger for the category. - public static ILogger CreateLogger(string categoryName) - { - ILoggerFactory loggerFactory = LoggerFactory; - return !ReferenceEquals(loggerFactory, NullLoggerFactory.Instance) - ? loggerFactory.CreateLogger(categoryName) - : Logger; - } - - /// - /// Creates a logger for the specified category using the provided options or global configuration. - /// - /// The category name for the logger. - /// The options used to configure logging. - /// The configured logger for the category. - public static ILogger CreateLogger(string categoryName, Options? options) => - options is null ? CreateLogger(categoryName) - : options.LoggerFactory is not null ? options.LoggerFactory.CreateLogger(categoryName) - : options.Logger ?? CreateLogger(categoryName); - - /// - /// Configures the library to use the specified logger factory. - /// - /// The factory to use for new loggers. - /// - /// This will only affect loggers created after this call. - /// Existing cached loggers remain unchanged. - /// - public static void SetFactory(ILoggerFactory loggerFactory) => LoggerFactory = loggerFactory; - - /// - /// Attempts to configure the library to use the specified logger factory if none is set. - /// - /// The factory to use for new loggers. - /// - /// true when the factory was set because no factory was configured; otherwise, false. - /// - /// - /// Use this method for one-time initialization to avoid overwriting an existing factory. - /// - public static bool TrySetFactory(ILoggerFactory loggerFactory) - { - ILoggerFactory candidate = loggerFactory ?? NullLoggerFactory.Instance; - ILoggerFactory original = Interlocked.CompareExchange( - ref s_loggerFactory, - candidate, - NullLoggerFactory.Instance - ); - - return ReferenceEquals(original, NullLoggerFactory.Instance); - } - - /// - /// Configures the library to use the specified global logger. - /// - /// The logger used as the global fallback. - /// - /// This will only affect loggers created after this call. - /// Existing cached loggers remain unchanged. - /// - public static void SetLogger(ILogger logger) => Logger = logger; - - /// - /// Attempts to configure the library to use the specified global logger if none is set. - /// - /// The logger used as the global fallback. - /// - /// true when the logger was set because no logger was configured; otherwise, false. - /// - /// - /// Use this method for one-time initialization to avoid overwriting an existing logger. - /// - public static bool TrySetLogger(ILogger logger) - { - ILogger candidate = logger ?? NullLogger.Instance; - ILogger original = Interlocked.CompareExchange( - ref s_logger, - candidate, - NullLogger.Instance - ); - - return ReferenceEquals(original, NullLogger.Instance); - } -} diff --git a/NuGetLibrary/NuGetLibrary.csproj b/NuGetLibrary/NuGetLibrary.csproj deleted file mode 100644 index f08d6fd9..00000000 --- a/NuGetLibrary/NuGetLibrary.csproj +++ /dev/null @@ -1,43 +0,0 @@ - - - 1.0.0.0 - Pieter Viljoen - Pieter Viljoen - Pieter Viljoen - C# .NET template project - true - 1.0.0.0 - true - true - true - 1.0.0-pre - true - true - ptr727.ProjectTemplate.Library - MIT - https://github.com/ptr727/ProjectTemplate - README.md - ProjectTemplate - 1.0.0-pre - true - https://github.com/ptr727/ProjectTemplate - ptr727.ProjectTemplate.NuGetLibrary - snupkg - 1.0.0.0 - - - true - - - - - - - - - - - - - - diff --git a/NuGetLibrary/Options.cs b/NuGetLibrary/Options.cs deleted file mode 100644 index 04d51a70..00000000 --- a/NuGetLibrary/Options.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace ptr727.ProjectTemplate.NuGetLibrary; - -/// -/// Options used to configure the library. -/// -public sealed class Options -{ - /// - /// Gets the logger factory used to create per-instance loggers. - /// - public ILoggerFactory? LoggerFactory { get; init; } - - /// - /// Gets the logger used by the library. - /// - public ILogger? Logger { get; init; } -} diff --git a/ProjectTemplate.code-workspace b/ProjectTemplate.code-workspace deleted file mode 100644 index 70594fd0..00000000 --- a/ProjectTemplate.code-workspace +++ /dev/null @@ -1,140 +0,0 @@ -{ - "folders": [ - { - "path": "." - } - ], - "settings": { - "cSpell.words": [ - "accessibilities", - "Allman", - "apikey", - "astral", - "autoremove", - "buildcache", - "buildtransitive", - "Buildx", - "codegen", - "commitish", - "contentfiles", - "csdevkit", - "datebadge", - "davidanson", - "debuglevel", - "devcontainer", - "distros", - "dockerbuild", - "Dockerfiles", - "dockerhub", - "dorny", - "dotnettools", - "downstreams", - "dryrun", - "Emby", - "envsubst", - "extensionless", - "finalizers", - "Genericize", - "gpgsign", - "gruntfuggly", - "HACS", - "hatchling", - "heredocs", - "homeassistant", - "Jellyfin", - "Keychain", - "kicad", - "lastbuild", - "libsecret", - "LINQ", - "logfile", - "mktemp", - "nameof", - "nbgv", - "nektos", - "Nerdbank", - "noninteractive", - "nugetlibrary", - "onCreateCommand", - "Optix", - "othercommand", - "Pieter", - "pipefail", - "postCreateCommand", - "ProjectTemplate", - "purpleair", - "pypi", - "pypilibrary", - "pyproject", - "pyright", - "quoteoftheday", - "resharper", - "rhysd", - "Rubba", - "ruff", - "Serilog", - "settingsfile", - "shellcheck", - "signingkey", - "slnx", - "snupkg", - "softprops", - "somecommand", - "subsetting", - "Triaging", - "tzdata", - "unvalidated", - "venv", - "Viljoen", - "winget", - "xunit", - "yzhang" - ], - "dotnet.defaultSolution": "ProjectTemplate.slnx", - "files.trimTrailingWhitespace": true, - "files.trimTrailingWhitespaceInRegexAndStrings": false, - "diffEditor.ignoreTrimWhitespace": false, - "editor.renderWhitespace": "boundary", - "dotnet.formatting.organizeImportsOnFormat": true, - "csharp.debug.symbolOptions.searchNuGetOrgSymbolServer": true, - "csharp.debug.symbolOptions.searchMicrosoftSymbolServer": true, - "files.encoding": "utf8", - "[markdown]": { - "files.trimTrailingWhitespace": false, - }, - "[plaintext]": { - "files.trimTrailingWhitespace": false, - }, - "[csharp]": { - "editor.formatOnSave": true, - "editor.defaultFormatter": "csharpier.csharpier-vscode" - }, - "[python]": { - "editor.formatOnSave": true, - "editor.defaultFormatter": "charliermarsh.ruff", - "editor.codeActionsOnSave": { - "source.organizeImports": "explicit" - } - }, - "python.terminal.activateEnvironment": false, - "git.alwaysSignOff": true, - "markdown.extension.toc.levels": "2..3" - }, - "extensions": { - "recommendations": [ - "charliermarsh.ruff", - "csharpier.csharpier-vscode", - "davidanson.vscode-markdownlint", - "editorconfig.editorconfig", - "fanaticpythoner.better-todo-tree", - "github.vscode-github-actions", - "ms-azuretools.vscode-docker", - "ms-dotnettools.csdevkit", - "ms-python.python", - "streetsidesoftware.code-spell-checker", - "yzhang.markdown-all-in-one", - "timonwong.shellcheck", - "arahata.linter-actionlint" - ] - } -} diff --git a/ProjectTemplate.slnx b/ProjectTemplate.slnx deleted file mode 100644 index ceffba5b..00000000 --- a/ProjectTemplate.slnx +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/PyPiLibrary/README.md b/PyPiLibrary/README.md deleted file mode 100644 index 323db12b..00000000 --- a/PyPiLibrary/README.md +++ /dev/null @@ -1,94 +0,0 @@ -# PyPiLibrary - -Python PyPI template - companion to the .NET `NuGetLibrary` in this repo. Published to PyPI as [`ptr727-projecttemplate-library`](https://pypi.org/project/ptr727-projecttemplate-library/). - -## Stack - -- **Build backend** - [`hatchling`](https://hatch.pypa.io/latest/) via `pyproject.toml` -- **Env / deps / publish** - [`uv`](https://docs.astral.sh/uv/) (Astral) -- **Lint + format** - [`ruff`](https://docs.astral.sh/ruff/) -- **Type checker** - [`pyright`](https://microsoft.github.io/pyright/) -- **Tests** - [`pytest`](https://docs.pytest.org/) -- **Publish** - [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/) via `pypa/gh-action-pypi-publish` (no API token in repo secrets) -- **Version** - [Nerdbank.GitVersioning](https://github.com/dotnet/Nerdbank.GitVersioning) (NBGV) shared with the .NET side. CI replaces the `__version__` line in `_version.py` (in place) before `uv build`. **Branch-aware**: on `main` the value is NBGV's `AssemblyFileVersion` (`Major.Minor.Patch.BuildNumber`, PEP 440 release); on `develop` it's `Major.Minor.Patch.BuildNumber.dev0` (PEP 440 dev release - `pip install` filters the `.dev` suffix unless `--pre` is passed; the BuildNumber stays in the release segment so develop's segment grows past main's per commit and `--pre` actually prefers develop). Matches how NuGet/Docker tag develop builds as prerelease. All four artifact families (.NET assemblies, NuGet, Docker, PyPI) derive from the same NBGV computation per commit; only the formatting differs. - -## Layout - -```text -PyPiLibrary/ - pyproject.toml - README.md - src/ - ptr727_projecttemplate_library/ - __init__.py - _version.py - example.py - tests/ - __init__.py - test_example.py -``` - -## Local Development - -The repo's [devcontainer](../docs/devcontainer.md) installs `uv` automatically and runs `uv sync` for this project on first open. To work outside the devcontainer: - -```shell -# from the repo root -cd PyPiLibrary -uv sync # creates .venv, installs deps + dev group -uv run ruff check # lint -uv run ruff format --check # formatting check -uv run pyright # type check -uv run pytest # tests -uv build # wheel + sdist into ./dist -``` - -## Publishing - -Releases are produced by `.github/workflows/build-pypilibrary-task.yml` (called from `build-release-task.yml` to build, lint, type-check, test, and upload the wheel + sdist as a workflow-run artifact). Publishing is a separate top-level `publish-pypi` job in `publish-release.yml` that downloads the artifact by name and runs [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) - no `PYPI_API_TOKEN` secret is involved. The publish job has `id-token: write` only at that single job level, so the test-pull-request flow (which calls the same build task during PR validation) doesn't need to propagate that permission through the reusable workflow chain. - -**Two-channel publishing**: pushes to both `main` and `develop` trigger `publish-release.yml`, and the **"Compute PyPI version step"** in `build-pypilibrary-task.yml` formats the version per branch: - -- `main` -> `Major.Minor.Patch.BuildNumber` (PEP 440 release). `pip install ptr727-projecttemplate-library` picks this up by default. -- `develop` -> `Major.Minor.Patch.BuildNumber.dev0` (PEP 440 dev release). The BuildNumber stays in the release segment so develop's release segment grows past main's per commit - that's what lets `pip install --pre ptr727-projecttemplate-library` actually resolve to a develop build (`--pre` would otherwise still pick the higher-on-release-segments main version). Same PyPI project; no separate "test" project required. - -Edge case worth knowing: in the window between a release merge to main and the next commit on develop, develop's BuildNumber equals main's (or is one lower), so `--pre` will still resolve to the main release until a new develop commit lands. Self-healing. - -This matches how NuGet (NBGV `SemVer2` prerelease tags), Docker (NBGV `SemVer2` image tags), and GitHub releases (softprops `prerelease: true` on develop) already mark develop builds. - -First-time setup (one-time, on PyPI): - -Prerequisite: enable **2FA** on the PyPI account (TOTP or hardware key). PyPI requires it before any trusted publisher can be registered. - -1. **PyPI** -> **Account settings** -> **Publishing** -> **Add a new pending publisher** ([direct link](https://pypi.org/manage/account/publishing/)). If the project already exists on PyPI, go to the project page -> **Manage** -> **Publishing** -> **Add a new publisher** instead - the "pending" form is only for projects that don't exist yet. Fields: - - **PyPI project name**: `ptr727-projecttemplate-library` - - **Owner**: `ptr727` - - **Repository name**: `ProjectTemplate` - - **Workflow filename**: `publish-release.yml` - - **Environment name**: `pypi` -2. **GitHub repo** -> **Settings** -> **Environments** -> **New environment** -> `pypi`. The environment owns deploy-time guardrails: - - **Deployment branch rule** -> **Selected branches and tags** -> add **both** `main` (release channel) and `develop` (prerelease channel). **This step is mandatory - Trusted Publishing without a branch restriction is a documented security anti-pattern.** Any other branch (feature branches, codegen, etc.) is blocked at the env gate even if a workflow misconfiguration ever tried to publish from it. - - (Optional) add yourself as a **required reviewer** so each publish requires a click - useful belt-and-suspenders against an accidental release. -3. The first successful release converts the pending publisher to a real publisher. After that the same OIDC exchange validates against the real publisher on every release. - -Troubleshooting: - -- `invalid-publisher: ... Publisher with matching claims was not found` - the publisher hasn't been registered yet, or one of the five claim fields (owner, repo, workflow filename, environment name, project name) doesn't match. Re-check step 1. -- `manifest unknown` from `docker:` pulling `ghcr.io/pypa/gh-action-pypi-publish` - the SHA pinned in `publish-release.yml` doesn't correspond to a release tag with a published GHCR image. Pin to the SHA that the upstream tag (`# vX.Y.Z` comment) actually points at on `pypa/gh-action-pypi-publish`. - -Fallback (API token instead of Trusted Publishing): drop the `id-token: write` permission from the `publish-pypi` job, add `password: ${{ secrets.PYPI_API_TOKEN }}` to the `pypa/gh-action-pypi-publish` step, and store the token as a repo secret. Also pass `attestations: false` since attestations require the OIDC token. The OIDC path is preferred - no long-lived secret in the repo - so use the token method only when Trusted Publishing isn't an option. - -## Template Adoption - -When deriving a new project from this template: - -- Replace the package name `ptr727-projecttemplate-library` (in `pyproject.toml`, this README, and CI) with your name. -- Rename `src/ptr727_projecttemplate_library/` to your import name. -- Re-register the trusted publisher on PyPI under the new project name. -- **Pick a versioning scheme.** The template defaults to **NBGV-driven** versioning shared with the .NET side: `_version.py` holds `__version__ = "0.0.0"` as a local-development placeholder, and the CI steps **"Compute PyPI version step"** + **"Write version into _version.py step"** in [`build-pypilibrary-task.yml`](../.github/workflows/build-pypilibrary-task.yml) compute and rewrite the value before `uv build`. The version is **branch-aware**: `main` pushes ship `M.N.P.B` (PEP 440 release), `develop` pushes ship `M.N.P.B.dev0` (PEP 440 dev release - same release segment as main, `.dev0` marks it as prerelease so `pip install` filters it unless `--pre` is passed). The BuildNumber stays in the release segment so develop's segment grows past main's per commit, which is what lets `--pre` actually prefer develop. On `main` the PyPI version equals the .NET `FileVersion` stamp exactly; on `develop` it equals the same `FileVersion` numerically but with a trailing `.dev0`. .NET's `AssemblyVersion` (a separate NBGV output) and NuGet/Docker (NBGV `SemVer2`) carry different strings across artifact families on both channels; all four derive from the same NBGV computation against `version.json` + git history per commit. If you want a different scheme, replace both `_version.py` and the workflow steps. Two common alternatives: - - [`hatch-vcs`](https://github.com/ofek/hatch-vcs) - derive the version from git tags. Add it to `[build-system].requires` and switch `[tool.hatch.version]` to `source = "vcs"`. Drop the CI overwrite step. Pairs well with tag-driven releases and removes the NBGV dependency. - - **Manual bumps** - edit `_version.py` in each release PR. Simplest, but easy to forget. Drop the CI overwrite step. - - The publish workflow uses `skip-existing: true` so a re-upload of the same version is a no-op instead of a failure - useful when iterating on releases without bumping NBGV. - -If you don't want a Python project at all, delete the `PyPiLibrary/` folder, the `build-pypilibrary-task.yml` workflow, the `build-pypilibrary` job in `build-release-task.yml`, the `publish-pypi` job in `publish-release.yml`, and the `uv` block in `.github/dependabot.yml`. diff --git a/PyPiLibrary/pyproject.toml b/PyPiLibrary/pyproject.toml deleted file mode 100644 index 9fb0321a..00000000 --- a/PyPiLibrary/pyproject.toml +++ /dev/null @@ -1,82 +0,0 @@ -[build-system] -requires = ["hatchling>=1.27"] -build-backend = "hatchling.build" - -[project] -name = "ptr727-projecttemplate-library" -description = "Python PyPI template library - companion to the .NET NuGetLibrary in this template repo." -readme = "README.md" -license = { text = "MIT" } -authors = [{ name = "Pieter Viljoen" }] -requires-python = ">=3.14" -keywords = ["template", "pypi", "library"] -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.14", - "Topic :: Software Development :: Libraries :: Python Modules", -] -dynamic = ["version"] -dependencies = [] - -[project.urls] -Homepage = "https://github.com/ptr727/ProjectTemplate" -Source = "https://github.com/ptr727/ProjectTemplate" -Issues = "https://github.com/ptr727/ProjectTemplate/issues" - -[dependency-groups] -dev = [ - "pytest>=8.3", - "ruff>=0.9", - "pyright>=1.1.390", -] - -[tool.hatch.version] -path = "src/ptr727_projecttemplate_library/_version.py" - -[tool.hatch.build.targets.wheel] -packages = ["src/ptr727_projecttemplate_library"] - -[tool.hatch.build.targets.sdist] -include = ["src", "tests", "README.md", "pyproject.toml"] - -[tool.ruff] -line-length = 120 -target-version = "py314" - -[tool.ruff.lint] -select = [ - "E", # pycodestyle errors - "W", # pycodestyle warnings - "F", # pyflakes - "I", # isort - "B", # flake8-bugbear - "UP", # pyupgrade - "N", # pep8-naming - "SIM", # flake8-simplify - "RUF", # ruff-specific -] - -[tool.ruff.format] -docstring-code-format = true - -[tool.pyright] -include = ["src", "tests"] -pythonVersion = "3.14" -typeCheckingMode = "standard" -# Per-path strictness: `strict` accepts directory paths and applies -# strict-mode type checking to everything under them - equivalent to -# placing `# pyright: strict` at the top of every file in those dirs. -# Public library surface (`src/`) needs tight types; tests inherit the -# standard mode set above (fixtures, mocks, and parametrize args are -# commonly looser). -strict = ["src"] - -[tool.pytest.ini_options] -minversion = "8.0" -testpaths = ["tests"] -addopts = ["-ra", "--strict-markers", "--strict-config"] diff --git a/PyPiLibrary/src/ptr727_projecttemplate_library/__init__.py b/PyPiLibrary/src/ptr727_projecttemplate_library/__init__.py deleted file mode 100644 index 8c603871..00000000 --- a/PyPiLibrary/src/ptr727_projecttemplate_library/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Python PyPI template library.""" - -from ptr727_projecttemplate_library._version import __version__ -from ptr727_projecttemplate_library.example import greet - -__all__ = ["__version__", "greet"] diff --git a/PyPiLibrary/src/ptr727_projecttemplate_library/_version.py b/PyPiLibrary/src/ptr727_projecttemplate_library/_version.py deleted file mode 100644 index 3609e354..00000000 --- a/PyPiLibrary/src/ptr727_projecttemplate_library/_version.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Single-source-of-truth for the package version. - -Hatchling reads ``__version__`` via ``[tool.hatch.version]``. ``0.0.0`` is a -local-dev placeholder; before ``uv build`` the release pipeline rewrites the -``__version__`` line with a branch-aware PEP 440 version: ``main`` -> -``Major.Minor.Patch.BuildNumber`` (release), ``develop`` -> the same with a -``.dev0`` suffix, so ``pip install --pre`` normally prefers the develop build -(except briefly after a develop->main promotion, until the next develop commit). See -``.github/workflows/build-pypilibrary-task.yml``. -""" - -__version__ = "0.0.0" diff --git a/PyPiLibrary/src/ptr727_projecttemplate_library/example.py b/PyPiLibrary/src/ptr727_projecttemplate_library/example.py deleted file mode 100644 index 97088328..00000000 --- a/PyPiLibrary/src/ptr727_projecttemplate_library/example.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Trivial example module - replace with your library code.""" - - -def greet(name: str) -> str: - """Return a friendly greeting for ``name``.""" - return f"Hello, {name}!" diff --git a/PyPiLibrary/src/ptr727_projecttemplate_library/py.typed b/PyPiLibrary/src/ptr727_projecttemplate_library/py.typed deleted file mode 100644 index e69de29b..00000000 diff --git a/PyPiLibrary/tests/__init__.py b/PyPiLibrary/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/PyPiLibrary/tests/test_example.py b/PyPiLibrary/tests/test_example.py deleted file mode 100644 index fe97742d..00000000 --- a/PyPiLibrary/tests/test_example.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Tests for ``ptr727_projecttemplate_library.example``.""" - -from ptr727_projecttemplate_library import __version__, greet - - -def test_version_is_string() -> None: - assert isinstance(__version__, str) - assert len(__version__) > 0 - - -def test_greet_uses_name() -> None: - assert greet("world") == "Hello, world!" - - -def test_greet_with_empty_name() -> None: - assert greet("") == "Hello, !" diff --git a/PyPiLibrary/uv.lock b/PyPiLibrary/uv.lock deleted file mode 100644 index cb734f96..00000000 --- a/PyPiLibrary/uv.lock +++ /dev/null @@ -1,140 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.14" - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "nodeenv" -version = "1.10.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, -] - -[[package]] -name = "packaging" -version = "26.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "ptr727-projecttemplate-library" -source = { editable = "." } - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.390" }, - { name = "pytest", specifier = ">=8.3" }, - { name = "ruff", specifier = ">=0.9" }, -] - -[[package]] -name = "pygments" -version = "2.20.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, -] - -[[package]] -name = "pyright" -version = "1.1.409" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nodeenv" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/51/4e/3aa27f74211522dba7e9cbc3e74de779c6d4b654c54e50a4840623be8014/pyright-1.1.409.tar.gz", hash = "sha256:986ee05beca9e077c165758ad123667c679e050059a2546aa02473930394bc93", size = 4430434, upload-time = "2026-04-23T11:02:03.799Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/16/6b/330d8ebae582b30c2959a1ef4c3bc344ebde48c2ff0c3f113c4710735e11/pyright-1.1.409-py3-none-any.whl", hash = "sha256:aa3ea228cab90c845c7a60d28db7a844c04315356392aa09fafcee98c8c22fb3", size = 6438161, upload-time = "2026-04-23T11:02:01.309Z" }, -] - -[[package]] -name = "pytest" -version = "9.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, -] - -[[package]] -name = "ruff" -version = "0.15.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, - { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, - { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, - { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, - { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, - { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, - { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, - { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, - { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, - { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, - { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, - { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] diff --git a/Tests/.editorconfig b/Tests/.editorconfig deleted file mode 100644 index 1b0e9335..00000000 --- a/Tests/.editorconfig +++ /dev/null @@ -1,19 +0,0 @@ -root = false - -# C# files -[*.cs] - -# Allow underscores in test method names -dotnet_diagnostic.CA1707.severity = none - -# Ignore unused private members -dotnet_diagnostic.IDE0052.severity = none - -# Ignore expression value is never used -dotnet_diagnostic.IDE0058.severity = none - -# Ignore missing XML docs for public test APIs -dotnet_diagnostic.CS1591.severity = none - -# Ignore making public types internal -dotnet_diagnostic.CA1515.severity = none diff --git a/Tests/Fixture.cs b/Tests/Fixture.cs deleted file mode 100644 index ca7d0fd2..00000000 --- a/Tests/Fixture.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Single instance for all tests in assembly -[assembly: AssemblyFixture(typeof(ptr727.ProjectTemplate.Tests.SingleInstanceFixture))] - -namespace ptr727.ProjectTemplate.Tests; - -// Sequential execution fixture -[CollectionDefinition("Sequential Test Collection", DisableParallelization = true)] -public class SequentialCollectionDefinition; - -[System.Diagnostics.CodeAnalysis.SuppressMessage( - "Design", - "CA1063:Implement IDisposable Correctly", - Justification = "Demonstration only" -)] -public class SingleInstanceFixture : IDisposable -{ - public void Dispose() => GC.SuppressFinalize(this); -} diff --git a/Tests/GlobalUsings.cs b/Tests/GlobalUsings.cs deleted file mode 100644 index d85913ed..00000000 --- a/Tests/GlobalUsings.cs +++ /dev/null @@ -1,2 +0,0 @@ -global using AwesomeAssertions; -global using Xunit; diff --git a/Tests/LoggingTests.cs b/Tests/LoggingTests.cs deleted file mode 100644 index 2d8f8a65..00000000 --- a/Tests/LoggingTests.cs +++ /dev/null @@ -1,228 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using ptr727.ProjectTemplate.NuGetLibrary; - -namespace ptr727.ProjectTemplate.Tests; - -[Collection("Sequential Test Collection")] -public sealed class LoggingTests : SingleInstanceFixture -{ - public LoggingTests() - { - LogOptions.LoggerFactory = NullLoggerFactory.Instance; - LogOptions.Logger = NullLogger.Instance; - } - - [Fact] - public void TrySetFactory_WhenUnset_ShouldReturnTrueAndSet() - { - // Arrange - using TestLoggerFactory factory = new(); - - // Act - bool configured = LogOptions.TrySetFactory(factory); - - // Assert - configured.Should().BeTrue(); - ReferenceEquals(LogOptions.LoggerFactory, factory).Should().BeTrue(); - } - - [Fact] - public void TrySetFactory_WhenAlreadySet_ShouldReturnFalseAndKeepOriginal() - { - // Arrange - using TestLoggerFactory originalFactory = new(); - using TestLoggerFactory newFactory = new(); - LogOptions.SetFactory(originalFactory); - - // Act - bool configured = LogOptions.TrySetFactory(newFactory); - - // Assert - configured.Should().BeFalse(); - ReferenceEquals(LogOptions.LoggerFactory, originalFactory).Should().BeTrue(); - } - - [Fact] - public void TrySetLogger_WhenUnset_ShouldReturnTrueAndSet() - { - // Arrange - TestLogger logger = new(); - - // Act - bool configured = LogOptions.TrySetLogger(logger); - - // Assert - configured.Should().BeTrue(); - ReferenceEquals(LogOptions.Logger, logger).Should().BeTrue(); - } - - [Fact] - public void TrySetLogger_WhenAlreadySet_ShouldReturnFalseAndKeepOriginal() - { - // Arrange - TestLogger originalLogger = new(); - TestLogger newLogger = new(); - LogOptions.SetLogger(originalLogger); - - // Act - bool configured = LogOptions.TrySetLogger(newLogger); - - // Assert - configured.Should().BeFalse(); - ReferenceEquals(LogOptions.Logger, originalLogger).Should().BeTrue(); - } - - [Fact] - public void CreateLogger_WithFactory_ShouldUseFactory() - { - // Arrange - using TestLoggerFactory factory = new(); - LogOptions.SetFactory(factory); - - // Act - ILogger logger = LogOptions.CreateLogger("Category"); - - // Assert - ReferenceEquals(logger, factory.Logger).Should().BeTrue(); - factory.LastCategoryName.Should().Be("Category"); - } - - [Fact] - public void CreateLogger_WithGlobalLogger_ShouldUseGlobalWhenNoFactory() - { - // Arrange - TestLogger logger = new(); - LogOptions.LoggerFactory = NullLoggerFactory.Instance; - LogOptions.Logger = logger; - - // Act - ILogger created = LogOptions.CreateLogger("Category"); - - // Assert - ReferenceEquals(created, logger).Should().BeTrue(); - } - - [Fact] - public void Log_WhenOptionsLoggerProvided_ShouldUseOptionsLogger() - { - // Arrange - TestLogger logger = new(); - Options options = new() { Logger = logger }; - - // Act - TemplateLibrary library = new(options); - - // Assert - ReferenceEquals(library.Log, logger).Should().BeTrue(); - } - - [Fact] - public void Log_WhenOptionsLoggerFactoryProvided_ShouldUseFactoryLogger() - { - // Arrange - using TestLoggerFactory factory = new(); - Options options = new() { LoggerFactory = factory }; - - // Act - TemplateLibrary library = new(options); - - // Assert - ReferenceEquals(library.Log, factory.Logger).Should().BeTrue(); - factory.LastCategoryName.Should().Be(typeof(TemplateLibrary).FullName); - } - - [Fact] - public void Static_WithFactory_ShouldLogInformation() - { - // Arrange - using TestLoggerFactory factory = new(); - LogOptions.SetFactory(factory); - - // Act - StaticTemplateLibrary.Test(); - - // Assert - factory - .Logger.Entries.Should() - .Contain(entry => entry.Level == LogLevel.Information && entry.Message == "Test"); - } - - [Fact] - public void Instance_WithOptions_ShouldNotChangeWhenGlobalFactoryChanges() - { - // Arrange - using TestLoggerFactory optionsFactory = new(); - using TestLoggerFactory globalFactory = new(); - Options options = new() { LoggerFactory = optionsFactory }; - TemplateLibrary library = new(options); - - // Act - first call uses options factory - library.Test(); - - // Change global factory (should not affect instance with options) - LogOptions.SetFactory(globalFactory); - - // Act - second call should still use options factory - library.Test(); - - // Assert - optionsFactory.Logger.Entries.Should().HaveCount(2); - globalFactory.Logger.Entries.Should().BeEmpty(); - } - - private sealed class TestLoggerFactory : ILoggerFactory - { - public string? LastCategoryName { get; private set; } - - public TestLogger Logger { get; } = new(); - - public void AddProvider(ILoggerProvider provider) => _ = provider; - - public ILogger CreateLogger(string categoryName) - { - LastCategoryName = categoryName; - return Logger; - } - - public void Dispose() { } - } - - private sealed class TestLogger : ILogger - { - private readonly List _entries = []; - - public IReadOnlyList Entries => _entries; - - public IDisposable BeginScope(TState state) - where TState : notnull - { - _ = state; - return NullScope.Instance; - } - - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log( - LogLevel logLevel, - EventId eventId, - TState state, - Exception? exception, - Func formatter - ) - { - _ = eventId; - string message = formatter(state, exception); - _entries.Add(new LogEntry(logLevel, message)); - } - } - - private sealed class NullScope : IDisposable - { - public static readonly NullScope Instance = new(); - - public void Dispose() { } - } - - private readonly record struct LogEntry(LogLevel Level, string Message); -} diff --git a/Tests/SampleTest.cs b/Tests/SampleTest.cs deleted file mode 100644 index 2d653a5d..00000000 --- a/Tests/SampleTest.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace ptr727.ProjectTemplate.Tests; - -public sealed class SampleTests : SingleInstanceFixture -{ - [Fact] - public void StringComparison_WithHelloWorld_ShouldPass() - { - // Arrange - const string testString = "Hello, World!"; - - // Act & Assert - testString.Should().NotBeEmpty().And.Contain("World").And.StartWith("Hello"); - } - - [Theory] - [InlineData(1, 2, 3)] - [InlineData(0, 0, 0)] - [InlineData(-1, 1, 0)] - [InlineData(100, 200, 300)] - public void Addition_WithInputs_ShouldReturnCorrectSum(int a, int b, int expected) - { - // Act - int result = a + b; - - // Assert - result.Should().Be(expected); - } -} diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj deleted file mode 100644 index 6cf10d59..00000000 --- a/Tests/Tests.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - true - ptr727.ProjectTemplate.Tests - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - diff --git a/cspell.json b/cspell.json new file mode 100644 index 00000000..83a93a9f --- /dev/null +++ b/cspell.json @@ -0,0 +1,94 @@ +{ + "version": "0.2", + "language": "en", + "ignorePaths": [ + ".git/**", + "reports/**", + "**/*.min.*" + ], + "words": [ + "accessibilities", + "Allman", + "apikey", + "astral", + "autoremove", + "buildcache", + "buildtransitive", + "Buildx", + "codegen", + "commitish", + "contentfiles", + "csdevkit", + "datebadge", + "davidanson", + "debuglevel", + "devcontainer", + "distros", + "dockerbuild", + "Dockerfiles", + "dockerhub", + "dorny", + "dotnettools", + "downstreams", + "dryrun", + "Emby", + "envsubst", + "extensionless", + "finalizers", + "Genericize", + "gpgsign", + "gruntfuggly", + "HACS", + "hatchling", + "heredocs", + "homeassistant", + "Jellyfin", + "Keychain", + "kicad", + "lastbuild", + "libsecret", + "LINQ", + "logfile", + "mktemp", + "nameof", + "nbgv", + "nektos", + "Nerdbank", + "noninteractive", + "nugetlibrary", + "onCreateCommand", + "Optix", + "othercommand", + "Pieter", + "pipefail", + "postCreateCommand", + "ProjectTemplate", + "purpleair", + "pypi", + "pypilibrary", + "pyproject", + "pyright", + "quoteoftheday", + "resharper", + "rhysd", + "Rubba", + "ruff", + "Serilog", + "settingsfile", + "shellcheck", + "signingkey", + "slnx", + "snupkg", + "softprops", + "somecommand", + "subsetting", + "Triaging", + "tzdata", + "unvalidated", + "venv", + "Viljoen", + "winget", + "xunit", + "yzhang" + ] +} From 3c6def54ad67811c59cd7792afd79462ef80c791 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 4 Jul 2026 12:46:26 -0700 Subject: [PATCH 02/17] Relocate rulesets to repo-config and app workflows to catalog Move branch rulesets out of .github/ (Actions-owned only) into repo-config/ with an idempotent configure.sh apply script, mirroring the downstream layout. Move the nine app-specific build/publish/codegen workflow tasks into catalog/snippets/workflows/ as inert reference (mapped to their WORKFLOW.md guarantees); keep only the source-only orchestrator set live. Move vscode-tasks, the Docker Hub readme, and the devcontainers into catalog/snippets. Reduce this repo's dependabot.yml to the github-actions ecosystem and keep the full multi-ecosystem config as a catalog reference. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/dependabot.yml | 77 +------------------ catalog/README.md | 14 ++++ catalog/snippets/configs/dependabot.yml | 72 +++++++++++++++++ .../snippets/configs/docker-hub-readme.md | 0 .../snippets/configs/vscode-tasks.json | 0 .../devcontainer}/dotnet/devcontainer.json | 0 .../devcontainer}/dotnet/post-create.sh | 0 .../devcontainer}/python/devcontainer.json | 0 .../devcontainer}/python/post-create.sh | 0 catalog/snippets/workflows/README.md | 19 +++++ .../workflows/build-datebadge-task.yml | 0 .../snippets}/workflows/build-docker-task.yml | 0 .../workflows/build-executable-task.yml | 0 .../workflows/build-nugetlibrary-task.yml | 0 .../workflows/build-pypilibrary-task.yml | 0 .../workflows/check-upstream-version-task.yml | 0 .../workflows/publish-docker-readme-task.yml | 0 .../run-codegen-pull-request-task.yml | 0 .../run-periodic-codegen-pull-request.yml | 0 repo-config/README.md | 14 ++++ repo-config/configure.sh | 26 +++++++ .../rulesets => repo-config}/develop.json | 0 {.github/rulesets => repo-config}/main.json | 0 reports/.gitkeep | 0 24 files changed, 149 insertions(+), 73 deletions(-) create mode 100644 catalog/README.md create mode 100644 catalog/snippets/configs/dependabot.yml rename Docker/README.md => catalog/snippets/configs/docker-hub-readme.md (100%) rename .vscode/tasks.json => catalog/snippets/configs/vscode-tasks.json (100%) rename {.devcontainer => catalog/snippets/devcontainer}/dotnet/devcontainer.json (100%) rename {.devcontainer => catalog/snippets/devcontainer}/dotnet/post-create.sh (100%) rename {.devcontainer => catalog/snippets/devcontainer}/python/devcontainer.json (100%) rename {.devcontainer => catalog/snippets/devcontainer}/python/post-create.sh (100%) create mode 100644 catalog/snippets/workflows/README.md rename {.github => catalog/snippets}/workflows/build-datebadge-task.yml (100%) rename {.github => catalog/snippets}/workflows/build-docker-task.yml (100%) rename {.github => catalog/snippets}/workflows/build-executable-task.yml (100%) rename {.github => catalog/snippets}/workflows/build-nugetlibrary-task.yml (100%) rename {.github => catalog/snippets}/workflows/build-pypilibrary-task.yml (100%) rename {.github => catalog/snippets}/workflows/check-upstream-version-task.yml (100%) rename {.github => catalog/snippets}/workflows/publish-docker-readme-task.yml (100%) rename {.github => catalog/snippets}/workflows/run-codegen-pull-request-task.yml (100%) rename {.github => catalog/snippets}/workflows/run-periodic-codegen-pull-request.yml (100%) create mode 100644 repo-config/README.md create mode 100755 repo-config/configure.sh rename {.github/rulesets => repo-config}/develop.json (100%) rename {.github/rulesets => repo-config}/main.json (100%) create mode 100644 reports/.gitkeep diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1657dcca..d796b022 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,59 +1,12 @@ # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file # -# Every ecosystem appears **twice**: once with `target-branch: "main"` -# and once with `target-branch: "develop"`. Dependabot will open -# parallel PRs against each branch, so both stay current on -# dependency versions independently of the develop -> main release -# cadence. -# -# Why dual-target and not develop-only: -# - `develop` is the integration branch and ships content forward to -# `main` through merge-commit releases, but the time between releases -# can be long (a feature branch may sit on develop for weeks). -# - Push-distribution channels (e.g. HACS for Home Assistant -# integrations, distros that pull from main) consume `main` directly. -# If `main` only got dependency bumps via the next develop -> main -# release, those channels would ship outdated code in the interim. -# - Codegen workflows take the same dual-target shape for the same -# reason - see .github/workflows/run-codegen-pull-request-task.yml. -# -# The merge-bot's `case` statement in -# .github/workflows/merge-bot-pull-request.yml dispatches the merge -# method per base ref (squash on develop, merge on main) so both bases -# auto-merge cleanly. `develop` remains strictly forward-only: there -# are no main -> develop back-merges; each branch absorbs its own -# Dependabot PRs and codegen PRs independently. -# -# Security update PRs (CVE-driven) are opened by Dependabot against -# the repo default branch (`main`) regardless of any `target-branch` -# config - the `case` statement handles them in the same code path. +# github-actions is the only ecosystem this repo ships. Dual-target (main + develop) so both branches +# stay current independently of the develop -> main release cadence; the merge-bot auto-merges each +# base with its per-base method. See catalog/snippets/configs/dependabot.yml for the multi-ecosystem +# reference (nuget, uv) a code-shipping repo uses. version: 2 updates: - # ----- nuget ----- - - - package-ecosystem: "nuget" - target-branch: "main" - directory: "/" - schedule: - interval: "daily" - groups: - nuget-deps: - patterns: - - "*" - - - package-ecosystem: "nuget" - target-branch: "develop" - directory: "/" - schedule: - interval: "daily" - groups: - nuget-deps: - patterns: - - "*" - - # ----- github-actions ----- - - package-ecosystem: "github-actions" target-branch: "main" directory: "/" @@ -73,25 +26,3 @@ updates: actions-deps: patterns: - "*" - - # ----- uv (PyPiLibrary) ----- - - - package-ecosystem: "uv" - target-branch: "main" - directory: "/PyPiLibrary" - schedule: - interval: "daily" - groups: - pypi-deps: - patterns: - - "*" - - - package-ecosystem: "uv" - target-branch: "develop" - directory: "/PyPiLibrary" - schedule: - interval: "daily" - groups: - pypi-deps: - patterns: - - "*" diff --git a/catalog/README.md b/catalog/README.md new file mode 100644 index 00000000..524a4630 --- /dev/null +++ b/catalog/README.md @@ -0,0 +1,14 @@ +# Catalog + +Reusable reference snippets: concrete config artifacts a repo can copy or compare against. These are +**reference material, not run here** - this repo ships no build, so the workflow snippets below are not +active. Each is the canonical shape the audit (`AUDIT.md`) checks a downstream implementation against. + +- `snippets/workflows/` - the reusable build/publish workflow tasks that a code-shipping repo runs + (this docs repo keeps only the source-only orchestrator set in `.github/workflows/`). See + `snippets/workflows/README.md` for the mapping from each file to the `WORKFLOW.md` guarantees it + implements. +- `snippets/configs/` - config exemplars: `vscode-tasks.json` (clean-compile task group), + `dependabot.yml` (multi-ecosystem dual-target reference), `docker-hub-readme.md` (the size-limited + Docker Hub overview, distinct from the project `README.md`). +- `snippets/devcontainer/` - `.devcontainer` definitions for the .NET and Python toolchains. diff --git a/catalog/snippets/configs/dependabot.yml b/catalog/snippets/configs/dependabot.yml new file mode 100644 index 00000000..d847fba9 --- /dev/null +++ b/catalog/snippets/configs/dependabot.yml @@ -0,0 +1,72 @@ +# Reference: multi-ecosystem dual-target Dependabot config for a code-shipping repo. +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +# +# Every ecosystem appears twice, once per target branch (main and develop), so both branches stay +# current on dependency versions independently of the develop -> main release cadence. Push-distribution +# channels (HACS, distros that pull main) consume main directly, so main must get bumps without waiting +# for the next release. The merge-bot dispatches the merge method per base ref (squash on develop, merge +# on main). develop is strictly forward-only; each branch absorbs its own PRs. Security (CVE) PRs always +# target the default branch regardless of target-branch. Keep only the ecosystems the repo actually ships. +version: 2 +updates: + + - package-ecosystem: "nuget" + target-branch: "main" + directory: "/" + schedule: + interval: "daily" + groups: + nuget-deps: + patterns: + - "*" + + - package-ecosystem: "nuget" + target-branch: "develop" + directory: "/" + schedule: + interval: "daily" + groups: + nuget-deps: + patterns: + - "*" + + - package-ecosystem: "github-actions" + target-branch: "main" + directory: "/" + schedule: + interval: "daily" + groups: + actions-deps: + patterns: + - "*" + + - package-ecosystem: "github-actions" + target-branch: "develop" + directory: "/" + schedule: + interval: "daily" + groups: + actions-deps: + patterns: + - "*" + + # uv directory points at the Python package root (e.g. /PackageName); adjust per repo layout. + - package-ecosystem: "uv" + target-branch: "main" + directory: "/PythonPackage" + schedule: + interval: "daily" + groups: + pypi-deps: + patterns: + - "*" + + - package-ecosystem: "uv" + target-branch: "develop" + directory: "/PythonPackage" + schedule: + interval: "daily" + groups: + pypi-deps: + patterns: + - "*" diff --git a/Docker/README.md b/catalog/snippets/configs/docker-hub-readme.md similarity index 100% rename from Docker/README.md rename to catalog/snippets/configs/docker-hub-readme.md diff --git a/.vscode/tasks.json b/catalog/snippets/configs/vscode-tasks.json similarity index 100% rename from .vscode/tasks.json rename to catalog/snippets/configs/vscode-tasks.json diff --git a/.devcontainer/dotnet/devcontainer.json b/catalog/snippets/devcontainer/dotnet/devcontainer.json similarity index 100% rename from .devcontainer/dotnet/devcontainer.json rename to catalog/snippets/devcontainer/dotnet/devcontainer.json diff --git a/.devcontainer/dotnet/post-create.sh b/catalog/snippets/devcontainer/dotnet/post-create.sh similarity index 100% rename from .devcontainer/dotnet/post-create.sh rename to catalog/snippets/devcontainer/dotnet/post-create.sh diff --git a/.devcontainer/python/devcontainer.json b/catalog/snippets/devcontainer/python/devcontainer.json similarity index 100% rename from .devcontainer/python/devcontainer.json rename to catalog/snippets/devcontainer/python/devcontainer.json diff --git a/.devcontainer/python/post-create.sh b/catalog/snippets/devcontainer/python/post-create.sh similarity index 100% rename from .devcontainer/python/post-create.sh rename to catalog/snippets/devcontainer/python/post-create.sh diff --git a/catalog/snippets/workflows/README.md b/catalog/snippets/workflows/README.md new file mode 100644 index 00000000..6e298f79 --- /dev/null +++ b/catalog/snippets/workflows/README.md @@ -0,0 +1,19 @@ +# Workflow snippets + +The reusable build/publish workflow tasks a code-shipping repo runs. They are **inert reference here** - +this repo is source-only and keeps just the orchestrator set (`test-pull-request`, `publish-release`, +`build-release-task`, `get-version-task`, `merge-bot-pull-request`) in `.github/workflows/`. Each file +below is the canonical implementation of one or more `WORKFLOW.md` guarantees; the audit asserts a +downstream repo's own Actions satisfy those guarantees, not that they match these bytes. + +| File | Role | WORKFLOW.md guarantees | +| --- | --- | --- | +| `build-executable-task.yml` | Console/executable per-runtime publish, aggregate to one release asset | D5, D6; §6 Console walkthrough | +| `build-nugetlibrary-task.yml` | Build + `dotnet nuget push` (OIDC), upload release asset | D3.4, D4.4, D6; §6 NuGet walkthrough | +| `build-pypilibrary-task.yml` | Build PyPI package; publish split to an OIDC job | D3.4, D4, D7.2; §6 PyPI walkthrough | +| `build-docker-task.yml` | Multi-arch image build + push, registry layer cache | D4.4, D6, D9.4; §6 Docker walkthrough | +| `build-datebadge-task.yml` | BYOB date/last-build badge on the default branch | D4; §3 Release Model | +| `publish-docker-readme-task.yml` | Push the size-limited Docker Hub overview | D2.4; §6 Docker walkthrough | +| `check-upstream-version-task.yml` | Upstream-version tracker for wrapper repos | D3.5, D8.3 | +| `run-codegen-pull-request-task.yml` | Deterministic codegen executor (per-branch PR) | D8.2 | +| `run-periodic-codegen-pull-request.yml` | Scheduled codegen trigger over both branches | D8.2 | diff --git a/.github/workflows/build-datebadge-task.yml b/catalog/snippets/workflows/build-datebadge-task.yml similarity index 100% rename from .github/workflows/build-datebadge-task.yml rename to catalog/snippets/workflows/build-datebadge-task.yml diff --git a/.github/workflows/build-docker-task.yml b/catalog/snippets/workflows/build-docker-task.yml similarity index 100% rename from .github/workflows/build-docker-task.yml rename to catalog/snippets/workflows/build-docker-task.yml diff --git a/.github/workflows/build-executable-task.yml b/catalog/snippets/workflows/build-executable-task.yml similarity index 100% rename from .github/workflows/build-executable-task.yml rename to catalog/snippets/workflows/build-executable-task.yml diff --git a/.github/workflows/build-nugetlibrary-task.yml b/catalog/snippets/workflows/build-nugetlibrary-task.yml similarity index 100% rename from .github/workflows/build-nugetlibrary-task.yml rename to catalog/snippets/workflows/build-nugetlibrary-task.yml diff --git a/.github/workflows/build-pypilibrary-task.yml b/catalog/snippets/workflows/build-pypilibrary-task.yml similarity index 100% rename from .github/workflows/build-pypilibrary-task.yml rename to catalog/snippets/workflows/build-pypilibrary-task.yml diff --git a/.github/workflows/check-upstream-version-task.yml b/catalog/snippets/workflows/check-upstream-version-task.yml similarity index 100% rename from .github/workflows/check-upstream-version-task.yml rename to catalog/snippets/workflows/check-upstream-version-task.yml diff --git a/.github/workflows/publish-docker-readme-task.yml b/catalog/snippets/workflows/publish-docker-readme-task.yml similarity index 100% rename from .github/workflows/publish-docker-readme-task.yml rename to catalog/snippets/workflows/publish-docker-readme-task.yml diff --git a/.github/workflows/run-codegen-pull-request-task.yml b/catalog/snippets/workflows/run-codegen-pull-request-task.yml similarity index 100% rename from .github/workflows/run-codegen-pull-request-task.yml rename to catalog/snippets/workflows/run-codegen-pull-request-task.yml diff --git a/.github/workflows/run-periodic-codegen-pull-request.yml b/catalog/snippets/workflows/run-periodic-codegen-pull-request.yml similarity index 100% rename from .github/workflows/run-periodic-codegen-pull-request.yml rename to catalog/snippets/workflows/run-periodic-codegen-pull-request.yml diff --git a/repo-config/README.md b/repo-config/README.md new file mode 100644 index 00000000..0ff0484e --- /dev/null +++ b/repo-config/README.md @@ -0,0 +1,14 @@ +# repo-config + +Repository and branch configuration held as committed files, kept out of `.github/` (which is reserved +for GitHub-Actions-owned content). This mirrors the layout downstream repos use. + +- `main.json`, `develop.json` - the branch rulesets as the writable API subset + (`name`, `target`, `enforcement`, `bypass_actors`, `conditions`, `rules`). These are the canonical + expected payload the audit (`AUDIT.md`) diffs each repo's live rulesets against. +- `configure.sh` - applies the rulesets to a repository via the GitHub API (create or full-payload + update, idempotent). Run `repo-config/configure.sh [owner/repo]`. + +`main` requires merge-commit merges (no linear-history rule); `develop` requires squash merges with +linear history. Both require signed commits, a passing `Check pull request workflow status`, resolved +review threads, and Copilot review, and block force-pushes and deletion. diff --git a/repo-config/configure.sh b/repo-config/configure.sh new file mode 100755 index 00000000..c08cfbc8 --- /dev/null +++ b/repo-config/configure.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Apply the committed branch rulesets in this directory to the repository via the GitHub API. +# Each .json holds the writable ruleset subset {name, target, enforcement, bypass_actors, +# conditions, rules}. An existing ruleset (matched by name) is updated with a full-payload PUT +# (partial PUTs 422); a missing one is created with POST. Rerunning is idempotent. +# +# Usage: repo-config/configure.sh [owner/repo] (defaults to the current repo via gh) +set -euo pipefail + +repo="${1:-$(gh repo view --json nameWithOwner --jq '.nameWithOwner')}" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +for file in "$script_dir"/*.json; do + [ -e "$file" ] || continue + name="$(jq -r '.name' "$file")" + id="$(gh api "repos/$repo/rulesets" --jq ".[] | select(.name==\"$name\") | .id" || true)" + if [ -n "$id" ]; then + echo "Updating ruleset '$name' (id $id) on $repo" + gh api --method PUT "repos/$repo/rulesets/$id" --input "$file" >/dev/null + else + echo "Creating ruleset '$name' on $repo" + gh api --method POST "repos/$repo/rulesets" --input "$file" >/dev/null + fi +done + +echo "Rulesets applied to $repo" diff --git a/.github/rulesets/develop.json b/repo-config/develop.json similarity index 100% rename from .github/rulesets/develop.json rename to repo-config/develop.json diff --git a/.github/rulesets/main.json b/repo-config/main.json similarity index 100% rename from .github/rulesets/main.json rename to repo-config/main.json diff --git a/reports/.gitkeep b/reports/.gitkeep new file mode 100644 index 00000000..e69de29b From 2ff129cfe86bee0160cdac40e97c1bc332e82304 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 4 Jul 2026 12:55:45 -0700 Subject: [PATCH 03/17] Repurpose governance docs; remove template-inheritance framing Rewrite AGENTS.md's opening and layout for the audit-catalog identity, delete the five inheritance sections (Quick Start, Carry Verbatim, Staying in Sync, Personas, Known Downstream), and add the "durable guidance lives in committed docs, not agent memory" principle. Repoint moved-file and ruleset links to their new homes. Neutralize "synced verbatim" wording in WORKFLOW.md and the derived-repo drift-report note in copilot-instructions. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/copilot-instructions.md | 2 +- AGENTS.md | 152 +++++++------------------------- WORKFLOW.md | 2 +- 3 files changed, 32 insertions(+), 124 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d7940af3..e1aa25ef 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -151,4 +151,4 @@ After the final push, sweep-resolve stale older threads for removed code paths. Read [AGENTS.md](../AGENTS.md) for this repo's conventions. For code-style rules, [`CODESTYLE.md`](../CODESTYLE.md) (its General section plus the relevant language section) is authoritative. Don't restate any of these files' rules in commit bodies or PR descriptions - keep those focused on the change itself. -**In a derived repo:** if you find a discrepancy that should be fixed in the template itself (this file or AGENTS.md is out of date, a rule is missing, something bit this repo and would bite the next), open an issue upstream in [`ptr727/ProjectTemplate`](https://github.com/ptr727/ProjectTemplate) rather than only fixing it locally - see the template's [AGENTS.md "Staying in Sync and Reporting Drift Upstream"](https://github.com/ptr727/ProjectTemplate/blob/main/AGENTS.md#staying-in-sync-and-reporting-drift-upstream). +If you find a gap in the governance itself (this file or AGENTS.md is out of date, a rule is missing, something bit this repo and would bite the next), fix it in the governance docs as part of your change rather than only working around it locally. diff --git a/AGENTS.md b/AGENTS.md index e73f36e5..1896be2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,10 @@ # Instructions for AI Coding Agents -**ProjectTemplate** is a polyglot template repo. The .NET side ships under [`NuGetLibrary/`](./NuGetLibrary/) (plus `Console/`, `Tests/`, `Benchmarks/`, `CodeGen/`); the Python side ships under [`PyPiLibrary/`](./PyPiLibrary/). This file is the single source of truth for cross-cutting rules. Code style lives in [`CODESTYLE.md`](./CODESTYLE.md) at the repo root - one guide with a General section that applies to every language plus per-language sections (.NET, Python); the file is carried whole and a repo reads only the sections for the languages it ships. +**ProjectTemplate** is a governance, agent-orchestration, and workflow-audit repo for a fleet of related projects. It holds the portable rules those projects follow, a machine-readable ground-truth spec ([`spec/`](./spec/)), a registry of the projects ([`registry/repos.json`](./registry/repos.json)), and an audit-agent instruction set ([`AUDIT.md`](./AUDIT.md)); it ships no sample application code. This file is the single source of truth for cross-cutting rules. Code style lives in [`CODESTYLE.md`](./CODESTYLE.md) (a General section plus per-language sections for .NET and Python), and the CI/CD workflow contract in [`WORKFLOW.md`](./WORKFLOW.md). -Treat this file as authoritative for everything else; don't restate its rules elsewhere. A derived repo's **project-specific conventions and public-API/behavioral contracts** (e.g. a "Library API Conventions" section) also live here, **not** in [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) - that file targets GitHub Copilot / VS Code specifically, while this file is the agent-agnostic one every coding agent is directed to read, so any rule a reviewer must honor has to live here to be provider-independent. +Treat this file as authoritative for everything else; don't restate its rules elsewhere. A project's **project-specific conventions and public-API/behavioral contracts** (e.g. a "Library API Conventions" section) live in that project's own `AGENTS.md`, **not** in [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) - that file targets GitHub Copilot / VS Code specifically, while this file is the agent-agnostic one every coding agent is directed to read, so any rule a reviewer must honor has to live here to be provider-independent. + +**Durable guidance lives in committed docs, not agent memory.** Agent memory does not persist across machines or environments; anything a future agent must honor belongs in a committed file - working rules here in `AGENTS.md`, the running backlog in [`README.md`](./README.md)'s TODO section, code style in `CODESTYLE.md`. Do not rely on a memory that a later session on another host will not have. ## Git and Commit Rules @@ -20,37 +22,37 @@ Treat this file as authoritative for everything else; don't restate its rules el - **Both rulesets intentionally omit "Require branches to be up to date before merging".** The flag is off on `main` and on `develop`, for related but distinct reasons. - *Main:* the check is graph-based - it asks whether main's tip commit is reachable from develop, not whether the two branches have the same content. After any develop -> main release, main's tip is a brand-new merge commit that develop's history doesn't contain. Forward-only develop never adds it (no back-merge of main into develop), so the check would fail on every subsequent release. Other technical workarounds - rebasing develop onto main, or rewriting develop's history - exist but contradict the squash-only develop ruleset and the linearity invariant. - *Develop:* the check stalls bot auto-merge when two bot PRs against develop land within the same window. As soon as the first merges, the second flips to `mergeStateStatus: BEHIND` and GitHub's auto-merge will not fire while strict is on. The merge-bot only *enables* auto-merge on `opened`/`reopened` (see below) and never auto-updates bot branches, and Dependabot's rebase isn't real-time, so the second PR sits OPEN with all checks green indefinitely. Squash mechanics still rebase the diff onto develop's tip on merge, `required_linear_history` still enforces linearity, textual conflicts still block `mergeable: CONFLICTING`, and the required `Check pull request workflow status` still gates merges - the only thing lost is pre-merge detection of *semantic-but-not-textual* conflicts, which the post-merge develop CI run catches anyway. - - See [`README.md`](./README.md#template---github-setup) "Rules / Rulesets" for the configured state. -- **Configuring branch protection on a derived repo: don't hand-build the rules.** Reconstructing the rules by hand is error-prone and has gone wrong on past ports. First delete **all** legacy classic branch-protection rules and any stray rulesets (this template uses rulesets *only*), then create **exactly two rulesets named `develop` and `main`** by exporting the template's two rulesets and re-importing them via `gh api -X POST .../rulesets` (`gh ruleset` is read-only). The names are load-bearing - this file and the workflows reference them. Full export/import procedure: [README "Rules / Rulesets"](./README.md#template---github-setup). **Brownfield repos** (pre-existing history) need an extra step: `Require signed commits` rejects legacy unsigned commits and the admin bypass does not cover `git push --force`, so re-signing requires temporarily disabling the ruleset - see the [brownfield migration procedure](./README.md#template---github-setup) in that section. -- **Bots (Dependabot and codegen) target both `main` and `develop` in parallel.** [`.github/dependabot.yml`](./.github/dependabot.yml) duplicates every ecosystem entry (one per branch) and [`.github/workflows/run-codegen-pull-request-task.yml`](./.github/workflows/run-codegen-pull-request-task.yml) runs as a matrix over both branches with branch names `codegen-main` and `codegen-develop`. Each branch absorbs its own bot PRs independently, so neither falls behind, and the forward-only rule still holds (nothing is back-merged from main to develop - both branches receive their updates directly). The merge-bot ([`.github/workflows/merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml)) dispatches `--squash` or `--merge` from each PR's base ref via a `case` statement so the form matches the ruleset on either base. Dependabot **security** PRs (CVE-driven) always open against the repo default branch (`main`) regardless of `target-branch` - the same `case` statement covers them. + - See [`README.md`](./repo-config/README.md) "Rules / Rulesets" for the configured state. +- **Configuring branch protection on a fleet repo: don't hand-build the rules.** Reconstructing the rules by hand is error-prone and has gone wrong on past ports. First delete **all** legacy classic branch-protection rules and any stray rulesets (this template uses rulesets *only*), then create **exactly two rulesets named `develop` and `main`** by exporting the template's two rulesets and re-importing them via `gh api -X POST .../rulesets` (`gh ruleset` is read-only). The names are load-bearing - this file and the workflows reference them. Full export/import procedure: [README "Rules / Rulesets"](./repo-config/README.md). **Brownfield repos** (pre-existing history) need an extra step: `Require signed commits` rejects legacy unsigned commits and the admin bypass does not cover `git push --force`, so re-signing requires temporarily disabling the ruleset - see the [brownfield migration procedure](./repo-config/README.md) in that section. +- **Bots (Dependabot and codegen) target both `main` and `develop` in parallel.** [`.github/dependabot.yml`](./.github/dependabot.yml) duplicates every ecosystem entry (one per branch) and [`catalog/snippets/workflows/run-codegen-pull-request-task.yml`](./catalog/snippets/workflows/run-codegen-pull-request-task.yml) runs as a matrix over both branches with branch names `codegen-main` and `codegen-develop`. Each branch absorbs its own bot PRs independently, so neither falls behind, and the forward-only rule still holds (nothing is back-merged from main to develop - both branches receive their updates directly). The merge-bot ([`.github/workflows/merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml)) dispatches `--squash` or `--merge` from each PR's base ref via a `case` statement so the form matches the ruleset on either base. Dependabot **security** PRs (CVE-driven) always open against the repo default branch (`main`) regardless of `target-branch` - the same `case` statement covers them. - **Maintainer-pushed commits on a bot PR auto-disable auto-merge.** The merge-bot's `merge-dependabot` and `merge-codegen` jobs only fire on `opened` / `reopened` events (auto-merge is enabled exactly once per PR). When a maintainer pushes commits to a bot's branch (a `synchronize` event with an actor that isn't the same bot), the merge-bot's `disable-auto-merge-on-maintainer-push` job fires and calls `gh pr merge --disable-auto`. The maintainer's commits stay in the PR but won't auto-merge with the bot's content; re-enable auto-merge manually (`gh pr merge --auto ` or the GitHub UI) when ready. - **Why parallel dual-target rather than develop-only with eventual flow-through:** push-distribution channels (HACS for Home Assistant integrations, Linux distros that vendor from `main`, etc.) consume `main` directly. A develop-only model would leave `main` running stale code during long-running develop features. Codegen content can also be production-critical (live API-derived data, language lists, build catalogs) rather than just sample/demo content, so both branches need fresh codegen on their own cadence. - **Codegen regenerates committed files; its output must be deterministic from its inputs, never per-run state.** The codegen workflow is a mechanism to refresh files that are checked into the repo: it runs a matrix over `main` and `develop`, each leg regenerating against its own checkout and opening its own PR (`codegen-main -> main`, `codegen-develop -> develop`). For the two legs not to conflict on `develop -> main`, the generated output must depend only on its inputs - never on per-invocation state (timestamps, GUIDs, build IDs), which would diverge every run and conflict on every release. **What** a derived repo regenerates (data files, source, or both; code changes or pure data) and **how** (download and process an external source, transform local inputs, whatever) is entirely its own concern - the template constrains only that the output be input-deterministic, not how it is produced. - - *Template demo only - replace the generator and drop this note when adopting:* the `CodeGen/` example exists solely to exercise the workflow. It reads a stand-in external source (the API Ninjas quote-of-the-day) and writes one file, so the two legs match on the same day and the rare cross-leg drift (the source updates between them) self-heals on the next run. Its `--runtime` option (`now` -> `DateTime.UtcNow`, or an ISO-8601 literal) is a **local-only** demonstration of the per-run-state failure mode; CI never passes it. -- **App-token workflows use Client ID, not App ID.** `actions/create-github-app-token` deprecated the numeric `app-id` input in v3.0.0; the template uses `client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}`. When adding new App-token call sites, use the same form - do not reintroduce `app-id` / `CODEGEN_APP_ID`. See [README "Template - GitHub Setup"](./README.md#template---github-setup) for the secret-setup procedure. + - *Reference:* the codegen workflow tasks are kept under [`catalog/snippets/workflows/`](./catalog/snippets/workflows/) (`run-codegen-pull-request-task.yml` and its scheduler). A repo adopting codegen supplies its own input-deterministic generator; this repo ships none. +- **App-token workflows use Client ID, not App ID.** `actions/create-github-app-token` deprecated the numeric `app-id` input in v3.0.0; the template uses `client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}`. When adding new App-token call sites, use the same form - do not reintroduce `app-id` / `CODEGEN_APP_ID`. See [README "Template - GitHub Setup"](./repo-config/README.md) for the secret-setup procedure. ## Release Model -The template uses a **two-phase model by default**: PRs build fast, publishing is batched. See [README "Release Distribution Model"](./README.md#template---release-distribution-model-two-phase-by-default) for the full rationale; the load-bearing rules: +The template uses a **two-phase model by default**: PRs build fast, publishing is batched. See [README "Release Distribution Model"](./WORKFLOW.md) for the full rationale; the load-bearing rules: - **PRs smoke-test only.** [`test-pull-request.yml`](./.github/workflows/test-pull-request.yml) always runs unit tests, then a `dorny/paths-filter` `changes` job gates a **reduced** build of only the changed targets (Docker `linux/amd64` only, executable on a representative runtime subset), never pushing. Build-workflow files are intentionally not in the path filters - a filter can't tell a logic change from an action-version bump - so a workflow-only change isn't smoke-built; the reusable workflows are exercised by the next run that uses them (a later code PR's smoke build, or the scheduled/publish run). There is no CI workflow-lint job; lint workflow edits with `actionlint` locally before pushing. - **Merges don't publish by default.** [`publish-release.yml`](./.github/workflows/publish-release.yml) is the sole publisher: its **weekly schedule** (Mondays 02:00 UTC) and **manual `workflow_dispatch`** always do the full build/publish of **both** `main` and `develop` (a branch matrix). Its `push` trigger publishes only when the **`PUBLISH_ON_MERGE` repository variable** is `true` (opt-in legacy continuous-release). Unset/`false` = two-phase. - **Required check.** The `changes` job is in the `Check pull request workflow status` aggregator's `needs` and **must succeed** (not just "not fail") - a paths-filter error must never let a target-changing PR merge with its smoke build silently skipped. Skipped smoke jobs (no matching change) pass; `failure`/`cancelled` blocks. - **Reusable-task parameter contract.** Every `build-*-task.yml` and `build-release-task.yml` takes `ref` (git ref to check out/version), `branch` (logical branch driving config/tags/prerelease - `main` => Release/`latest`/non-prerelease, else Debug/`develop`/prerelease), and where relevant `smoke`. **Branch-derived config keys off `inputs.branch`, never `github.ref_name`** - the publisher's matrix builds `develop` from a run whose `github.ref_name` is `main`, so `ref_name` would be wrong. Artifact names are branch-suffixed so both matrix legs coexist in one run. `get-version-task.yml` takes a `ref` so NBGV versions the right branch. -- **Per-target subsetting (derived projects).** `build-release-task.yml` has per-target `enable_*` gates and self-contained leaf tasks, so a project that drops a target deletes: its `build--task.yml`, the matching job + `github-release` `needs` entry in `build-release-task.yml`, its path-filter entry in `test-pull-request.yml`, and (for PyPI) the `publish-pypi` job in `publish-release.yml`. CodeGen, versioning, badge, merge-bot, and Dependabot are target-agnostic. -- **Orchestration vs. build - the override seam.** The pipeline splits into two layers. The **orchestration** layer is generic and meant to be synced verbatim from upstream: [`publish-release.yml`](./.github/workflows/publish-release.yml) (publish plan + branch matrix), the `get-version` + `github-release` jobs inside [`build-release-task.yml`](./.github/workflows/build-release-task.yml), [`get-version-task.yml`](./.github/workflows/get-version-task.yml), [`build-datebadge-task.yml`](./.github/workflows/build-datebadge-task.yml), and the aggregator shape of [`test-pull-request.yml`](./.github/workflows/test-pull-request.yml). Within `test-pull-request.yml`, only the `changes -> smoke-build -> check-workflow-status` aggregator wiring and the ruleset-bound job name are verbatim orchestration; the `unit-test` job and the `dorny/paths-filter` entries are owned/per-target. The **build** layer - the `build--task.yml` leaf tasks - is what a derived project owns and replaces. The contract that keeps the seam clean: **a target contributes files to the GitHub release by uploading a workflow artifact named `release-asset--`.** The `github-release` job collects every `release-asset--*` artifact by pattern - its `download-artifact` step uses `pattern:`/`merge-multiple:`, **never an `artifact-ids:` that names a build job's output** (the producing build jobs still appear in `needs` for sequencing) - so it (the tag-the-commit + create-the-release + attach-the-assets logic) is reusable **verbatim** - that is the part a downstream previously had to fork and rewrite, and no longer does. **This name-pattern handoff is canonical for every repo, single-target included** - name your one asset `release-asset--` and the verbatim `github-release` globs it; do not switch a single-target repo to an `artifact-id` output plus `download-artifact` `artifact-ids:`, which looks tidier for 1:1 but forks the `github-release` download (`pattern:`/`merge-multiple:`) and breaks its verbatim carry. - - **What a downstream still curates** (this is by design, not a leak): the *list* of leaf jobs in `build-release-task.yml`. Per **Per-target subsetting** above, you delete the target jobs you don't ship and add the one(s) you do - `build-release-task.yml`'s `github-release` job is untouched, but the file is not byte-identical because its `needs`/job list reflects your targets. Making that list itself target-agnostic is a larger "factor build from orchestration" refactor that is intentionally **not** done. +- **Per-target subsetting.** `build-release-task.yml` has per-target `enable_*` gates and self-contained leaf tasks, so a project that drops a target deletes: its `build--task.yml`, the matching job + `github-release` `needs` entry in `build-release-task.yml`, its path-filter entry in `test-pull-request.yml`, and (for PyPI) the `publish-pypi` job in `publish-release.yml`. CodeGen, versioning, badge, merge-bot, and Dependabot are target-agnostic. +- **Orchestration vs. build - the override seam.** The pipeline splits into two layers. The **orchestration** layer is generic and is the standardization baseline: [`publish-release.yml`](./.github/workflows/publish-release.yml) (publish plan + branch matrix), the `get-version` + `github-release` jobs inside [`build-release-task.yml`](./.github/workflows/build-release-task.yml), [`get-version-task.yml`](./.github/workflows/get-version-task.yml), [`build-datebadge-task.yml`](./catalog/snippets/workflows/build-datebadge-task.yml), and the aggregator shape of [`test-pull-request.yml`](./.github/workflows/test-pull-request.yml). Within `test-pull-request.yml`, only the `changes -> smoke-build -> check-workflow-status` aggregator wiring and the ruleset-bound job name are verbatim orchestration; the `unit-test` job and the `dorny/paths-filter` entries are owned/per-target. The **build** layer - the `build--task.yml` leaf tasks - is what a derived project owns and replaces. The contract that keeps the seam clean: **a target contributes files to the GitHub release by uploading a workflow artifact named `release-asset--`.** The `github-release` job collects every `release-asset--*` artifact by pattern - its `download-artifact` step uses `pattern:`/`merge-multiple:`, **never an `artifact-ids:` that names a build job's output** (the producing build jobs still appear in `needs` for sequencing) - so it (the tag-the-commit + create-the-release + attach-the-assets logic) is reusable **as-is** across repos. **This name-pattern handoff is canonical for every repo, single-target included** - name your one asset `release-asset--` and the verbatim `github-release` globs it; do not switch a single-target repo to an `artifact-id` output plus `download-artifact` `artifact-ids:`, which looks tidier for 1:1 but forks the `github-release` download (`pattern:`/`merge-multiple:`) and breaks its verbatim carry. + - **What a repo still curates** (this is by design, not a leak): the *list* of leaf jobs in `build-release-task.yml`. Per **Per-target subsetting** above, you delete the target jobs you don't ship and add the one(s) you do - `build-release-task.yml`'s `github-release` job is untouched, but the file is not byte-identical because its `needs`/job list reflects your targets. Making that list itself target-agnostic is a larger "factor build from orchestration" refactor that is intentionally **not** done. - **Map your outputs to the right seam** - pick by where each artifact *goes*, not by language: - *Files attached to the GitHub Release* (zips, binaries, packaged libraries): one leaf task per output, each uploading `release-asset--`. A data-only repo (e.g. a symbol library) has exactly one such task: validate -> `zip` -> upload `release-asset--library`; it deletes the nuget/pypi/executable/docker jobs and the `publish-pypi` job, keeps `github-release` as-is. This is also where the .NET `build-executable-task` lives - it is *not* a generic file step, it is specifically `dotnet publish` of the console app; replace it wholesale, don't adapt it. - *Package-registry pushes* (NuGet.org, PyPI): the leaf task both builds **and** publishes to its registry. NuGet pushes from inside `build-nugetlibrary-task` (`dotnet nuget push --skip-duplicate`) *and* also uploads a `release-asset-*` (.7z) for the GitHub release. PyPI is split: `build-pypilibrary-task` only builds + uploads the `pypilibrary-build-` artifact, and the separate `publish-pypi` job in `publish-release.yml` does the OIDC Trusted-Publishing upload (so `id-token: write` is granted only at that one entry point) - PyPI contributes **no** `release-asset-*`. - - *Image-registry pushes* (Docker Hub): `build-docker-task` pushes multi-arch tags directly; contributes **no** `release-asset-*`. The image tag is build-layer-owned - drive it from whatever version source fits (NBGV `SemVer2`, an upstream-release pin, or a per-image matrix). To publish the Docker Hub repository overview, [`publish-docker-readme-task.yml`](./.github/workflows/publish-docker-readme-task.yml) pushes `Docker/README.md` via `peter-evans/dockerhub-description` (single-repo by default; matrix per image for multi-image repos), wired into `publish-release.yml` and gated to `main`. + - *Image-registry pushes* (Docker Hub): `build-docker-task` pushes multi-arch tags directly; contributes **no** `release-asset-*`. The image tag is build-layer-owned - drive it from whatever version source fits (NBGV `SemVer2`, an upstream-release pin, or a per-image matrix). To publish the Docker Hub repository overview, [`publish-docker-readme-task.yml`](./catalog/snippets/workflows/publish-docker-readme-task.yml) pushes `Docker/README.md` via `peter-evans/dockerhub-description` (single-repo by default; matrix per image for multi-image repos), wired into `publish-release.yml` and gated to `main`. - *Source-only / no build* (validate + tag + release): you need none of the package/image leaf tasks - only your validation in `test-pull-request.yml`, one `release-asset-*` leaf task for the artifact you attach (or zero, if the release is just a tag), and the verbatim `get-version` + `github-release` + `date-badge` orchestration. - - `get-version-task.yml` installs the .NET SDK only because NBGV needs the runtime to compute the version/tag - heavyweight but expected even for a non-.NET downstream; acceptable as-is. + - `get-version-task.yml` installs the .NET SDK only because NBGV needs the runtime to compute the version/tag - heavyweight but expected even for a non-.NET repo; acceptable as-is. - **No-op republish guarantee.** A weekly/dispatch publish where NBGV `SemVer2` is **unchanged** (no new commit since the last publish) re-pushes **nothing** to GitHub Releases (the `github-release` job's `release-exists` check skips the create step), NuGet (`dotnet nuget push --skip-duplicate`), or PyPI (`gh-action-pypi-publish` `skip-existing: true`) - all three key on the version string. **Docker always re-pushes** by design: it picks up upstream base-image refreshes (e.g. `ubuntu:rolling`) that aren't visible in the repo. Boundary: `version.json` has **no `pathFilters`**, so *any* commit - including a CI/workflow-only or docs-only change - advances the NBGV git height and therefore `SemVer2`, and the next publish *does* create a fresh release for it even when the shipped binary is byte-identical. This is accepted NBGV behavior; `pathFilters` are intentionally not added. -- **Versioning is semantic and maintainer-controlled.** The `version` (major.minor) in [`version.json`](./version.json) is the version floor; NBGV appends the git height (the SemVer patch position) for the build version. `main` (the public release ref) builds a stable `X.Y.`; `develop` builds a prerelease `X.Y.-g`. The maintainer edits `version.json`; dependency bumps, CI/workflow fixes, doc edits, and template re-syncs leave it untouched. +- **Versioning is semantic and maintainer-controlled.** The `version` (major.minor) in [`version.json`](./version.json) is the version floor; NBGV appends the git height (the SemVer patch position) for the build version. `main` (the public release ref) builds a stable `X.Y.`; `develop` builds a prerelease `X.Y.-g`. The maintainer edits `version.json`; dependency bumps, CI/workflow fixes, and doc edits leave it untouched. - **Bump `version.json` only for functional changes, by maintainer instruction.** Raise the major/minor when the work being introduced warrants a new semantic version - a new feature, a behavior or API change, a breaking change - and do it in the PR that introduces that work (typically on `develop`). Do **not** bump on a fixed cadence or mechanically after a release. NBGV advances the patch (git height) on every commit automatically, so a release always gets a fresh build version without any `version.json` edit. - **No post-release bump; no develop-ahead requirement.** NBGV advances the patch (git height) on every commit, so a release always gets a fresh build version with no `version.json` edit and there is no `bump-version-X.Y` PR after a release. A `develop -> main` promotion carries whatever `version.json` is current: a promotion with a functional bump releases that new version on `main`; a maintenance-only promotion carries the unchanged `version.json` and `main` advances only its NBGV height. -- **Wrapper repos that track an upstream release.** A repo wrapping an upstream release uses [`check-upstream-version-task.yml`](./.github/workflows/check-upstream-version-task.yml): a resolver command prints the upstream version(s) as a **JSON object of `name -> version`**, written to a committed state file at the **repo root beside `version.json`** (default `upstream-version.json` - it is a build-input version source, not GitHub-platform config, so it does not belong under `.github/`), and opens a rolling App-signed bump PR per branch that the merge-bot auto-merges (`merge-upstream-version`). The object carries one key for the common single-version case (`{"version":"X"}`) or N keys for a wrapper that pins several upstream components (e.g. an image plus a companion tool), and the build reads each component by key; the bump PR's title/body name only the keys that actually moved. Call it from a scheduled entry-point workflow and matrix only the branches that ship the version (a CI-only version uses `["develop"]`). A merged bump ships on the **next publish**, not immediately - the two-phase latency tradeoff. +- **Wrapper repos that track an upstream release.** A repo wrapping an upstream release uses [`check-upstream-version-task.yml`](./catalog/snippets/workflows/check-upstream-version-task.yml): a resolver command prints the upstream version(s) as a **JSON object of `name -> version`**, written to a committed state file at the **repo root beside `version.json`** (default `upstream-version.json` - it is a build-input version source, not GitHub-platform config, so it does not belong under `.github/`), and opens a rolling App-signed bump PR per branch that the merge-bot auto-merges (`merge-upstream-version`). The object carries one key for the common single-version case (`{"version":"X"}`) or N keys for a wrapper that pins several upstream components (e.g. an image plus a companion tool), and the build reads each component by key; the bump PR's title/body name only the keys that actually moved. Call it from a scheduled entry-point workflow and matrix only the branches that ship the version (a CI-only version uses `["develop"]`). A merged bump ships on the **next publish**, not immediately - the two-phase latency tradeoff. ## Pull Request Title and Commit Message Conventions @@ -115,7 +117,7 @@ Applies to code and workflow (`#`) comments alike. - **New files:** create them with the `.editorconfig`-mandated ending. - **Editing an existing file:** **preserve the file's current line endings** - do not reflow them as a side effect of a content change, even if the file is already non-compliant. A tool that rewrites a file in text mode (a script, a bulk find/replace) can silently flip CRLF to LF and turn a one-line change into a whole-file diff. After any programmatic edit, verify before staging: `git diff --stat` should touch only the lines you changed, and `file ` should report the file's expected ending. If a diff balloons to the whole file, you flipped the endings - restore them and re-stage. - **Fixing a non-compliant file:** bring it to its `.editorconfig` ending as a **deliberate** change, and prefer to isolate it in its own EOL-only commit so the churn is reviewable. When a broader maintenance change has to normalize endings alongside content edits (a repo-wide cleanup sometimes does), call it out explicitly in the commit/PR description and verify the content separately with `git diff --ignore-cr-at-eol`. -- **Derived repos must carry both files.** [`.editorconfig`](./.editorconfig) **and** [`.gitattributes`](./.gitattributes) are mandatory carries (see [Files and Sections Derived Repos Must Carry Verbatim](#files-and-sections-derived-repos-must-carry-verbatim)). A derived repo missing either file, or one whose `.editorconfig` sets `end_of_line` only under `[*.md]` instead of carrying the full per-extension rules, will accumulate files mixed between LF and CRLF - the exact failure these two files prevent. Carry both files **whole** (the `[*.cs]` block is inert without `.cs` files), including the `*.sh text eol=lf` pin and any extensionless-script path pins. Adopting `.gitattributes` for the first time requires a one-time normalization pass - see the verbatim-carry entry. +- **Both `.editorconfig` and `.gitattributes` are required.** [`.editorconfig`](./.editorconfig) **and** [`.gitattributes`](./.gitattributes) together govern line endings. A repo missing either file, or one whose `.editorconfig` sets `end_of_line` only under `[*.md]` instead of carrying the full per-extension rules, will accumulate files mixed between LF and CRLF - the exact failure these two files prevent. Carry both files **whole** (the `[*.cs]` block is inert without `.cs` files), including the `*.sh text eol=lf` pin and any extensionless-script path pins. Adopting `.gitattributes` for the first time requires a one-time normalization pass. ### Quantitative Claims @@ -123,7 +125,7 @@ Applies to code and workflow (`#`) comments alike. ## PR Review Etiquette -> **Mandatory in every derived repo.** This entire "PR Review Etiquette" section is the provider-agnostic review-loop *contract* and must be carried **verbatim** into every repo derived from this template, alongside the [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) "GitHub Copilot Review Runbook" that implements it. Without both in-repo, an agent working in the derived repo has no pointer to the reliable Copilot mechanics and falls back to ad-hoc (and known-broken) behavior. +> This "PR Review Etiquette" section is the provider-agnostic review-loop *contract* every fleet repo follows, alongside the [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) "GitHub Copilot Review Runbook" that implements it. Without both in-repo, an agent has no pointer to the reliable Copilot mechanics and falls back to ad-hoc (and known-broken) behavior. The repo runs a review loop on every PR: local agent iteration plus remote automated review (GitHub Copilot is the configured reviewer). Treat this as a contract regardless of which local agent authored the changes. @@ -192,7 +194,7 @@ These conventions describe the target state. New and modified workflows must res - **Shells**: multi-line `run:` blocks with bash start with `set -euo pipefail` - fail fast, fail on undefined vars, fail on a failed pipe segment. - **Conditionals**: multi-line `if:` uses folded scalar `if: >-` so YAML preserves whitespace correctly. Literal block (`if: |`) is wrong because it embeds newlines inside the boolean expression. - **Boolean inputs**: workflows triggered both via `workflow_call` and `workflow_dispatch` must declare each boolean input in *both* trigger blocks - one definition does not propagate to the other. `workflow_call` delivers booleans as actual booleans; `workflow_dispatch` delivers them as the *strings* `"true"`/`"false"`. Any `if:` consuming a boolean input must compare against both forms - `if: ${{ inputs.foo == true || inputs.foo == 'true' }}`. -- **Validate input/state consistency at entry, fail fast**: when a workflow's inputs must satisfy a cross-input or input-versus-derived-state invariant (e.g. the release branch must match the computed version's prerelease status, or two inputs are mutually exclusive), assert it **once** in a dedicated entry validation step/job that the downstream jobs `needs:`, before any expensive build or publish work - not as partial checks scattered deep in later jobs. One gate that fails fast with a clear `::error::` beats a late or one-directional check. Examples: [`build-release-task.yml`](./.github/workflows/build-release-task.yml)'s `validate-release` job (branch-versus-prerelease, both directions) and [`publish-docker-readme-task.yml`](./.github/workflows/publish-docker-readme-task.yml)'s "Validate inputs step". +- **Validate input/state consistency at entry, fail fast**: when a workflow's inputs must satisfy a cross-input or input-versus-derived-state invariant (e.g. the release branch must match the computed version's prerelease status, or two inputs are mutually exclusive), assert it **once** in a dedicated entry validation step/job that the downstream jobs `needs:`, before any expensive build or publish work - not as partial checks scattered deep in later jobs. One gate that fails fast with a clear `::error::` beats a late or one-directional check. Examples: [`build-release-task.yml`](./.github/workflows/build-release-task.yml)'s `validate-release` job (branch-versus-prerelease, both directions) and [`publish-docker-readme-task.yml`](./catalog/snippets/workflows/publish-docker-readme-task.yml)'s "Validate inputs step". - **Reusable workflows**: job-level `permissions:` are validated *before* the `if:` evaluates, so even a skipped job needs valid permissions declared. A `release` job with `permissions: contents: write` and `if: ${{ inputs.publish }}` will still cause `startup_failure` on a caller that doesn't grant `contents: write`. Either declare permissions at the call site, or omit the inner block and inherit. - **Allowlist `success` and `skipped` explicitly** when chaining jobs across optional dependencies - `!= 'failure'` lets `cancelled` through (timeout, runner failure, manual cancel). Use `(needs.X.result == 'success' || needs.X.result == 'skipped')`. - **Artifact retention**: workflow artifacts are an intra-run handoff only - durable copies live on the GitHub release, not in workflow artifacts - so they must not survive the run and accumulate against the small account-wide artifact-storage quota. **Clean up each transfer artifact surgically at its point of consumption**: the job that downloads it deletes it by exact name/pattern right after consuming it (the `github-release` job deletes `release-asset--*` after attaching them to the release; `publish-release.yml`'s `publish-pypi` deletes `pypilibrary-build-` after publishing). Deletion needs `actions: write` granted on that job - for a reusable callee (e.g. `github-release` inside `build-release-task.yml`) the **caller** grants it (`publish-release.yml`'s `publish` job does). **Never blanket-delete the run's artifacts** (`gh api .../artifacts --jq '.artifacts[].id'`) - that also destroys diagnostic/log artifacts and the build-records actions emit automatically (`docker/build-push-action`'s `.dockerbuild`), which are exactly what you need to debug a failed run. Set `retention-days: 1` on **every** explicit `upload-artifact`: it is the failure-path backstop - a job that dies before its consumer runs leaves its artifact, reaped within a day - so no separate terminal cleanup job is needed. A derived repo customizing these jobs must preserve the consume-then-delete shape. @@ -223,110 +225,16 @@ When pulling a public image fails on a Docker-Desktop/WSL credential-helper erro ## Devcontainer -The repo ships **two per-language devcontainers** so each container carries only one toolchain (and the matching VS Code extensions): [`.devcontainer/dotnet/devcontainer.json`](./.devcontainer/dotnet/devcontainer.json) (.NET 10 SDK) and [`.devcontainer/python/devcontainer.json`](./.devcontainer/python/devcontainer.json) (Python 3.14 + version-pinned `uv`). Open [`DotNet.code-workspace`](./DotNet.code-workspace) or [`Python.code-workspace`](./Python.code-workspace) and pick **Reopen in Container** to land in the matching one. - -Both containers bind-mount the host SSH signing key's *public half* (`~/.ssh/id_ed25519.pub`), `~/.config/git/allowed_signers`, and `~/.config/gh` so commits inside the container are SSH-signed (signing happens via the forwarded `ssh-agent` socket - the private key never enters the container) and, *when the host's `gh` token is file-backed*, `gh` is pre-authenticated. On Keychain (macOS) or libsecret (Linux) hosts, `~/.config/gh/hosts.yml` carries no `oauth_token`, so container `gh` is unauthenticated until the contributor opts into `gh auth login` inside the container. See [docs/devcontainer.md](./docs/devcontainer.md) for full setup, [docs/host-setup.md](./docs/host-setup.md) for prerequisites, and [docs/ssh-signing.md](./docs/ssh-signing.md) for the SSH commit signing details. - -Each devcontainer's `customizations.vscode.extensions` mirrors the `recommendations` array in its matching workspace file - when you add an extension to one, add it to the other. - -## Project Structure (Languages) - -- **.NET projects** (build with `dotnet build`, test with `dotnet test`): - - `NuGetLibrary/` - core reusable .NET NuGet library (published as `ptr727.ProjectTemplate.Library`) - - `Console/` - CLI app using System.CommandLine - - `Tests/` - xUnit + AwesomeAssertions - - `Benchmarks/` - BenchmarkDotNet - - `CodeGen/` - internal codegen tooling - - **Style guide: [`CODESTYLE.md`](./CODESTYLE.md) ".NET" section**. -- **Python project** (env/build/test with `uv` from inside `PyPiLibrary/`): - - `PyPiLibrary/` - PyPI library template, published as `ptr727-projecttemplate-library` - - **Style guide: [`CODESTYLE.md`](./CODESTYLE.md) "Python" section**. -- **Cross-cutting**: - - `.github/` - workflows, Dependabot, Copilot instructions - - `.devcontainer/dotnet/` and `.devcontainer/python/` - per-language devcontainer configs + post-create scripts - - `DotNet.code-workspace`, `Python.code-workspace` - per-language VS Code workspace files (each pairs with its devcontainer) - - `.vscode/` - debug configs and tasks, grouped by language (the template ships the .NET group); carry your language's named clean-compile tasks verbatim (see [`CODESTYLE.md`](./CODESTYLE.md)) - - `Docker/` - multi-platform Linux container build for the Console app - -When you touch code in either language, also respect that language's style guide. After editing, that language's **clean-compile** must pass before commit, and new-port/brownfield status never licenses relaxing analyzer/linter severities or silencing newly surfaced diagnostics - both rules live in [`CODESTYLE.md`](./CODESTYLE.md) "General". Conventions in this file (PR titles, branching, US English, devcontainer behavior, workflow YAML) apply uniformly to both languages. - -## Quick Start for Derived Projects - -1. **Clone this template** as the baseline for your project. -2. **Decide** which language sides you need. If you need only one, delete the other language's folder and its build/release wiring; keep [CODESTYLE.md](./CODESTYLE.md) **whole** (carry the file in full per [Files and Sections Derived Repos Must Carry Verbatim](#files-and-sections-derived-repos-must-carry-verbatim)) and simply ignore the unused language's section. -3. **Read** [CODESTYLE.md](./CODESTYLE.md) - the General section plus the section(s) for the language(s) you keep (.NET, Python). -4. **Carry the mandatory shared files and sections verbatim** - do not re-invent them per repo. See [Files and Sections Derived Repos Must Carry Verbatim](#files-and-sections-derived-repos-must-carry-verbatim) for the exact list (review-loop contract + runbook, lint config, line-ending governance) and what to adapt. -5. **Update project-specific values** - `PackageId`/`RootNamespace` in `.csproj`, `name` in `pyproject.toml`, namespace conventions, `README.md`, `HISTORY.md`, `version.json`, `LICENSE`, NuGet/PyPI badge URLs. -6. **Run tools before first commit**: - - .NET: `dotnet tool restore`. - - Python: `cd PyPiLibrary && uv sync`. - - Optional pre-commit hooks (off by default) - see README "Optional: enable git hooks locally". -7. **Wire up release credentials** when ready to publish - see the README's release notes section and [PyPiLibrary/README.md](./PyPiLibrary/README.md) for PyPI Trusted Publisher setup. -8. **Maintain release notes** - every derived repo keeps a short release-notes summary in [`README.md`](./README.md) and the full history in [`HISTORY.md`](./HISTORY.md); update both when cutting a release. - -### Files and Sections Derived Repos Must Carry Verbatim - -These artifacts are the template's cross-cutting contract. A derived repo must carry **each** of them; copy the file/section as-is and change only the noted placeholders. Re-inventing or omitting any of these is the drift the template exists to prevent. - -**Carry each shared *file* in full** - do not trim sections that don't currently apply. An inert `[*.cs]` block in a non-.NET repo or an unused-language section in `CODESTYLE.md` costs nothing, and keeping it makes every re-sync a clean wholesale overwrite instead of an error-prone partial merge. Only genuinely per-language *task definitions* in [`.vscode/tasks.json`](./.vscode/tasks.json) track the repo's own language, because the template ships only the task groups for the languages it uses. - -**A carried file must be self-contained - no template, demo, or cross-project references.** Because the file is copied verbatim into every derived repo, it has to read as if it belongs to *that* repo: a developer there has only their project's context, no knowledge of this template or its example projects. So a carried file names **no** template demo project (e.g. an example `NuGetLibrary/`, `PyPiLibrary/`), **no** sibling repo, and **no** "this template ships X / a derived repo adapts Y" meta-instruction. The one sanctioned exception is the upstream-drift-report pointer to this template (the [Staying in Sync](#staying-in-sync-and-reporting-drift-upstream) rule, mirrored in [`.github/copilot-instructions.md`](./.github/copilot-instructions.md)) - a derived repo's sole legitimate cross-repo mention. Write rules generically (or with neutral placeholders); keep template-onboarding (how to strip a demo, which folders to delete on adoption) in this template's `README.md`, never in a carried style/config file. Genericize on the way *into* the carry set, not per-repo on the way out. - -- **[`AGENTS.md`](./AGENTS.md) "PR Review Etiquette" section** - the provider-agnostic review-loop contract. Copy verbatim. No placeholders to change (it names no owner/repo). -- **[`AGENTS.md`](./AGENTS.md) "Git and Commit Rules" and "Pull Request Title and Commit Message Conventions" sections** - the commit/PR-title contract that [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) summarizes inline and links to. Copy verbatim (no owner/repo to change). Without them in the derived repo's `AGENTS.md`, the runbook's deferral link (`AGENTS.md#pull-request-title-and-commit-message-conventions`) is broken and the full rules are missing. -- **[`.github/copilot-instructions.md`](./.github/copilot-instructions.md)** - the whole file is a drop-in; its "GitHub Copilot Review Runbook" carries the provider mechanics. Copy verbatim and change only the `` / `` / `` placeholders in the API snippets; drop language-specific style pointers that don't apply. Keep this file **narrow** - provider-specific mechanics (the Copilot review runbook) plus the inline commit/PR-title summary. **Project-specific conventions and API/behavioral contracts do not belong here**; put them in [`AGENTS.md`](./AGENTS.md), the agent-agnostic file every coding agent reads. Non-Copilot agents (Claude Code, Codex, Cursor, ...) are not directed to this file and don't read it by default, so any rule a reviewer must honor has to live in `AGENTS.md` to be provider-independent. -- **[`.markdownlint-cli2.jsonc`](./.markdownlint-cli2.jsonc)** - the shared lint config read by both the davidanson `markdownlint` IDE extension and CLI/CI `markdownlint-cli2`, so the IDE and command line stay in lock-step. Copy verbatim (it is repo-agnostic). **On first adoption**, a repo's existing docs often carry structural debt this config surfaces (MD022/MD031/MD032 blank lines around headings/fences/lists, MD040 unlabeled fences). Clear it in one pass by running the markdownlint-cli2 Docker command from [Running the Linters Locally](#running-the-linters-locally-known-working-invocations) with `--fix` added (`docker run --rm -v "$PWD":/workdir davidanson/markdownlint-cli2:latest --fix "**/*.md"`), then hand-label any remaining unlabeled fences (MD040 - usually `text` for format/example blocks) and **re-verify the line endings of touched `.md` files** (`--fix` can rewrite a CRLF file as LF). -- **[`.editorconfig`](./.editorconfig) and [`.gitattributes`](./.gitattributes)** - line-ending governance (see [Line Endings](#line-endings)). `.editorconfig` sets `end_of_line` per file type and `.gitattributes` (`* -text`) stops git from normalizing; a repo missing either, or one that only sets `end_of_line` for `[*.md]` instead of carrying the full per-extension rules, drifts between LF and CRLF. **Carry the whole file verbatim**, including the `[*.cs]` + ReSharper style block at the end - it is inert in a repo with no `.cs` files, and keeping it makes re-sync a clean overwrite (the block still marks the .NET boundary for readers). A repo adopting `.gitattributes` for the **first time** must do a one-time explicit line-ending normalization: `* -text` tells git to stop normalizing, so pre-existing files keep whatever (possibly mixed) endings they have - convert each to its `.editorconfig` ending and commit that as a deliberate one-time pass, best isolated in its own commit. -- **[`CODESTYLE.md`](./CODESTYLE.md)** - the single code-style guide. **Carry the whole file verbatim**, all language sections (.NET, Python) included - a section for a language the repo doesn't ship is inert and costs nothing, and keeping it makes re-sync a clean overwrite rather than a per-section merge. **Repo-root placement is load-bearing**: `AGENTS.md` links it as `./CODESTYLE.md` and `.github/copilot-instructions.md` as `../CODESTYLE.md`, so moving it breaks those links. The file ships generic, with neutral placeholders for the few repo-specific values it can't avoid (e.g. the `InternalsVisibleTo` project names); fill those placeholders in for your repo - that is filling a blank, not editing carried prose, so re-sync stays a full replacement. -- **[`.vscode/tasks.json`](./.vscode/tasks.json)** - carry your language's **named clean-compile definitions verbatim**: as VS Code tasks where the template ships them that way (the .NET group - `.NET Build` / `CSharpier Format` / `.NET Format`), or as the documented commands where it doesn't (Python's `ruff` / `pyright`, in `CODESTYLE.md`). The **clean-compile** task names are owned by the matching `CODESTYLE.md` language section and their command sequence + arguments are the canonical clean-compile spec. Convenience and project-specific tasks (e.g. tool updates, dependency upgrades, benchmarks) are the adapt zone the repo owns; a non-.NET repo drops the .NET task group and carries its own language's definitions. - -When the template changes one of these, re-sync the derived repo from the new version (see below). - -The branch rulesets ([`.github/rulesets/{develop,main}.json`](./.github/rulesets/)) are deliberately **not** in this carry set: they are live GitHub config, not a file a derived repo consumes, so carrying and re-syncing them downstream only adds noise. They are maintained **in this template** as the source of truth and are reconciled against each repo's *live* config during porting/re-sync - see [Staying in Sync](#staying-in-sync-and-reporting-drift-upstream). - -### Staying in Sync and Reporting Drift Upstream - -A derived repo is expected to **re-sync against the template periodically**, not just at creation: pull the current version of each verbatim-carry artifact above and re-apply it by **full replacement** - replace the whole file or carried section, never reconcile a partial hand-merge - adapting only the noted placeholders. For [`CODESTYLE.md`](./CODESTYLE.md) and [`.editorconfig`](./.editorconfig), re-sync the **whole file** from the template - every section, including languages the repo doesn't ship (inert sections cost nothing) - so re-sync stays a clean overwrite, never a per-section merge. Re-syncing is **not** an occasion to add or grow comments: the carried text is authoritative as-is (see [Comments](#comments)). - -**Rulesets are reconciled live, not carried as files.** The branch rulesets are maintained **in this template** as [`.github/rulesets/{develop,main}.json`](./.github/rulesets/) - they are live GitHub config, not a file a derived repo consumes, so they are **not** carried and re-synced downstream as a per-repo copy. Instead they ride the re-sync loop from the hub: working from the template checkout, diff each derived repo's *live* rulesets against the template's committed JSON and correct any drift with a **full-payload PUT** (GET -> change -> PUT the whole object; partial PUTs `422`, per [README "Rules / Rulesets"](./README.md#rules--rulesets)). The diff catches drift either way - a corrected template ruleset a derived repo never picked up, or a live ruleset hand-edited away from the committed intent (`strict` re-enabled, a rule dropped, a merge method changed): - -```sh -# Sort the order-insensitive rules[] / bypass_actors[] before diffing - GitHub returns -# them unordered, so a reordered-but-equivalent ruleset must not read as drift. -norm='{name,target,enforcement,bypass_actors,conditions,rules} | .rules|=sort_by(.type) | .bypass_actors|=sort_by(.actor_id)' -for b in develop main; do - id=$(gh api "repos///rulesets" --jq ".[]|select(.name==\"$b\").id") - diff <(jq -S "$norm" ".github/rulesets/$b.json") \ - <(gh api "repos///rulesets/$id" --jq '{name,target,enforcement,bypass_actors,conditions,rules}' | jq -S "$norm") \ - && echo "$b: in sync" || echo "$b: DRIFT (see diff)" -done -``` - -**Drift flows back upstream as an issue, not a private fix.** When porting or re-syncing, if you find a discrepancy that should be fixed in the **template itself** - a gap, an outdated instruction, a missing rule, something that bit this repo and would bite the next derived repo too - **open an issue in [`ptr727/ProjectTemplate`](https://github.com/ptr727/ProjectTemplate)** describing it, rather than only patching it locally. A local fix realigns *this* repo; an upstream issue (then fix) corrects it *for every future derived repo* and keeps the template the single source of truth. This is exactly how the current review-loop / lint-config / brownfield-migration gaps were surfaced. - -#### Orchestrated Re-Sync: Hub and Downstream Personas - -When one operator re-syncs the whole fleet from the hub (every derived repo checked out on one machine), the work splits into two personas with separate duties. This playbook lives **here, committed**, because per-machine agent memory does not survive a machine switch. - -- **Hub / orchestrator** (acting in this template repo): owns the source of truth and *drives* consolidation. It directs each downstream sync, then **validates the result against the template** - confirming carried artifacts were **fully replaced, not partially hand-merged**, that **no comments were added or grown** (see [Comments](#comments)), and that line endings and lint match spec. It collects the template gaps the syncs surface, fixes them in the template through the normal review gate, has affected downstreams re-pull, and **never merges without the maintainer's OK**. -- **Downstream / derived** (acting in a derived repo): performs the local re-sync **under the orchestrator's direction** - **full-replace** each carried artifact, **honor the [Comments](#comments) rules** (no new prose, no growth), and **report any template gap upstream** rather than patching the template's intent locally. - -This guards the two recurring downstream regressions: *partial* updates where carry means full replacement, and comment accretion against the comment rules. The orchestrator catches both by diffing every result against the template. - -#### Known Downstream Projects - -Sync is **bidirectional**. The flow above is the downstream-to-upstream direction (derived repos report drift up). The reverse direction is the maintainer's: **when changing a verbatim-carry artifact or another cross-cutting contract in this template, file a heads-up issue in each affected downstream repo below** so it can re-sync, rather than letting the change be discovered only on the next ad-hoc port. Keep this table current as projects are derived from or retired from the template. +Contributors commit to this repo with signed commits; the SSH-signing setup lives in [docs/ssh-signing.md](./docs/ssh-signing.md), host prerequisites in [docs/host-setup.md](./docs/host-setup.md), and devcontainer SSH-agent forwarding in [docs/devcontainer.md](./docs/devcontainer.md). This repo ships no application toolchain; the per-language devcontainer definitions it once used are kept as reference under [`catalog/snippets/devcontainer/`](./catalog/snippets/devcontainer/). -This registry and the maintainer fan-out duty live in the **template hub only**. A derived repo does **not** carry this table and never names sibling repos in its own docs, comments, or AGENTS - a reader or agent there cares only about that project. Its sole cross-repo obligation is the upstream-issue rule above. +## Repository Layout -| Repo | Ships | Consumer model | -| --- | --- | --- | -| [`ptr727/NxWitness`](https://github.com/ptr727/NxWitness) | Network Optix VMS Docker images | pull (Docker Hub) | -| [`ptr727/PlexCleaner`](https://github.com/ptr727/PlexCleaner) | Media-optimization tool (Docker + release binaries) | pull (Docker Hub / GitHub releases) | -| [`ptr727/Utilities`](https://github.com/ptr727/Utilities) | C# .NET utility library | pull (NuGet) | -| [`ptr727/LanguageTags`](https://github.com/ptr727/LanguageTags) | ISO 639 / BCP 47 language-tag library | pull (NuGet) | -| [`ptr727/ESPHome-NonRoot`](https://github.com/ptr727/ESPHome-NonRoot) | Non-root ESPHome Docker image | pull (Docker Hub) | -| [`ptr727/VSCode-Server-DotNetCore`](https://github.com/ptr727/VSCode-Server-DotNetCore) | VS Code Server + .NET SDK Docker image | pull (Docker Hub) | -| [`ptr727/homeassistant-purpleair`](https://github.com/ptr727/homeassistant-purpleair) | PurpleAir Home Assistant integration | push (HACS) | +- [`AGENTS.md`](./AGENTS.md), [`CODESTYLE.md`](./CODESTYLE.md), [`WORKFLOW.md`](./WORKFLOW.md), [`AUDIT.md`](./AUDIT.md) - the governance and audit docs; this file is the cross-cutting-rules authority. +- [`spec/`](./spec/) - the machine-readable ground truth: project-type requirements, the file/section baseline, secrets, and the preferred README structure. +- [`registry/repos.json`](./registry/repos.json) - the fleet registry: every project, its type(s), and its status. +- [`repo-config/`](./repo-config/) - branch rulesets and the apply script, kept out of `.github/` (which is Actions-owned). +- [`catalog/`](./catalog/) - reusable reference snippets (workflows, configs, devcontainers) the audit compares implementations against. +- [`reports/`](./reports/) - per-repo audit output. +- [`.github/workflows/`](./.github/workflows/) - this repo's own lint-only CI and source-only release, the workflow contract it dogfoods. -The *consumer model* column (push = forced update on every release, pull = consumer updates on its own cadence) is the signal that drives a downstream's two-phase vs `PUBLISH_ON_MERGE` choice - see [README "Release Distribution Model"](./README.md#template---release-distribution-model-two-phase-by-default). +After editing a doc, run the linters (see "Running the Linters Locally") before commit. The Comments, Character Set, and Line Endings rules above are the frequent regressions - hold them exactly. diff --git a/WORKFLOW.md b/WORKFLOW.md index c985e47a..07e5f63a 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -44,7 +44,7 @@ Prescriptive style/legibility rules. Cheap to check, necessary but not sufficien ### Two Layers: Orchestration vs Build -- **Orchestration** is generic and intended to be synced verbatim **at the job level**: the publish-plan + branch matrix in the publisher, the `get-version`, `validate-release`, and `github-release` jobs, the date-badge job, and the `changes -> smoke-build -> aggregator` shape of the PR workflow. These job *bodies* should not need per-repo edits. +- **Orchestration** is generic and forms the standardization baseline **at the job level**: the publish-plan + branch matrix in the publisher, the `get-version`, `validate-release`, and `github-release` jobs, the date-badge job, and the `changes -> smoke-build -> aggregator` shape of the PR workflow. These job *bodies* should not need per-repo edits. - **Build** is repo-owned: the `build--task.yml` leaf tasks. - **What the repo curates** (by design, not a leak): the *list* of targets. This is **not** a byte-for-byte file carry. Adding or dropping a target edits the orchestrator's surface - the `enable_` inputs and the `build-` job + its `github-release` `needs:` entry in the release task, **and** the `changes` paths-filter entry + output + the `smoke-build` enable-forward in the PR workflow. "Verbatim" applies to the `github-release` job and the version/publish-plan logic, not to the release task's job list or the paths-filter. Subsetting is symmetric: the same surface you trim to drop a target you extend to add a new one (e.g. a `release-asset--library` producer needs a new `enable_library` input, a `build-library` job, a `needs:` entry, and a `library` paths-filter). From e8e05ba6a0ffe7f87bb1c9bcbe9f7b847cdd38ba Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 4 Jul 2026 12:58:51 -0700 Subject: [PATCH 04/17] Split README; add fleet README exemplar and GitHub-setup reference Rewrite the root README as this repo's own (purpose, how it operates, dev environment, TODO backlog, license), dropping the sample-app and derived-repo onboarding content. Add spec/readme-structure.md as the preferred README shape the audit checks fleet repos against. Expand repo-config/README.md with the salvaged GitHub setup reference: ruleset import, required secrets, repo settings, and the maintainer-only brownfield re-sign migration. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 690 ++------------------------------------- repo-config/README.md | 56 +++- spec/readme-structure.md | 45 +++ 3 files changed, 127 insertions(+), 664 deletions(-) create mode 100644 spec/readme-structure.md diff --git a/README.md b/README.md index f29e275c..893949c4 100644 --- a/README.md +++ b/README.md @@ -1,678 +1,46 @@ # ProjectTemplate -C# .NET project template. +Governance, agent-orchestration, and workflow-audit hub for a fleet of related repositories. - +[![Last Commit](https://img.shields.io/github/last-commit/ptr727/ProjectTemplate?logo=github)](https://github.com/ptr727/ProjectTemplate/commits) +[![License](https://img.shields.io/github/license/ptr727/ProjectTemplate)](./LICENSE) -## Build and Distribution +## What This Repo Is -- **Source Code**: [GitHub][github-link] - Source code, issues, discussions, and CI/CD pipelines. -- **Versioned Releases**: [GitHub Releases][releases-link] - Version tagged source code and build artifacts. -- **Docker Images**: [Docker Hub][docker-link] - Container images with all tools pre-installed. -- **NuGet Packages**: [NuGet Packages][nuget-link] - .NET libraries published to NuGet.org. -- **PyPI Packages**: [PyPI Packages][pypi-link] - Python library published to PyPI.org. +This repo no longer ships sample application code. It is the single home for the shared rules the fleet follows, a machine-readable spec those rules are checked against, a registry of the projects, and an audit-agent instruction set. Instead of copying files between a template and its derivatives, each project owns its own implementation and is **audited** against the ground truth here - to the letter (exact file, section, or config) or to intent (an equivalent outcome). -### Build Status +- **[AGENTS.md](./AGENTS.md)** - cross-cutting rules for AI coding agents: git, branching, release model, doc style, the recurring-violation rules (comments, ASCII charset, US spelling, line endings), PR review etiquette, and workflow YAML conventions. +- **[CODESTYLE.md](./CODESTYLE.md)** - code style for .NET and Python. +- **[WORKFLOW.md](./WORKFLOW.md)** - the CI/CD workflow contract (behavioral guarantees D1-D9) and its audit methodology. +- **[AUDIT.md](./AUDIT.md)** - how an agent audits a repository against the spec and reports drift. +- **[spec/](./spec/)** - the machine-readable ground truth: project-type requirements, the file/section baseline, required/forbidden secrets, and the preferred README structure. +- **[registry/repos.json](./registry/repos.json)** - the fleet registry: every project, its type(s), publish mechanism, and status (cataloged or standardization backlog). +- **[repo-config/](./repo-config/)** - branch rulesets and the apply script (kept out of `.github/`, which is Actions-owned), plus the GitHub setup reference. +- **[catalog/](./catalog/)** - reusable reference snippets (workflow tasks, config exemplars, devcontainers) the audit compares implementations against. +- **[reports/](./reports/)** - per-repo audit output. -[![Release Status][releasebuildstatus-shield]][actions-link]\ -[![Docker Status][dockerbuildstatus-shield]][actions-link]\ -[![Last Commit][lastcommit-shield]][commits-link]\ -[![Last Build][lastbuild-shield]][actions-link] +## How This Repo Operates -### Releases +ProjectTemplate follows the same model it documents, and audits its own rules against itself (it classifies as the source-only project type in [WORKFLOW.md](./WORKFLOW.md)). -[![GitHub Release][releaseversion-shield]][releases-link]\ -[![GitHub Pre-Release][prereleaseversion-shield]][releases-link]\ -[![Docker Latest][dockerlatestversion-shield]][docker-link]\ -[![Docker Develop][dockerdevelopversion-shield]][docker-link]\ -[![NuGet Release][nugetreleaseversion-shield]][nuget-link]\ -[![PyPI Release][pypireleaseversion-shield]][pypi-link] +- **Branching.** Persistent `main` and `develop`, each with its own ruleset. Commit on feature branches only. Feature branch to `develop` is squash-merged; `develop` to `main` is a merge commit. `develop` is forward-only (no `main -> develop` back-merges). See [AGENTS.md "Branching Model"](./AGENTS.md#branching-model). +- **CI is lint-only.** There is no build or unit test; the PR gate runs markdownlint, cspell, JSON-schema validation, and actionlint, and exposes the ruleset-bound `Check pull request workflow status` aggregator. The same lint configs (`.markdownlint-cli2.jsonc`, `cspell.json`) drive the editor extensions, the CLI, and CI. +- **Review loop.** Every PR is reviewed by GitHub Copilot; the agent drives the review loop to green and merges only with explicit maintainer permission. See [AGENTS.md "PR Review Etiquette"](./AGENTS.md#pr-review-etiquette). +- **Release.** A `develop -> main` merge is promoted through a GitHub release (tag plus a source zip, README, and LICENSE); versioning is NBGV-driven from [version.json](./version.json). See [WORKFLOW.md](./WORKFLOW.md). -### Release Notes +## Development Environment -**Version: 1.0**: +Contributors sign every commit. See [docs/ssh-signing.md](./docs/ssh-signing.md) for SSH commit-signing setup, [docs/host-setup.md](./docs/host-setup.md) for host prerequisites, and [docs/devcontainer.md](./docs/devcontainer.md) for devcontainer SSH-agent forwarding. Run the linters before pushing (see [AGENTS.md "Running the Linters Locally"](./AGENTS.md#running-the-linters-locally-known-working-invocations)). -**Summary**: +## TODO -- Something. -- And something else. +Running backlog (kept here, in a committed file, rather than in agent memory that does not persist across environments). -> **⚠️ Breaking Changes**: -> -> - Something. -> - And something else. - -See [Release History](./HISTORY.md) for complete release notes and older versions. - -## Getting Started - -Get started with ProjectTemplate in three easy steps: - -> **⚠️ Important**: Some important warning. -> -> **ℹ️ Note**: Some interesting note. - -1. **Install ProjectTemplate**: - - Do something. -2. **Configure ProjectTemplate**: - - Then something else. -3. **Run ProjectTemplate**: - - ```shell - Console --loglevel=Debug - ``` - -See [Installation](#installation) for detailed setup instructions. - -## Table of Contents - -- [Build and Distribution](#build-and-distribution) - - [Build Status](#build-status) - - [Releases](#releases) - - [Release Notes](#release-notes) -- [Getting Started](#getting-started) -- [Table of Contents](#table-of-contents) -- [Use Cases](#use-cases) -- [Installation](#installation) -- [Configuration](#configuration) -- [Usage](#usage) - - [Command Quick Reference](#command-quick-reference) - - [Global Options](#global-options) - - [Test Command](#test-command) -- [Questions or Issues](#questions-or-issues) -- [Development Environment Setup](#development-environment-setup) -- [3rd Party Tools](#3rd-party-tools) -- [License](#license) -- [Template Project Setup](#template-project-setup) - - [Template - TODO List](#template---todo-list) - - [Template - Developer Environment Setup](#template---developer-environment-setup) - - [Template - GitHub Setup](#template---github-setup) - - [Template - Branching Workflow](#template---branching-workflow) - - [Template - Release Distribution Model: Two-Phase by Default](#template---release-distribution-model-two-phase-by-default) - - [Template - Deferred Patterns](#template---deferred-patterns) - -## Use Cases - -> **ℹ️ TL;DR**: This widget is special because it does something special. - -- It does something special. -- And it does something else special. - -## Installation - -Choose an installation method based on your platform and requirements: - -- **Method 1** (Recommended): Easiest and most up-to-date option. - - ✅ Some good reason. - - ⚠️ Some not so good reason. - - ❌ Strong reason to avoid. - - Best for: Linux, NAS devices, servers, cross-platform deployments. -- **Method 2**: Custom configuration options. - - ✅ Some good reason. - - ⚠️ Some not so good reason. - - ❌ Strong reason to avoid. - - Best for: Specialized devices. - -## Configuration - -> **⚠️ Important**: The spinner setting must be configured before first use. - -**Required configuration**: - -- Set `foo` to something. -- Set `bar` to something else. - -**Optional configuration**: - -- Set `advanced` to `special`. -- Some other custom option. - -## Usage - -`Console [global options] [command options]` - -### Command Quick Reference - -| Command | Description | Notes | -| ------- | ------- | ----------- | -| default | Default action when no command is specified | First time setup | -| `test` | Do something else useful | Some note | -| `--help` | Show help output | Use ` --help` for command specific help | -| `--version` | Show version output | | - -Use the `--help` option to get a list of all commands and global options.\ -To get help for a specific command run `Console --help`. - -### Global Options - -**Global options apply to all commands**: - -| Option | Description | Default | -| ------- | ------- | ----------- | -| `--logfile` | Debug log file | Optional | -| `--loglevel` | Debug log level | Default is `Information` | -| `--logfile-clear` | Clear log file at startup | Default is `false` | - -**General help**: - -```text ->.\Console\bin\Debug\net10.0\Console --help -Description: - C# .NET console project - -Usage: - Console [command] [options] - -Options: - -l, --loglevel Set the log level (default: Information). [default: Information] - -f, --logfile Write logs to the specified file (optional). - -c, --logfile-clear Clear the log file before writing (default: false). - -?, -h, --help Show help and usage information - --version Show version information - -Commands: - test Test command -``` - -### Test Command - -**Test command options**: - -| Option | Description | Default | -| ------- | ------- | ----------- | -| `--test` | Test options | Optional | - -**Test command help**: - -```text ->.\Console\bin\Debug\net10.0\Console test --help -Description: - Test command - -Usage: - Console test [options] - -Options: - -t, --test Test command option (optional). - -?, -h, --help Show help and usage information - -l, --loglevel Set the log level (default: Information). [default: Information] - -f, --logfile Write logs to the specified file (optional). - -c, --logfile-clear Clear the log file before writing (default: false). -``` - -## Questions or Issues - -**For General Questions**: - -- Use the [Discussions][discussions-link] forum for general questions. - -**For Bug Reports**: - -- Ask in the [Discussions][discussions-link] forum if you are not sure if it is a bug. -- Check the existing [Issues][issues-link] tracker for known problems. -- If the issue is unique and a bug, file it in [Issues][issues-link], and include all pertinent steps to reproduce the issue. - -## Development Environment Setup - -The recommended setup is one of the per-language [Dev Containers](./docs/devcontainer.md) under `.devcontainer/`: - -- **`.devcontainer/dotnet/`** - .NET 10 SDK + GitHub CLI. Pair with `DotNet.code-workspace`. -- **`.devcontainer/python/`** - Python 3.14 + `uv` + GitHub CLI. Pair with `Python.code-workspace`. - -Each container bind-mounts your SSH public key, allowed-signers file, and `gh` config from the host so commits sign correctly. `gh` is pre-authenticated when the host token is file-backed; macOS Keychain and Linux libsecret-backed tokens require an in-container `gh auth login` - see the [credential-store nuance](./docs/devcontainer.md#gh-credential-store) section. - -> **Windows note**: Python work is intentionally not supported on the Windows host. The Python extension caches the Linux-layout `PyPiLibrary/.venv/bin/python` against a venv whose actual Windows path is `PyPiLibrary\.venv\Scripts\python.exe`, breaking Ruff. Use the python devcontainer. - -**Recommended (devcontainer)**: - -1. Complete [host setup](./docs/host-setup.md) once per machine (git identity, SSH key, allowed_signers, `gh auth login`, [SSH commit signing](./docs/ssh-signing.md)). -2. Clone the repo, open the matching workspace (`DotNet.code-workspace` or `Python.code-workspace`) in VS Code with the [Dev Containers extension][devcontainers-link], and run **Reopen in Container** - pick the language flavor. -3. The `postCreateCommand` runs `dotnet tool restore` (.NET container) or installs `uv` and runs `uv sync` (Python container). No git hooks are installed by default - see "Optional: enable git hooks locally" below. - -**Alternative (host install)**: - -- **Install Developer Tools**: - - - Install [.NET SDK](https://dotnet.microsoft.com/en-us/download): - - ```shell - # Windows - winget install Microsoft.DotNet.SDK.10 - - # Linux - apt install dotnet-sdk-10.0 - ``` - - - Install [Visual Studio Code](https://code.visualstudio.com/download): - - ```shell - # Windows - winget install Microsoft.VisualStudioCode - ``` - - - Install [Visual Studio](https://visualstudio.microsoft.com/downloads/): - - ```shell - # Windows - winget install Microsoft.VisualStudio.Community - ``` - -- **Clone and Configure Project**: - - - Clone the repository and initialize tools: - - ```shell - # Clone from CLI (or clone from VSCode) - git clone -b main https://github.com/ptr727/[Project].git ./[Project] - - # Initialize dotnet tools - cd ./[Project] - dotnet tool restore - ``` - - - Open `DotNet.code-workspace` (or `Python.code-workspace`) in Visual Studio Code. - - Open `[Project].slnx` in Visual Studio. - -**Optional: enable git hooks locally**: - -Hooks are not shipped with the template - CI is the lint backstop. Opt in per language if you want pre-commit checks locally. - -- **For .NET work** - install [Husky.Net][huskynet-link]: - - ```shell - dotnet new tool-manifest # if no tool manifest exists yet - dotnet tool install Husky - dotnet husky install - dotnet husky add pre-commit -c "dotnet csharpier check . && dotnet format style --verify-no-changes --severity=info" - ``` - -- **For Python work** - install [pre-commit][precommit-link]: - - ```shell - uv tool install pre-commit - pre-commit install - ``` - - Sample `.pre-commit-config.yaml` (the hooks shell into `PyPiLibrary/` because the uv project - and therefore ruff/pyright and their configs - lives there, not at the repo root): - - ```yaml - repos: - - repo: local - hooks: - - id: ruff-check - name: ruff check - entry: uv run --directory PyPiLibrary ruff check - language: system - files: ^PyPiLibrary/.*\.py$ - pass_filenames: false - - id: ruff-format - name: ruff format - entry: uv run --directory PyPiLibrary ruff format --check - language: system - files: ^PyPiLibrary/.*\.py$ - pass_filenames: false - - id: pyright - name: pyright - entry: uv run --directory PyPiLibrary pyright - language: system - files: ^PyPiLibrary/.*\.py$ - pass_filenames: false - ``` - -CI runs these same checks on every PR, so hooks are purely a local convenience. - -## 3rd Party Tools - -**3rd Party tools used in this project**: - -- [API Ninjas][apininjas-link] -- [AwesomeAssertions][awesomeassertions-link] -- [Bring Your Own Badge][byob-link] -- [Create Pull Request][createpr-link] -- [CSharpier][csharpier-link] -- [GH Release][ghrelease-link] -- [Git Auto Commit][ghautocommit-link] -- [GitHub Actions][ghactions-link] -- [GitHub Dependabot][ghdependabot-link] -- [Nerdbank.GitVersioning][nerbankgitversion-link] -- [Serilog][serilog-link] -- [xUnit.Net][xunit-link] +- Run the first per-repo audits and populate [reports/](./reports/) for the seven cataloged repos. +- Classify the standardization-backlog repos in [registry/repos.json](./registry/repos.json) (marked `classificationPending`) on first audit. +- Canonicalize Python linter-config placement on `pyproject.toml` (one cataloged repo uses standalone `.ruff.toml` + `pyrightconfig.json`); track as a drift finding, fix downstream. +- Consider renaming this repo to reflect the audit-catalog identity (updates badge and link URLs across the fleet). ## License -Licensed under the [MIT License][license-link]\ -![GitHub License][license-shield] - - - -## Template Project Setup - -### Template - TODO List - -- [ ] Configure git for SSH signing **before making any commits** (the signed-commits ruleset rejects unsigned history, and retrofitting it forces a full re-sign - see [GitHub Setup](#template---github-setup)), plus SSH forwarding in dev containers - see [docs/host-setup.md](./docs/host-setup.md), [docs/ssh-signing.md](./docs/ssh-signing.md), and [docs/devcontainer.md](./docs/devcontainer.md). -- [ ] Decide whether your project needs the .NET (`NuGetLibrary/`) side, the Python (`PyPiLibrary/`) side, or both. Delete the unused folder and remove its references from `ProjectTemplate.slnx`, `.github/dependabot.yml`, and the corresponding `.github/workflows/build-*-task.yml`. -- [ ] Start on Linux to avoid file permission issues when moving from Windows. -- [ ] Configure the [Developer Environment](#template---developer-environment-setup). -- [ ] Open the project directory (*not the workspace*) in Visual Studio Code, and rename (Ctrl-Shift-H) all instances of `ProjectTemplate` to `[NewProject]` in code. -- [ ] Rename `DotNet.code-workspace` to `[NewProject].code-workspace` and `Python.code-workspace` to `[NewProject]-Python.code-workspace`, or delete the workspace for the language you don't need. Rename `ProjectTemplate.slnx` to `[NewProject].slnx`. -- [ ] Open the workspace file for the language you kept (`[NewProject].code-workspace` and/or `[NewProject]-Python.code-workspace`) in Visual Studio Code. -- [ ] Delete any projects and associated actions that will not be used, update dependencies in actions to remove deleted actions. -- [ ] Rename projects to match the naming, update `.slnx` and `.csproj` files, and update actions to match the naming. -- [ ] Update the `namespace` in `.cs` and `.csproj` files to match the naming. -- [ ] Update all ref-links in `README.md` to point to the naming. -- [ ] Keep the template's mandatory shared files and sections - do **not** re-invent them per repo. Carry **verbatim** the `AGENTS.md` "PR Review Etiquette" section, `.github/copilot-instructions.md` (the Copilot review runbook), `.markdownlint-cli2.jsonc`, `.editorconfig`, `.gitattributes`, `CODESTYLE.md` (its General section plus the section(s) for the language(s) you ship, kept at the repo root), and your language's named clean-compile definitions (the .NET tasks in `.vscode/tasks.json`, or the documented commands for a language the template ships that way, per `CODESTYLE.md`), adapting only the ``/``/`` placeholders in its API snippets and the noted per-repo zones. See [AGENTS.md "Files and Sections Derived Repos Must Carry Verbatim"](./AGENTS.md#files-and-sections-derived-repos-must-carry-verbatim), and re-sync them from the template periodically - filing an upstream issue in [`ptr727/ProjectTemplate`](https://github.com/ptr727/ProjectTemplate) when you spot a template gap or hit a problem adopting the template. Sync is bidirectional: add your repo to [AGENTS.md "Known Downstream Projects"](./AGENTS.md#known-downstream-projects) (via an upstream PR) so template-side contract changes reach you as heads-up issues. A repo adopting `.gitattributes` (`* -text`) for the first time must do a one-time explicit line-ending normalization - `* -text` stops git normalizing, so convert each existing file to its `.editorconfig` ending and commit that as a deliberate one-time pass. -- [ ] Publish to GitHub from VSCode to create a new empty GitHub repository. -- [ ] Commit and push the `first-branch`. -- [ ] Edit and iterate only in `first-branch` until ready to start with git history. -- [ ] Setup `main` as the first permanent branch when ready. -- [ ] Configure [GitHub](#template---github-setup) for the new repository - including deleting any classic branch protection and creating the two `develop`/`main` rulesets by exporting/importing the template's (see [Rules / Rulesets](#template---github-setup)). -- [ ] Follow the [Branching Workflow](#template---branching-workflow). -- [ ] Delete the `Project Template Setup` section from `README.md`. - -### Template - Developer Environment Setup - -#### Template - Git Setup - -- **⚠️ Prerequisites**: - - Configure git for SSH signing - see [SSH commit signing](./docs/ssh-signing.md). - - Configure host prerequisites (SSH key, `allowed_signers`, `gh` auth) - see [host setup](./docs/host-setup.md). - - Configure SSH forwarding for dev containers - see [devcontainer setup](./docs/devcontainer.md). -- Setup new project from template: - - ```shell - # Clone the template project - git clone -b main https://github.com/ptr727/ProjectTemplate.git ./[NewProject] - - # Reset git to start a new repo - rm -r ./[NewProject]/.git - cd ./[NewProject] - git init -b first-branch - - # Init dotnet tools - dotnet tool restore - - # Update dotnet tools - dotnet tool update --all - dotnet outdated --upgrade:prompt - ``` - -- Setup new project from scratch: - - > **⚠️ Linux**: Start configuration on Linux to avoid file permission issues. - - ```shell - # Init git - mkdir ./[NewProject] - cd ./[NewProject] - git init -b first-branch - - # Init dotnet tools - dotnet new tool-manifest - dotnet tool install csharpier - dotnet tool install dotnet-outdated-tool - ``` - -- Use `first-branch` for all the initial project setup and testing. -- When ready, *only when ready*, create `main` branch from `first-branch` with no history: - - > **Warning - sign from the very first commit.** The `Initial import (squashed)` commit below - and every commit after it - must be **cryptographically signed**. Configure SSH signing (the Prerequisites above) *before* running these commands, and verify it is live: `git config --get commit.gpgsign` is `true` and a signer is loaded (`ssh-add -L` for SSH). Do **not** enable the `Require signed commits` ruleset (below) until the branch's history is fully signed. Enabling it on a branch that already contains unsigned commits forces you to **rewrite the entire history to re-sign it** - which changes every commit SHA and makes whoever does the rewrite the committer and signer of every commit (you cannot sign another contributor's commits for them; a rebase preserves the `author` field but not the original signatures). Order: signing first -> clean signed history -> then the ruleset. - - ```shell - # Create main branch with no history - git checkout --orphan main - git commit --allow-empty -m "temp" - - # Squash merge changes - git merge --squash first-branch - git commit -m "Initial import (squashed)" - - # Drop the temporary commit - git reset --hard HEAD~1 - - # Delete first-branch - git branch -D first-branch - ``` - -### Template - GitHub Setup - -**GitHub secrets setup**: - -- Create a [NuGet API Key](https://www.nuget.org/account/apikeys). - - Save the Key as `NUGET_API_KEY` in: - - GitHub project Settings / Secrets / Actions. - - GitHub project Settings / Secrets / Dependabot. - - GitHub Local Actions Settings / Secrets. -- Create a [Docker Hub Personal Access Token](https://app.docker.com/accounts/ptr727/settings/personal-access-tokens). - - Save the PAT as `DOCKER_HUB_ACCESS_TOKEN` and `DOCKER_HUB_USERNAME` in: - - GitHub project Settings / Secrets / Actions. - - GitHub project Settings / Secrets / Dependabot. -- Create a [GitHub App](https://github.com/settings/apps) for the codegen and merge-bot workflows. - - App name: `ptr727-codegen`. - - Bot user: `ptr727-codegen[bot]`. - - Permissions required (repository scope): - - Contents: Read & write - push commits to the `codegen` branch and merge bot PRs. - - Pull requests: Read & write - open, update, and merge pull requests. - - Metadata: Read-only (auto-required). - - Note the client id, and download and secure the private key `.pem` file. - - Grant the app access to the repository, or all repositories. - - GitHub Settings / Developer settings / GitHub Apps / ptr727-codegen / Configure - - The app must be both created **and** installed - creating it alone is not sufficient (`actions/create-github-app-token` fails with `Not Found` if the app isn't installed on the repository). - - Save the Client ID as `CODEGEN_APP_CLIENT_ID` and the private key contents as `CODEGEN_APP_PRIVATE_KEY` in **both** of: - - GitHub project Settings / Secrets / Actions - for the codegen workflow and the codegen merge job. - - GitHub project Settings / Secrets / Dependabot - **required** because Dependabot-triggered `pull_request` workflow runs use a separate, restricted secret context that doesn't see Actions secrets. Without the App secrets in the Dependabot store, the `merge-dependabot` job in `merge-bot-pull-request.yml` can't mint an App token and the PR will never auto-merge. - - If the codegen workflows require additional secrets (e.g. third-party API keys), register them in the Actions store; if a Dependabot-triggered workflow ever needs them, register them in the Dependabot store too. - - The App token is used by **both** the codegen workflow (`run-codegen-pull-request-task.yml`) **and** every job in `merge-bot-pull-request.yml`. App-authored pushes/PRs trigger downstream `pull_request` and `push` workflow events directly - unlike `GITHUB_TOKEN`-authored events, which are blocked by GitHub's recursion guard. This matters for two reasons: bot-opened PRs trigger the `test-pull-request.yml` smoke build (so they can't auto-merge unvalidated), and - when `PUBLISH_ON_MERGE` is enabled - the merge commit triggers `publish-release.yml`. App-authored events also let the codegen workflow's auto-merge fire directly. - - The codegen auto-merge condition in `merge-bot-pull-request.yml` (`merge-codegen` job) requires: - - **Event is `opened` or `reopened`** - auto-merge is enabled once per PR at open time; subsequent `synchronize` events do not re-enable. This is what lets the `disable-auto-merge-on-maintainer-push` safeguard (below) stick. - - `github.event.pull_request.user.login == 'ptr727-codegen[bot]'` - PR was opened by the App. - - `github.event.pull_request.head.repo.full_name == github.repository` - PR is from this repo (not a fork). - - **Strict head/base pairing** - `(head.ref == 'codegen-main' && base.ref == 'main') || (head.ref == 'codegen-develop' && base.ref == 'develop')`. Codegen runs as a matrix opening one PR per branch; this pairing prevents a misconfigured workflow from sneaking a `codegen-develop` branch into `main` or vice versa. - - The `disable-auto-merge-on-maintainer-push` job in `merge-bot-pull-request.yml` runs on `synchronize` events against bot-authored PRs (Dependabot or codegen) when the event actor is NOT the same bot - i.e. a maintainer pushed commits. It calls `gh pr merge --disable-auto` so the maintainer's commits don't auto-merge along with the bot's content. Re-enable auto-merge manually (`gh pr merge --auto ` or the GitHub UI) when ready. - - `merge-bot-pull-request.yml` triggers on **`pull_request_target`**, not `pull_request`: its jobs mint an App token, so the workflow definition and action SHAs must resolve from the trusted base branch rather than a PR head. This is safe because no job checks out PR code - each only runs `gh pr merge` against the PR by URL. - - Codegen targets `main` AND `develop` in parallel (matrix in `run-codegen-pull-request-task.yml`), so generated content lands on both branches independently without any back-merging. See [AGENTS.md "Branching Model"](./AGENTS.md#branching-model) for why this dual-target pattern beats develop-only-with-flow-through. - -**Codegen workflow schedule**: - -- `run-periodic-codegen-pull-request.yml` runs **daily** at 04:00 UTC (staggered two hours after the weekly publish), plus on-demand via `workflow_dispatch`. It uses the App token (`CODEGEN_APP_CLIENT_ID` + `CODEGEN_APP_PRIVATE_KEY`) to commit, open the PR as `ptr727-codegen[bot]`, and let the merge-bot auto-merge once CI passes. No PAT, no close/reopen dance. Daily is cheap in the default two-phase model - codegen merges only smoke-test; the weekly publish batches the actual release. - -**GitHub project settings**: - -- General: - - Default branch: `main` - - Pull requests - **both** merge methods enabled at the repo level so each branch ruleset can pick the right one (develop = `Squash`, main = `Merge`): - - `Allow merge commits` ✓ (required for develop -> main releases) - - `Allow squash merging` ✓ (required for feature -> develop merges) - - `Allow rebase merging` - disabled (no flow uses it; the develop ruleset forbids it anyway) - - `Always suggest updating pull request branches` - - `Allow auto-merge` -- Rules / Rulesets - **separate rulesets per branch**. Develop and main intentionally diverge on two rules - allowed merge methods and `Require linear history`. `Require branches to be up to date before merging` is **off on both** for related-but-distinct reasons (below); everything else is shared. - - **Configure these by importing the committed ruleset JSON (`.github/rulesets/develop.json`, `.github/rulesets/main.json`) - do not hand-build the rules.** Those files are the versioned, PR-gated source of truth - they are maintained here in the template and imported into / diffed against a repo's live ruleset config during porting and re-sync (see [AGENTS.md "Staying in Sync"](./AGENTS.md#staying-in-sync-and-reporting-drift-upstream)), not carried and re-synced as a per-repo copy; the result must be **exactly two rulesets named `develop` and `main`** (the names are load-bearing: `AGENTS.md` and these docs reference them). Reconstructing each rule by hand is the step that has gone wrong on past ports. - - **Step 0 - remove ALL legacy protection first.** Delete **every** classic branch-protection rule (Settings -> Branches) and **every** pre-existing or stray **ruleset** (Settings -> Rules -> Rulesets) - not just some - so enforcement isn't doubled or contradicted. This template uses rulesets *only*, configured exclusively by the JSON export/import in Steps 1-2 below; never hand-build the rules in the UI. Partial cleanup (leaving a stray ruleset or a classic rule behind) is what has gone wrong on past ports. Equivalent API: - - ```sh - # Delete classic branch protection if present (404 = none, which is fine) - for b in main develop; do gh api -X DELETE "repos///branches/$b/protection" 2>/dev/null || true; done - # List existing rulesets; delete any that are not the two created below - gh api "repos///rulesets" --jq '.[] | "\(.id)\t\(.name)"' - # gh api -X DELETE "repos///rulesets/" - ``` - - - **Step 1 - the canonical rulesets are the committed files** `.github/rulesets/{develop,main}.json`, each holding only the re-importable writable subset (`{name, target, enforcement, bypass_actors, conditions, rules}` - the live GET response also carries `id`, timestamps, `_links`, `source`, etc. that a create call rejects). They port verbatim with no placeholders (`conditions` key on `refs/heads/develop`|`refs/heads/main`, `bypass_actors` uses the global Admin role `actor_id: 5`, the required check binds by name), so import them as-is - no per-repo edits. **To change a ruleset, edit the live template rulesets, then regenerate the committed files from them** (this export is the source-of-truth refresh, run in the template repo and committed via PR - never the per-port export it replaces): - - ```sh - for name in develop main; do - id=$(gh api repos/ptr727/ProjectTemplate/rulesets --jq ".[] | select(.name==\"$name\") | .id") - gh api "repos/ptr727/ProjectTemplate/rulesets/$id" \ - --jq '{name, target, enforcement, bypass_actors, conditions, rules}' \ - | jq -S '.' > ".github/rulesets/$name.json" - done - ``` - - - **Step 2 - import into the new repo from the committed files:** - - ```sh - for b in develop main; do - gh api -X POST "repos///rulesets" --input ".github/rulesets/$b.json" - done - ``` - - - Caveats: `bypass_actors` uses the **Admin** repository role (`actor_id: 5`), a global GitHub id that ports across repos as-is. The required status-check context (`Check pull request workflow status`) is matched by **name** and only turns green after `test-pull-request.yml` has run at least once. `gh ruleset` is read-only (list/view) - creation must go through `gh api -X POST` as above. If a field is rejected, edit the JSON and re-run the import. - - **Renaming or updating an existing ruleset needs a FULL-payload PUT, not a partial one.** `gh api -X PUT "repos///rulesets/" -f name=develop` (name only) fails with `422 Unexpected parameter 'allowed_dismissal_actors'`: GitHub re-validates the stored `pull_request` rule on a partial update, and that rule carries fields the GET response does not return. To rename (e.g. legacy `Develop`/`Main` -> `develop`/`main`) or otherwise edit a ruleset, **GET it, change the field, and PUT the whole `{name, target, enforcement, bypass_actors, conditions, rules}` back** (the same writable-field subset used for export above). Back up the GET first and verify afterward that the rule types, `required_signatures`, `non_fast_forward`, and the required status-check context are all still present. Renaming is safe for *enforcement* - the required status-check binds by check name, not ruleset name, so a rename won't break CI - but the template still expects the exact `develop`/`main` names that `AGENTS.md` and these docs reference (which is precisely what a legacy `Develop`/`Main` repo is renaming *to*); the rename removes inconsistency, it isn't a license for arbitrary names. - - **Migrating a brownfield repo with unsigned history.** The shared `Require signed commits` rule (below) rejects any commit made before signing was enabled, so on a pre-existing repo the first `develop -> main` release is blocked the moment it tries to introduce that legacy history. The fix is to re-sign the legacy commits, but that rewrite is a non-fast-forward and the `Block force pushes` rule rejects it - **and the ruleset's admin bypass does not cover `git push --force` (GitHub honors ruleset bypass for UI/API operations, not git force-push).** So even the owner cannot complete the re-sign without temporarily relaxing the ruleset. This is a **one-time, maintainer-performed manual migration** - it deliberately uses the force-push that [AGENTS.md "Git and Commit Rules"](./AGENTS.md#git-and-commit-rules) forbids agents from running, so an AI agent must **never** execute this procedure; surface it to the maintainer instead. Procedure: - - Recommended order: **re-sign while the repo's rulesets are still permissive, then import the strict rulesets** (Steps 1-2 above). Re-signing before the strict rules exist avoids the disable/re-enable dance entirely; if the strict rulesets are already in place, disable enforcement per affected branch first (step 2). - - 1. **Re-sign the divergent history with a committer rewrite.** For merge-heavy history, prefer `git filter-branch` with a `--commit-filter`: it rewrites every commit object in place, preserving the exact DAG with zero conflicts, and rewriting both refs at once keeps shared ancestry consistent (same new SHA on both branches). It **must rewrite the committer** to the signing identity - `filter-branch` otherwise preserves the original committer, so the commit ends up `committer != signer`, GitHub does **not** mark it Verified, and `Require signed commits` still rejects it: - - ```sh - FILTER_BRANCH_SQUELCH_WARNING=1 git filter-branch --commit-filter ' - GIT_COMMITTER_NAME="You" GIT_COMMITTER_EMAIL="you@users.noreply.github.com" git commit-tree -S "$@" - ' -- develop main - ``` - - Pass `-- develop main` (or `--all`) to re-sign all the way to the root; add `--tag-name-filter cat` only if you want existing tags to follow the rewrite. On some hosts `filter-branch` prints a benign `envsubst: not found` warning from git's i18n setup - ignore it. - - A rebase is an alternative for simple, mostly-linear history - it re-signs each commit and sets the committer to the rewriter automatically (which is *why* it verifies), but its merge-rewriting todo is fragile when there are many merge commits: - - ```sh - git rebase --rebase-merges --exec 'git commit --amend --no-edit -S' - ``` - - 2. **If the strict rulesets are already imported, disable enforcement on each affected branch.** The push rejection is per-branch and depends on which rules that branch carries: `Block force pushes` **or** `Require a pull request` rejects the direct force-push, and admin bypass covers neither for git force-push. A branch with only a deletion rule may accept the push; a branch with a `pull_request` rule rejects it with `GH013 ... Changes must be made through a pull request` (which doesn't read like a force-push error). Set each affected ruleset's **Enforcement** to **Disabled** (Settings -> Rules -> Rulesets). - 3. **(Maintainer only)** Force-push the re-signed branches. Force-push is restricted to the maintainer because it rewrites shared history and can lose commits if done wrong - a destructive operation, not a signing concern (signing comes from local git config and is unaffected by who runs the push). It is the single manual force-push the template sanctions; agents must never run it (see [AGENTS.md "Git and Commit Rules"](./AGENTS.md#git-and-commit-rules)). - 4. Re-enable **Enforcement** (or import the strict rulesets now, if you deferred them per the recommended order). - 5. **Verify via the API, not local `%G?`.** `git log --pretty=%G?` can show `U` (good signature, untrusted) on a fresh machine even when the signature is fine, or `G` only because `allowed_signers` happens to be configured - neither reflects what GitHub accepts. The authoritative check is: - - ```sh - gh api repos///commits/ --jq '.commit.verification' # expect verified: true, reason: valid - ``` - - 6. **Clean up.** A root rewrite orphans existing tags (they keep pointing at the pre-rewrite commits; `Require signed commits` applies to branches not tags, so leaving them is fine). Open bot PRs/branches based on the old history go stale after the force-push - let Dependabot recreate them (or rebase) so reviewers aren't confused. - - Alternatively, enable `Require signed commits` only on a repo whose **full history is already signed** - greenfield repos created from this template (where signing is live before the first commit, per [AGENTS.md "Git and Commit Rules"](./AGENTS.md#git-and-commit-rules)) never hit this. - - The per-branch settings below are the **reference for what each ruleset contains and why** (and the manual fallback if you configure via the UI): - - "Develop": - - Target branches: `develop`. - - Allowed merge methods: `Squash` - - `Require linear history` (develop is kept linear; main carries merge commits by design, so this setting belongs to develop only) - - `Require status checks to pass` -> `Require branches to be up to date before merging` **intentionally OFF**. Leaving it on stalls bot auto-merge when two bot PRs against develop land within the same window - the first merge flips the second to `mergeStateStatus: BEHIND`, and GitHub's auto-merge will not fire while strict is on. The merge-bot in [`.github/workflows/merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) only enables auto-merge on `opened`/`reopened` and never auto-updates bot branches; Dependabot's rebase isn't real-time. With strict off, squash mechanics still rebase the diff onto develop's tip on merge, `Require linear history` still enforces linearity, textual conflicts still block `mergeable: CONFLICTING`, and the required `Check pull request workflow status` still gates merges. See [AGENTS.md "Branching Model"](./AGENTS.md#branching-model) for the full reasoning. - - Plus shared settings (below). - - "Main": - - Target branches: `main`. - - Allowed merge methods: `Merge` - - `Require status checks to pass` -> `Require branches to be up to date before merging` **intentionally OFF**. This rule is incompatible with the forward-only develop model. GitHub's "up to date" check is graph-based: it asks whether main's tip commit is reachable from develop. After any develop -> main release, main's new tip is a brand-new merge commit that develop's history doesn't contain. Forward-only develop never adds it (no back-merge of main into develop, no rebase of develop onto main), so the check fails permanently on every subsequent release. Leaving the rule on would force every release through an admin bypass. See [AGENTS.md "Branching Model"](./AGENTS.md#branching-model) for the full reasoning. - - Plus shared settings (below). - - Shared settings (apply to both rulesets): - - `Restrict deletions` - - `Require signed commits` - - `Require a pull request before merging` - - `Dismiss stale pull request approvals when new commits are pushed` - - `Require conversation resolution before merging` - - `Require status checks to pass` - - Status checks that are required: `Check pull request workflow status` - - `Block force pushes` - - `Automatically request Copilot code review` - - `Review new pushes` - - `Review draft pull requests` -- Actions / General: - - `Allow GitHub Actions to create and approve pull requests` - -### Template - Branching Workflow - -See [AGENTS.md "Branching Model"](./AGENTS.md#branching-model) for the authoritative definition. Summary: - -- Persistent `main` and `develop` branches, each with its own ruleset (above). Both must always be building error free. -- Feature branches off `develop`. Only commit on feature branches, never directly to `develop` or `main`. -- Feature -> `develop`: **squash-merge** (develop ruleset enforces this; develop is kept linear). -- `develop` -> `main`: **merge-commit** (preserves develop's commit list as a real second-parent reference on main; main ruleset enforces this). -- **`develop` is forward-only.** No `main -> develop` back-merges. The develop squash-only ruleset physically blocks merge commits. -- **Versioning is semantic and maintainer-driven.** Raise the `version` major/minor in [`version.json`](./version.json) only when the change being introduced warrants a new semantic version (a feature, a behavior/API change, a breaking change), in the PR that introduces it - not on a cadence and not mechanically after a release. NBGV advances the patch (git height) on every commit automatically. `develop` builds are always prereleases, so they need no artificial lead over `main` and there is no post-release `version.json` bump; a `develop -> main` promotion carries whatever `version.json` is current. See [AGENTS.md "Release Model"](./AGENTS.md#release-model). -- **Bots open parallel PRs against both branches.** [`.github/dependabot.yml`](./.github/dependabot.yml) duplicates each ecosystem entry per branch, and [`.github/workflows/run-codegen-pull-request-task.yml`](./.github/workflows/run-codegen-pull-request-task.yml) runs as a matrix (branch names `codegen-main` and `codegen-develop`). Each branch absorbs its own bot PRs independently - neither falls behind, no back-merges needed. -- **Review-then-merge loop.** Every PR is reviewed by GitHub Copilot. The agent pushes, re-requests a review on the new head (via the `requestReviews` GraphQL mutation), addresses and resolves each finding, repeats until green, and then **waits for the maintainer's explicit permission to merge** - it does not self-merge. See [AGENTS.md "PR Review Etiquette"](./AGENTS.md#pr-review-etiquette) and the [Copilot Review Runbook](./.github/copilot-instructions.md#github-copilot-review-runbook) for the mechanics. - -### Template - Release Distribution Model: Two-Phase by Default - -This template ships with a **two-phase model** that decouples merging from publishing: - -- **Pull requests smoke-test only.** [`.github/workflows/test-pull-request.yml`](./.github/workflows/test-pull-request.yml) always runs unit tests, then path-gates a **reduced** build of only the targets a PR touches (`dorny/paths-filter`): Docker as `linux/amd64` only (no QEMU/arm64), the executable as a representative runtime subset, and nothing is pushed. A docs-only PR runs unit tests alone; a Dependabot github-actions bump is unit-tests-only. This is fast feedback, not a release. -- **Merges to `main`/`develop` do not publish.** A push only smoke-tested the PR; merging it republishes nothing. -- **The weekly schedule + manual dispatch are the sole publishers.** [`.github/workflows/publish-release.yml`](./.github/workflows/publish-release.yml) runs every **Monday 02:00 UTC** and on-demand via `workflow_dispatch`, and on either trigger does the **full** build/publish of **both** `main` (Release / `latest` / non-prerelease) and `develop` (Debug / `develop` / prerelease) - GitHub release, NuGet/PyPI uploads, multi-arch Docker tags, platform executables, and a refreshed Docker base image. Trigger a release on demand from the Actions UI when you want one between weekly runs. - -This batches cheap bot churn (Dependabot/codegen merge daily, validated by smoke builds) into one periodic publish instead of one release per merge, and keeps PR feedback fast by deferring the slow `arm64`/full-matrix builds to the publisher. A no-op weekly run (no new commit, so an unchanged `SemVer2`) re-pushes nothing to GitHub Releases / NuGet / PyPI - only Docker re-pushes, to pick up upstream base-image refreshes. - -#### Reusing the Release Pipeline in a Derived Project (Any Language) - -The pipeline is built in two layers so you only customize one of them: - -- **Orchestration (sync verbatim - don't rewrite):** the publish plan and branch matrix in `publish-release.yml`, the version step (`get-version-task.yml`), the date badge (`build-datebadge-task.yml`), and - the key part - the **`github-release` job** that tags the built commit, creates the GitHub Release, and attaches assets. It collects assets by the pattern `release-asset--*` and never names a build job, so it works unchanged no matter what you ship. -- **Build (you own these):** the `build--task.yml` leaf tasks. Each one builds an output and either pushes it to a registry, uploads a `release-asset--` artifact for the GitHub Release, or both. - -**The one rule:** to put a file on the GitHub Release, upload it as an artifact named `release-asset--`. That's the seam - implement it in a leaf task and the rest of the release just works. - -Customize by **where your outputs go**, not by language: - -| What you ship | What to do | Goes to | -| --- | --- | --- | -| **A zip / packaged files / a binary** on the GitHub Release (e.g. a data or asset library) | One leaf task: validate -> `zip` -> upload `release-asset--library` | GitHub Release asset | -| **A NuGet package** | Keep/adapt `build-nugetlibrary-task` (it `dotnet nuget push`es *and* uploads a `release-asset-*`) | NuGet.org **+** GitHub Release asset | -| **A PyPI package** | Keep/adapt `build-pypilibrary-task` (build + artifact) and the `publish-pypi` job in `publish-release.yml` (OIDC upload) | PyPI only | -| **A Docker image** | Keep/adapt `build-docker-task` (pushes multi-arch tags) | Docker Hub only | -| **A compiled app/CLI** | Keep/adapt `build-executable-task` - note it is specifically `dotnet publish`; replace it wholesale for another toolchain | GitHub Release asset | -| **Just validate + tag a release** (no build output) | Put your checks in `test-pull-request.yml`; attach a `release-asset-*` only if you have a file | GitHub Release (tag, optionally an asset) | - -For each output you **don't** ship, delete its `build--task.yml`, its job + `needs` entry in `build-release-task.yml`, its `test-pull-request.yml` path filter, and (PyPI) the `publish-pypi` job. So `build-release-task.yml` is edited to reflect *your* set of leaf jobs, but its `github-release` logic stays as-is - that's the part you reuse rather than fork. `get-version-task.yml` installs the .NET SDK to run NBGV (which produces the version/tag) even in a non-.NET repo. See [`AGENTS.md`](./AGENTS.md) "Release Model" for the full seam contract and the no-op-republish guarantee. - -**Opt in to publish-on-merge.** Set the repository variable `PUBLISH_ON_MERGE` to `true` (Settings -> Secrets and variables -> Actions -> Variables) to switch to the **continuous-release** model: every push/merge to `main` publishes `main` and every push to `develop` publishes `develop`, immediately. The weekly and manual publishers also run. Leave the variable unset (or `false`) for the two-phase default. It's a repository variable, not a workflow edit, so pulling template updates never conflicts with your choice. - -Which to pick: two-phase suits projects whose consumers are **pushed** updates (HACS for Home Assistant, package managers that auto-update, Linux distros that vendor from `main`) where every release is a forced update and frequent bot-driven releases are noise. `PUBLISH_ON_MERGE=true` suits projects whose consumers **pull** at their own cadence (Docker pulls, NuGet/PyPI installs, manual downloads) and want every merged change available immediately. For an example of a push-distribution project, see [homeassistant-purpleair](https://github.com/ptr727/homeassistant-purpleair) (ships through HACS). - -### Template - Deferred Patterns - -Template improvements identified but deferred until a real project needs them, so they aren't lost and aren't force-fit prematurely. Implement an entry (and remove it here) when a new or existing derived project first hits the use case. - -- **Factor the unit-test job out of `test-pull-request.yml` into a `test-*-task.yml`** so the entry-point file is target-agnostic. Trigger: a non-.NET repo that wants the aggregator without hand-deleting the `unit-test` job. -- **Per-language / per-project-type test scaffolds** (a Python test task, a Docker smoke/health-check test, etc.), added as each language or project type is actually exercised downstream. Trigger: the first repo that ships that language/type and needs CI coverage for it. -- **Resync the `publish-docker-readme-task.yml` "Validate inputs step" downstream** so derived repos pick up the input-contract guard (mutually-exclusive `repositories` vs `manifest`, paired `manifest` + `manifest-jq`). Trigger: the next orchestrated template re-sync, or a derived repo that hits a silent fall-through from a half-filled manifest pair. - - - -[actions-link]: https://github.com/ptr727/ProjectTemplate/actions -[commits-link]: https://github.com/ptr727/ProjectTemplate/commits/main -[discussions-link]: https://github.com/ptr727/ProjectTemplate/discussions -[docker-link]: https://hub.docker.com/r/ptr727/projecttemplate -[dockerbuildstatus-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/ProjectTemplate/publish-release.yml?event=schedule&logo=github&label=Docker%20Build -[dockerdevelopversion-shield]: https://img.shields.io/docker/v/ptr727/projecttemplate/develop?label=Docker%20Develop&logo=docker&color=orange -[dockerlatestversion-shield]: https://img.shields.io/docker/v/ptr727/projecttemplate/latest?label=Docker%20Latest&logo=docker -[github-link]: https://github.com/ptr727/ProjectTemplate -[issues-link]: https://github.com/ptr727/ProjectTemplate/issues -[lastbuild-shield]: https://byob.yarr.is/ptr727/ProjectTemplate/lastbuild -[lastcommit-shield]: https://img.shields.io/github/last-commit/ptr727/ProjectTemplate?logo=github&label=Last%20Commit -[license-link]: ./LICENSE -[license-shield]: https://img.shields.io/github/license/ptr727/ProjectTemplate?label=License -[nuget-link]: https://www.nuget.org/packages/ptr727.ProjectTemplate.Library/ -[nugetreleaseversion-shield]: https://img.shields.io/nuget/v/ptr727.ProjectTemplate.Library?logo=nuget&label=NuGet%20Release -[prereleaseversion-shield]: https://img.shields.io/github/v/release/ptr727/ProjectTemplate?include_prereleases&filter=*-g*&label=GitHub%20Pre-Release&logo=github -[pypi-link]: https://pypi.org/project/ptr727-projecttemplate-library/ -[pypireleaseversion-shield]: https://img.shields.io/pypi/v/ptr727-projecttemplate-library?logo=pypi&label=PyPI%20Release -[releasebuildstatus-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/ProjectTemplate/publish-release.yml?event=schedule&logo=github&label=Releases%20Build -[releases-link]: https://github.com/ptr727/ProjectTemplate/releases -[releaseversion-shield]: https://img.shields.io/github/v/release/ptr727/ProjectTemplate?logo=github&label=GitHub%20Release - - - -[apininjas-link]: https://api-ninjas.com/api/quotes -[awesomeassertions-link]: https://awesomeassertions.org/ -[byob-link]: https://github.com/marketplace/actions/bring-your-own-badge -[createpr-link]: https://github.com/marketplace/actions/create-pull-request -[csharpier-link]: https://csharpier.com/ -[devcontainers-link]: https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers -[ghactions-link]: https://github.com/actions -[ghautocommit-link]: https://github.com/marketplace/actions/git-auto-commit -[ghdependabot-link]: https://github.com/dependabot -[ghrelease-link]: https://github.com/marketplace/actions/gh-release -[huskynet-link]: https://alirezanet.github.io/Husky.Net/ -[nerbankgitversion-link]: https://github.com/marketplace/actions/nerdbank-gitversioning -[precommit-link]: https://pre-commit.com/ -[serilog-link]: https://serilog.net/ -[xunit-link]: https://xunit.net/ +See [LICENSE](./LICENSE). diff --git a/repo-config/README.md b/repo-config/README.md index 0ff0484e..093e3dbf 100644 --- a/repo-config/README.md +++ b/repo-config/README.md @@ -1,14 +1,64 @@ # repo-config Repository and branch configuration held as committed files, kept out of `.github/` (which is reserved -for GitHub-Actions-owned content). This mirrors the layout downstream repos use. +for GitHub-Actions-owned content). This mirrors the layout the fleet repos use. - `main.json`, `develop.json` - the branch rulesets as the writable API subset (`name`, `target`, `enforcement`, `bypass_actors`, `conditions`, `rules`). These are the canonical - expected payload the audit (`AUDIT.md`) diffs each repo's live rulesets against. + expected payload the audit ([AUDIT.md](../AUDIT.md)) diffs each repo's live rulesets against. - `configure.sh` - applies the rulesets to a repository via the GitHub API (create or full-payload update, idempotent). Run `repo-config/configure.sh [owner/repo]`. +## Rulesets + `main` requires merge-commit merges (no linear-history rule); `develop` requires squash merges with linear history. Both require signed commits, a passing `Check pull request workflow status`, resolved -review threads, and Copilot review, and block force-pushes and deletion. +review threads, and Copilot review, and block force-pushes and deletion. Both intentionally leave +"Require branches to be up to date before merging" **off** - see [AGENTS.md "Branching Model"](../AGENTS.md#branching-model). + +**Configure by importing these JSON files, never by hand-building the rules** (hand reconstruction has +gone wrong on past setups). The result must be **exactly two rulesets named `develop` and `main`** - the +names are load-bearing (`AGENTS.md` and the workflows reference them). First remove all legacy classic +branch-protection rules and any stray rulesets, then run `configure.sh` (or `gh api -X POST +repos///rulesets --input repo-config/.json` per file). `gh ruleset` is read-only; +creation goes through `gh api`. The required check binds by name and only turns green after +`test-pull-request.yml` runs once. To edit a ruleset, GET it, change the field, and PUT the whole +writable subset back (a partial PUT `422`s). + +To change the canonical rulesets, edit the live rulesets here, then regenerate the committed files: + +```sh +for name in develop main; do + id=$(gh api repos/ptr727/ProjectTemplate/rulesets --jq ".[] | select(.name==\"$name\") | .id") + gh api "repos/ptr727/ProjectTemplate/rulesets/$id" \ + --jq '{name, target, enforcement, bypass_actors, conditions, rules}' \ + | jq -S '.' > "repo-config/$name.json" +done +``` + +## Secrets + +Publish credentials required per mechanism are enumerated in [spec/secrets.json](../spec/secrets.json). +NuGet and PyPI use keyless OIDC Trusted Publishing (no stored key; the publish job needs `id-token: +write`, and PyPI additionally an `environment: pypi` gate). Docker Hub has no OIDC equivalent and uses a +stored `DOCKER_HUB_USERNAME` + `DOCKER_HUB_ACCESS_TOKEN` in both the Actions and Dependabot secret +stores. Codegen and merge-bot repos add a GitHub App (`CODEGEN_APP_CLIENT_ID` + `CODEGEN_APP_PRIVATE_KEY` +in both stores; the app must be installed, not just created). App-token call sites use `client-id`, never +the deprecated `app-id`. + +## Repo Settings + +- Default branch `main`. Enable both `Allow merge commits` and `Allow squash merging` at the repo level + so each branch ruleset can pick its method; leave rebase disabled. Enable auto-merge. +- Actions / General: allow GitHub Actions to create and approve pull requests (for the bots). + +## Brownfield Migration (Maintainer Only) + +`Require signed commits` rejects any pre-existing unsigned commit, so the first `develop -> main` release +on a repo with unsigned history is blocked. Re-signing that history is a non-fast-forward that the +`Block force pushes` rule rejects, **and the admin bypass does not cover `git push --force`**. Completing +it requires temporarily disabling the ruleset and a maintainer force-push. This is a one-time, +maintainer-performed migration that deliberately uses the force-push [AGENTS.md "Git and Commit +Rules"](../AGENTS.md#git-and-commit-rules) forbids agents from running - **an agent must never execute it; +surface it to the maintainer**. Greenfield repos where signing is live before the first commit never hit +this. diff --git a/spec/readme-structure.md b/spec/readme-structure.md new file mode 100644 index 00000000..cbc2c64e --- /dev/null +++ b/spec/readme-structure.md @@ -0,0 +1,45 @@ +# README Structure + +The preferred `README.md` shape for a fleet project. The audit's `readme-structure` dimension checks a +repo's README against this: the required sections in this order (to the letter where a section applies, +to intent where a project legitimately has nothing to say). Sections that do not apply to a project type +are N/A, not a defect (a library has no "Installation" of a running app; a source-only repo has no +"Usage"). + +## Required Sections and Order + +1. **Title (`# `)** - the repo name, followed by a one-line description of what it is. +2. **Shields** - build/release status and version badges immediately under the title, one logical line + per group with a trailing backslash for the hard break. Alphabetize the shield link-reference + definitions block at the bottom of the file (per AGENTS.md Markdown rules). +3. **Build and Distribution** - where releases and packages are published (GitHub Releases, NuGet, PyPI, + Docker Hub), and a short **Release Notes** summary (full history in `HISTORY.md`). +4. **Getting Started** - the shortest path to using the project. +5. **Use Cases** - what problems it solves (optional for a library whose purpose is obvious from the + description). +6. **Installation** - how to install or pull it, per distribution channel. N/A for source-only. +7. **Configuration** - settings, environment, config files. N/A when there is nothing to configure. +8. **Usage** - commands, API, or integration steps. For a CLI, a command quick-reference and the global + options. N/A for source-only. +9. **Questions or Issues** - where to file issues and ask questions. +10. **Development Environment** - how to build, test, and lint locally; commit-signing setup; devcontainer + notes. Point at shared docs rather than restating them. +11. **3rd Party Tools** - notable dependencies and their licenses, where relevant. +12. **License** - a pointer to `LICENSE`. + +## Style + +- Write in the current state, not as a change from a prior one (AGENTS.md Documentation Style). +- One logical paragraph per line; no hard-wrap. +- Title-case headings with lowercase short bind words. +- US English, ASCII only (no em-dash; use a spaced hyphen), straight quotes. +- Verify every quantitative claim (counts, versions, supported platforms) against current code. +- A project README describes only that project - no cross-repo references and no template or + inheritance framing. + +## Docker Hub README + +A repo that publishes a Docker image keeps a **separate** `Docker/README.md` for the Docker Hub +repository overview: Docker Hub's description has a much smaller size limit than a project README, so it +carries a trimmed overview, not the full README. It is published by the docker-readme workflow task, not +copied from the root README. From 5eaf9bcd0c3346240428edca15cfb2ce7ddf2b0d Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 4 Jul 2026 13:03:19 -0700 Subject: [PATCH 05/17] Add fleet registry, ground-truth spec, schemas, and validator Author registry/repos.json (self + 7 cataloged + 14 backlog) and the spec manifests: project-types.json (per-type letter/intent checks plus cross-cutting workflow, branch-model, repo-setup, linter-parity, recurring-violations, and readme-structure dimensions), files.json (standardization baseline), and secrets.json (publish mechanism -> required/forbidden secrets). Each JSON check points at the prose section that owns the rationale. spec/validate.py is the dependency-free classification dry-run: it confirms every cataloged repo's types, publish mechanisms, and secrets classify against the spec. Co-Authored-By: Claude Opus 4.8 (1M context) --- registry/repos.json | 142 +++++++++++++++++++++++++++++++++ registry/repos.schema.json | 67 ++++++++++++++++ spec/files.json | 22 +++++ spec/files.schema.json | 28 +++++++ spec/project-types.json | 136 +++++++++++++++++++++++++++++++ spec/project-types.schema.json | 36 +++++++++ spec/secrets.json | 36 +++++++++ spec/secrets.schema.json | 29 +++++++ spec/validate.py | 77 ++++++++++++++++++ 9 files changed, 573 insertions(+) create mode 100644 registry/repos.json create mode 100644 registry/repos.schema.json create mode 100644 spec/files.json create mode 100644 spec/files.schema.json create mode 100644 spec/project-types.json create mode 100644 spec/project-types.schema.json create mode 100644 spec/secrets.json create mode 100644 spec/secrets.schema.json create mode 100644 spec/validate.py diff --git a/registry/repos.json b/registry/repos.json new file mode 100644 index 00000000..be8399b9 --- /dev/null +++ b/registry/repos.json @@ -0,0 +1,142 @@ +{ + "$schema": "./repos.schema.json", + "owner": "ptr727", + "defaults": { + "groundTruthBranch": "main", + "releaseTrigger": "two-phase" + }, + "repos": [ + { + "name": "ProjectTemplate", + "url": "https://github.com/ptr727/ProjectTemplate", + "status": "cataloged", + "types": ["source-only", "docs"], + "groundTruthBranch": "main", + "hasDevelop": true, + "publish": [{ "target": "github-release", "mechanism": "none" }], + "requiredSecrets": [], + "consumerModel": "pull", + "releaseTrigger": "two-phase", + "configLayout": { "rulesetsDir": "repo-config", "pythonConfig": null }, + "driftNotes": ["Governance hub; audits its own rules against itself."] + }, + { + "name": "Utilities", + "url": "https://github.com/ptr727/Utilities", + "status": "cataloged", + "types": ["csharp", "nuget"], + "groundTruthBranch": "main", + "hasDevelop": true, + "publish": [{ "target": "nuget", "mechanism": "oidc" }], + "requiredSecrets": [], + "consumerModel": "pull", + "releaseTrigger": "two-phase", + "configLayout": { "rulesetsDir": "repo-config", "pythonConfig": null }, + "driftNotes": ["No get-version-task; relies on validate-task."] + }, + { + "name": "LanguageTags", + "url": "https://github.com/ptr727/LanguageTags", + "status": "cataloged", + "types": ["csharp", "nuget", "codegen"], + "groundTruthBranch": "main", + "hasDevelop": true, + "publish": [{ "target": "nuget", "mechanism": "oidc" }], + "requiredSecrets": ["CODEGEN_APP_CLIENT_ID", "CODEGEN_APP_PRIVATE_KEY"], + "consumerModel": "pull", + "releaseTrigger": "two-phase", + "configLayout": { "rulesetsDir": "repo-config", "pythonConfig": null }, + "driftNotes": ["No get-version-task; relies on validate-task."] + }, + { + "name": "aiopurpleair", + "url": "https://github.com/ptr727/aiopurpleair", + "status": "cataloged", + "types": ["python", "pypi"], + "groundTruthBranch": "main", + "hasDevelop": true, + "publish": [{ "target": "pypi", "mechanism": "oidc" }], + "requiredSecrets": [], + "consumerModel": "pull", + "releaseTrigger": "two-phase", + "configLayout": { "rulesetsDir": "repo-config", "pythonConfig": "pyproject.toml" }, + "driftNotes": [] + }, + { + "name": "homeassistant-purpleair", + "url": "https://github.com/ptr727/homeassistant-purpleair", + "status": "cataloged", + "types": ["python", "homeassistant"], + "groundTruthBranch": "main", + "hasDevelop": true, + "publish": [{ "target": "github-release", "mechanism": "none" }], + "requiredSecrets": [], + "consumerModel": "push", + "releaseTrigger": "dispatch-only", + "configLayout": { "rulesetsDir": "repo-config", "pythonConfig": "standalone" }, + "driftNotes": [ + "ruff/pyright in standalone .ruff.toml + pyrightconfig.json instead of pyproject.toml (canonicalize).", + "cspell.json missing." + ] + }, + { + "name": "PlexCleaner", + "url": "https://github.com/ptr727/PlexCleaner", + "status": "cataloged", + "types": ["csharp", "console", "docker"], + "groundTruthBranch": "main", + "hasDevelop": true, + "publish": [ + { "target": "docker", "mechanism": "static-secret" }, + { "target": "github-release", "mechanism": "none" } + ], + "requiredSecrets": ["DOCKER_HUB_USERNAME", "DOCKER_HUB_ACCESS_TOKEN"], + "consumerModel": "pull", + "releaseTrigger": "two-phase", + "configLayout": { "rulesetsDir": "repo-config", "pythonConfig": null }, + "driftNotes": ["Carries ARCHITECTURE.md and codecov.yml beyond the baseline."] + }, + { + "name": "ESPHome-NonRoot", + "url": "https://github.com/ptr727/ESPHome-NonRoot", + "status": "cataloged", + "types": ["docker", "upstream-wrapper"], + "groundTruthBranch": "main", + "hasDevelop": true, + "publish": [{ "target": "docker", "mechanism": "static-secret" }], + "requiredSecrets": ["DOCKER_HUB_USERNAME", "DOCKER_HUB_ACCESS_TOKEN"], + "consumerModel": "pull", + "releaseTrigger": "two-phase", + "configLayout": { "rulesetsDir": "repo-config", "pythonConfig": null }, + "driftNotes": ["Upstream-version tracker drives the image tag."] + }, + { + "name": "VSCode-Server-DotNetCore", + "url": "https://github.com/ptr727/VSCode-Server-DotNetCore", + "status": "cataloged", + "types": ["docker"], + "groundTruthBranch": "main", + "hasDevelop": true, + "publish": [{ "target": "docker", "mechanism": "static-secret" }], + "requiredSecrets": ["DOCKER_HUB_USERNAME", "DOCKER_HUB_ACCESS_TOKEN"], + "consumerModel": "pull", + "releaseTrigger": "two-phase", + "configLayout": { "rulesetsDir": "repo-config", "pythonConfig": null }, + "driftNotes": ["Separate publish-docker-readme-task for the Hub overview."] + }, + { "name": "NxWitness", "url": "https://github.com/ptr727/NxWitness", "status": "backlog", "types": [], "classificationPending": true }, + { "name": "HomeAutomation", "url": "https://github.com/ptr727/HomeAutomation", "status": "backlog", "types": [], "classificationPending": true }, + { "name": "KiCadLibrary", "url": "https://github.com/ptr727/KiCadLibrary", "status": "backlog", "types": [], "classificationPending": true }, + { "name": "EspDinIoT", "url": "https://github.com/ptr727/EspDinIoT", "status": "backlog", "types": [], "classificationPending": true }, + { "name": "ESPHome-Config", "url": "https://github.com/ptr727/ESPHome-Config", "status": "backlog", "types": [], "classificationPending": true }, + { "name": "HomeAssistant-Config", "url": "https://github.com/ptr727/HomeAssistant-Config", "status": "backlog", "types": [], "classificationPending": true }, + { "name": "DevKitCIoT", "url": "https://github.com/ptr727/DevKitCIoT", "status": "backlog", "types": [], "classificationPending": true }, + { "name": "PhotoCleaner", "url": "https://github.com/ptr727/PhotoCleaner", "status": "backlog", "types": [], "classificationPending": true }, + { "name": "MediaTools", "url": "https://github.com/ptr727/MediaTools", "status": "backlog", "types": [], "classificationPending": true }, + { "name": "AudioCleaner", "url": "https://github.com/ptr727/AudioCleaner", "status": "backlog", "types": [], "classificationPending": true }, + { "name": "Vantage-Config", "url": "https://github.com/ptr727/Vantage-Config", "status": "backlog", "types": [], "classificationPending": true }, + { "name": "HolidayLights", "url": "https://github.com/ptr727/HolidayLights", "status": "backlog", "types": [], "classificationPending": true }, + { "name": ".github", "url": "https://github.com/ptr727/.github", "status": "backlog", "types": [], "classificationPending": true }, + { "name": "TelemetryImporter", "url": "https://github.com/ptr727/TelemetryImporter", "status": "backlog", "types": [], "classificationPending": true } + ] +} diff --git a/registry/repos.schema.json b/registry/repos.schema.json new file mode 100644 index 00000000..57c2908b --- /dev/null +++ b/registry/repos.schema.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/ptr727/ProjectTemplate/registry/repos.schema.json", + "title": "Fleet registry", + "type": "object", + "required": ["owner", "repos"], + "additionalProperties": false, + "properties": { + "$schema": { "type": "string" }, + "owner": { "type": "string" }, + "defaults": { + "type": "object", + "additionalProperties": false, + "properties": { + "groundTruthBranch": { "type": "string" }, + "releaseTrigger": { "$ref": "#/$defs/releaseTrigger" } + } + }, + "repos": { + "type": "array", + "items": { "$ref": "#/$defs/repo" } + } + }, + "$defs": { + "releaseTrigger": { "enum": ["two-phase", "publish-on-merge", "dispatch-only"] }, + "mechanism": { "enum": ["oidc", "static-secret", "none"] }, + "target": { "enum": ["nuget", "pypi", "docker", "github-release"] }, + "repo": { + "type": "object", + "required": ["name", "url", "status"], + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "url": { "type": "string", "format": "uri" }, + "status": { "enum": ["cataloged", "backlog"] }, + "types": { "type": "array", "items": { "type": "string" } }, + "classificationPending": { "type": "boolean" }, + "groundTruthBranch": { "type": "string" }, + "hasDevelop": { "type": "boolean" }, + "publish": { + "type": "array", + "items": { + "type": "object", + "required": ["target", "mechanism"], + "additionalProperties": false, + "properties": { + "target": { "$ref": "#/$defs/target" }, + "mechanism": { "$ref": "#/$defs/mechanism" } + } + } + }, + "requiredSecrets": { "type": "array", "items": { "type": "string" } }, + "consumerModel": { "enum": ["push", "pull"] }, + "releaseTrigger": { "$ref": "#/$defs/releaseTrigger" }, + "configLayout": { + "type": "object", + "additionalProperties": false, + "properties": { + "rulesetsDir": { "type": "string" }, + "pythonConfig": { "type": ["string", "null"] } + } + }, + "driftNotes": { "type": "array", "items": { "type": "string" } } + } + } + } +} diff --git a/spec/files.json b/spec/files.json new file mode 100644 index 00000000..9b59d284 --- /dev/null +++ b/spec/files.json @@ -0,0 +1,22 @@ +{ + "$schema": "./files.schema.json", + "note": "The standardization baseline: files and sections a fleet repo is expected to carry, and their intent authority. The audit checks presence (letter) and equivalence (intent); a section for an absent language or target is N/A.", + "baseline": [ + { "path": "AGENTS.md", "sections": ["Git and Commit Rules", "Branching Model", "Release Model", "Pull Request Title and Commit Message Conventions", "Documentation Style Conventions", "PR Review Etiquette", "Workflow YAML Conventions"], "intentRef": "AGENTS.md", "appliesTo": "*" }, + { "path": "CODESTYLE.md", "whole": true, "placeholders": ["InternalsVisibleTo project names"], "intentRef": "CODESTYLE.md", "appliesTo": "*" }, + { "path": "WORKFLOW.md", "whole": true, "intentRef": "WORKFLOW.md", "appliesTo": "*" }, + { "path": ".github/copilot-instructions.md", "whole": true, "placeholders": ["", "", ""], "appliesTo": "*" }, + { "path": ".editorconfig", "whole": true, "intentRef": "AGENTS.md#line-endings", "appliesTo": "*" }, + { "path": ".gitattributes", "whole": true, "intentRef": "AGENTS.md#line-endings", "appliesTo": "*" }, + { "path": ".markdownlint-cli2.jsonc", "whole": true, "appliesTo": "*" }, + { "path": "cspell.json", "whole": true, "appliesTo": "*" }, + { "path": ".gitignore", "appliesTo": "*" }, + { "path": "version.json", "intentRef": "WORKFLOW.md#d3---versioning-and-classification", "appliesTo": "*" }, + { "path": "repo-config/develop.json", "intentRef": "repo-config/README.md", "appliesTo": "*" }, + { "path": "repo-config/main.json", "intentRef": "repo-config/README.md", "appliesTo": "*" }, + { "path": ".github/dependabot.yml", "appliesTo": "*" }, + { "path": ".vscode/tasks.json", "sections": ["clean-compile task group"], "reference": "catalog/snippets/configs/vscode-tasks.json", "appliesTo": ["csharp", "python"] }, + { "path": ".dockerignore", "appliesTo": ["docker"] }, + { "path": "Docker/README.md", "reference": "catalog/snippets/configs/docker-hub-readme.md", "appliesTo": ["docker"] } + ] +} diff --git a/spec/files.schema.json b/spec/files.schema.json new file mode 100644 index 00000000..7013e1a7 --- /dev/null +++ b/spec/files.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/ptr727/ProjectTemplate/spec/files.schema.json", + "title": "Standardization baseline files", + "type": "object", + "required": ["baseline"], + "properties": { + "$schema": { "type": "string" }, + "note": { "type": "string" }, + "baseline": { + "type": "array", + "items": { + "type": "object", + "required": ["path"], + "additionalProperties": false, + "properties": { + "path": { "type": "string" }, + "whole": { "type": "boolean" }, + "sections": { "type": "array", "items": { "type": "string" } }, + "placeholders": { "type": "array", "items": { "type": "string" } }, + "reference": { "type": "string" }, + "intentRef": { "type": "string" }, + "appliesTo": { "type": ["string", "array"] } + } + } + } + } +} diff --git a/spec/project-types.json b/spec/project-types.json new file mode 100644 index 00000000..60ceae76 --- /dev/null +++ b/spec/project-types.json @@ -0,0 +1,136 @@ +{ + "$schema": "./project-types.schema.json", + "note": "Type -> requirements and checks. Each check names a verdict tier (letter = exact form, intent = equivalent outcome) and points at the prose section that owns the rationale. The audit (AUDIT.md) runs the checks for a repo's applicable types plus the cross-cutting dimensions; an absent construct is N/A, not a defect.", + "types": { + "csharp": { + "detect": ["*.csproj", "*.slnx", "*.sln"], + "intentRefs": ["CODESTYLE.md", "AGENTS.md#line-endings"], + "requiredFiles": [".editorconfig", ".gitattributes"], + "checks": [ + { "id": "csharp.editorconfig.ruleblock", "verdict": "letter", "assert": ".editorconfig carries the shared [*.cs] plus ReSharper rule block.", "intentRef": "CODESTYLE.md" }, + { "id": "csharp.analyzers.zerowarnings", "verdict": "intent", "assert": "Analyzer severities are enforced; warnings are not relaxed or suppressed wholesale.", "intentRef": "CODESTYLE.md" } + ] + }, + "nuget": { + "detect": ["build-nugetlibrary-task.yml", "dotnet nuget push"], + "forbiddenSecrets": ["NUGET_API_KEY"], + "checks": [ + { "id": "nuget.publish.oidc", "verdict": "intent", "assert": "NuGet publish uses OIDC Trusted Publishing (NuGet/login), not a stored API key.", "workflowRef": "WORKFLOW.md#d4-release--publish" }, + { "id": "nuget.publish.skipduplicate", "verdict": "letter", "assert": "The push uses --skip-duplicate and is gated on push, not on an existence check.", "workflowRef": "WORKFLOW.md#d4-release--publish" } + ] + }, + "pypi": { + "detect": ["build-pypilibrary-task.yml", "pypa/gh-action-pypi-publish"], + "forbiddenSecrets": ["PYPI_API_TOKEN", "TWINE_PASSWORD"], + "checks": [ + { "id": "pypi.publish.oidc", "verdict": "intent", "assert": "PyPI publish uses OIDC Trusted Publishing (no stored token).", "workflowRef": "WORKFLOW.md#d4-release--publish" }, + { "id": "pypi.publish.environment", "verdict": "letter", "assert": "The publish job declares environment: pypi and id-token: write, with skip-existing: true.", "workflowRef": "WORKFLOW.md#d7-concurrency-permissions-safety" } + ] + }, + "python": { + "detect": ["pyproject.toml", "setup.py"], + "canonicalPlacement": "pyproject.toml", + "checks": [ + { "id": "python.ruff.config", "verdict": "intent", "assert": "A ruff configuration is present.", "intentRef": "CODESTYLE.md" }, + { "id": "python.pyright.config", "verdict": "intent", "assert": "A pyright configuration is present.", "intentRef": "CODESTYLE.md" }, + { "id": "python.config.placement", "verdict": "letter", "assert": "ruff and pyright config live in pyproject.toml (canonical); standalone .ruff.toml / pyrightconfig.json is a drift finding.", "intentRef": "CODESTYLE.md" } + ] + }, + "console": { + "detect": ["build-executable-task.yml", "System.CommandLine"], + "checks": [ + { "id": "console.smoke.subset", "verdict": "letter", "assert": "The smoke runtime matrix is a strict non-empty subset of the full matrix.", "workflowRef": "WORKFLOW.md#6-per-project-type-test-walkthroughs" }, + { "id": "console.aggregate.asset", "verdict": "letter", "assert": "Per-runtime outputs are aggregated by pattern+merge-multiple into one release-asset--, gated !smoke.", "workflowRef": "WORKFLOW.md#d6---seam--architecture-conformance" } + ] + }, + "docker": { + "detect": ["Dockerfile", "build-docker-task.yml"], + "requiredSecrets": ["DOCKER_HUB_USERNAME", "DOCKER_HUB_ACCESS_TOKEN"], + "checks": [ + { "id": "docker.cache.registry", "verdict": "intent", "assert": "Layer cache targets a registry tag (buildcache-), never type=gha.", "workflowRef": "WORKFLOW.md#d9---style--static-see-section-2" }, + { "id": "docker.hub.readme", "verdict": "intent", "assert": "Where the Docker Hub overview differs from the project README, a Docker/README.md is published via the docker-readme task (Hub description is size-limited).", "workflowRef": "WORKFLOW.md#6-per-project-type-test-walkthroughs" }, + { "id": "docker.always.repush", "verdict": "letter", "assert": "The image always re-pushes on publish (base-image refresh), independent of the release-create skip.", "workflowRef": "WORKFLOW.md#d4-release--publish" } + ] + }, + "homeassistant": { + "detect": ["custom_components/*/manifest.json", "hacs.json"], + "checks": [ + { "id": "ha.release.only", "verdict": "intent", "assert": "Distribution is a GitHub release for HACS (push consumer model); no registry publish.", "workflowRef": "WORKFLOW.md#d4-release--publish" } + ] + }, + "codegen": { + "detect": ["run-codegen-pull-request-task.yml"], + "requiredSecrets": ["CODEGEN_APP_CLIENT_ID", "CODEGEN_APP_PRIVATE_KEY"], + "checks": [ + { "id": "codegen.deterministic", "verdict": "intent", "assert": "Codegen output is deterministic from its inputs (no per-run timestamps/GUIDs); runs as a matrix over both branches.", "workflowRef": "WORKFLOW.md#d8---bots--automation" } + ] + }, + "upstream-wrapper": { + "detect": ["check-upstream-version-task.yml", "upstream-version.json"], + "checks": [ + { "id": "wrapper.tag.fromstate", "verdict": "letter", "assert": "The build reads the committed name->version state file for the tag instead of SemVer2.", "workflowRef": "WORKFLOW.md#d3---versioning-and-classification" }, + { "id": "wrapper.tracker.branches", "verdict": "letter", "assert": "The tracker's bump-branch-prefix and branches match the merge-bot's head/base pairs.", "workflowRef": "WORKFLOW.md#d8---bots--automation" } + ] + }, + "source-only": { + "detect": ["no build-*-task.yml"], + "checks": [ + { "id": "sourceonly.release.tagonly", "verdict": "letter", "assert": "The caller passes expect_release_assets:false; the release is tag + source zip + README + LICENSE.", "workflowRef": "WORKFLOW.md#6-per-project-type-test-walkthroughs" }, + { "id": "sourceonly.nbgv.retained", "verdict": "letter", "assert": "version.json and the NBGV get-version step are retained (they own the tag).", "workflowRef": "WORKFLOW.md#d3---versioning-and-classification" } + ] + }, + "docs": { + "detect": ["governance-only repo"], + "checks": [ + { "id": "docs.ci.lintonly", "verdict": "intent", "assert": "CI runs linting only (markdownlint, cspell, JSON schema, actionlint); no build/test.", "intentRef": "AGENTS.md" } + ] + } + }, + "crossCutting": { + "workflow": { + "appliesTo": "*", + "contract": "WORKFLOW.md", + "method": "Run the WORKFLOW.md 5A static audit and 5B trace scenarios for each applicable D-guarantee against the repo's own Actions.", + "note": "GitHub Actions may be repo-specific; assert they implement the contract by outcome, not that they match the catalog snippets byte for byte." + }, + "branch-model": { + "appliesTo": "*", + "checks": [ + { "id": "branch.both-protected", "verdict": "letter", "assert": "main and develop both exist and are protected.", "intentRef": "AGENTS.md#branching-model" }, + { "id": "branch.ruleset.develop", "verdict": "letter", "assert": "The live develop ruleset matches repo-config/develop.json (normalized diff).", "intentRef": "repo-config/README.md" }, + { "id": "branch.ruleset.main", "verdict": "letter", "assert": "The live main ruleset matches repo-config/main.json (normalized diff).", "intentRef": "repo-config/README.md" } + ] + }, + "repo-setup": { + "appliesTo": "*", + "checks": [ + { "id": "setup.secrets.present", "verdict": "letter", "assert": "Every requiredSecret for the repo's publish mechanisms is configured (per spec/secrets.json).", "intentRef": "repo-config/README.md" }, + { "id": "setup.secrets.noforbidden", "verdict": "letter", "assert": "No forbidden secret is present (e.g. a static NUGET_API_KEY on an OIDC repo).", "intentRef": "spec/secrets.json" } + ] + }, + "linter-parity": { + "appliesTo": "*", + "checks": [ + { "id": "parity.markdownlint", "verdict": "letter", "assert": "One .markdownlint-cli2.jsonc drives the VS Code extension, the CLI, and CI, and CI runs it.", "intentRef": "AGENTS.md#running-the-linters-locally-known-working-invocations" }, + { "id": "parity.cspell", "verdict": "letter", "assert": "A cspell.json (not only a workspace word list) drives the extension, the CLI, and CI.", "intentRef": "AGENTS.md" }, + { "id": "parity.lang", "verdict": "intent", "assert": "ruff/pyright (Python) and editorconfig/csharpier (C#) configs are shared by editor, CLI, and CI where those languages are present.", "intentRef": "CODESTYLE.md" } + ] + }, + "recurring-violations": { + "appliesTo": "*", + "priority": "high", + "checks": [ + { "id": "recurring.comments", "verdict": "letter", "assert": "Comments are concise, only the non-obvious, no prose narration, and do not grow on re-edit.", "intentRef": "AGENTS.md#comments" }, + { "id": "recurring.charset", "verdict": "letter", "assert": "ASCII only in agent-authored text: no em-dash (use a spaced hyphen), no smart quotes, no stray non-ASCII.", "intentRef": "AGENTS.md#character-set" }, + { "id": "recurring.spelling", "verdict": "letter", "assert": "US English spelling.", "intentRef": "AGENTS.md#pull-request-title-and-commit-message-conventions" }, + { "id": "recurring.eol", "verdict": "letter", "assert": "Line endings follow .editorconfig (Windows+Linux friendly: CRLF default, LF for execution-sensitive files); edits preserve the file's endings.", "intentRef": "AGENTS.md#line-endings" } + ] + }, + "readme-structure": { + "appliesTo": "*", + "checks": [ + { "id": "readme.sections", "verdict": "intent", "assert": "The README follows the sections and order in spec/readme-structure.md (applicable sections only).", "intentRef": "spec/readme-structure.md" } + ] + } + } +} diff --git a/spec/project-types.schema.json b/spec/project-types.schema.json new file mode 100644 index 00000000..fb88ec98 --- /dev/null +++ b/spec/project-types.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/ptr727/ProjectTemplate/spec/project-types.schema.json", + "title": "Project types and cross-cutting dimensions", + "type": "object", + "required": ["types", "crossCutting"], + "properties": { + "$schema": { "type": "string" }, + "note": { "type": "string" }, + "types": { "type": "object", "additionalProperties": { "$ref": "#/$defs/typeDef" } }, + "crossCutting": { "type": "object", "additionalProperties": { "$ref": "#/$defs/typeDef" } } + }, + "$defs": { + "check": { + "type": "object", + "required": ["id", "verdict", "assert"], + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "verdict": { "enum": ["letter", "intent"] }, + "assert": { "type": "string" }, + "intentRef": { "type": "string" }, + "workflowRef": { "type": "string" } + } + }, + "typeDef": { + "type": "object", + "additionalProperties": true, + "properties": { + "checks": { "type": "array", "items": { "$ref": "#/$defs/check" } }, + "appliesTo": { "type": ["string", "array"] }, + "priority": { "enum": ["high", "normal"] } + } + } + } +} diff --git a/spec/secrets.json b/spec/secrets.json new file mode 100644 index 00000000..7a1d0fea --- /dev/null +++ b/spec/secrets.json @@ -0,0 +1,36 @@ +{ + "$schema": "./secrets.schema.json", + "note": "Publish mechanism -> the secrets it requires and forbids, and any workflow constructs it needs. The audit cross-checks a repo's configured secrets against the mechanisms its publish targets use.", + "mechanisms": { + "nuget-oidc": { + "requires": [], + "forbids": ["NUGET_API_KEY"], + "workflowNeeds": ["id-token: write"], + "stores": [] + }, + "pypi-oidc": { + "requires": [], + "forbids": ["PYPI_API_TOKEN", "TWINE_PASSWORD"], + "workflowNeeds": ["environment: pypi", "id-token: write", "skip-existing: true"], + "stores": [] + }, + "docker-hub": { + "requires": ["DOCKER_HUB_USERNAME", "DOCKER_HUB_ACCESS_TOKEN"], + "forbids": [], + "workflowNeeds": [], + "stores": ["actions", "dependabot"] + }, + "codegen-app": { + "requires": ["CODEGEN_APP_CLIENT_ID", "CODEGEN_APP_PRIVATE_KEY"], + "forbids": ["CODEGEN_APP_ID"], + "workflowNeeds": ["client-id (not app-id)"], + "stores": ["actions", "dependabot"] + } + }, + "targetMechanisms": { + "nuget": "nuget-oidc", + "pypi": "pypi-oidc", + "docker": "docker-hub", + "github-release": null + } +} diff --git a/spec/secrets.schema.json b/spec/secrets.schema.json new file mode 100644 index 00000000..50916764 --- /dev/null +++ b/spec/secrets.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/ptr727/ProjectTemplate/spec/secrets.schema.json", + "title": "Publish mechanism secrets", + "type": "object", + "required": ["mechanisms"], + "properties": { + "$schema": { "type": "string" }, + "note": { "type": "string" }, + "mechanisms": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": ["requires", "forbids"], + "additionalProperties": false, + "properties": { + "requires": { "type": "array", "items": { "type": "string" } }, + "forbids": { "type": "array", "items": { "type": "string" } }, + "workflowNeeds": { "type": "array", "items": { "type": "string" } }, + "stores": { "type": "array", "items": { "enum": ["actions", "dependabot"] } } + } + } + }, + "targetMechanisms": { + "type": "object", + "additionalProperties": { "type": ["string", "null"] } + } + } +} diff --git a/spec/validate.py b/spec/validate.py new file mode 100644 index 00000000..12140721 --- /dev/null +++ b/spec/validate.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Validate the registry and spec cross-references (stdlib only). + +Checks that every cataloged repo classifies against the spec: its types resolve, +its publish mechanisms are recognized, and its secrets are consistent with +spec/secrets.json. Exits non-zero on any failure. This is the classification +dry-run the CI lint job runs; it needs no third-party packages. +""" +import json +import pathlib +import sys + +ROOT = pathlib.Path(__file__).resolve().parent.parent + + +def load(rel): + return json.loads((ROOT / rel).read_text(encoding="utf-8")) + + +def main(): + errors = [] + repos = load("registry/repos.json") + types = load("spec/project-types.json") + secrets = load("spec/secrets.json") + + known_types = set(types["types"]) + target_mech = secrets["targetMechanisms"] + mechanisms = secrets["mechanisms"] + + for repo in repos["repos"]: + name = repo["name"] + if repo["status"] == "backlog": + if not repo.get("classificationPending"): + errors.append(f"{name}: backlog repo without classificationPending") + continue + + for t in repo.get("types", []): + if t not in known_types: + errors.append(f"{name}: type '{t}' not defined in project-types.json") + + required = set(repo.get("requiredSecrets", [])) + for pub in repo.get("publish", []): + target, mech = pub["target"], pub["mechanism"] + if target not in target_mech: + errors.append(f"{name}: publish target '{target}' unknown") + continue + mech_key = target_mech[target] + if mech_key is None: + continue + spec_mech = mechanisms[mech_key] + # docker/static-secret must carry its required secrets + for req in spec_mech["requires"]: + if req not in required: + errors.append(f"{name}: {target} requires secret '{req}' (missing)") + # oidc mechanisms must not carry a forbidden static key + for bad in spec_mech["forbids"]: + if bad in required: + errors.append(f"{name}: {target} forbids secret '{bad}' (present)") + # mechanism label must match the target's expected mechanism family + if mech == "static-secret" and not spec_mech["requires"]: + errors.append(f"{name}: {target} marked static-secret but mechanism needs no secret") + if mech == "oidc" and spec_mech["requires"]: + errors.append(f"{name}: {target} marked oidc but mechanism requires stored secrets") + + if errors: + print("Spec validation FAILED:") + for e in errors: + print(f" - {e}") + return 1 + cataloged = sum(1 for r in repos["repos"] if r["status"] == "cataloged") + backlog = sum(1 for r in repos["repos"] if r["status"] == "backlog") + print(f"Spec validation OK: {cataloged} cataloged, {backlog} backlog repos classify cleanly.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From dd5d1ff1e7474f1fe3e9b5a79d96b2b69921fc16 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 4 Jul 2026 13:04:52 -0700 Subject: [PATCH 06/17] Add AUDIT.md audit-agent instruction set and report template AUDIT.md defines the read-only per-repo audit: main as ground truth (develop divergence is a drift finding), type resolution from the registry, the applicability gate, per-dimension letter/intent checks, asserting the Actions implement WORKFLOW.md, the ruleset normalized-diff and secret checks, the verdict model (letter miss = drift, letter+intent miss = defect), and the report format. reports/_template.md is the report skeleton. Co-Authored-By: Claude Opus 4.8 (1M context) --- AUDIT.md | 124 +++++++++++++++++++++++++++++++++++++++++++ reports/_template.md | 41 ++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 AUDIT.md create mode 100644 reports/_template.md diff --git a/AUDIT.md b/AUDIT.md new file mode 100644 index 00000000..9d5071ad --- /dev/null +++ b/AUDIT.md @@ -0,0 +1,124 @@ +# AUDIT.md + +How an agent audits a repository against the fleet ground truth in this repo and reports drift. This is +the procedure; the ground truth it checks against is [`registry/repos.json`](./registry/repos.json), the +[`spec/`](./spec/) manifests, [`repo-config/`](./repo-config/), and the prose authorities +([`AGENTS.md`](./AGENTS.md), [`CODESTYLE.md`](./CODESTYLE.md), [`WORKFLOW.md`](./WORKFLOW.md)). The audit +is read-only: it produces a report under [`reports/`](./reports/), never edits the target repo. + +The verdict vocabulary is [`WORKFLOW.md`](./WORKFLOW.md)'s: **operational / not operational**, **N/A**, +**defect**, and the applicable/absent rule. Do not invent a parallel scheme. + +## 1. Scope and Ground-Truth Branch + +Audit one repository at a time. Read the target's **`main` branch** as ground truth: `main` is the +released, authoritative state. Read `develop` only to detect divergence - a stale or diverged `develop` +(behind `main`, or diverged) is reported as a **drift finding**, never audited as the truth. Do not treat +a `develop`-only file as present if it is absent on `main`. + +## 2. Resolve the Repo's Type(s) + +Look up the repo in [`registry/repos.json`](./registry/repos.json) and read its `types[]`. If the entry +is `classificationPending` (a backlog repo), classify it from the tree and propose a registry update: + +- `*.csproj` / `*.slnx` -> `csharp`; a `dotnet nuget push` workflow -> `nuget`; a `System.CommandLine` + console -> `console`. +- `pyproject.toml` / `setup.py` -> `python`; a `pypa/gh-action-pypi-publish` workflow -> `pypi`. +- `Dockerfile` + a docker build/push workflow -> `docker`; an `upstream-version.json` tracker -> + `upstream-wrapper`. +- `custom_components/*/manifest.json` + `hacs.json` -> `homeassistant`; a codegen workflow -> `codegen`; + no `build-*` task -> `source-only`; governance-only -> `docs`. + +## 3. Applicability Gate + +Reuse [`WORKFLOW.md`](./WORKFLOW.md) section 1: a check that governs a construct the repo does not +contain is **N/A** - record it as N/A and **exclude it from the verdict**. N/A is never a defect. A +Docker check on a repo with no image, a NuGet check on a Python package, the artifact-lifecycle clauses +on a source-only repo - all N/A. + +## 4. Per-Dimension Checks (Letter and Intent) + +For each applicable type in [`spec/project-types.json`](./spec/project-types.json) and every cross-cutting +dimension, evaluate each check at its stated verdict tier: + +- **letter** - the exact file, section, config, or construct is present. +- **intent** - an equivalent outcome holds even if the form differs. + +A check with `intentRef`/`workflowRef` points at the prose section that owns the rationale; read it to +judge intent. The dimensions: + +- **csharp** - `.editorconfig` carries the shared `[*.cs]` rule block (letter); analyzer severities are + enforced, not relaxed (intent). +- **nuget** - publish uses OIDC Trusted Publishing, no `NUGET_API_KEY` (letter+intent); `--skip-duplicate`. +- **pypi** - OIDC publish job with `environment: pypi`, `id-token: write`, `skip-existing: true`; no + stored token. +- **python** - ruff and pyright present (intent), canonical in `pyproject.toml` (letter); standalone + `.ruff.toml` / `pyrightconfig.json` is a drift finding. +- **console** - smoke runtime matrix is a strict subset; per-runtime outputs aggregate to one + `release-asset-*`, gated `!smoke`. +- **docker** - registry layer cache (`buildcache-`, never `type=gha`); the size-limited Docker + Hub README is published via the docker-readme task; the image always re-pushes on publish. +- **branch-model** - `main` and `develop` both exist and are protected; the live rulesets match + [`repo-config/*.json`](./repo-config/) by normalized diff (below). +- **repo-setup** - every required secret for the repo's publish mechanisms is configured, and no + forbidden secret is present (per [`spec/secrets.json`](./spec/secrets.json)). +- **linter-parity** - one config per linter (`.markdownlint-cli2.jsonc`, `cspell.json`, ruff/pyright, + editorconfig/csharpier, actionlint) drives the editor extension, the CLI, and CI, and CI runs each. +- **recurring-violations** (high priority, always run) - comments concise and non-narrative; ASCII only + (no em-dash, no smart quotes); US spelling; line endings per `.editorconfig`. These are frequent + regressions; each is a grep-able check (see below). +- **readme-structure** - the README follows [`spec/readme-structure.md`](./spec/readme-structure.md) + (applicable sections, in order). + +## 5. Assert the Actions Implement WORKFLOW.md + +Run [`WORKFLOW.md`](./WORKFLOW.md)'s methodology against the repo's **own** Actions: the 5A static audit +(structural facts per applicable D-guarantee, each with a `file:line` citation) and the 5B trace +scenarios (predicted run/skip + version + release + artifact-end-state vs expected). The contract in +WORKFLOW.md section 4 is satisfied by **outcome**, not by matching the catalog snippets in +[`catalog/snippets/workflows/`](./catalog/snippets/workflows/) byte for byte - those are the reference +implementation, not required bytes. + +## 6. Validate Rulesets and Secrets + +- **Rulesets** - diff each live ruleset against the committed expected payload with a normalized + comparison (sort the order-insensitive `rules[]` and `bypass_actors[]` before diffing so a reordered + but equivalent ruleset does not read as drift): + + ```sh + norm='{name,target,enforcement,bypass_actors,conditions,rules} | .rules|=sort_by(.type) | .bypass_actors|=sort_by(.actor_id)' + for b in develop main; do + id=$(gh api "repos///rulesets" --jq ".[]|select(.name==\"$b\").id") + diff <(jq -S "$norm" "repo-config/$b.json") \ + <(gh api "repos///rulesets/$id" --jq '{name,target,enforcement,bypass_actors,conditions,rules}' | jq -S "$norm") \ + && echo "$b: in sync" || echo "$b: DRIFT" + done + ``` + +- **Secrets** - confirm each required secret exists (name only; values are not readable). Check the + Actions store and, where the mechanism needs it (Docker Hub, codegen App), the Dependabot store too. + +## 7. Verdict Model + +Per dimension, record `operational | not-operational | N/A`, each with a letter verdict and an intent +verdict: + +- letter miss but intent satisfied -> **drift finding** (equivalent outcome in a non-standard form; worth + fixing, not a break). +- letter and intent both miss -> **defect** (not operational). + +A repo is **operational** only if every applicable check passes. A single applicable defect makes it not +operational, regardless of how clean the rest looks. N/A items are excluded, never counted as failures. + +## 8. Report + +Write `reports//audit.md` from [`reports/_template.md`](./reports/_template.md): a dimension x +{letter, intent, verdict, evidence} table with `file:line` citations (WORKFLOW.md 5A style), a drift +section, and a list of proposed registry/spec updates (e.g. a resolved `classificationPending`). Rank +findings most severe first. + +## 9. Escalate + +Surface spec questions rather than resolving them silently - e.g. the Python config-placement +canonicalization, or a new construct no type covers. A repeated letter miss that many repos share is a +signal the spec (not each repo) needs adjusting; raise it. diff --git a/reports/_template.md b/reports/_template.md new file mode 100644 index 00000000..0399ec22 --- /dev/null +++ b/reports/_template.md @@ -0,0 +1,41 @@ +# Audit: + +- **Audited branch:** main (``) +- **Types:** +- **Verdict:** operational | not operational +- **Date:** + +## Develop Drift + +`develop` vs `main`: ahead , behind . + +## Dimensions + +| Dimension | Letter | Intent | Verdict | Evidence (file:line) | +| --- | --- | --- | --- | --- | +| csharp | | | | | +| nuget | | | | | +| pypi | | | | | +| python | | | | | +| console | | | | | +| docker | | | | | +| branch-model | | | | | +| repo-setup | | | | | +| linter-parity | | | | | +| recurring-violations | | | | | +| readme-structure | | | | | +| workflow (WORKFLOW.md 5A/5B) | | | | | + +Verdict values: pass | drift | defect | N/A. Remove rows that are N/A for the repo's types, or mark them N/A. + +## Defects (most severe first) + +1. - input/condition -> observed vs expected; `file:line`. + +## Drift Findings + +- - `file:line`. + +## Proposed Registry / Spec Updates + +- From 73441acf0b58e1997656d75c815d3e11a45022e8 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 4 Jul 2026 13:08:43 -0700 Subject: [PATCH 07/17] Add workflow flow diagrams; complete cspell dictionary Add four mermaid diagrams to WORKFLOW.md section 3: the branch model, the PR smoke-feedback path, the two-phase publish matrix, and the artifact seam handoff. Configure cspell to skip mermaid blocks and add the technical words the docs use so cspell passes repo-wide, matching the markdownlint parity. Co-Authored-By: Claude Opus 4.8 (1M context) --- WORKFLOW.md | 39 +++++++++++++++++++++++++++++++++++++++ cspell.json | 27 +++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/WORKFLOW.md b/WORKFLOW.md index 07e5f63a..4497d002 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -42,6 +42,15 @@ Prescriptive style/legibility rules. Cheap to check, necessary but not sufficien ## 3. Architecture +### Branch Model + +```mermaid +flowchart LR + feature[feature branch] -->|squash| develop + develop -->|merge commit| main + main -.->|no back-merge| develop +``` + ### Two Layers: Orchestration vs Build - **Orchestration** is generic and forms the standardization baseline **at the job level**: the publish-plan + branch matrix in the publisher, the `get-version`, `validate-release`, and `github-release` jobs, the date-badge job, and the `changes -> smoke-build -> aggregator` shape of the PR workflow. These job *bodies* should not need per-repo edits. @@ -52,6 +61,14 @@ Prescriptive style/legibility rules. Cheap to check, necessary but not sufficien A target contributes a file to the GitHub release by uploading a workflow artifact named `release-asset--`. The release job collects **every** matching artifact by **pattern** (`pattern: release-asset--*` + `merge-multiple: true`), never an `artifact-ids:` naming one job's output. Canonical for **every** repo, single-target included; switching to an `artifact-id` handoff forks the release download and breaks the verbatim carry. +```mermaid +flowchart LR + leafa[leaf: target A] -->|release-asset-branch-A| store[(run artifacts)] + leafb[leaf: target B] -->|release-asset-branch-B| store + store -->|pattern + merge-multiple| rel[github-release job] + reg[registry leaf: nuget / pypi / docker] -->|push, no asset| registries[(registries)] +``` + ### Reusable-Task Parameter Contract Every leaf and the release task take `ref`, `branch` (the **logical** branch that drives config/tags/prerelease), and where relevant `smoke`. Branch-derived config keys off `inputs.branch`, **never** `github.ref_name` (the publisher matrix builds the non-default branch from a run whose `github.ref_name` is the default branch). Artifact names are branch-suffixed so both legs coexist. @@ -72,10 +89,32 @@ Workflow artifacts are an **intra-run handoff** only; durable copies live on the PRs validate fast and never publish: a paths-filter smoke-builds only changed targets; a validation job always runs; smoke builds compile/lint/test but upload nothing and push nothing; one required aggregator gates the merge. See D1. +```mermaid +flowchart TD + pr[pull request] --> ch[changes paths-filter] + ch -->|target changed| sb[smoke-build changed targets] + ch -->|workflow-only or docs| skip[smoke-build skipped] + val[validation job] --> agg[Check pull request workflow status] + sb --> agg + skip --> agg + agg -->|success| ok[merge allowed] +``` + ### Release Model Two-phase by default: PRs smoke-test, merges do not publish. The publisher (weekly schedule + manual dispatch) builds and publishes **both** branches via a matrix; its `push` trigger publishes only when an opt-in repository variable is set. Every release is a tag on the built commit plus a source zip, README, and LICENSE; targets amend it with `release-asset-*` files or push to their own registry. An unchanged version re-pushes nothing (no-op republish); Docker re-pushes by design. +```mermaid +flowchart TD + trig[schedule / dispatch / opt-in push] --> plan[publish plan + branch matrix] + plan --> mmain[leg: main] + plan --> mdev[leg: develop] + mmain --> vmain[version X.Y.Z stable] + mdev --> vdev[version X.Y.Z-g-sha prerelease] + vmain --> relm[github-release + registries: latest] + vdev --> reld[github-release + registries: prerelease] +``` + ### Output Seam by Destination Pick each output's path by **where the artifact goes**: diff --git a/cspell.json b/cspell.json index 83a93a9f..727e896b 100644 --- a/cspell.json +++ b/cspell.json @@ -6,6 +6,9 @@ "reports/**", "**/*.min.*" ], + "ignoreRegExpList": [ + "/```mermaid[\\s\\S]*?```/g" + ], "words": [ "accessibilities", "Allman", @@ -13,11 +16,18 @@ "astral", "autoremove", "buildcache", + "buildmetadata", "buildtransitive", "Buildx", + "charliermarsh", + "chowned", + "chowns", "codegen", "commitish", + "conftest", "contentfiles", + "cref", + "crefs", "csdevkit", "datebadge", "davidanson", @@ -27,6 +37,8 @@ "dockerbuild", "Dockerfiles", "dockerhub", + "Docstrings", + "dogfoods", "dorny", "dotnettools", "downstreams", @@ -42,6 +54,7 @@ "hatchling", "heredocs", "homeassistant", + "isort", "Jellyfin", "Keychain", "kicad", @@ -50,29 +63,39 @@ "LINQ", "logfile", "mktemp", + "mypy", "nameof", "nbgv", "nektos", "Nerdbank", "noninteractive", + "noqa", + "nuget", "nugetlibrary", "onCreateCommand", "Optix", "othercommand", + "paramref", "Pieter", "pipefail", "postCreateCommand", + "prerelease", "ProjectTemplate", "purpleair", + "pypa", "pypi", "pypilibrary", "pyproject", "pyright", + "pyrightconfig", + "pytest", "quoteoftheday", "resharper", "rhysd", "Rubba", "ruff", + "sdist", + "semver", "Serilog", "settingsfile", "shellcheck", @@ -81,13 +104,17 @@ "snupkg", "softprops", "somecommand", + "sshconfig", "subsetting", "Triaging", "tzdata", + "unbuilt", "unvalidated", + "USERPROFILE", "venv", "Viljoen", "winget", + "Xsession", "xunit", "yzhang" ] From 80c02bac82a500d0116bef76857be5fdf1c9e47d Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 4 Jul 2026 13:13:29 -0700 Subject: [PATCH 08/17] Adapt CI to lint-only and a source-only release Rewrite test-pull-request.yml as a lint-only gate: markdownlint, cspell, actionlint, and jq/spec validation, all sharing the repo's own configs, behind the ruleset-bound Check pull request workflow status aggregator. Rewrite publish-release.yml as a self-contained source-only release (NBGV version then a tag + source archive + README + LICENSE per branch), and move the multi-target build-release-task and get-version-task orchestrators to the catalog as reference. Repoint the moved-orchestrator links in the docs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/publish-release.yml | 119 ++++++---------- .github/workflows/test-pull-request.yml | 127 +++--------------- AGENTS.md | 6 +- catalog/snippets/workflows/README.md | 2 + .../workflows/build-release-task.yml | 0 .../snippets}/workflows/get-version-task.yml | 0 6 files changed, 67 insertions(+), 187 deletions(-) rename {.github => catalog/snippets}/workflows/build-release-task.yml (100%) rename {.github => catalog/snippets}/workflows/get-version-task.yml (100%) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 272070d5..3cf036e4 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -64,106 +64,67 @@ jobs: echo "publish=$publish" >> "$GITHUB_OUTPUT" # Full build + publish of every target per planned branch. The matrix lets one run publish main (Release/latest) - # and develop (Debug/develop) - each leg checks out and versions its own branch via the threaded ref/branch. + # Publish each planned branch: NBGV computes the tag from the checked-out branch, then a GitHub release is created + # (tag + auto source archive + README + LICENSE). Source-only repo - no package or image targets. publish: name: Publish project release job needs: [setup] if: ${{ needs.setup.outputs.publish == 'true' }} - strategy: - fail-fast: false - matrix: - branch: ${{ fromJSON(needs.setup.outputs.branches) }} - uses: ./.github/workflows/build-release-task.yml - secrets: inherit permissions: contents: write - # actions:write lets the github-release job delete the release-asset-* artifacts it consumes (surgical cleanup). - actions: write - with: - ref: ${{ matrix.branch }} - branch: ${{ matrix.branch }} - smoke: false - # Push to GitHub and NuGet and Docker Hub. - github: true - nuget: true - dockerhub: true - - publish-pypi: - name: Publish PyPI library job - needs: [setup, publish] - if: ${{ needs.setup.outputs.publish == 'true' }} strategy: fail-fast: false matrix: branch: ${{ fromJSON(needs.setup.outputs.branches) }} runs-on: ubuntu-latest - # build-pypilibrary-task.yml picks the PEP 440 version per branch (M.N.P.B on main, M.N.P.B.dev0 on develop). The - # pypi environment's deployment branch rule restricts uploads to main + develop as defense in depth. - environment: - name: pypi - url: https://pypi.org/project/ptr727-projecttemplate-library/ - # id-token:write for Trusted Publishing's OIDC exchange, contents:read for repo metadata, actions:write so - # download-artifact can fetch this run's build artifact and the surgical cleanup step can delete it afterwards. - permissions: - id-token: write - contents: read - actions: write steps: - - name: Download PyPI library build artifacts step - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - name: Setup .NET SDK step + uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 with: - # Branch-suffixed name so both branch legs in this run stay distinct. - name: pypilibrary-build-${{ matrix.branch }} - path: ./dist + dotnet-version: 10.x - - name: Publish to PyPI step - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + # Full history so NBGV can compute the git height for the checked-out branch. + - name: Checkout code step + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - packages-dir: ./dist - # Skip rather than fail when the version already exists; the weekly republish re-uploads unchanged versions. - skip-existing: true + ref: ${{ matrix.branch }} + fetch-depth: 0 - # Surgical cleanup at the point of consumption: the pypilibrary-build- artifact has been published, so - # delete it by exact name to free the storage quota. A failure before this leaves it for the retention-days: 1 - # backstop. - - name: Delete consumed PyPI build artifact step - # Best-effort: PyPI is already published, so a listing/delete hiccup must never red the job; the - # retention-days: 1 backstop reaps anything missed. Deletes every matching id (a rerun can upload duplicates). - continue-on-error: true + # nbgv floats on @master (its tag stream lags master). IGNORE_GITHUB_REF versions the checked-out branch, not the + # CI ref, so a dispatch from main still classifies the develop leg as a prerelease. + - name: Compute version step + id: nbgv + uses: dotnet/nbgv@master + env: + IGNORE_GITHUB_REF: "true" + + # The weekly publisher re-runs with no new commits, so the tag may already exist. Skip create on an existing tag + # (no-op republish); a manual dispatch refreshes it. + - name: Check for existing release step + id: release-exists env: GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.nbgv.outputs.SemVer2 }} run: | set -euo pipefail - if ! ids=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/${{ github.run_id }}/artifacts" --paginate \ - --jq ".artifacts[] | select(.name == \"pypilibrary-build-${{ matrix.branch }}\") | .id"); then - echo "::warning::Could not list run artifacts; retention-days backstop will reap them." - ids="" + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" fi - for id in $ids; do - gh api --method DELETE "repos/$GITHUB_REPOSITORY/actions/artifacts/$id" \ - || echo "::warning::Failed to delete artifact $id; retention-days backstop will reap it." - done - # Caller-gated to main: the badge and Docker Hub overview have no per-branch context, so they update only when main - # is among the published branches (a develop-only push skips them). One invocation, not a per-branch matrix leg. - date-badge: - name: Create BYOB date badge job - needs: [setup, publish] - if: ${{ needs.setup.outputs.publish == 'true' && contains(fromJSON(needs.setup.outputs.branches), 'main') }} - uses: ./.github/workflows/build-datebadge-task.yml - secrets: inherit - permissions: - contents: write - - docker-readme: - name: Publish Docker Hub readme job - needs: [setup, publish] - if: ${{ needs.setup.outputs.publish == 'true' && contains(fromJSON(needs.setup.outputs.branches), 'main') }} - uses: ./.github/workflows/publish-docker-readme-task.yml - secrets: inherit - permissions: - contents: read - with: - ref: main + # target_commitish pins the tag to the exact built commit (GitCommitId), not the default branch. The release is + # the tag plus GitHub's auto source archive, README, and LICENSE - no build assets (source-only). + - name: Create GitHub release step + if: ${{ steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch' }} + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + with: + generate_release_notes: true + tag_name: ${{ steps.nbgv.outputs.SemVer2 }} + target_commitish: ${{ steps.nbgv.outputs.GitCommitId }} + prerelease: ${{ matrix.branch != 'main' }} + files: | + LICENSE + README.md diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index 7d0fcb74..28fa6927 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -11,131 +11,48 @@ concurrency: jobs: - # Detect which delivery targets a PR touches so we smoke-build only what changed. Workflow files are excluded - # (a path filter can't judge them); workflow_dispatch forces every target on. - changes: - name: Detect changed targets job + # Source-only repo: CI is lint-only. Markdown, spelling, workflow YAML, and the registry/spec JSON are validated with + # the same configs the editor extensions and CLI use (linter parity). There is no build or unit test. + lint: + name: Lint sources job runs-on: ubuntu-latest - # dorny/paths-filter reads changed files via the API (no checkout), so grant pull-requests: read. permissions: contents: read - pull-requests: read - outputs: - docker: ${{ github.event_name == 'pull_request' && steps.filter.outputs.docker || 'true' }} - nuget: ${{ github.event_name == 'pull_request' && steps.filter.outputs.nuget || 'true' }} - pypi: ${{ github.event_name == 'pull_request' && steps.filter.outputs.pypi || 'true' }} - executable: ${{ github.event_name == 'pull_request' && steps.filter.outputs.executable || 'true' }} - steps: - - name: Filter changed paths step - id: filter - if: ${{ github.event_name == 'pull_request' }} - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 - with: - filters: | - shared: &shared - - 'Directory.Build.props' - - 'Directory.Packages.props' - - 'version.json' - - '*.slnx' - docker: - - *shared - - 'Docker/**' - - 'Console/**' - - 'NuGetLibrary/**' - executable: - - *shared - - 'Console/**' - - 'NuGetLibrary/**' - nuget: - - *shared - - 'NuGetLibrary/**' - pypi: - - 'PyPiLibrary/**' - # version.json drives the NBGV-computed PyPI version; the other shared files are .NET-only. - - 'version.json' - - # Unit tests are cheap and validate the shared C# code, so they always run. - # This job is .NET-owned/per-target - a non-.NET repo replaces or removes it. - unit-test: - name: Run unit tests job - runs-on: ubuntu-latest steps: - - name: Setup .NET SDK step - uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 - with: - dotnet-version: 10.x - - name: Checkout code step uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Restore .NET local tools step - run: dotnet tool restore + - name: Lint Markdown step + run: docker run --rm -v "$PWD":/workdir davidanson/markdownlint-cli2:latest "**/*.md" - - name: Check formatting with CSharpier step - run: dotnet csharpier check . + - name: Spell check step + run: docker run --rm -v "$PWD":/workdir --workdir /workdir ghcr.io/streetsidesoftware/cspell:latest "**/*.md" - - name: Verify .NET style with dotnet format step - run: dotnet format style --verify-no-changes --severity=info --verbosity=detailed + - name: Lint workflows step + run: docker run --rm -v "$PWD":/repo --workdir /repo rhysd/actionlint:latest -color - - name: Run unit tests step - run: dotnet test - - # Build only the changed targets in smoke mode (Docker amd64-only, reduced matrix, - # no publishing), in the PR base-branch configuration. Skipped when no target changed. - smoke-build: - name: Smoke build changed targets job - # Gate on unit-test too: the smoke build runs a Docker build, so skip it when unit tests fail. - needs: [changes, unit-test] - if: >- - needs.changes.outputs.docker == 'true' || - needs.changes.outputs.nuget == 'true' || - needs.changes.outputs.pypi == 'true' || - needs.changes.outputs.executable == 'true' - uses: ./.github/workflows/build-release-task.yml - secrets: inherit - with: - smoke: true - # Do not publish anything from a PR. - github: false - nuget: false - dockerhub: false - # Check out the PR head by SHA: refs/pull/N/head is reachable in the base repo even for - # forks, unlike the head_ref branch name. workflow_dispatch falls back to the triggering ref. - ref: ${{ github.event.pull_request.head.sha || github.ref_name }} - branch: ${{ github.base_ref || github.ref_name }} - enable_docker: ${{ needs.changes.outputs.docker == 'true' }} - enable_nuget: ${{ needs.changes.outputs.nuget == 'true' }} - enable_pypi: ${{ needs.changes.outputs.pypi == 'true' }} - enable_executable: ${{ needs.changes.outputs.executable == 'true' }} + - name: Validate registry and spec step + run: | + set -euo pipefail + for f in registry/*.json spec/*.json repo-config/*.json; do + jq empty "$f" + done + python3 spec/validate.py - # TODO: GitHub Actions does not support required status checks on conditional jobs. - # https://github.com/orgs/community/discussions/12395#discussioncomment-12970019 - # This aggregator and its job name are verbatim orchestration: the name is the - # ruleset-bound required status-check context - do NOT rename it. + # GitHub Actions does not support required status checks on conditional jobs, so a single always-run aggregator gates + # the merge. Its name is the ruleset-bound required status-check context - do NOT rename it. check-workflow-status: name: Check pull request workflow status runs-on: ubuntu-latest - needs: - [ changes, unit-test, smoke-build ] + needs: [ lint ] if: always() steps: - name: Check workflow results step run: | set -euo pipefail - exit_on_result() { - if [[ "$2" == "failure" || "$2" == "cancelled" ]]; then - echo "Job '$1' failed or was cancelled." - exit 1 - fi - } - # The changes job MUST succeed - a paths-filter error must not let a - # target-changing PR merge with its smoke build silently skipped. - if [[ "${{ needs.changes.result }}" != "success" ]]; then - echo "Job 'changes' did not succeed (${{ needs.changes.result }}); refusing to pass." + if [[ "${{ needs.lint.result }}" != "success" ]]; then + echo "Job 'lint' did not succeed (${{ needs.lint.result }}); refusing to pass." exit 1 fi - # smoke-build may be legitimately skipped (no target changed); only failure/cancelled blocks. - exit_on_result "unit-test" "${{ needs.unit-test.result }}" - exit_on_result "smoke-build" "${{ needs.smoke-build.result }}" diff --git a/AGENTS.md b/AGENTS.md index 1896be2a..daae4213 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,7 @@ The template uses a **two-phase model by default**: PRs build fast, publishing i - **Required check.** The `changes` job is in the `Check pull request workflow status` aggregator's `needs` and **must succeed** (not just "not fail") - a paths-filter error must never let a target-changing PR merge with its smoke build silently skipped. Skipped smoke jobs (no matching change) pass; `failure`/`cancelled` blocks. - **Reusable-task parameter contract.** Every `build-*-task.yml` and `build-release-task.yml` takes `ref` (git ref to check out/version), `branch` (logical branch driving config/tags/prerelease - `main` => Release/`latest`/non-prerelease, else Debug/`develop`/prerelease), and where relevant `smoke`. **Branch-derived config keys off `inputs.branch`, never `github.ref_name`** - the publisher's matrix builds `develop` from a run whose `github.ref_name` is `main`, so `ref_name` would be wrong. Artifact names are branch-suffixed so both matrix legs coexist in one run. `get-version-task.yml` takes a `ref` so NBGV versions the right branch. - **Per-target subsetting.** `build-release-task.yml` has per-target `enable_*` gates and self-contained leaf tasks, so a project that drops a target deletes: its `build--task.yml`, the matching job + `github-release` `needs` entry in `build-release-task.yml`, its path-filter entry in `test-pull-request.yml`, and (for PyPI) the `publish-pypi` job in `publish-release.yml`. CodeGen, versioning, badge, merge-bot, and Dependabot are target-agnostic. -- **Orchestration vs. build - the override seam.** The pipeline splits into two layers. The **orchestration** layer is generic and is the standardization baseline: [`publish-release.yml`](./.github/workflows/publish-release.yml) (publish plan + branch matrix), the `get-version` + `github-release` jobs inside [`build-release-task.yml`](./.github/workflows/build-release-task.yml), [`get-version-task.yml`](./.github/workflows/get-version-task.yml), [`build-datebadge-task.yml`](./catalog/snippets/workflows/build-datebadge-task.yml), and the aggregator shape of [`test-pull-request.yml`](./.github/workflows/test-pull-request.yml). Within `test-pull-request.yml`, only the `changes -> smoke-build -> check-workflow-status` aggregator wiring and the ruleset-bound job name are verbatim orchestration; the `unit-test` job and the `dorny/paths-filter` entries are owned/per-target. The **build** layer - the `build--task.yml` leaf tasks - is what a derived project owns and replaces. The contract that keeps the seam clean: **a target contributes files to the GitHub release by uploading a workflow artifact named `release-asset--`.** The `github-release` job collects every `release-asset--*` artifact by pattern - its `download-artifact` step uses `pattern:`/`merge-multiple:`, **never an `artifact-ids:` that names a build job's output** (the producing build jobs still appear in `needs` for sequencing) - so it (the tag-the-commit + create-the-release + attach-the-assets logic) is reusable **as-is** across repos. **This name-pattern handoff is canonical for every repo, single-target included** - name your one asset `release-asset--` and the verbatim `github-release` globs it; do not switch a single-target repo to an `artifact-id` output plus `download-artifact` `artifact-ids:`, which looks tidier for 1:1 but forks the `github-release` download (`pattern:`/`merge-multiple:`) and breaks its verbatim carry. +- **Orchestration vs. build - the override seam.** The pipeline splits into two layers. The **orchestration** layer is generic and is the standardization baseline: [`publish-release.yml`](./.github/workflows/publish-release.yml) (publish plan + branch matrix), the `get-version` + `github-release` jobs inside [`build-release-task.yml`](./catalog/snippets/workflows/build-release-task.yml), [`get-version-task.yml`](./catalog/snippets/workflows/get-version-task.yml), [`build-datebadge-task.yml`](./catalog/snippets/workflows/build-datebadge-task.yml), and the aggregator shape of [`test-pull-request.yml`](./.github/workflows/test-pull-request.yml). Within `test-pull-request.yml`, only the `changes -> smoke-build -> check-workflow-status` aggregator wiring and the ruleset-bound job name are verbatim orchestration; the `unit-test` job and the `dorny/paths-filter` entries are owned/per-target. The **build** layer - the `build--task.yml` leaf tasks - is what a derived project owns and replaces. The contract that keeps the seam clean: **a target contributes files to the GitHub release by uploading a workflow artifact named `release-asset--`.** The `github-release` job collects every `release-asset--*` artifact by pattern - its `download-artifact` step uses `pattern:`/`merge-multiple:`, **never an `artifact-ids:` that names a build job's output** (the producing build jobs still appear in `needs` for sequencing) - so it (the tag-the-commit + create-the-release + attach-the-assets logic) is reusable **as-is** across repos. **This name-pattern handoff is canonical for every repo, single-target included** - name your one asset `release-asset--` and the verbatim `github-release` globs it; do not switch a single-target repo to an `artifact-id` output plus `download-artifact` `artifact-ids:`, which looks tidier for 1:1 but forks the `github-release` download (`pattern:`/`merge-multiple:`) and breaks its verbatim carry. - **What a repo still curates** (this is by design, not a leak): the *list* of leaf jobs in `build-release-task.yml`. Per **Per-target subsetting** above, you delete the target jobs you don't ship and add the one(s) you do - `build-release-task.yml`'s `github-release` job is untouched, but the file is not byte-identical because its `needs`/job list reflects your targets. Making that list itself target-agnostic is a larger "factor build from orchestration" refactor that is intentionally **not** done. - **Map your outputs to the right seam** - pick by where each artifact *goes*, not by language: - *Files attached to the GitHub Release* (zips, binaries, packaged libraries): one leaf task per output, each uploading `release-asset--`. A data-only repo (e.g. a symbol library) has exactly one such task: validate -> `zip` -> upload `release-asset--library`; it deletes the nuget/pypi/executable/docker jobs and the `publish-pypi` job, keeps `github-release` as-is. This is also where the .NET `build-executable-task` lives - it is *not* a generic file step, it is specifically `dotnet publish` of the console app; replace it wholesale, don't adapt it. @@ -186,7 +186,7 @@ Anti-pattern: don't keep flipping the code on the same style point. Flip the rul These conventions describe the target state. New and modified workflows must respect them; the rest of the repo is expected to be brought up to the same standard. Sweep PRs that apply a rule everywhere are welcome when a rule changes. -- **Action pinning**: pin **every** action - first-party (`actions/*`) and third-party - to a commit SHA with a trailing `# vX.Y.Z` comment, so Renovate / Dependabot can still bump it but a tag swap can't change the executed code. Use `# vX` (major-only) only when the upstream's floating major tag doesn't correspond to a specific patch/minor release SHA - pinning to the floating-tag SHA still gives the SHA guarantee, the version comment just records the major line. Documented exception (no SHA pin at all): [`dotnet/nbgv`](./.github/workflows/get-version-task.yml) is consumed via `@master` because the upstream tag stream lags `master` substantially and Dependabot's tag-tracking would propose a downgrade. **This applies to repo-owned build-layer leaves too** - a leaf owning its build specifics is not a reason to use floating tags; Dependabot still bumps SHA pins (updating the SHA + version comment). +- **Action pinning**: pin **every** action - first-party (`actions/*`) and third-party - to a commit SHA with a trailing `# vX.Y.Z` comment, so Renovate / Dependabot can still bump it but a tag swap can't change the executed code. Use `# vX` (major-only) only when the upstream's floating major tag doesn't correspond to a specific patch/minor release SHA - pinning to the floating-tag SHA still gives the SHA guarantee, the version comment just records the major line. Documented exception (no SHA pin at all): [`dotnet/nbgv`](./catalog/snippets/workflows/get-version-task.yml) is consumed via `@master` because the upstream tag stream lags `master` substantially and Dependabot's tag-tracking would propose a downgrade. **This applies to repo-owned build-layer leaves too** - a leaf owning its build specifics is not a reason to use floating tags; Dependabot still bumps SHA pins (updating the SHA + version comment). - **Filename**: reusable workflows (those with `on: workflow_call`) end in `-task.yml`. Entry-point workflows (`on: push` / `pull_request` / `schedule` / `workflow_dispatch`) do NOT use the `-task` suffix; they end with what they do - `-pull-request.yml`, `-release.yml`, etc. The suffix carries semantic meaning: a `-task.yml` file is meant to be `uses:`-d, never triggered directly. - **Workflow `name:`** (the top-level `name:` field): reusable workflow names end in **"task"** (e.g. `Build PyPI library task`); entry-point workflow names end in **"action"** (e.g. `Publish project release action`, `Test pull request action`). The displayed action name in the GitHub Actions UI tells you at a glance whether you're looking at an orchestrator or a callee. - **Job and step `name:` suffixes**: every job's `name:` ends in **"job"**; every step's `name:` ends in **"step"**. **Exception**: a job whose `name:` is also referenced as a required-status-check `context:` in a branch ruleset (currently `Check pull request workflow status` in `test-pull-request.yml`) keeps the ruleset-bound name verbatim - renaming would silently break required-status-check enforcement. Do not "fix" that name; if a future job becomes ruleset-bound, mark it the same way. @@ -194,7 +194,7 @@ These conventions describe the target state. New and modified workflows must res - **Shells**: multi-line `run:` blocks with bash start with `set -euo pipefail` - fail fast, fail on undefined vars, fail on a failed pipe segment. - **Conditionals**: multi-line `if:` uses folded scalar `if: >-` so YAML preserves whitespace correctly. Literal block (`if: |`) is wrong because it embeds newlines inside the boolean expression. - **Boolean inputs**: workflows triggered both via `workflow_call` and `workflow_dispatch` must declare each boolean input in *both* trigger blocks - one definition does not propagate to the other. `workflow_call` delivers booleans as actual booleans; `workflow_dispatch` delivers them as the *strings* `"true"`/`"false"`. Any `if:` consuming a boolean input must compare against both forms - `if: ${{ inputs.foo == true || inputs.foo == 'true' }}`. -- **Validate input/state consistency at entry, fail fast**: when a workflow's inputs must satisfy a cross-input or input-versus-derived-state invariant (e.g. the release branch must match the computed version's prerelease status, or two inputs are mutually exclusive), assert it **once** in a dedicated entry validation step/job that the downstream jobs `needs:`, before any expensive build or publish work - not as partial checks scattered deep in later jobs. One gate that fails fast with a clear `::error::` beats a late or one-directional check. Examples: [`build-release-task.yml`](./.github/workflows/build-release-task.yml)'s `validate-release` job (branch-versus-prerelease, both directions) and [`publish-docker-readme-task.yml`](./catalog/snippets/workflows/publish-docker-readme-task.yml)'s "Validate inputs step". +- **Validate input/state consistency at entry, fail fast**: when a workflow's inputs must satisfy a cross-input or input-versus-derived-state invariant (e.g. the release branch must match the computed version's prerelease status, or two inputs are mutually exclusive), assert it **once** in a dedicated entry validation step/job that the downstream jobs `needs:`, before any expensive build or publish work - not as partial checks scattered deep in later jobs. One gate that fails fast with a clear `::error::` beats a late or one-directional check. Examples: [`build-release-task.yml`](./catalog/snippets/workflows/build-release-task.yml)'s `validate-release` job (branch-versus-prerelease, both directions) and [`publish-docker-readme-task.yml`](./catalog/snippets/workflows/publish-docker-readme-task.yml)'s "Validate inputs step". - **Reusable workflows**: job-level `permissions:` are validated *before* the `if:` evaluates, so even a skipped job needs valid permissions declared. A `release` job with `permissions: contents: write` and `if: ${{ inputs.publish }}` will still cause `startup_failure` on a caller that doesn't grant `contents: write`. Either declare permissions at the call site, or omit the inner block and inherit. - **Allowlist `success` and `skipped` explicitly** when chaining jobs across optional dependencies - `!= 'failure'` lets `cancelled` through (timeout, runner failure, manual cancel). Use `(needs.X.result == 'success' || needs.X.result == 'skipped')`. - **Artifact retention**: workflow artifacts are an intra-run handoff only - durable copies live on the GitHub release, not in workflow artifacts - so they must not survive the run and accumulate against the small account-wide artifact-storage quota. **Clean up each transfer artifact surgically at its point of consumption**: the job that downloads it deletes it by exact name/pattern right after consuming it (the `github-release` job deletes `release-asset--*` after attaching them to the release; `publish-release.yml`'s `publish-pypi` deletes `pypilibrary-build-` after publishing). Deletion needs `actions: write` granted on that job - for a reusable callee (e.g. `github-release` inside `build-release-task.yml`) the **caller** grants it (`publish-release.yml`'s `publish` job does). **Never blanket-delete the run's artifacts** (`gh api .../artifacts --jq '.artifacts[].id'`) - that also destroys diagnostic/log artifacts and the build-records actions emit automatically (`docker/build-push-action`'s `.dockerbuild`), which are exactly what you need to debug a failed run. Set `retention-days: 1` on **every** explicit `upload-artifact`: it is the failure-path backstop - a job that dies before its consumer runs leaves its artifact, reaped within a day - so no separate terminal cleanup job is needed. A derived repo customizing these jobs must preserve the consume-then-delete shape. diff --git a/catalog/snippets/workflows/README.md b/catalog/snippets/workflows/README.md index 6e298f79..3ead0bfe 100644 --- a/catalog/snippets/workflows/README.md +++ b/catalog/snippets/workflows/README.md @@ -8,6 +8,8 @@ downstream repo's own Actions satisfy those guarantees, not that they match thes | File | Role | WORKFLOW.md guarantees | | --- | --- | --- | +| `build-release-task.yml` | Multi-target release orchestrator: get-version, validate-release, github-release plus per-target build jobs | D3, D4, D5, D6 | +| `get-version-task.yml` | NBGV version/tag computation (reusable) | D3 | | `build-executable-task.yml` | Console/executable per-runtime publish, aggregate to one release asset | D5, D6; §6 Console walkthrough | | `build-nugetlibrary-task.yml` | Build + `dotnet nuget push` (OIDC), upload release asset | D3.4, D4.4, D6; §6 NuGet walkthrough | | `build-pypilibrary-task.yml` | Build PyPI package; publish split to an OIDC job | D3.4, D4, D7.2; §6 PyPI walkthrough | diff --git a/.github/workflows/build-release-task.yml b/catalog/snippets/workflows/build-release-task.yml similarity index 100% rename from .github/workflows/build-release-task.yml rename to catalog/snippets/workflows/build-release-task.yml diff --git a/.github/workflows/get-version-task.yml b/catalog/snippets/workflows/get-version-task.yml similarity index 100% rename from .github/workflows/get-version-task.yml rename to catalog/snippets/workflows/get-version-task.yml From 71edae488293607330f06d0a078336ae63ce6124 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 4 Jul 2026 13:17:31 -0700 Subject: [PATCH 09/17] Verification cleanups: ASCII, links, EOL, residual framing Replace a non-ASCII section sign in the catalog workflows README, neutralize the last derived-repo phrasings in AGENTS.md and CODESTYLE.md, repoint the moved tasks.json and devcontainer links (CODESTYLE.md, docs/devcontainer.md), and normalize the repo-config rulesets to CRLF per .editorconfig. All docs pass markdownlint and cspell, workflows pass actionlint, spec/validate.py classifies the fleet cleanly, all relative links resolve, and the rulesets match the documented branch model (self-audit dogfood). Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 4 +- CODESTYLE.md | 8 +- catalog/snippets/workflows/README.md | 12 +-- docs/devcontainer.md | 10 +- repo-config/develop.json | 136 +++++++++++++-------------- repo-config/main.json | 130 ++++++++++++------------- 6 files changed, 150 insertions(+), 150 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index daae4213..833af73c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ Treat this file as authoritative for everything else; don't restate its rules el - **Bots (Dependabot and codegen) target both `main` and `develop` in parallel.** [`.github/dependabot.yml`](./.github/dependabot.yml) duplicates every ecosystem entry (one per branch) and [`catalog/snippets/workflows/run-codegen-pull-request-task.yml`](./catalog/snippets/workflows/run-codegen-pull-request-task.yml) runs as a matrix over both branches with branch names `codegen-main` and `codegen-develop`. Each branch absorbs its own bot PRs independently, so neither falls behind, and the forward-only rule still holds (nothing is back-merged from main to develop - both branches receive their updates directly). The merge-bot ([`.github/workflows/merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml)) dispatches `--squash` or `--merge` from each PR's base ref via a `case` statement so the form matches the ruleset on either base. Dependabot **security** PRs (CVE-driven) always open against the repo default branch (`main`) regardless of `target-branch` - the same `case` statement covers them. - **Maintainer-pushed commits on a bot PR auto-disable auto-merge.** The merge-bot's `merge-dependabot` and `merge-codegen` jobs only fire on `opened` / `reopened` events (auto-merge is enabled exactly once per PR). When a maintainer pushes commits to a bot's branch (a `synchronize` event with an actor that isn't the same bot), the merge-bot's `disable-auto-merge-on-maintainer-push` job fires and calls `gh pr merge --disable-auto`. The maintainer's commits stay in the PR but won't auto-merge with the bot's content; re-enable auto-merge manually (`gh pr merge --auto ` or the GitHub UI) when ready. - **Why parallel dual-target rather than develop-only with eventual flow-through:** push-distribution channels (HACS for Home Assistant integrations, Linux distros that vendor from `main`, etc.) consume `main` directly. A develop-only model would leave `main` running stale code during long-running develop features. Codegen content can also be production-critical (live API-derived data, language lists, build catalogs) rather than just sample/demo content, so both branches need fresh codegen on their own cadence. -- **Codegen regenerates committed files; its output must be deterministic from its inputs, never per-run state.** The codegen workflow is a mechanism to refresh files that are checked into the repo: it runs a matrix over `main` and `develop`, each leg regenerating against its own checkout and opening its own PR (`codegen-main -> main`, `codegen-develop -> develop`). For the two legs not to conflict on `develop -> main`, the generated output must depend only on its inputs - never on per-invocation state (timestamps, GUIDs, build IDs), which would diverge every run and conflict on every release. **What** a derived repo regenerates (data files, source, or both; code changes or pure data) and **how** (download and process an external source, transform local inputs, whatever) is entirely its own concern - the template constrains only that the output be input-deterministic, not how it is produced. +- **Codegen regenerates committed files; its output must be deterministic from its inputs, never per-run state.** The codegen workflow is a mechanism to refresh files that are checked into the repo: it runs a matrix over `main` and `develop`, each leg regenerating against its own checkout and opening its own PR (`codegen-main -> main`, `codegen-develop -> develop`). For the two legs not to conflict on `develop -> main`, the generated output must depend only on its inputs - never on per-invocation state (timestamps, GUIDs, build IDs), which would diverge every run and conflict on every release. **What** a repo regenerates (data files, source, or both; code changes or pure data) and **how** (download and process an external source, transform local inputs, whatever) is entirely its own concern - the template constrains only that the output be input-deterministic, not how it is produced. - *Reference:* the codegen workflow tasks are kept under [`catalog/snippets/workflows/`](./catalog/snippets/workflows/) (`run-codegen-pull-request-task.yml` and its scheduler). A repo adopting codegen supplies its own input-deterministic generator; this repo ships none. - **App-token workflows use Client ID, not App ID.** `actions/create-github-app-token` deprecated the numeric `app-id` input in v3.0.0; the template uses `client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}`. When adding new App-token call sites, use the same form - do not reintroduce `app-id` / `CODEGEN_APP_ID`. See [README "Template - GitHub Setup"](./repo-config/README.md) for the secret-setup procedure. @@ -197,7 +197,7 @@ These conventions describe the target state. New and modified workflows must res - **Validate input/state consistency at entry, fail fast**: when a workflow's inputs must satisfy a cross-input or input-versus-derived-state invariant (e.g. the release branch must match the computed version's prerelease status, or two inputs are mutually exclusive), assert it **once** in a dedicated entry validation step/job that the downstream jobs `needs:`, before any expensive build or publish work - not as partial checks scattered deep in later jobs. One gate that fails fast with a clear `::error::` beats a late or one-directional check. Examples: [`build-release-task.yml`](./catalog/snippets/workflows/build-release-task.yml)'s `validate-release` job (branch-versus-prerelease, both directions) and [`publish-docker-readme-task.yml`](./catalog/snippets/workflows/publish-docker-readme-task.yml)'s "Validate inputs step". - **Reusable workflows**: job-level `permissions:` are validated *before* the `if:` evaluates, so even a skipped job needs valid permissions declared. A `release` job with `permissions: contents: write` and `if: ${{ inputs.publish }}` will still cause `startup_failure` on a caller that doesn't grant `contents: write`. Either declare permissions at the call site, or omit the inner block and inherit. - **Allowlist `success` and `skipped` explicitly** when chaining jobs across optional dependencies - `!= 'failure'` lets `cancelled` through (timeout, runner failure, manual cancel). Use `(needs.X.result == 'success' || needs.X.result == 'skipped')`. -- **Artifact retention**: workflow artifacts are an intra-run handoff only - durable copies live on the GitHub release, not in workflow artifacts - so they must not survive the run and accumulate against the small account-wide artifact-storage quota. **Clean up each transfer artifact surgically at its point of consumption**: the job that downloads it deletes it by exact name/pattern right after consuming it (the `github-release` job deletes `release-asset--*` after attaching them to the release; `publish-release.yml`'s `publish-pypi` deletes `pypilibrary-build-` after publishing). Deletion needs `actions: write` granted on that job - for a reusable callee (e.g. `github-release` inside `build-release-task.yml`) the **caller** grants it (`publish-release.yml`'s `publish` job does). **Never blanket-delete the run's artifacts** (`gh api .../artifacts --jq '.artifacts[].id'`) - that also destroys diagnostic/log artifacts and the build-records actions emit automatically (`docker/build-push-action`'s `.dockerbuild`), which are exactly what you need to debug a failed run. Set `retention-days: 1` on **every** explicit `upload-artifact`: it is the failure-path backstop - a job that dies before its consumer runs leaves its artifact, reaped within a day - so no separate terminal cleanup job is needed. A derived repo customizing these jobs must preserve the consume-then-delete shape. +- **Artifact retention**: workflow artifacts are an intra-run handoff only - durable copies live on the GitHub release, not in workflow artifacts - so they must not survive the run and accumulate against the small account-wide artifact-storage quota. **Clean up each transfer artifact surgically at its point of consumption**: the job that downloads it deletes it by exact name/pattern right after consuming it (the `github-release` job deletes `release-asset--*` after attaching them to the release; `publish-release.yml`'s `publish-pypi` deletes `pypilibrary-build-` after publishing). Deletion needs `actions: write` granted on that job - for a reusable callee (e.g. `github-release` inside `build-release-task.yml`) the **caller** grants it (`publish-release.yml`'s `publish` job does). **Never blanket-delete the run's artifacts** (`gh api .../artifacts --jq '.artifacts[].id'`) - that also destroys diagnostic/log artifacts and the build-records actions emit automatically (`docker/build-push-action`'s `.dockerbuild`), which are exactly what you need to debug a failed run. Set `retention-days: 1` on **every** explicit `upload-artifact`: it is the failure-path backstop - a job that dies before its consumer runs leaves its artifact, reaped within a day - so no separate terminal cleanup job is needed. A repo customizing these jobs must preserve the consume-then-delete shape. - **Docker layer cache**: cache to/from a registry tag (`type=registry`, e.g. `buildcache-` on Docker Hub), not the GitHub Actions cache (`type=gha`), to keep large image layers off the 10 GB Actions cache. A **multi-image** repo uses a **per-image** buildcache tag (`:buildcache-` for each image, plus the base image's own tag and inline cache); it does not fall back to `type=gha` for the extra images. - **Tag pinning on releases**: when using `softprops/action-gh-release` (or any tag-creating action), pass `target_commitish` explicitly - without it, GitHub's REST API defaults the new tag to the repository's default branch instead of the commit that built the artifact. Pin it to the **exact built commit's SHA** (the publisher uses NBGV's `GitCommitId` output), not `github.sha` (wrong branch in the publisher's branch matrix - a `develop` leg runs with `github.sha` = main's tip) and not a branch name (a moving ref that a mid-run commit could advance past the built tree). diff --git a/CODESTYLE.md b/CODESTYLE.md index 7dcad425..32b4e458 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -1,6 +1,6 @@ # Code Style and Formatting Rules -This is the single code-style guide for the repo. The **General** section applies to every language. Each **language section** (.NET, Python) is self-contained: a repo reads only the section(s) for the languages it ships and ignores the rest. The whole file is carried, not trimmed - an unused-language section costs nothing and keeps re-sync a clean overwrite, the same carry-whole model as [`.editorconfig`](./.editorconfig), whose inert `[*.cs]` block a non-.NET repo keeps. +This is the single code-style guide for the fleet. The **General** section applies to every language. Each **language section** (.NET, Python) is self-contained: a repo follows only the section(s) for the languages it ships and ignores the rest. A repo keeps the whole file rather than trimming it - an unused-language section costs nothing, the same whole-file model as [`.editorconfig`](./.editorconfig), whose inert `[*.cs]` block a non-.NET repo keeps. Cross-cutting *process* rules (PR titles, branching, US English, markdown style, comments philosophy, workflow YAML, PR review etiquette) live in [AGENTS.md](./AGENTS.md) and are not repeated here. @@ -14,7 +14,7 @@ Use each tool's official casing in task labels, docs, and prose - `.NET` (not `. ### Clean-Compile Verification -Each language defines a **clean-compile** verification - the combination of build, formatter, linter, and code-analysis tools that must report clean before a commit. It is exposed as one or more **named** VS Code tasks (or, where a language ships no tasks, documented commands), and those definitions are **carried verbatim** across derived repos. The concrete names live in each language section below. +Each language defines a **clean-compile** verification - the combination of build, formatter, linter, and code-analysis tools that must report clean before a commit. It is exposed as one or more **named** VS Code tasks (or, where a language ships no tasks, documented commands), and those definitions are the same across the fleet. The concrete names live in each language section below. - **Run it after every code change.** The relevant language's clean-compile must pass before you commit; CI runs the same checks as a backstop. - **The named task definition is the canonical spec** - its exact command sequence, arguments, and strictness. You may run it through the VS Code task **or** by invoking the equivalent native commands directly; either is fine **only if the sequence, arguments, and strictness match exactly**. No shortcuts and no more-lenient options (for example, never drop `--verify-no-changes` or loosen a `--severity`). @@ -49,8 +49,8 @@ This is the style guide for any **.NET projects** in this repo. **CRITICAL**: All builds must complete without warnings. The project enforces this through: 1. **The `.NET Format` clean-compile task** (see [Clean-Compile Verification](#clean-compile-verification)) - - The .NET clean-compile is the **`.NET Format`** VS Code task, which chains `CSharpier Format` -> `.NET Build` -> `dotnet format style --verify-no-changes`. These three task definitions are carried verbatim in [`.vscode/tasks.json`](./.vscode/tasks.json). - - After any code change it must pass before commit. Run the `.NET Format` task. To run it natively instead, reproduce that task chain from [`.vscode/tasks.json`](./.vscode/tasks.json) exactly - `CSharpier Format`, then `.NET Build`, then the `dotnet format style --verify-no-changes --severity=info ...` verify - without dropping or loosening any argument (tasks.json is the canonical command spec). Bare `dotnet format` alone, skipping CSharpier or the build, is not sufficient. + - The .NET clean-compile is the **`.NET Format`** VS Code task, which chains `CSharpier Format` -> `.NET Build` -> `dotnet format style --verify-no-changes`. These three task definitions are carried verbatim in [`.vscode/tasks.json`](./catalog/snippets/configs/vscode-tasks.json). + - After any code change it must pass before commit. Run the `.NET Format` task. To run it natively instead, reproduce that task chain from [`.vscode/tasks.json`](./catalog/snippets/configs/vscode-tasks.json) exactly - `CSharpier Format`, then `.NET Build`, then the `dotnet format style --verify-no-changes --severity=info ...` verify - without dropping or loosening any argument (tasks.json is the canonical command spec). Bare `dotnet format` alone, skipping CSharpier or the build, is not sufficient. 2. **Analyzer configuration** - `true` with `latest-all` and `All` (full analyzer set enabled) diff --git a/catalog/snippets/workflows/README.md b/catalog/snippets/workflows/README.md index 3ead0bfe..0175cd9f 100644 --- a/catalog/snippets/workflows/README.md +++ b/catalog/snippets/workflows/README.md @@ -10,12 +10,12 @@ downstream repo's own Actions satisfy those guarantees, not that they match thes | --- | --- | --- | | `build-release-task.yml` | Multi-target release orchestrator: get-version, validate-release, github-release plus per-target build jobs | D3, D4, D5, D6 | | `get-version-task.yml` | NBGV version/tag computation (reusable) | D3 | -| `build-executable-task.yml` | Console/executable per-runtime publish, aggregate to one release asset | D5, D6; §6 Console walkthrough | -| `build-nugetlibrary-task.yml` | Build + `dotnet nuget push` (OIDC), upload release asset | D3.4, D4.4, D6; §6 NuGet walkthrough | -| `build-pypilibrary-task.yml` | Build PyPI package; publish split to an OIDC job | D3.4, D4, D7.2; §6 PyPI walkthrough | -| `build-docker-task.yml` | Multi-arch image build + push, registry layer cache | D4.4, D6, D9.4; §6 Docker walkthrough | -| `build-datebadge-task.yml` | BYOB date/last-build badge on the default branch | D4; §3 Release Model | -| `publish-docker-readme-task.yml` | Push the size-limited Docker Hub overview | D2.4; §6 Docker walkthrough | +| `build-executable-task.yml` | Console/executable per-runtime publish, aggregate to one release asset | D5, D6; section 6 Console walkthrough | +| `build-nugetlibrary-task.yml` | Build + `dotnet nuget push` (OIDC), upload release asset | D3.4, D4.4, D6; section 6 NuGet walkthrough | +| `build-pypilibrary-task.yml` | Build PyPI package; publish split to an OIDC job | D3.4, D4, D7.2; section 6 PyPI walkthrough | +| `build-docker-task.yml` | Multi-arch image build + push, registry layer cache | D4.4, D6, D9.4; section 6 Docker walkthrough | +| `build-datebadge-task.yml` | BYOB date/last-build badge on the default branch | D4; section 3 Release Model | +| `publish-docker-readme-task.yml` | Push the size-limited Docker Hub overview | D2.4; section 6 Docker walkthrough | | `check-upstream-version-task.yml` | Upstream-version tracker for wrapper repos | D3.5, D8.3 | | `run-codegen-pull-request-task.yml` | Deterministic codegen executor (per-branch PR) | D8.2 | | `run-periodic-codegen-pull-request.yml` | Scheduled codegen trigger over both branches | D8.2 | diff --git a/docs/devcontainer.md b/docs/devcontainer.md index e838f0e1..650e438c 100644 --- a/docs/devcontainer.md +++ b/docs/devcontainer.md @@ -4,8 +4,8 @@ The repo ships **two per-language [Dev Containers](https://containers.dev/)** so | Workspace | Devcontainer | Image | Toolchain | | --------- | ------------ | ----- | --------- | -| [`DotNet.code-workspace`](../DotNet.code-workspace) | [`.devcontainer/dotnet/devcontainer.json`](../.devcontainer/dotnet/devcontainer.json) | `mcr.microsoft.com/devcontainers/dotnet:1-10.0` | .NET 10 SDK | -| [`Python.code-workspace`](../Python.code-workspace) | [`.devcontainer/python/devcontainer.json`](../.devcontainer/python/devcontainer.json) | `mcr.microsoft.com/devcontainers/python:1-3.14-bookworm` | Python 3.14 + version-pinned `uv` | +| `DotNet.code-workspace` | [`catalog/snippets/devcontainer/dotnet/devcontainer.json`](../catalog/snippets/devcontainer/dotnet/devcontainer.json) | `mcr.microsoft.com/devcontainers/dotnet:1-10.0` | .NET 10 SDK | +| `Python.code-workspace` | [`catalog/snippets/devcontainer/python/devcontainer.json`](../catalog/snippets/devcontainer/python/devcontainer.json) | `mcr.microsoft.com/devcontainers/python:1-3.14-bookworm` | Python 3.14 + version-pinned `uv` | Open the workspace file matching the language you want, install the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers), and pick **Reopen in Container**. @@ -19,7 +19,7 @@ Prerequisite: complete [host setup](./host-setup.md) first - without git config, | Common utilities | `ghcr.io/devcontainers/features/common-utils:2` | bash, curl, wget, sudo, `vscode` user | | VS Code extensions | `customizations.vscode.extensions` in each `devcontainer.json` | Mirrors the matching workspace's `recommendations` so the container has the same tooling | -The .NET container additionally ships the `csharpier`/`dotnet-outdated` local tools (restored by `.devcontainer/dotnet/post-create.sh`). The Python container additionally ships `uv` (installed by `.devcontainer/python/post-create.sh` from a version-pinned URL) and pre-syncs the `PyPiLibrary` venv. +The .NET container additionally ships the `csharpier`/`dotnet-outdated` local tools (restored by `catalog/snippets/devcontainer/dotnet/post-create.sh`). The Python container additionally ships `uv` (installed by `catalog/snippets/devcontainer/python/post-create.sh` from a version-pinned URL) and pre-syncs the `PyPiLibrary` venv. Each devcontainer's extension list and the matching workspace's `recommendations` are kept identical - when you add an extension to one, add it to the other. @@ -43,8 +43,8 @@ Both `devcontainer.json` files run two scripts at well-defined points: - **`onCreateCommand`** - `sudo install -d -m 700 -o vscode -g vscode /home/vscode/.ssh`. On macOS hosts the bind-mount surfaces `/home/vscode/.ssh` as root-owned, which would block writes from inside the container (e.g. `gh` updating `known_hosts`). This chown fixes it. Idempotent on Linux and WSL2. - **`postCreateCommand`** - language-specific: - - .NET: `.devcontainer/dotnet/post-create.sh` - runs `dotnet tool restore` (csharpier, dotnet-outdated). - - Python: `.devcontainer/python/post-create.sh` - installs the pinned `uv` and pre-syncs `PyPiLibrary` if it exists. + - .NET: `catalog/snippets/devcontainer/dotnet/post-create.sh` - runs `dotnet tool restore` (csharpier, dotnet-outdated). + - Python: `catalog/snippets/devcontainer/python/post-create.sh` - installs the pinned `uv` and pre-syncs `PyPiLibrary` if it exists. Re-runs of either are idempotent. No git hooks are installed by default - see the README's **Optional: enable git hooks locally** section if you want pre-commit checks. diff --git a/repo-config/develop.json b/repo-config/develop.json index 59c94d49..4b772e84 100644 --- a/repo-config/develop.json +++ b/repo-config/develop.json @@ -1,68 +1,68 @@ -{ - "bypass_actors": [ - { - "actor_id": 5, - "actor_type": "RepositoryRole", - "bypass_mode": "always" - } - ], - "conditions": { - "ref_name": { - "exclude": [], - "include": [ - "refs/heads/develop" - ] - } - }, - "enforcement": "active", - "name": "develop", - "rules": [ - { - "type": "deletion" - }, - { - "type": "non_fast_forward" - }, - { - "type": "required_linear_history" - }, - { - "type": "required_signatures" - }, - { - "parameters": { - "allowed_merge_methods": [ - "squash" - ], - "dismiss_stale_reviews_on_push": true, - "require_code_owner_review": false, - "require_last_push_approval": false, - "required_approving_review_count": 0, - "required_review_thread_resolution": true, - "required_reviewers": [] - }, - "type": "pull_request" - }, - { - "parameters": { - "do_not_enforce_on_create": false, - "required_status_checks": [ - { - "context": "Check pull request workflow status", - "integration_id": 15368 - } - ], - "strict_required_status_checks_policy": false - }, - "type": "required_status_checks" - }, - { - "parameters": { - "review_draft_pull_requests": true, - "review_on_push": true - }, - "type": "copilot_code_review" - } - ], - "target": "branch" -} +{ + "bypass_actors": [ + { + "actor_id": 5, + "actor_type": "RepositoryRole", + "bypass_mode": "always" + } + ], + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/develop" + ] + } + }, + "enforcement": "active", + "name": "develop", + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_linear_history" + }, + { + "type": "required_signatures" + }, + { + "parameters": { + "allowed_merge_methods": [ + "squash" + ], + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_approving_review_count": 0, + "required_review_thread_resolution": true, + "required_reviewers": [] + }, + "type": "pull_request" + }, + { + "parameters": { + "do_not_enforce_on_create": false, + "required_status_checks": [ + { + "context": "Check pull request workflow status", + "integration_id": 15368 + } + ], + "strict_required_status_checks_policy": false + }, + "type": "required_status_checks" + }, + { + "parameters": { + "review_draft_pull_requests": true, + "review_on_push": true + }, + "type": "copilot_code_review" + } + ], + "target": "branch" +} diff --git a/repo-config/main.json b/repo-config/main.json index f95b66e4..cb9bfd15 100644 --- a/repo-config/main.json +++ b/repo-config/main.json @@ -1,65 +1,65 @@ -{ - "bypass_actors": [ - { - "actor_id": 5, - "actor_type": "RepositoryRole", - "bypass_mode": "always" - } - ], - "conditions": { - "ref_name": { - "exclude": [], - "include": [ - "refs/heads/main" - ] - } - }, - "enforcement": "active", - "name": "main", - "rules": [ - { - "type": "deletion" - }, - { - "type": "non_fast_forward" - }, - { - "type": "required_signatures" - }, - { - "parameters": { - "allowed_merge_methods": [ - "merge" - ], - "dismiss_stale_reviews_on_push": true, - "require_code_owner_review": false, - "require_last_push_approval": false, - "required_approving_review_count": 0, - "required_review_thread_resolution": true, - "required_reviewers": [] - }, - "type": "pull_request" - }, - { - "parameters": { - "do_not_enforce_on_create": false, - "required_status_checks": [ - { - "context": "Check pull request workflow status", - "integration_id": 15368 - } - ], - "strict_required_status_checks_policy": false - }, - "type": "required_status_checks" - }, - { - "parameters": { - "review_draft_pull_requests": true, - "review_on_push": true - }, - "type": "copilot_code_review" - } - ], - "target": "branch" -} +{ + "bypass_actors": [ + { + "actor_id": 5, + "actor_type": "RepositoryRole", + "bypass_mode": "always" + } + ], + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/main" + ] + } + }, + "enforcement": "active", + "name": "main", + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_signatures" + }, + { + "parameters": { + "allowed_merge_methods": [ + "merge" + ], + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_approving_review_count": 0, + "required_review_thread_resolution": true, + "required_reviewers": [] + }, + "type": "pull_request" + }, + { + "parameters": { + "do_not_enforce_on_create": false, + "required_status_checks": [ + { + "context": "Check pull request workflow status", + "integration_id": 15368 + } + ], + "strict_required_status_checks_policy": false + }, + "type": "required_status_checks" + }, + { + "parameters": { + "review_draft_pull_requests": true, + "review_on_push": true + }, + "type": "copilot_code_review" + } + ], + "target": "branch" +} From fa1dca0f688da8a8713365a20cb62004f2474630 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 4 Jul 2026 14:40:24 -0700 Subject: [PATCH 10/17] Adopt grouped reference-style links in human-facing docs Document the rule in AGENTS.md and spec/readme-structure.md: every human-facing markdown file (all except AGENTS.md and copilot-instructions.md, which optimize for agents) uses reference-style links only, defined at the bottom of the file grouped by type under HTML-comment headers and alphabetized within each group. Convert README, CODESTYLE, WORKFLOW, AUDIT, the docs/ guides, the catalog and repo-config READMEs, and the Docker Hub README exemplar; reflow the docs I had hard-wrapped back to one paragraph per line. Add a human-readable Rules index to the README (short single-sentence rules grouped by always/never and by project type) as the reference for what agents enforce and audit. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 3 +- AUDIT.md | 117 +++++++----------- CODESTYLE.md | 63 ++++++---- README.md | 115 ++++++++++++++--- WORKFLOW.md | 10 +- catalog/README.md | 12 +- catalog/snippets/configs/docker-hub-readme.md | 12 +- catalog/snippets/workflows/README.md | 6 +- docs/devcontainer.md | 30 +++-- docs/host-setup.md | 20 ++- docs/ssh-signing.md | 9 +- repo-config/README.md | 51 +++----- spec/readme-structure.md | 30 ++--- 13 files changed, 273 insertions(+), 205 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 833af73c..8e01d931 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,8 +82,7 @@ Clarify devcontainer setup steps in README ### Markdown -- Use reference-style links for any URL referenced more than once or appearing in lists; alphabetize the reference definitions block. -- Inline single-use relative links (e.g. `[CODESTYLE.md](./CODESTYLE.md)`) are fine. +- **Reference-style links in human-facing docs.** Every markdown file **except** the agent-instruction files (`AGENTS.md` and `.github/copilot-instructions.md`, which optimize for agents and keep inline links) uses reference-style links only: every URI - internal path, anchor, external URL, or shield image - is defined at the **bottom of the file**, split into groups by type under an HTML-comment header (e.g. ``, ``, ``, ``) with each group's definitions alphabetized by reference name. No inline `[text](uri)` targets in prose. - One logical paragraph per line; no hard-wrap line-length limit. For an intentional hard line break within a block - stacked badges, status, or license lines - end the line with a trailing backslash (`\`); this explicit form is preferred over trailing whitespace and is not treated as a paragraph split. - Headings follow the title-case-with-short-bind-words rule from the PR-title section. - **Write docs in the current state, not as a change from a prior one.** The reader has no memory of the previous behavior, so describe what *is*: "X does Y", never "X *now* does Y", "X *no longer* does Z", "changed/switched/restored to Y", or "X *still* does W". Before/after framing belongs in changelogs, commit messages, and PR descriptions - where the prior state is the point - not in `README.md` or other living docs. diff --git a/AUDIT.md b/AUDIT.md index 9d5071ad..e3500fe7 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -1,89 +1,55 @@ # AUDIT.md -How an agent audits a repository against the fleet ground truth in this repo and reports drift. This is -the procedure; the ground truth it checks against is [`registry/repos.json`](./registry/repos.json), the -[`spec/`](./spec/) manifests, [`repo-config/`](./repo-config/), and the prose authorities -([`AGENTS.md`](./AGENTS.md), [`CODESTYLE.md`](./CODESTYLE.md), [`WORKFLOW.md`](./WORKFLOW.md)). The audit -is read-only: it produces a report under [`reports/`](./reports/), never edits the target repo. +How an agent audits a repository against the fleet ground truth in this repo and reports drift. This is the procedure; the ground truth it checks against is [`registry/repos.json`][repos], the [`spec/`][spec] manifests, [`repo-config/`][repo-config], and the prose authorities ([`AGENTS.md`][agents], [`CODESTYLE.md`][codestyle], [`WORKFLOW.md`][workflow]). The audit is read-only: it produces a report under [`reports/`][reports], never edits the target repo. -The verdict vocabulary is [`WORKFLOW.md`](./WORKFLOW.md)'s: **operational / not operational**, **N/A**, +The verdict vocabulary is [`WORKFLOW.md`][workflow]'s: **operational / not operational**, **N/A**, **defect**, and the applicable/absent rule. Do not invent a parallel scheme. ## 1. Scope and Ground-Truth Branch -Audit one repository at a time. Read the target's **`main` branch** as ground truth: `main` is the -released, authoritative state. Read `develop` only to detect divergence - a stale or diverged `develop` -(behind `main`, or diverged) is reported as a **drift finding**, never audited as the truth. Do not treat -a `develop`-only file as present if it is absent on `main`. +Audit one repository at a time. Read the target's **`main` branch** as ground truth: `main` is the released, authoritative state. Read `develop` only to detect divergence - a stale or diverged `develop` (behind `main`, or diverged) is reported as a **drift finding**, never audited as the truth. Do not treat a `develop`-only file as present if it is absent on `main`. ## 2. Resolve the Repo's Type(s) -Look up the repo in [`registry/repos.json`](./registry/repos.json) and read its `types[]`. If the entry -is `classificationPending` (a backlog repo), classify it from the tree and propose a registry update: +Look up the repo in [`registry/repos.json`][repos] and read its `types[]`. If the entry is `classificationPending` (a backlog repo), classify it from the tree and propose a registry update: -- `*.csproj` / `*.slnx` -> `csharp`; a `dotnet nuget push` workflow -> `nuget`; a `System.CommandLine` - console -> `console`. +- `*.csproj` / `*.slnx` -> `csharp`; a `dotnet nuget push` workflow -> `nuget`; a `System.CommandLine` console -> `console`. - `pyproject.toml` / `setup.py` -> `python`; a `pypa/gh-action-pypi-publish` workflow -> `pypi`. -- `Dockerfile` + a docker build/push workflow -> `docker`; an `upstream-version.json` tracker -> - `upstream-wrapper`. -- `custom_components/*/manifest.json` + `hacs.json` -> `homeassistant`; a codegen workflow -> `codegen`; - no `build-*` task -> `source-only`; governance-only -> `docs`. +- `Dockerfile` + a docker build/push workflow -> `docker`; an `upstream-version.json` tracker -> `upstream-wrapper`. +- `custom_components/*/manifest.json` + `hacs.json` -> `homeassistant`; a codegen workflow -> `codegen`; no `build-*` task -> `source-only`; governance-only -> `docs`. ## 3. Applicability Gate -Reuse [`WORKFLOW.md`](./WORKFLOW.md) section 1: a check that governs a construct the repo does not -contain is **N/A** - record it as N/A and **exclude it from the verdict**. N/A is never a defect. A -Docker check on a repo with no image, a NuGet check on a Python package, the artifact-lifecycle clauses -on a source-only repo - all N/A. +Reuse [`WORKFLOW.md`][workflow] section 1: a check that governs a construct the repo does not contain is **N/A** - record it as N/A and **exclude it from the verdict**. N/A is never a defect. A Docker check on a repo with no image, a NuGet check on a Python package, the artifact-lifecycle clauses on a source-only repo - all N/A. ## 4. Per-Dimension Checks (Letter and Intent) -For each applicable type in [`spec/project-types.json`](./spec/project-types.json) and every cross-cutting -dimension, evaluate each check at its stated verdict tier: +For each applicable type in [`spec/project-types.json`][project-types] and every cross-cutting dimension, evaluate each check at its stated verdict tier: - **letter** - the exact file, section, config, or construct is present. - **intent** - an equivalent outcome holds even if the form differs. -A check with `intentRef`/`workflowRef` points at the prose section that owns the rationale; read it to -judge intent. The dimensions: +A check with `intentRef`/`workflowRef` points at the prose section that owns the rationale; read it to judge intent. The dimensions: -- **csharp** - `.editorconfig` carries the shared `[*.cs]` rule block (letter); analyzer severities are - enforced, not relaxed (intent). +- **csharp** - `.editorconfig` carries the shared `[*.cs]` rule block (letter); analyzer severities are enforced, not relaxed (intent). - **nuget** - publish uses OIDC Trusted Publishing, no `NUGET_API_KEY` (letter+intent); `--skip-duplicate`. -- **pypi** - OIDC publish job with `environment: pypi`, `id-token: write`, `skip-existing: true`; no - stored token. -- **python** - ruff and pyright present (intent), canonical in `pyproject.toml` (letter); standalone - `.ruff.toml` / `pyrightconfig.json` is a drift finding. -- **console** - smoke runtime matrix is a strict subset; per-runtime outputs aggregate to one - `release-asset-*`, gated `!smoke`. -- **docker** - registry layer cache (`buildcache-`, never `type=gha`); the size-limited Docker - Hub README is published via the docker-readme task; the image always re-pushes on publish. -- **branch-model** - `main` and `develop` both exist and are protected; the live rulesets match - [`repo-config/*.json`](./repo-config/) by normalized diff (below). -- **repo-setup** - every required secret for the repo's publish mechanisms is configured, and no - forbidden secret is present (per [`spec/secrets.json`](./spec/secrets.json)). -- **linter-parity** - one config per linter (`.markdownlint-cli2.jsonc`, `cspell.json`, ruff/pyright, - editorconfig/csharpier, actionlint) drives the editor extension, the CLI, and CI, and CI runs each. -- **recurring-violations** (high priority, always run) - comments concise and non-narrative; ASCII only - (no em-dash, no smart quotes); US spelling; line endings per `.editorconfig`. These are frequent - regressions; each is a grep-able check (see below). -- **readme-structure** - the README follows [`spec/readme-structure.md`](./spec/readme-structure.md) - (applicable sections, in order). +- **pypi** - OIDC publish job with `environment: pypi`, `id-token: write`, `skip-existing: true`; no stored token. +- **python** - ruff and pyright present (intent), canonical in `pyproject.toml` (letter); standalone `.ruff.toml` / `pyrightconfig.json` is a drift finding. +- **console** - smoke runtime matrix is a strict subset; per-runtime outputs aggregate to one `release-asset-*`, gated `!smoke`. +- **docker** - registry layer cache (`buildcache-`, never `type=gha`); the size-limited Docker Hub README is published via the docker-readme task; the image always re-pushes on publish. +- **branch-model** - `main` and `develop` both exist and are protected; the live rulesets match [`repo-config/*.json`][repo-config] by normalized diff (below). +- **repo-setup** - every required secret for the repo's publish mechanisms is configured, and no forbidden secret is present (per [`spec/secrets.json`][secrets]). +- **linter-parity** - one config per linter (`.markdownlint-cli2.jsonc`, `cspell.json`, ruff/pyright, editorconfig/csharpier, actionlint) drives the editor extension, the CLI, and CI, and CI runs each. +- **recurring-violations** (high priority, always run) - comments concise and non-narrative; ASCII only (no em-dash, no smart quotes); US spelling; line endings per `.editorconfig`. These are frequent regressions; each is a grep-able check (see below). +- **readme-structure** - the README follows [`spec/readme-structure.md`][readme-structure] (applicable sections, in order). ## 5. Assert the Actions Implement WORKFLOW.md -Run [`WORKFLOW.md`](./WORKFLOW.md)'s methodology against the repo's **own** Actions: the 5A static audit -(structural facts per applicable D-guarantee, each with a `file:line` citation) and the 5B trace -scenarios (predicted run/skip + version + release + artifact-end-state vs expected). The contract in -WORKFLOW.md section 4 is satisfied by **outcome**, not by matching the catalog snippets in -[`catalog/snippets/workflows/`](./catalog/snippets/workflows/) byte for byte - those are the reference -implementation, not required bytes. +Run [`WORKFLOW.md`][workflow]'s methodology against the repo's **own** Actions: the 5A static audit (structural facts per applicable D-guarantee, each with a `file:line` citation) and the 5B trace scenarios (predicted run/skip + version + release + artifact-end-state vs expected). The contract in WORKFLOW.md section 4 is satisfied by **outcome**, not by matching the catalog snippets in [`catalog/snippets/workflows/`][workflows] byte for byte - those are the reference implementation, not required bytes. ## 6. Validate Rulesets and Secrets -- **Rulesets** - diff each live ruleset against the committed expected payload with a normalized - comparison (sort the order-insensitive `rules[]` and `bypass_actors[]` before diffing so a reordered - but equivalent ruleset does not read as drift): +- **Rulesets** - diff each live ruleset against the committed expected payload with a normalized comparison (sort the order-insensitive `rules[]` and `bypass_actors[]` before diffing so a reordered but equivalent ruleset does not read as drift): ```sh norm='{name,target,enforcement,bypass_actors,conditions,rules} | .rules|=sort_by(.type) | .bypass_actors|=sort_by(.actor_id)' @@ -95,30 +61,39 @@ implementation, not required bytes. done ``` -- **Secrets** - confirm each required secret exists (name only; values are not readable). Check the - Actions store and, where the mechanism needs it (Docker Hub, codegen App), the Dependabot store too. +- **Secrets** - confirm each required secret exists (name only; values are not readable). Check the Actions store and, where the mechanism needs it (Docker Hub, codegen App), the Dependabot store too. ## 7. Verdict Model -Per dimension, record `operational | not-operational | N/A`, each with a letter verdict and an intent -verdict: +Per dimension, record `operational | not-operational | N/A`, each with a letter verdict and an intent verdict: -- letter miss but intent satisfied -> **drift finding** (equivalent outcome in a non-standard form; worth - fixing, not a break). +- letter miss but intent satisfied -> **drift finding** (equivalent outcome in a non-standard form; worth fixing, not a break). - letter and intent both miss -> **defect** (not operational). -A repo is **operational** only if every applicable check passes. A single applicable defect makes it not -operational, regardless of how clean the rest looks. N/A items are excluded, never counted as failures. +A repo is **operational** only if every applicable check passes. A single applicable defect makes it not operational, regardless of how clean the rest looks. N/A items are excluded, never counted as failures. ## 8. Report -Write `reports//audit.md` from [`reports/_template.md`](./reports/_template.md): a dimension x -{letter, intent, verdict, evidence} table with `file:line` citations (WORKFLOW.md 5A style), a drift -section, and a list of proposed registry/spec updates (e.g. a resolved `classificationPending`). Rank -findings most severe first. +Write `reports//audit.md` from [`reports/_template.md`][template]: a dimension x {letter, intent, verdict, evidence} table with `file:line` citations (WORKFLOW.md 5A style), a drift section, and a list of proposed registry/spec updates (e.g. a resolved `classificationPending`). Rank findings most severe first. ## 9. Escalate -Surface spec questions rather than resolving them silently - e.g. the Python config-placement -canonicalization, or a new construct no type covers. A repeated letter miss that many repos share is a -signal the spec (not each repo) needs adjusting; raise it. +Surface spec questions rather than resolving them silently - e.g. the Python config-placement canonicalization, or a new construct no type covers. A repeated letter miss that many repos share is a signal the spec (not each repo) needs adjusting; raise it. + + + +[workflows]: ./catalog/snippets/workflows/ + + + +[agents]: ./AGENTS.md +[codestyle]: ./CODESTYLE.md +[project-types]: ./spec/project-types.json +[readme-structure]: ./spec/readme-structure.md +[repo-config]: ./repo-config/ +[reports]: ./reports/ +[repos]: ./registry/repos.json +[secrets]: ./spec/secrets.json +[spec]: ./spec/ +[template]: ./reports/_template.md +[workflow]: ./WORKFLOW.md diff --git a/CODESTYLE.md b/CODESTYLE.md index 32b4e458..6574d83c 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -1,8 +1,8 @@ # Code Style and Formatting Rules -This is the single code-style guide for the fleet. The **General** section applies to every language. Each **language section** (.NET, Python) is self-contained: a repo follows only the section(s) for the languages it ships and ignores the rest. A repo keeps the whole file rather than trimming it - an unused-language section costs nothing, the same whole-file model as [`.editorconfig`](./.editorconfig), whose inert `[*.cs]` block a non-.NET repo keeps. +This is the single code-style guide for the fleet. The **General** section applies to every language. Each **language section** (.NET, Python) is self-contained: a repo follows only the section(s) for the languages it ships and ignores the rest. A repo keeps the whole file rather than trimming it - an unused-language section costs nothing, the same whole-file model as [`.editorconfig`][root], whose inert `[*.cs]` block a non-.NET repo keeps. -Cross-cutting *process* rules (PR titles, branching, US English, markdown style, comments philosophy, workflow YAML, PR review etiquette) live in [AGENTS.md](./AGENTS.md) and are not repeated here. +Cross-cutting *process* rules (PR titles, branching, US English, markdown style, comments philosophy, workflow YAML, PR review etiquette) live in [AGENTS.md][agents] and are not repeated here. ## General @@ -22,7 +22,7 @@ Each language defines a **clean-compile** verification - the combination of buil ### Analyzer Diagnostics and Suppressions -- **A new port is not a license to silence diagnostics.** Brownfield / just-ported status never justifies relaxing analyzer or linter severities or muting newly surfaced warnings - fix them. (The only brownfield allowance in this template is the one-time git-signing / line-ending migration described in [AGENTS.md](./AGENTS.md) and [README.md](./README.md), which has nothing to do with code analysis.) +- **A new port is not a license to silence diagnostics.** Brownfield / just-ported status never justifies relaxing analyzer or linter severities or muting newly surfaced warnings - fix them. (The only brownfield allowance in this template is the one-time git-signing / line-ending migration described in [AGENTS.md][agents] and [README.md][readme], which has nothing to do with code analysis.) - **Suppress only genuine false-positives or deliberate, documented exceptions**, always at the **narrowest scope that fits**, in this order of preference: 1. An **in-code annotation on the specific symbol**, with a justification - the language's attribute/comment form, never a blanket pragma spanning a region. 2. The **owning project's local config** when the exception is project-wide for one project (e.g. a test project's own `.editorconfig` / `pyproject.toml`). @@ -33,8 +33,8 @@ Each language defines a **clean-compile** verification - the combination of buil These apply repo-wide, in every directory: -1. **Markdown linting**: All `.md` files must be lint-clean (error and warning free) via the VS Code `markdownlint` extension. [`.markdownlint-cli2.jsonc`](./.markdownlint-cli2.jsonc) at the repo root is the single source of truth - the davidanson `markdownlint` extension and a command-line `markdownlint-cli2` run both read it, so the IDE and CLI stay in lock-step. Rules it deliberately disables (e.g. `MD013` line-length, `MD033` inline HTML) are **intentional** - do not "fix" them. Fix violations at the source rather than disabling rules. -2. **Spelling**: All spelling must be clean via the CSpell VS Code integration; words must be correctly spelled in **US English** (the repo-wide convention - see [AGENTS.md](./AGENTS.md)). Project-specific terms go in the workspace CSpell config. +1. **Markdown linting**: All `.md` files must be lint-clean (error and warning free) via the VS Code `markdownlint` extension. [`.markdownlint-cli2.jsonc`][markdownlint-cli2] at the repo root is the single source of truth - the davidanson `markdownlint` extension and a command-line `markdownlint-cli2` run both read it, so the IDE and CLI stay in lock-step. Rules it deliberately disables (e.g. `MD013` line-length, `MD033` inline HTML) are **intentional** - do not "fix" them. Fix violations at the source rather than disabling rules. +2. **Spelling**: All spelling must be clean via the CSpell VS Code integration; words must be correctly spelled in **US English** (the repo-wide convention - see [AGENTS.md][agents]). Project-specific terms go in the workspace CSpell config. ## .NET @@ -48,13 +48,13 @@ This is the style guide for any **.NET projects** in this repo. **CRITICAL**: All builds must complete without warnings. The project enforces this through: -1. **The `.NET Format` clean-compile task** (see [Clean-Compile Verification](#clean-compile-verification)) - - The .NET clean-compile is the **`.NET Format`** VS Code task, which chains `CSharpier Format` -> `.NET Build` -> `dotnet format style --verify-no-changes`. These three task definitions are carried verbatim in [`.vscode/tasks.json`](./catalog/snippets/configs/vscode-tasks.json). - - After any code change it must pass before commit. Run the `.NET Format` task. To run it natively instead, reproduce that task chain from [`.vscode/tasks.json`](./catalog/snippets/configs/vscode-tasks.json) exactly - `CSharpier Format`, then `.NET Build`, then the `dotnet format style --verify-no-changes --severity=info ...` verify - without dropping or loosening any argument (tasks.json is the canonical command spec). Bare `dotnet format` alone, skipping CSharpier or the build, is not sufficient. +1. **The `.NET Format` clean-compile task** (see [Clean-Compile Verification][clean-compile-verification]) + - The .NET clean-compile is the **`.NET Format`** VS Code task, which chains `CSharpier Format` -> `.NET Build` -> `dotnet format style --verify-no-changes`. These three task definitions are carried verbatim in [`.vscode/tasks.json`][vscode-tasks]. + - After any code change it must pass before commit. Run the `.NET Format` task. To run it natively instead, reproduce that task chain from [`.vscode/tasks.json`][vscode-tasks] exactly - `CSharpier Format`, then `.NET Build`, then the `dotnet format style --verify-no-changes --severity=info ...` verify - without dropping or loosening any argument (tasks.json is the canonical command spec). Bare `dotnet format` alone, skipping CSharpier or the build, is not sufficient. 2. **Analyzer configuration** - `true` with `latest-all` and `All` (full analyzer set enabled) - - `true` - any diagnostic surfaced as a warning fails the build, so it must be fixed or deliberately suppressed, not left to accumulate (see [Analyzer Diagnostics and Suppressions](#analyzer-diagnostics-and-suppressions)) + - `true` - any diagnostic surfaced as a warning fails the build, so it must be fixed or deliberately suppressed, not left to accumulate (see [Analyzer Diagnostics and Suppressions][analyzer-diagnostics-and-suppressions]) 3. **CI lint backstop** - CI runs the clean-compile checks on every PR as the authoritative backstop @@ -227,7 +227,7 @@ Note: Code snippets are illustrative examples only. Replace namespaces/types to #### Analyzer Suppressions (.NET) -Follow the scope hierarchy in [Analyzer Diagnostics and Suppressions](#analyzer-diagnostics-and-suppressions). .NET mechanics, narrowest first: +Follow the scope hierarchy in [Analyzer Diagnostics and Suppressions][analyzer-diagnostics-and-suppressions]. .NET mechanics, narrowest first: - **Never use `#pragma warning disable`** to silence an analyzer. - **Symbol-scoped**: a `[System.Diagnostics.CodeAnalysis.SuppressMessage(...)]` attribute with a `Justification`, on the specific member or type: @@ -351,11 +351,11 @@ This is the style guide for any **Python project(s)** in this repo. | Tool | Role | Config | |---|---|---| -| [uv](https://docs.astral.sh/uv/) | env, deps, build, publish | `pyproject.toml` `[dependency-groups]`, `uv.lock` | -| [hatchling](https://hatch.pypa.io/latest/) | build backend | `pyproject.toml` `[build-system]` | -| [ruff](https://docs.astral.sh/ruff/) | lint + format + import sort | `pyproject.toml` `[tool.ruff]` | -| [pyright](https://microsoft.github.io/pyright/) | type checker | `pyproject.toml` `[tool.pyright]` | -| [pytest](https://docs.pytest.org/) | test runner | `pyproject.toml` `[tool.pytest.ini_options]` | +| [uv][uv-link] | env, deps, build, publish | `pyproject.toml` `[dependency-groups]`, `uv.lock` | +| [hatchling][latest-link] | build backend | `pyproject.toml` `[build-system]` | +| [ruff][ruff-link] | lint + format + import sort | `pyproject.toml` `[tool.ruff]` | +| [pyright][pyright-link] | type checker | `pyproject.toml` `[tool.pyright]` | +| [pytest][docs-link] | test runner | `pyproject.toml` `[tool.pytest.ini_options]` | `pyright` is consumed in two places: as a dev dependency (`uv run pyright` for CI/scripted runs) and via VS Code's **Pylance** extension (which embeds pyright). The standalone `ms-pyright.pyright` extension is in `unwantedRecommendations` because Pylance covers it. `mypy` is **not used** here - don't introduce it. @@ -374,7 +374,7 @@ uv run pytest # run tests uv build # produce wheel + sdist in ./dist ``` -The Python clean-compile (see [Clean-Compile Verification](#clean-compile-verification)) is `uv run ruff format` + `uv run ruff check` + `uv run pyright`; run it (plus `uv run pytest`) before committing. These are documented commands, not VS Code tasks. CI runs the same clean-compile commands as the authoritative backstop. Git hooks are opt-in; wire `pre-commit` for `ruff` and `pyright` yourself if you want local enforcement. +The Python clean-compile (see [Clean-Compile Verification][clean-compile-verification]) is `uv run ruff format` + `uv run ruff check` + `uv run pyright`; run it (plus `uv run pytest`) before committing. These are documented commands, not VS Code tasks. CI runs the same clean-compile commands as the authoritative backstop. Git hooks are opt-in; wire `pre-commit` for `ruff` and `pyright` yourself if you want local enforcement. ### Layout @@ -401,7 +401,7 @@ The Python clean-compile (see [Clean-Compile Verification](#clean-compile-verifi - **`ruff format` is authoritative.** Don't argue with the formatter; if it reformats your code, that's the final form. Configure (line length, target version) in `pyproject.toml` `[tool.ruff]`, not via inline `# fmt:` directives. - **Run `ruff check --fix` before committing.** Most ruff lint rules have safe autofixes; let the tool handle them. The configured rule families are listed under `[tool.ruff.lint]` `select`. Add new rule families project-wide rather than scattering inline `# noqa` markers. -- **`# noqa` is a last resort.** When you must use one, scope it narrowly (`# noqa: E501`, not bare `# noqa`) and add a short comment on the same line explaining why. False-positive patterns that recur across the codebase belong in `[tool.ruff.lint]` `ignore` or per-file `[tool.ruff.lint.per-file-ignores]`, with a comment. Porting an existing codebase is not a license to add `ignore` / `per-file-ignores` blocks to mute newly surfaced lint - fix it (see [Analyzer Diagnostics and Suppressions](#analyzer-diagnostics-and-suppressions)). +- **`# noqa` is a last resort.** When you must use one, scope it narrowly (`# noqa: E501`, not bare `# noqa`) and add a short comment on the same line explaining why. False-positive patterns that recur across the codebase belong in `[tool.ruff.lint]` `ignore` or per-file `[tool.ruff.lint.per-file-ignores]`, with a comment. Porting an existing codebase is not a license to add `ignore` / `per-file-ignores` blocks to mute newly surfaced lint - fix it (see [Analyzer Diagnostics and Suppressions][analyzer-diagnostics-and-suppressions]). #### Comments @@ -410,16 +410,16 @@ The Python clean-compile (see [Clean-Compile Verification](#clean-compile-verifi #### Docstrings -- Follow [PEP 257](https://peps.python.org/pep-0257/). Focus docstrings primarily on the **behavior contract** (what callers and tests can rely on), public semantics, and edge-case expectations. Implementation-local rationale belongs in inline `#` comments, not docstrings. +- Follow [PEP 257][pep-0257-link]. Focus docstrings primarily on the **behavior contract** (what callers and tests can rely on), public semantics, and edge-case expectations. Implementation-local rationale belongs in inline `#` comments, not docstrings. - A short one-liner is fine for trivial functions and tests with self-documenting names. - For non-trivial behavior - non-obvious test scenarios, contracts a test pins, edge cases callers must know about, design trade-offs that are load-bearing for future maintainers - write a one-line summary, blank line, then a details paragraph. Multi-paragraph docstrings are fine when the contract earns it. -- Design notes belong **in the code** (docstrings or inline comments). They do NOT belong in [`HISTORY.md`](./HISTORY.md) - that file is end-user release notes, not a design log. +- Design notes belong **in the code** (docstrings or inline comments). They do NOT belong in [`HISTORY.md`][history] - that file is end-user release notes, not a design log. #### Type Hints - **All public APIs are typed.** Pyright runs on `src/` in strict mode (`[tool.pyright]` `strict = ["src"]`); tests run in standard mode. - **Use modern syntax**: `list[int]` not `List[int]`, `dict[str, X]` not `Dict[str, X]`, `X | None` not `Optional[X]`, `from __future__ import annotations` only when needed for forward references. -- **Don't add `# type: ignore` to silence pyright errors without a comment** explaining the constraint. If a recurring false positive needs suppression, configure it project-wide in `[tool.pyright]`. A new port doesn't change this - fix freshly surfaced type errors rather than muting them (see [Analyzer Diagnostics and Suppressions](#analyzer-diagnostics-and-suppressions)). +- **Don't add `# type: ignore` to silence pyright errors without a comment** explaining the constraint. If a recurring false positive needs suppression, configure it project-wide in `[tool.pyright]`. A new port doesn't change this - fix freshly surfaced type errors rather than muting them (see [Analyzer Diagnostics and Suppressions][analyzer-diagnostics-and-suppressions]). #### Naming @@ -460,4 +460,25 @@ Before pushing or opening a PR: - VS Code's **Problems** pane should be quiet for the files you touched. The relevant linters are ruff (via the `charliermarsh.ruff` extension) and pyright (via the `ms-python.python` extension's bundled Pylance). - The CI gate is `uv run ruff check && uv run ruff format --check && uv run pyright && uv run pytest` - same as the local commands above, run from the Python project directory. -- Markdown in this directory follows the repo-wide [Markdown and Spelling](#markdown-and-spelling) rules. +- Markdown in this directory follows the repo-wide [Markdown and Spelling][markdown-and-spelling] rules. + + + +[agents]: ./AGENTS.md +[analyzer-diagnostics-and-suppressions]: #analyzer-diagnostics-and-suppressions +[clean-compile-verification]: #clean-compile-verification +[history]: ./HISTORY.md +[markdown-and-spelling]: #markdown-and-spelling +[markdownlint-cli2]: ./.markdownlint-cli2.jsonc +[readme]: ./README.md +[root]: ./.editorconfig +[vscode-tasks]: ./catalog/snippets/configs/vscode-tasks.json + + + +[docs-link]: https://docs.pytest.org/ +[latest-link]: https://hatch.pypa.io/latest/ +[pep-0257-link]: https://peps.python.org/pep-0257/ +[pyright-link]: https://microsoft.github.io/pyright/ +[ruff-link]: https://docs.astral.sh/ruff/ +[uv-link]: https://docs.astral.sh/uv/ diff --git a/README.md b/README.md index 893949c4..6e1361c0 100644 --- a/README.md +++ b/README.md @@ -2,45 +2,122 @@ Governance, agent-orchestration, and workflow-audit hub for a fleet of related repositories. -[![Last Commit](https://img.shields.io/github/last-commit/ptr727/ProjectTemplate?logo=github)](https://github.com/ptr727/ProjectTemplate/commits) -[![License](https://img.shields.io/github/license/ptr727/ProjectTemplate)](./LICENSE) +[![Last Commit][last-commit-shield]][commits-link]\ +[![License][license-shield]][license] ## What This Repo Is This repo no longer ships sample application code. It is the single home for the shared rules the fleet follows, a machine-readable spec those rules are checked against, a registry of the projects, and an audit-agent instruction set. Instead of copying files between a template and its derivatives, each project owns its own implementation and is **audited** against the ground truth here - to the letter (exact file, section, or config) or to intent (an equivalent outcome). -- **[AGENTS.md](./AGENTS.md)** - cross-cutting rules for AI coding agents: git, branching, release model, doc style, the recurring-violation rules (comments, ASCII charset, US spelling, line endings), PR review etiquette, and workflow YAML conventions. -- **[CODESTYLE.md](./CODESTYLE.md)** - code style for .NET and Python. -- **[WORKFLOW.md](./WORKFLOW.md)** - the CI/CD workflow contract (behavioral guarantees D1-D9) and its audit methodology. -- **[AUDIT.md](./AUDIT.md)** - how an agent audits a repository against the spec and reports drift. -- **[spec/](./spec/)** - the machine-readable ground truth: project-type requirements, the file/section baseline, required/forbidden secrets, and the preferred README structure. -- **[registry/repos.json](./registry/repos.json)** - the fleet registry: every project, its type(s), publish mechanism, and status (cataloged or standardization backlog). -- **[repo-config/](./repo-config/)** - branch rulesets and the apply script (kept out of `.github/`, which is Actions-owned), plus the GitHub setup reference. -- **[catalog/](./catalog/)** - reusable reference snippets (workflow tasks, config exemplars, devcontainers) the audit compares implementations against. -- **[reports/](./reports/)** - per-repo audit output. +- **[AGENTS.md][agents]** - cross-cutting rules for AI coding agents: git, branching, release model, doc style, the recurring-violation rules (comments, ASCII charset, US spelling, line endings), PR review etiquette, and workflow YAML conventions. +- **[CODESTYLE.md][codestyle]** - code style for .NET and Python. +- **[WORKFLOW.md][workflow]** - the CI/CD workflow contract (behavioral guarantees D1-D9) and its audit methodology. +- **[AUDIT.md][audit]** - how an agent audits a repository against the spec and reports drift. +- **[spec/][spec]** - the machine-readable ground truth: project-type requirements, the file/section baseline, required/forbidden secrets, and the preferred README structure. +- **[registry/repos.json][repos]** - the fleet registry: every project, its type(s), publish mechanism, and status (cataloged or standardization backlog). +- **[repo-config/][repo-config]** - branch rulesets and the apply script (kept out of `.github/`, which is Actions-owned), plus the GitHub setup reference. +- **[catalog/][catalog]** - reusable reference snippets (workflow tasks, config exemplars, devcontainers) the audit compares implementations against. +- **[reports/][reports]** - per-repo audit output. ## How This Repo Operates -ProjectTemplate follows the same model it documents, and audits its own rules against itself (it classifies as the source-only project type in [WORKFLOW.md](./WORKFLOW.md)). +ProjectTemplate follows the same model it documents, and audits its own rules against itself (it classifies as the source-only project type in [WORKFLOW.md][workflow]). -- **Branching.** Persistent `main` and `develop`, each with its own ruleset. Commit on feature branches only. Feature branch to `develop` is squash-merged; `develop` to `main` is a merge commit. `develop` is forward-only (no `main -> develop` back-merges). See [AGENTS.md "Branching Model"](./AGENTS.md#branching-model). +- **Branching.** Persistent `main` and `develop`, each with its own ruleset. Commit on feature branches only. Feature branch to `develop` is squash-merged; `develop` to `main` is a merge commit. `develop` is forward-only (no `main -> develop` back-merges). See [AGENTS.md "Branching Model"][agents-branching-model]. - **CI is lint-only.** There is no build or unit test; the PR gate runs markdownlint, cspell, JSON-schema validation, and actionlint, and exposes the ruleset-bound `Check pull request workflow status` aggregator. The same lint configs (`.markdownlint-cli2.jsonc`, `cspell.json`) drive the editor extensions, the CLI, and CI. -- **Review loop.** Every PR is reviewed by GitHub Copilot; the agent drives the review loop to green and merges only with explicit maintainer permission. See [AGENTS.md "PR Review Etiquette"](./AGENTS.md#pr-review-etiquette). -- **Release.** A `develop -> main` merge is promoted through a GitHub release (tag plus a source zip, README, and LICENSE); versioning is NBGV-driven from [version.json](./version.json). See [WORKFLOW.md](./WORKFLOW.md). +- **Review loop.** Every PR is reviewed by GitHub Copilot; the agent drives the review loop to green and merges only with explicit maintainer permission. See [AGENTS.md "PR Review Etiquette"][agents-pr-review-etiquette]. +- **Release.** A `develop -> main` merge is promoted through a GitHub release (tag plus a source zip, README, and LICENSE); versioning is NBGV-driven from [version.json][version]. See [WORKFLOW.md][workflow]. + +## Rules + +A human-readable index of the rules agents enforce, implement, and audit. The authority for each is [AGENTS.md][agents], [CODESTYLE.md][codestyle], and [WORKFLOW.md][workflow]; the machine-checkable form lives in [spec/][spec]. + +### Always + +- Sign every commit (SSH or GPG). +- Branch feature -> develop (squash) -> main (merge commit); develop is forward-only. +- Drive every PR through the Copilot review loop and merge only with maintainer approval. +- Write US English and ASCII only (no em-dash, straight quotes). +- Keep comments concise and only for the non-obvious, and never grow them on edit. +- Follow `.editorconfig` line endings (CRLF default, LF for shell and Docker) and preserve a file's endings on edit. +- One logical paragraph per line, with a trailing `\` for an intentional hard break. +- Pin every GitHub Action to a commit SHA with a version comment. +- Share one lint config per tool across the editor, the CLI, and CI. + +### Never + +- Never force-push or rewrite shared history. +- Never treat a merge as a release; publishing is a separate, explicit step. +- Never blanket-delete a workflow run's artifacts. +- Never store a static key when OIDC Trusted Publishing is available. + +### If a C# Project + +- Carry the shared `[*.cs]` block in `.editorconfig` and build with zero warnings. + +### If a Python Project + +- Configure ruff and pyright in `pyproject.toml`. + +### If Publishing a Package (NuGet or PyPI) + +- Publish via OIDC Trusted Publishing, never a stored API key. + +### If a Docker Image + +- Cache layers to a registry tag (never `type=gha`) and publish the size-limited Docker Hub README separately. + +### For a README or Human-Facing Doc + +- Follow the section order in [spec/readme-structure.md][readme-structure] and put every URI as a grouped, alphabetized reference link at the bottom. + +### For Workflows + +- Make GitHub Actions satisfy the [WORKFLOW.md][workflow] contract (guarantees D1-D9), which the audit verifies. ## Development Environment -Contributors sign every commit. See [docs/ssh-signing.md](./docs/ssh-signing.md) for SSH commit-signing setup, [docs/host-setup.md](./docs/host-setup.md) for host prerequisites, and [docs/devcontainer.md](./docs/devcontainer.md) for devcontainer SSH-agent forwarding. Run the linters before pushing (see [AGENTS.md "Running the Linters Locally"](./AGENTS.md#running-the-linters-locally-known-working-invocations)). +Contributors sign every commit. See [docs/ssh-signing.md][ssh-signing] for SSH commit-signing setup, [docs/host-setup.md][host-setup] for host prerequisites, and [docs/devcontainer.md][devcontainer] for devcontainer SSH-agent forwarding. Run the linters before pushing (see [AGENTS.md "Running the Linters Locally"][agents-running-the-linters-locally-known-working-invocations]). ## TODO Running backlog (kept here, in a committed file, rather than in agent memory that does not persist across environments). -- Run the first per-repo audits and populate [reports/](./reports/) for the seven cataloged repos. -- Classify the standardization-backlog repos in [registry/repos.json](./registry/repos.json) (marked `classificationPending`) on first audit. +- Run the first per-repo audits and populate [reports/][reports] for the seven cataloged repos. +- Classify the standardization-backlog repos in [registry/repos.json][repos] (marked `classificationPending`) on first audit. - Canonicalize Python linter-config placement on `pyproject.toml` (one cataloged repo uses standalone `.ruff.toml` + `pyrightconfig.json`); track as a drift finding, fix downstream. - Consider renaming this repo to reflect the audit-catalog identity (updates badge and link URLs across the fleet). ## License -See [LICENSE](./LICENSE). +See [LICENSE][license]. + + + +[last-commit-shield]: https://img.shields.io/github/last-commit/ptr727/ProjectTemplate?logo=github +[license-shield]: https://img.shields.io/github/license/ptr727/ProjectTemplate + + + +[agents]: ./AGENTS.md +[agents-branching-model]: ./AGENTS.md#branching-model +[agents-pr-review-etiquette]: ./AGENTS.md#pr-review-etiquette +[agents-running-the-linters-locally-known-working-invocations]: ./AGENTS.md#running-the-linters-locally-known-working-invocations +[audit]: ./AUDIT.md +[catalog]: ./catalog/ +[codestyle]: ./CODESTYLE.md +[devcontainer]: ./docs/devcontainer.md +[host-setup]: ./docs/host-setup.md +[license]: ./LICENSE +[readme-structure]: ./spec/readme-structure.md +[repo-config]: ./repo-config/ +[reports]: ./reports/ +[repos]: ./registry/repos.json +[spec]: ./spec/ +[ssh-signing]: ./docs/ssh-signing.md +[version]: ./version.json +[workflow]: ./WORKFLOW.md + + + +[commits-link]: https://github.com/ptr727/ProjectTemplate/commits diff --git a/WORKFLOW.md b/WORKFLOW.md index 4497d002..10559770 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -1,6 +1,6 @@ # WORKFLOW.md -The guide for CI/CD **workflows** (GitHub Actions): a deliberate mixture of code style, architecture, a **behavioral contract** (expected inputs and outputs), and a **test methodology**. Code style lives in [`CODESTYLE.md`](./CODESTYLE.md); this file is its sibling for everything under [`.github/workflows/`](./.github/workflows/). +The guide for CI/CD **workflows** (GitHub Actions): a deliberate mixture of code style, architecture, a **behavioral contract** (expected inputs and outputs), and a **test methodology**. Code style lives in [`CODESTYLE.md`][codestyle]; this file is its sibling for everything under [`.github/workflows/`][workflows]. Its defining principle: **it describes required outcomes, not a required implementation.** Two repos may implement the same guarantee with different YAML. A workflow is correct when it **satisfies the contract** in section 4 and is **defect-free against the expected inputs and outputs** - not when it matches the template byte for byte. The conventions in section 2 keep workflows legible; the contract in section 4 is what they must *do*. @@ -264,3 +264,11 @@ Each type maps the *applicable* S-scenarios onto its targets; the differences ar - **Docker image.** The leaf pushes multi-arch tags with a per-branch registry buildcache (`buildcache-`; a multi-image repo adds a per-image tag) (`cache-to` only the built branch and only on push, `cache-from` both branches); no `release-asset-*`, so a Docker-only repo's caller passes `expect_release_assets: false`; the readme (`peter-evans/dockerhub-description`, `DOCKER_HUB_ACCESS_TOKEN`) and date-badge jobs run **only** when the default branch publishes; the docker-readme task validates `repositories` XOR `manifest`+`manifest-jq` and a multi-image repo derives its publish matrix from the manifest. Docker **always re-pushes** the image, independently of a skipped release-create (S9). A **wrapper** repo tracks an upstream release: the upstream tracker writes a `name -> version` state file and the merge-bot auto-merges the bump PR (S11), and the leaf MUST read that file for the immutable tag instead of `SemVer2` (the template ships the tracker but not this consumer wiring). Test: S7 default leg pushes `latest` + the version tag and updates readme/badge; non-default pushes the develop tag; S9 still re-pushes; S11 ships the bumped upstream version next publish. 5C Docker probe needs `DOCKER_HUB_*` secrets and same-repo (not fork) runs. - **Data / asset library.** A single new leaf: validate -> zip -> upload `release-asset--library` (`retention-days: 1`, upload gated `!smoke` - mirror the nugetlibrary leaf's shape). Because the template has no such leaf, you **add a target** (D6.4): a new `enable_library` input + `build-library` job + `github-release` `needs:` entry in the release task, and a `library` paths-filter entry + `changes` output + `smoke-build` enable-forward in the PR workflow (without it, D1.1 never smoke-builds the library). Keep `expect_release_assets: true` (it has a file target, unlike Docker). The .NET `unit-test` job is replaced by a type-appropriate validator with the aggregator **and** `smoke-build` both re-pointed to it (D1.2/D1.5); `version.json` + the NBGV `get-version` step are retained (they own the tag). Test: S1 smoke runs validate+zip and uploads nothing; S7 attaches the zip, prerelease on the non-default leg; S9 on a *scheduled* re-run release-create + asset-delete skip (the existing zip is untouched, no registry push), while a `workflow_dispatch` re-run **refreshes** the release and re-runs the asset-delete (the asset is re-uploaded then re-deleted). N/A: the nuget/pypi/docker/executable 5A addenda and their scenario clauses. - **Source-only / no build.** No package/image leaf: remove all four `build-*` jobs and their `github-release` `needs:` entries (leaving `get-version -> validate-release -> github-release`, which fires on `github && !smoke`), and the caller passes `expect_release_assets: false` so the release is tag + source zip + README + LICENSE with no asset download. With no target the paths-filter matches nothing, so `smoke-build` is **structurally always skipped** - validation is carried solely by the (replaced, non-.NET) validation job that the aggregator and `smoke-build`'s own `needs:` must both point at (D1.2; or drop the never-running `smoke-build` job). NBGV and `version.json` are still retained (they own the tag). Applicable scenarios: S1 (validation only), S5/S6 (publish gating), S7 (tag-only release), S8 (dispatch guard), S9 (no-op republish), S10 (classification gate). N/A: S2-S4 (assume a smoke-built target), the artifact-lifecycle and registry clauses of S7/S9, the D5/D6 artifact items, and all per-type 5A addenda - recorded N/A, not failed. + + + +[workflows]: ./.github/workflows/ + + + +[codestyle]: ./CODESTYLE.md diff --git a/catalog/README.md b/catalog/README.md index 524a4630..c0b3af49 100644 --- a/catalog/README.md +++ b/catalog/README.md @@ -1,14 +1,8 @@ # Catalog Reusable reference snippets: concrete config artifacts a repo can copy or compare against. These are -**reference material, not run here** - this repo ships no build, so the workflow snippets below are not -active. Each is the canonical shape the audit (`AUDIT.md`) checks a downstream implementation against. +**reference material, not run here** - this repo ships no build, so the workflow snippets below are not active. Each is the canonical shape the audit (`AUDIT.md`) checks a downstream implementation against. -- `snippets/workflows/` - the reusable build/publish workflow tasks that a code-shipping repo runs - (this docs repo keeps only the source-only orchestrator set in `.github/workflows/`). See - `snippets/workflows/README.md` for the mapping from each file to the `WORKFLOW.md` guarantees it - implements. -- `snippets/configs/` - config exemplars: `vscode-tasks.json` (clean-compile task group), - `dependabot.yml` (multi-ecosystem dual-target reference), `docker-hub-readme.md` (the size-limited - Docker Hub overview, distinct from the project `README.md`). +- `snippets/workflows/` - the reusable build/publish workflow tasks that a code-shipping repo runs (this docs repo keeps only the source-only orchestrator set in `.github/workflows/`). See `snippets/workflows/README.md` for the mapping from each file to the `WORKFLOW.md` guarantees it implements. +- `snippets/configs/` - config exemplars: `vscode-tasks.json` (clean-compile task group), `dependabot.yml` (multi-ecosystem dual-target reference), `docker-hub-readme.md` (the size-limited Docker Hub overview, distinct from the project `README.md`). - `snippets/devcontainer/` - `.devcontainer` definitions for the .NET and Python toolchains. diff --git a/catalog/snippets/configs/docker-hub-readme.md b/catalog/snippets/configs/docker-hub-readme.md index 7f1b1b42..4e17500a 100644 --- a/catalog/snippets/configs/docker-hub-readme.md +++ b/catalog/snippets/configs/docker-hub-readme.md @@ -16,6 +16,12 @@ docker run --rm docker.io/ptr727/projecttemplate:latest ## Links -- Source, issues, and CI/CD: [GitHub](https://github.com/ptr727/ProjectTemplate) -- Versioned releases: [GitHub Releases](https://github.com/ptr727/ProjectTemplate/releases) -- License: [MIT](https://github.com/ptr727/ProjectTemplate/blob/main/LICENSE) +- Source, issues, and CI/CD: [GitHub][projecttemplate-link] +- Versioned releases: [GitHub Releases][releases-link] +- License: [MIT][license-link] + + + +[license-link]: https://github.com/ptr727/ProjectTemplate/blob/main/LICENSE +[projecttemplate-link]: https://github.com/ptr727/ProjectTemplate +[releases-link]: https://github.com/ptr727/ProjectTemplate/releases diff --git a/catalog/snippets/workflows/README.md b/catalog/snippets/workflows/README.md index 0175cd9f..49265ae9 100644 --- a/catalog/snippets/workflows/README.md +++ b/catalog/snippets/workflows/README.md @@ -1,10 +1,6 @@ # Workflow snippets -The reusable build/publish workflow tasks a code-shipping repo runs. They are **inert reference here** - -this repo is source-only and keeps just the orchestrator set (`test-pull-request`, `publish-release`, -`build-release-task`, `get-version-task`, `merge-bot-pull-request`) in `.github/workflows/`. Each file -below is the canonical implementation of one or more `WORKFLOW.md` guarantees; the audit asserts a -downstream repo's own Actions satisfy those guarantees, not that they match these bytes. +The reusable build/publish workflow tasks a code-shipping repo runs. They are **inert reference here** - this repo is source-only and keeps just the orchestrator set (`test-pull-request`, `publish-release`, `build-release-task`, `get-version-task`, `merge-bot-pull-request`) in `.github/workflows/`. Each file below is the canonical implementation of one or more `WORKFLOW.md` guarantees; the audit asserts a downstream repo's own Actions satisfy those guarantees, not that they match these bytes. | File | Role | WORKFLOW.md guarantees | | --- | --- | --- | diff --git a/docs/devcontainer.md b/docs/devcontainer.md index 650e438c..0d0457ec 100644 --- a/docs/devcontainer.md +++ b/docs/devcontainer.md @@ -1,15 +1,15 @@ # Devcontainer Setup -The repo ships **two per-language [Dev Containers](https://containers.dev/)** so each container carries only one toolchain, one extension surface, and one `postCreateCommand` - matching the language you'll actually edit. +The repo ships **two per-language [Dev Containers][containers-link]** so each container carries only one toolchain, one extension surface, and one `postCreateCommand` - matching the language you'll actually edit. | Workspace | Devcontainer | Image | Toolchain | | --------- | ------------ | ----- | --------- | -| `DotNet.code-workspace` | [`catalog/snippets/devcontainer/dotnet/devcontainer.json`](../catalog/snippets/devcontainer/dotnet/devcontainer.json) | `mcr.microsoft.com/devcontainers/dotnet:1-10.0` | .NET 10 SDK | -| `Python.code-workspace` | [`catalog/snippets/devcontainer/python/devcontainer.json`](../catalog/snippets/devcontainer/python/devcontainer.json) | `mcr.microsoft.com/devcontainers/python:1-3.14-bookworm` | Python 3.14 + version-pinned `uv` | +| `DotNet.code-workspace` | [`catalog/snippets/devcontainer/dotnet/devcontainer.json`][devcontainer] | `mcr.microsoft.com/devcontainers/dotnet:1-10.0` | .NET 10 SDK | +| `Python.code-workspace` | [`catalog/snippets/devcontainer/python/devcontainer.json`][devcontainer-2] | `mcr.microsoft.com/devcontainers/python:1-3.14-bookworm` | Python 3.14 + version-pinned `uv` | -Open the workspace file matching the language you want, install the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers), and pick **Reopen in Container**. +Open the workspace file matching the language you want, install the [Dev Containers extension][marketplace-link], and pick **Reopen in Container**. -Prerequisite: complete [host setup](./host-setup.md) first - without git config, an SSH key, and the allowed-signers file on the host, neither devcontainer will be able to sign commits. +Prerequisite: complete [host setup][host-setup] first - without git config, an SSH key, and the allowed-signers file on the host, neither devcontainer will be able to sign commits. ## What's Inside (Both Containers) @@ -25,13 +25,13 @@ Each devcontainer's extension list and the matching workspace's `recommendations ## Bind Mounts (Both Containers) -The host SSH key, allowed-signers file, and `gh` config directory are mounted into the container so commits sign correctly and `gh` is pre-authenticated **when the host stores its `gh` token in a file** (`~/.config/gh/hosts.yml`). Hosts that store the token in macOS Keychain or Linux libsecret will need an in-container `gh auth login` instead - see [`gh` credential store](#gh-credential-store) below for the full picture. +The host SSH key, allowed-signers file, and `gh` config directory are mounted into the container so commits sign correctly and `gh` is pre-authenticated **when the host stores its `gh` token in a file** (`~/.config/gh/hosts.yml`). Hosts that store the token in macOS Keychain or Linux libsecret will need an in-container `gh auth login` instead - see [`gh` credential store][gh-credential-store] below for the full picture. | Host path | Container path | Mode | Purpose | | --------- | -------------- | ---- | ------- | | `~/.ssh/id_ed25519.pub` | `/home/vscode/.ssh/id_ed25519.pub` | read-only | Public half of the SSH key. The private key never enters the container - SSH agent forwarding handles signing. | | `~/.config/git/allowed_signers` | `/home/vscode/.config/git/allowed_signers` | read-only | Maps your email to your public key so `git verify-commit` and `git log --show-signature` work inside the container. | -| `~/.config/gh` | `/home/vscode/.config/gh` | read-write | `gh` CLI auth state shared with the host. See [`gh` credential store](#gh-credential-store) below. | +| `~/.config/gh` | `/home/vscode/.config/gh` | read-write | `gh` CLI auth state shared with the host. See [`gh` credential store][gh-credential-store] below. | VS Code Dev Containers automatically copies your host `~/.gitconfig` into the container at startup, so `user.name`, `user.email`, `user.signingkey`, `gpg.format`, and `commit.gpgsign` propagate without an explicit mount. @@ -96,16 +96,28 @@ which dotnet # nothing - dotnet intentiona cd PyPiLibrary && uv sync && uv run pytest # tests pass ``` -If `git -c gpg.format=ssh commit -S` errors with `signing failed: no allowed signers`, the bind-mount of `allowed_signers` is missing or the file on the host is empty - re-run the snippet in [host setup](./host-setup.md). +If `git -c gpg.format=ssh commit -S` errors with `signing failed: no allowed signers`, the bind-mount of `allowed_signers` is missing or the file on the host is empty - re-run the snippet in [host setup][host-setup]. ## Troubleshooting **Permission denied writing to `~/.ssh/known_hosts` in the container** - The `onCreateCommand` should have chowned `~/.ssh` to `vscode`. Rebuild the container; if it persists, open a shell and run the same `sudo install -d -m 700 -o vscode -g vscode ~/.ssh` manually. -**`git commit` fails with "no SSH agent socket"** - VS Code Dev Containers forwards `SSH_AUTH_SOCK` automatically, but only if the host has `ssh-agent` running with at least one key. Run `ssh-add -l` on the host first; if it says "could not open a connection to your authentication agent", start the agent (see [host setup](./host-setup.md)). +**`git commit` fails with "no SSH agent socket"** - VS Code Dev Containers forwards `SSH_AUTH_SOCK` automatically, but only if the host has `ssh-agent` running with at least one key. Run `ssh-add -l` on the host first; if it says "could not open a connection to your authentication agent", start the agent (see [host setup][host-setup]). **uv not on `PATH` after rebuild** (Python container) - The post-create installer adds `~/.local/bin` to `PATH` via the user shell init scripts, which take effect on next shell. Either re-open the integrated terminal or `source ~/.bashrc`. **Container builds but extensions don't auto-install** - Make sure VS Code is using the Dev Containers extension (not "Remote - SSH" or "Remote - Tunnels"). The extension auto-install is keyed on `customizations.vscode.extensions` and only Dev Containers honors that. **Wrong-language work in the wrong container** - The `.NET` container has no `uv` and no Python extensions; the Python container has no `dotnet` SDK and no C# extensions. This is intentional - open the matching workspace and rebuild rather than installing the missing toolchain ad hoc. + + + +[devcontainer]: ../catalog/snippets/devcontainer/dotnet/devcontainer.json +[devcontainer-2]: ../catalog/snippets/devcontainer/python/devcontainer.json +[gh-credential-store]: #gh-credential-store +[host-setup]: ./host-setup.md + + + +[containers-link]: https://containers.dev/ +[marketplace-link]: https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers diff --git a/docs/host-setup.md b/docs/host-setup.md index 8c1ddae0..a103678c 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -29,7 +29,7 @@ ssh-keygen -t ed25519 -C "you@example.com" -f ~/.ssh/id_ed25519 Add the public key (`~/.ssh/id_ed25519.pub`) to GitHub twice: -1. **Authentication key** - [GitHub -> Settings -> SSH and GPG keys -> New SSH key](https://github.com/settings/keys), key type **Authentication Key**. +1. **Authentication key** - [GitHub -> Settings -> SSH and GPG keys -> New SSH key][keys-link], key type **Authentication Key**. 2. **Signing key** - same page, but **Signing Key** type. GitHub treats these independently even though it's the same public key. Test the auth key: @@ -108,11 +108,11 @@ git config --global commit.gpgsign true git config --global tag.gpgsign true ``` -See [SSH commit signing](./ssh-signing.md) for verification steps and per-OS troubleshooting. +See [SSH commit signing][ssh-signing] for verification steps and per-OS troubleshooting. ## GitHub CLI -Install [`gh`](https://cli.github.com/) and authenticate. +Install [`gh`][cli-link] and authenticate. ```shell gh auth login --hostname github.com --git-protocol ssh @@ -134,5 +134,15 @@ If signing fails locally, the devcontainer will fail too - fix here first. ## Next Steps -- [Devcontainer setup](./devcontainer.md) - open the repo in the per-language .NET or Python devcontainer. -- [SSH commit signing](./ssh-signing.md) - per-OS setup details, verification, and troubleshooting. +- [Devcontainer setup][devcontainer] - open the repo in the per-language .NET or Python devcontainer. +- [SSH commit signing][ssh-signing] - per-OS setup details, verification, and troubleshooting. + + + +[devcontainer]: ./devcontainer.md +[ssh-signing]: ./ssh-signing.md + + + +[cli-link]: https://cli.github.com/ +[keys-link]: https://github.com/settings/keys diff --git a/docs/ssh-signing.md b/docs/ssh-signing.md index 52f62e3e..b10dfd4f 100644 --- a/docs/ssh-signing.md +++ b/docs/ssh-signing.md @@ -2,7 +2,7 @@ This repo enforces signed commits on `main` and `develop` via branch protection. Use SSH signing - one Ed25519 key serves both authentication (push) and signing. -If you haven't generated a key and configured git yet, follow [host setup](./host-setup.md) first. +If you haven't generated a key and configured git yet, follow [host setup][host-setup] first. ## Why SSH Signing @@ -109,7 +109,7 @@ The container picks up: - The `~/.ssh/id_ed25519.pub` and `~/.config/git/allowed_signers` files via bind-mount declared in `devcontainer.json`. - The forwarded SSH agent socket from `SSH_AUTH_SOCK`, so signing happens with the host's loaded private key without the private key ever entering the container. -If the container's `~/.ssh` directory exists with the wrong owner (root, surfaced by macOS bind-mount semantics), `gh auth login` writes to `~/.ssh/known_hosts` may fail. The `onCreateCommand` in `devcontainer.json` chowns the directory to `vscode` to fix this - see [devcontainer setup](./devcontainer.md) for the rationale. +If the container's `~/.ssh` directory exists with the wrong owner (root, surfaced by macOS bind-mount semantics), `gh auth login` writes to `~/.ssh/known_hosts` may fail. The `onCreateCommand` in `devcontainer.json` chowns the directory to `vscode` to fix this - see [devcontainer setup][devcontainer] for the rationale. ## Troubleshooting @@ -120,3 +120,8 @@ If the container's `~/.ssh` directory exists with the wrong owner (root, surface **Verifies on the host but not in the container** - The bind-mount source path differs. `${localEnv:HOME}` resolves on Linux/macOS hosts; on Windows hosts (WSL2 backend) the `${localEnv:USERPROFILE}` fallback in `devcontainer.json` handles it. Check the actual mount with `mount | grep ssh` inside the container. **SSH agent says "could not open a connection"** - The host's agent isn't running. Linux: `systemctl --user start ssh-agent.socket`. macOS: open a new terminal so launchd starts the agent. + + + +[devcontainer]: ./devcontainer.md +[host-setup]: ./host-setup.md diff --git a/repo-config/README.md b/repo-config/README.md index 093e3dbf..67f70a66 100644 --- a/repo-config/README.md +++ b/repo-config/README.md @@ -1,29 +1,15 @@ # repo-config -Repository and branch configuration held as committed files, kept out of `.github/` (which is reserved -for GitHub-Actions-owned content). This mirrors the layout the fleet repos use. +Repository and branch configuration held as committed files, kept out of `.github/` (which is reserved for GitHub-Actions-owned content). This mirrors the layout the fleet repos use. -- `main.json`, `develop.json` - the branch rulesets as the writable API subset - (`name`, `target`, `enforcement`, `bypass_actors`, `conditions`, `rules`). These are the canonical - expected payload the audit ([AUDIT.md](../AUDIT.md)) diffs each repo's live rulesets against. -- `configure.sh` - applies the rulesets to a repository via the GitHub API (create or full-payload - update, idempotent). Run `repo-config/configure.sh [owner/repo]`. +- `main.json`, `develop.json` - the branch rulesets as the writable API subset (`name`, `target`, `enforcement`, `bypass_actors`, `conditions`, `rules`). These are the canonical expected payload the audit ([AUDIT.md][audit]) diffs each repo's live rulesets against. +- `configure.sh` - applies the rulesets to a repository via the GitHub API (create or full-payload update, idempotent). Run `repo-config/configure.sh [owner/repo]`. ## Rulesets -`main` requires merge-commit merges (no linear-history rule); `develop` requires squash merges with -linear history. Both require signed commits, a passing `Check pull request workflow status`, resolved -review threads, and Copilot review, and block force-pushes and deletion. Both intentionally leave -"Require branches to be up to date before merging" **off** - see [AGENTS.md "Branching Model"](../AGENTS.md#branching-model). +`main` requires merge-commit merges (no linear-history rule); `develop` requires squash merges with linear history. Both require signed commits, a passing `Check pull request workflow status`, resolved review threads, and Copilot review, and block force-pushes and deletion. Both intentionally leave "Require branches to be up to date before merging" **off** - see [AGENTS.md "Branching Model"][agents-branching-model]. -**Configure by importing these JSON files, never by hand-building the rules** (hand reconstruction has -gone wrong on past setups). The result must be **exactly two rulesets named `develop` and `main`** - the -names are load-bearing (`AGENTS.md` and the workflows reference them). First remove all legacy classic -branch-protection rules and any stray rulesets, then run `configure.sh` (or `gh api -X POST -repos///rulesets --input repo-config/.json` per file). `gh ruleset` is read-only; -creation goes through `gh api`. The required check binds by name and only turns green after -`test-pull-request.yml` runs once. To edit a ruleset, GET it, change the field, and PUT the whole -writable subset back (a partial PUT `422`s). +**Configure by importing these JSON files, never by hand-building the rules** (hand reconstruction has gone wrong on past setups). The result must be **exactly two rulesets named `develop` and `main`** - the names are load-bearing (`AGENTS.md` and the workflows reference them). First remove all legacy classic branch-protection rules and any stray rulesets, then run `configure.sh` (or `gh api -X POST repos///rulesets --input repo-config/.json` per file). `gh ruleset` is read-only; creation goes through `gh api`. The required check binds by name and only turns green after `test-pull-request.yml` runs once. To edit a ruleset, GET it, change the field, and PUT the whole writable subset back (a partial PUT `422`s). To change the canonical rulesets, edit the live rulesets here, then regenerate the committed files: @@ -38,27 +24,20 @@ done ## Secrets -Publish credentials required per mechanism are enumerated in [spec/secrets.json](../spec/secrets.json). -NuGet and PyPI use keyless OIDC Trusted Publishing (no stored key; the publish job needs `id-token: -write`, and PyPI additionally an `environment: pypi` gate). Docker Hub has no OIDC equivalent and uses a -stored `DOCKER_HUB_USERNAME` + `DOCKER_HUB_ACCESS_TOKEN` in both the Actions and Dependabot secret -stores. Codegen and merge-bot repos add a GitHub App (`CODEGEN_APP_CLIENT_ID` + `CODEGEN_APP_PRIVATE_KEY` -in both stores; the app must be installed, not just created). App-token call sites use `client-id`, never -the deprecated `app-id`. +Publish credentials required per mechanism are enumerated in [spec/secrets.json][secrets]. NuGet and PyPI use keyless OIDC Trusted Publishing (no stored key; the publish job needs `id-token: write`, and PyPI additionally an `environment: pypi` gate). Docker Hub has no OIDC equivalent and uses a stored `DOCKER_HUB_USERNAME` + `DOCKER_HUB_ACCESS_TOKEN` in both the Actions and Dependabot secret stores. Codegen and merge-bot repos add a GitHub App (`CODEGEN_APP_CLIENT_ID` + `CODEGEN_APP_PRIVATE_KEY` in both stores; the app must be installed, not just created). App-token call sites use `client-id`, never the deprecated `app-id`. ## Repo Settings -- Default branch `main`. Enable both `Allow merge commits` and `Allow squash merging` at the repo level - so each branch ruleset can pick its method; leave rebase disabled. Enable auto-merge. +- Default branch `main`. Enable both `Allow merge commits` and `Allow squash merging` at the repo level so each branch ruleset can pick its method; leave rebase disabled. Enable auto-merge. - Actions / General: allow GitHub Actions to create and approve pull requests (for the bots). ## Brownfield Migration (Maintainer Only) -`Require signed commits` rejects any pre-existing unsigned commit, so the first `develop -> main` release -on a repo with unsigned history is blocked. Re-signing that history is a non-fast-forward that the -`Block force pushes` rule rejects, **and the admin bypass does not cover `git push --force`**. Completing -it requires temporarily disabling the ruleset and a maintainer force-push. This is a one-time, -maintainer-performed migration that deliberately uses the force-push [AGENTS.md "Git and Commit -Rules"](../AGENTS.md#git-and-commit-rules) forbids agents from running - **an agent must never execute it; -surface it to the maintainer**. Greenfield repos where signing is live before the first commit never hit -this. +`Require signed commits` rejects any pre-existing unsigned commit, so the first `develop -> main` release on a repo with unsigned history is blocked. Re-signing that history is a non-fast-forward that the `Block force pushes` rule rejects, **and the admin bypass does not cover `git push --force`**. Completing it requires temporarily disabling the ruleset and a maintainer force-push. This is a one-time, maintainer-performed migration that deliberately uses the force-push [AGENTS.md "Git and Commit Rules"][agents-git-and-commit-rules] forbids agents from running - **an agent must never execute it; surface it to the maintainer**. Greenfield repos where signing is live before the first commit never hit this. + + + +[agents-branching-model]: ../AGENTS.md#branching-model +[agents-git-and-commit-rules]: ../AGENTS.md#git-and-commit-rules +[audit]: ../AUDIT.md +[secrets]: ../spec/secrets.json diff --git a/spec/readme-structure.md b/spec/readme-structure.md index cbc2c64e..04135eef 100644 --- a/spec/readme-structure.md +++ b/spec/readme-structure.md @@ -1,29 +1,19 @@ # README Structure -The preferred `README.md` shape for a fleet project. The audit's `readme-structure` dimension checks a -repo's README against this: the required sections in this order (to the letter where a section applies, -to intent where a project legitimately has nothing to say). Sections that do not apply to a project type -are N/A, not a defect (a library has no "Installation" of a running app; a source-only repo has no -"Usage"). +The preferred `README.md` shape for a fleet project. The audit's `readme-structure` dimension checks a repo's README against this: the required sections in this order (to the letter where a section applies, to intent where a project legitimately has nothing to say). Sections that do not apply to a project type are N/A, not a defect (a library has no "Installation" of a running app; a source-only repo has no "Usage"). ## Required Sections and Order 1. **Title (`# `)** - the repo name, followed by a one-line description of what it is. -2. **Shields** - build/release status and version badges immediately under the title, one logical line - per group with a trailing backslash for the hard break. Alphabetize the shield link-reference - definitions block at the bottom of the file (per AGENTS.md Markdown rules). -3. **Build and Distribution** - where releases and packages are published (GitHub Releases, NuGet, PyPI, - Docker Hub), and a short **Release Notes** summary (full history in `HISTORY.md`). +2. **Shields** - build/release status and version badges immediately under the title, one logical line per group with a trailing backslash for the hard break. Alphabetize the shield link-reference definitions block at the bottom of the file (per AGENTS.md Markdown rules). +3. **Build and Distribution** - where releases and packages are published (GitHub Releases, NuGet, PyPI, Docker Hub), and a short **Release Notes** summary (full history in `HISTORY.md`). 4. **Getting Started** - the shortest path to using the project. -5. **Use Cases** - what problems it solves (optional for a library whose purpose is obvious from the - description). +5. **Use Cases** - what problems it solves (optional for a library whose purpose is obvious from the description). 6. **Installation** - how to install or pull it, per distribution channel. N/A for source-only. 7. **Configuration** - settings, environment, config files. N/A when there is nothing to configure. -8. **Usage** - commands, API, or integration steps. For a CLI, a command quick-reference and the global - options. N/A for source-only. +8. **Usage** - commands, API, or integration steps. For a CLI, a command quick-reference and the global options. N/A for source-only. 9. **Questions or Issues** - where to file issues and ask questions. -10. **Development Environment** - how to build, test, and lint locally; commit-signing setup; devcontainer - notes. Point at shared docs rather than restating them. +10. **Development Environment** - how to build, test, and lint locally; commit-signing setup; devcontainer notes. Point at shared docs rather than restating them. 11. **3rd Party Tools** - notable dependencies and their licenses, where relevant. 12. **License** - a pointer to `LICENSE`. @@ -34,12 +24,8 @@ are N/A, not a defect (a library has no "Installation" of a running app; a sourc - Title-case headings with lowercase short bind words. - US English, ASCII only (no em-dash; use a spaced hyphen), straight quotes. - Verify every quantitative claim (counts, versions, supported platforms) against current code. -- A project README describes only that project - no cross-repo references and no template or - inheritance framing. +- A project README describes only that project - no cross-repo references and no template or inheritance framing. ## Docker Hub README -A repo that publishes a Docker image keeps a **separate** `Docker/README.md` for the Docker Hub -repository overview: Docker Hub's description has a much smaller size limit than a project README, so it -carries a trimmed overview, not the full README. It is published by the docker-readme workflow task, not -copied from the root README. +A repo that publishes a Docker image keeps a **separate** `Docker/README.md` for the Docker Hub repository overview: Docker Hub's description has a much smaller size limit than a project README, so it carries a trimmed overview, not the full README. It is published by the docker-readme workflow task, not copied from the root README. From e8f3681cc24004867da58dc8073d440b1cd92417 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 4 Jul 2026 14:51:17 -0700 Subject: [PATCH 11/17] Resolve Copilot review: harden spec validator and secrets schema Require targetMechanisms in secrets.schema.json (it was used by the validator but not required), and have spec/validate.py report a missing mechanisms / targetMechanisms / types key via the errors list instead of raising KeyError. Narrow the cspell ignore to reports/*/** so the maintained reports/_template.md is spell-checked while per-repo audit reports stay ignored. Co-Authored-By: Claude Opus 4.8 (1M context) --- cspell.json | 2 +- spec/secrets.schema.json | 2 +- spec/validate.py | 13 +++++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/cspell.json b/cspell.json index 727e896b..ac336107 100644 --- a/cspell.json +++ b/cspell.json @@ -3,7 +3,7 @@ "language": "en", "ignorePaths": [ ".git/**", - "reports/**", + "reports/*/**", "**/*.min.*" ], "ignoreRegExpList": [ diff --git a/spec/secrets.schema.json b/spec/secrets.schema.json index 50916764..3b066626 100644 --- a/spec/secrets.schema.json +++ b/spec/secrets.schema.json @@ -3,7 +3,7 @@ "$id": "https://github.com/ptr727/ProjectTemplate/spec/secrets.schema.json", "title": "Publish mechanism secrets", "type": "object", - "required": ["mechanisms"], + "required": ["mechanisms", "targetMechanisms"], "properties": { "$schema": { "type": "string" }, "note": { "type": "string" }, diff --git a/spec/validate.py b/spec/validate.py index 12140721..1aa90e38 100644 --- a/spec/validate.py +++ b/spec/validate.py @@ -23,6 +23,19 @@ def main(): types = load("spec/project-types.json") secrets = load("spec/secrets.json") + for key, obj, fname in [ + ("types", types, "project-types.json"), + ("mechanisms", secrets, "secrets.json"), + ("targetMechanisms", secrets, "secrets.json"), + ]: + if key not in obj: + errors.append(f"{fname}: missing required key '{key}'") + if errors: + print("Spec validation FAILED:") + for e in errors: + print(f" - {e}") + return 1 + known_types = set(types["types"]) target_mech = secrets["targetMechanisms"] mechanisms = secrets["mechanisms"] From 6a6e14fa58056d36f32b692bc9710dad55474d3e Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 4 Jul 2026 14:56:55 -0700 Subject: [PATCH 12/17] Resolve Copilot round 2: more defensive validator and ruleset script Add repos.json's top-level `repos` key to validate.py's required-key check and report an undefined target->mechanism mapping via the errors list rather than raising KeyError. In configure.sh, fetch the ruleset list once and fail loudly if the API call itself fails, so a failed fetch is never mistaken for "no ruleset exists" and silently turned into a create. Co-Authored-By: Claude Opus 4.8 (1M context) --- repo-config/configure.sh | 9 ++++++++- spec/validate.py | 4 ++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/repo-config/configure.sh b/repo-config/configure.sh index c08cfbc8..885d8040 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -10,10 +10,17 @@ set -euo pipefail repo="${1:-$(gh repo view --json nameWithOwner --jq '.nameWithOwner')}" script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Fetch the ruleset list once and fail loudly if the API call itself fails (auth/404/network), so a +# failed fetch is never mistaken for "no ruleset exists" and silently turned into a create. +if ! rulesets="$(gh api "repos/$repo/rulesets")"; then + echo "Failed to list rulesets for $repo (check auth and repo access)." >&2 + exit 1 +fi + for file in "$script_dir"/*.json; do [ -e "$file" ] || continue name="$(jq -r '.name' "$file")" - id="$(gh api "repos/$repo/rulesets" --jq ".[] | select(.name==\"$name\") | .id" || true)" + id="$(jq -r ".[] | select(.name==\"$name\") | .id" <<<"$rulesets")" if [ -n "$id" ]; then echo "Updating ruleset '$name' (id $id) on $repo" gh api --method PUT "repos/$repo/rulesets/$id" --input "$file" >/dev/null diff --git a/spec/validate.py b/spec/validate.py index 1aa90e38..8f667548 100644 --- a/spec/validate.py +++ b/spec/validate.py @@ -24,6 +24,7 @@ def main(): secrets = load("spec/secrets.json") for key, obj, fname in [ + ("repos", repos, "repos.json"), ("types", types, "project-types.json"), ("mechanisms", secrets, "secrets.json"), ("targetMechanisms", secrets, "secrets.json"), @@ -60,6 +61,9 @@ def main(): mech_key = target_mech[target] if mech_key is None: continue + if mech_key not in mechanisms: + errors.append(f"{name}: target '{target}' maps to undefined mechanism '{mech_key}'") + continue spec_mech = mechanisms[mech_key] # docker/static-secret must carry its required secrets for req in spec_mech["requires"]: From f3d651e8e6e661038137c751b4a8a6e5e949dfdb Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 4 Jul 2026 15:04:45 -0700 Subject: [PATCH 13/17] Resolve Copilot round 3: fail on unclassified repo; refresh devcontainer doc validate.py now flags a cataloged repo with no types (an unclassified entry would otherwise pass silently). Reframe docs/devcontainer.md: this repo ships no toolchain and keeps the two devcontainer definitions as catalog reference, so drop the removed *.code-workspace / PyPiLibrary references and present the SSH signing, bind-mount, and gh-auth mechanics as reference for a repo that uses them. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/devcontainer.md | 28 ++++++++++++++-------------- spec/validate.py | 5 ++++- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/docs/devcontainer.md b/docs/devcontainer.md index 0d0457ec..6ee03e84 100644 --- a/docs/devcontainer.md +++ b/docs/devcontainer.md @@ -1,15 +1,15 @@ # Devcontainer Setup -The repo ships **two per-language [Dev Containers][containers-link]** so each container carries only one toolchain, one extension surface, and one `postCreateCommand` - matching the language you'll actually edit. +This repo ships no application toolchain. It keeps two per-language [Dev Container][containers-link] definitions under `catalog/snippets/devcontainer/` as reference for fleet code repos; each carries one toolchain, extension surface, and `postCreateCommand`. The mechanics below - SSH commit signing, bind mounts, and `gh` auth - apply to any repo that uses them. -| Workspace | Devcontainer | Image | Toolchain | -| --------- | ------------ | ----- | --------- | -| `DotNet.code-workspace` | [`catalog/snippets/devcontainer/dotnet/devcontainer.json`][devcontainer] | `mcr.microsoft.com/devcontainers/dotnet:1-10.0` | .NET 10 SDK | -| `Python.code-workspace` | [`catalog/snippets/devcontainer/python/devcontainer.json`][devcontainer-2] | `mcr.microsoft.com/devcontainers/python:1-3.14-bookworm` | Python 3.14 + version-pinned `uv` | +| Devcontainer | Image | Toolchain | +| ------------ | ----- | --------- | +| [`catalog/snippets/devcontainer/dotnet/devcontainer.json`][devcontainer] | `mcr.microsoft.com/devcontainers/dotnet:1-10.0` | .NET 10 SDK | +| [`catalog/snippets/devcontainer/python/devcontainer.json`][devcontainer-2] | `mcr.microsoft.com/devcontainers/python:1-3.14-bookworm` | Python 3.14 + version-pinned `uv` | -Open the workspace file matching the language you want, install the [Dev Containers extension][marketplace-link], and pick **Reopen in Container**. +In a repo that carries one of these definitions, install the [Dev Containers extension][marketplace-link] and pick **Reopen in Container**. -Prerequisite: complete [host setup][host-setup] first - without git config, an SSH key, and the allowed-signers file on the host, neither devcontainer will be able to sign commits. +Prerequisite: complete [host setup][host-setup] first - without git config, an SSH key, and the allowed-signers file on the host, the devcontainer cannot sign commits. ## What's Inside (Both Containers) @@ -19,7 +19,7 @@ Prerequisite: complete [host setup][host-setup] first - without git config, an S | Common utilities | `ghcr.io/devcontainers/features/common-utils:2` | bash, curl, wget, sudo, `vscode` user | | VS Code extensions | `customizations.vscode.extensions` in each `devcontainer.json` | Mirrors the matching workspace's `recommendations` so the container has the same tooling | -The .NET container additionally ships the `csharpier`/`dotnet-outdated` local tools (restored by `catalog/snippets/devcontainer/dotnet/post-create.sh`). The Python container additionally ships `uv` (installed by `catalog/snippets/devcontainer/python/post-create.sh` from a version-pinned URL) and pre-syncs the `PyPiLibrary` venv. +The .NET container additionally ships the `csharpier`/`dotnet-outdated` local tools (restored by `catalog/snippets/devcontainer/dotnet/post-create.sh`). The Python container additionally ships `uv` (installed by `catalog/snippets/devcontainer/python/post-create.sh` from a version-pinned URL) and pre-syncs the Python package venv where one is present. Each devcontainer's extension list and the matching workspace's `recommendations` are kept identical - when you add an extension to one, add it to the other. @@ -44,9 +44,9 @@ Both `devcontainer.json` files run two scripts at well-defined points: - **`onCreateCommand`** - `sudo install -d -m 700 -o vscode -g vscode /home/vscode/.ssh`. On macOS hosts the bind-mount surfaces `/home/vscode/.ssh` as root-owned, which would block writes from inside the container (e.g. `gh` updating `known_hosts`). This chown fixes it. Idempotent on Linux and WSL2. - **`postCreateCommand`** - language-specific: - .NET: `catalog/snippets/devcontainer/dotnet/post-create.sh` - runs `dotnet tool restore` (csharpier, dotnet-outdated). - - Python: `catalog/snippets/devcontainer/python/post-create.sh` - installs the pinned `uv` and pre-syncs `PyPiLibrary` if it exists. + - Python: `catalog/snippets/devcontainer/python/post-create.sh` - installs the pinned `uv` and pre-syncs the Python package if present. -Re-runs of either are idempotent. No git hooks are installed by default - see the README's **Optional: enable git hooks locally** section if you want pre-commit checks. +Re-runs of either are idempotent. No git hooks are installed by default. To force them to run again after editing a script: VS Code -> Command Palette -> **Dev Containers: Rebuild Container**. @@ -79,7 +79,7 @@ git -c gpg.format=ssh commit -S --allow-empty -m "verify-signing" git log --show-signature -1 # "Good 'git' signature for ..." ``` -**.NET container** (`DotNet.code-workspace` -> Reopen in Container -> "dotnet"): +**.NET container** (in a repo that ships .NET): ```shell dotnet --version # 10.x @@ -88,12 +88,12 @@ dotnet build # 0 warnings, 0 errors dotnet test # tests pass ``` -**Python container** (`Python.code-workspace` -> Reopen in Container -> "python"): +**Python container** (in a repo that ships Python): ```shell uv --version # uv 0.x which dotnet # nothing - dotnet intentionally absent -cd PyPiLibrary && uv sync && uv run pytest # tests pass +cd && uv sync && uv run pytest # tests pass ``` If `git -c gpg.format=ssh commit -S` errors with `signing failed: no allowed signers`, the bind-mount of `allowed_signers` is missing or the file on the host is empty - re-run the snippet in [host setup][host-setup]. @@ -108,7 +108,7 @@ If `git -c gpg.format=ssh commit -S` errors with `signing failed: no allowed sig **Container builds but extensions don't auto-install** - Make sure VS Code is using the Dev Containers extension (not "Remote - SSH" or "Remote - Tunnels"). The extension auto-install is keyed on `customizations.vscode.extensions` and only Dev Containers honors that. -**Wrong-language work in the wrong container** - The `.NET` container has no `uv` and no Python extensions; the Python container has no `dotnet` SDK and no C# extensions. This is intentional - open the matching workspace and rebuild rather than installing the missing toolchain ad hoc. +**Wrong-language work in the wrong container** - The `.NET` container has no `uv` and no Python extensions; the Python container has no `dotnet` SDK and no C# extensions. This is intentional - use the matching container rather than installing the missing toolchain ad hoc. diff --git a/spec/validate.py b/spec/validate.py index 8f667548..546eeba6 100644 --- a/spec/validate.py +++ b/spec/validate.py @@ -48,7 +48,10 @@ def main(): errors.append(f"{name}: backlog repo without classificationPending") continue - for t in repo.get("types", []): + repo_types = repo.get("types", []) + if not repo_types: + errors.append(f"{name}: cataloged repo has no types (add types or mark it backlog)") + for t in repo_types: if t not in known_types: errors.append(f"{name}: type '{t}' not defined in project-types.json") From 2dd363f449c185d84d566f40ed4f663a36ecb5ab Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 4 Jul 2026 15:11:11 -0700 Subject: [PATCH 14/17] Resolve Copilot round 4: pin CI images by digest; LF-pin Python Pin the markdownlint-cli2, cspell, and actionlint Docker images in test-pull-request.yml to specific digests (version in a trailing comment) so CI is reproducible and not exposed to a floating :latest. Add *.py to the LF pins in .editorconfig and .gitattributes so shebang scripts like spec/validate.py cannot drift to CRLF. Co-Authored-By: Claude Opus 4.8 (1M context) --- .editorconfig | 4 +++ .gitattributes | 33 +++++++++++++------------ .github/workflows/test-pull-request.yml | 7 +++--- 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/.editorconfig b/.editorconfig index 22e58dde..203dd333 100644 --- a/.editorconfig +++ b/.editorconfig @@ -44,6 +44,10 @@ end_of_line = crlf [*.sh] end_of_line = lf +# Python - LF so shebang scripts stay executable +[*.py] +end_of_line = lf + # Dockerfiles - CRLF breaks RUN heredocs and line continuations [{Dockerfile,*.Dockerfile}] end_of_line = lf diff --git a/.gitattributes b/.gitattributes index cae17288..046e12af 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,16 +1,17 @@ -# Default: do not normalize line endings (`* -text`); .editorconfig end_of_line rules guide what the editor writes. -# The exception pins below are git's own enforcement - they force LF for execution-sensitive classes regardless of editor. -# git config --global core.autocrlf false -# git add --renormalize . -# git ls-files --eol -* -text - -# Exception: scripts must stay LF regardless of the `* -text` default - a CRLF shebang breaks execution. `.editorconfig` -# covers `*.sh`, but extensionless executables match no extension rule, so pin them here so git enforces LF on checkout -# and `--renormalize`. Any repo whose tooling ships extensionless scripts adds the matching path pin, e.g. s6-overlay -# init `Docker/s6-overlay/** text eol=lf` or husky/git hooks `.husky/pre-commit text eol=lf`. -*.sh text eol=lf - -# Dockerfiles must be LF - a CRLF breaks RUN heredocs and line continuations. -Dockerfile text eol=lf -*.Dockerfile text eol=lf +# Default: do not normalize line endings (`* -text`); .editorconfig end_of_line rules guide what the editor writes. +# The exception pins below are git's own enforcement - they force LF for execution-sensitive classes regardless of editor. +# git config --global core.autocrlf false +# git add --renormalize . +# git ls-files --eol +* -text + +# Exception: scripts must stay LF regardless of the `* -text` default - a CRLF shebang breaks execution. `.editorconfig` +# covers `*.sh`, but extensionless executables match no extension rule, so pin them here so git enforces LF on checkout +# and `--renormalize`. Any repo whose tooling ships extensionless scripts adds the matching path pin, e.g. s6-overlay +# init `Docker/s6-overlay/** text eol=lf` or husky/git hooks `.husky/pre-commit text eol=lf`. +*.sh text eol=lf +*.py text eol=lf + +# Dockerfiles must be LF - a CRLF breaks RUN heredocs and line continuations. +Dockerfile text eol=lf +*.Dockerfile text eol=lf diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index 28fa6927..c2a80849 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -24,14 +24,15 @@ jobs: - name: Checkout code step uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + # Docker images are digest-pinned (version in the trailing comment) so CI is reproducible. - name: Lint Markdown step - run: docker run --rm -v "$PWD":/workdir davidanson/markdownlint-cli2:latest "**/*.md" + run: docker run --rm -v "$PWD":/workdir davidanson/markdownlint-cli2@sha256:0ed9a5f4c77ef447da2a2ac6e67caf74b214a7f80288819565e8b7d2ac148fe5 "**/*.md" # markdownlint-cli2 v0.22.1 - name: Spell check step - run: docker run --rm -v "$PWD":/workdir --workdir /workdir ghcr.io/streetsidesoftware/cspell:latest "**/*.md" + run: docker run --rm -v "$PWD":/workdir --workdir /workdir ghcr.io/streetsidesoftware/cspell@sha256:cb2eab4ec34956aca554e35615da65401d510fa5983cce2773391e2fd9f4fc20 "**/*.md" # cspell v10.0.1 - name: Lint workflows step - run: docker run --rm -v "$PWD":/repo --workdir /repo rhysd/actionlint:latest -color + run: docker run --rm -v "$PWD":/repo --workdir /repo rhysd/actionlint@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 -color # actionlint v1.7.12 - name: Validate registry and spec step run: | From f3c6413407b2617550f1e62dc5aece9e809f9c53 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 4 Jul 2026 15:17:04 -0700 Subject: [PATCH 15/17] Resolve Copilot round 5: paginate the ruleset lookup configure.sh now uses gh api --paginate for the ruleset name lookup, so a repo with more rulesets than one page can't hide a match and cause a duplicate create. Still fails loudly if the list call itself fails. Co-Authored-By: Claude Opus 4.8 (1M context) --- repo-config/configure.sh | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/repo-config/configure.sh b/repo-config/configure.sh index 885d8040..87bb0e59 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -10,17 +10,15 @@ set -euo pipefail repo="${1:-$(gh repo view --json nameWithOwner --jq '.nameWithOwner')}" script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# Fetch the ruleset list once and fail loudly if the API call itself fails (auth/404/network), so a -# failed fetch is never mistaken for "no ruleset exists" and silently turned into a create. -if ! rulesets="$(gh api "repos/$repo/rulesets")"; then - echo "Failed to list rulesets for $repo (check auth and repo access)." >&2 - exit 1 -fi - for file in "$script_dir"/*.json; do [ -e "$file" ] || continue name="$(jq -r '.name' "$file")" - id="$(jq -r ".[] | select(.name==\"$name\") | .id" <<<"$rulesets")" + # Paginate so a name match on a later page is never missed (which would create a duplicate ruleset), and + # fail loudly if the API call itself fails (auth/404/network) rather than treating it as "not found". + if ! id="$(gh api --paginate "repos/$repo/rulesets" --jq ".[] | select(.name==\"$name\") | .id")"; then + echo "Failed to list rulesets for $repo (check auth and repo access)." >&2 + exit 1 + fi if [ -n "$id" ]; then echo "Updating ruleset '$name' (id $id) on $repo" gh api --method PUT "repos/$repo/rulesets/$id" --input "$file" >/dev/null From a3278fbb688d53497da356c4e091c7e48db90410 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 4 Jul 2026 15:24:12 -0700 Subject: [PATCH 16/17] Resolve Copilot round 6: fully defensive validator; duplicate-safe script validate.py now defensively handles every repo entry: non-object entries, a missing status, an unknown status, and malformed publish entries all produce an actionable error instead of a KeyError/traceback, and the summary counts guard their field access. configure.sh takes the first ruleset when a name has duplicates (drift) and warns rather than PUTting to a multi-id URL. Co-Authored-By: Claude Opus 4.8 (1M context) --- repo-config/configure.sh | 8 +++++++- spec/validate.py | 23 ++++++++++++++++++----- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/repo-config/configure.sh b/repo-config/configure.sh index 87bb0e59..20263f0d 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -15,10 +15,16 @@ for file in "$script_dir"/*.json; do name="$(jq -r '.name' "$file")" # Paginate so a name match on a later page is never missed (which would create a duplicate ruleset), and # fail loudly if the API call itself fails (auth/404/network) rather than treating it as "not found". - if ! id="$(gh api --paginate "repos/$repo/rulesets" --jq ".[] | select(.name==\"$name\") | .id")"; then + if ! ids="$(gh api --paginate "repos/$repo/rulesets" --jq ".[] | select(.name==\"$name\") | .id")"; then echo "Failed to list rulesets for $repo (check auth and repo access)." >&2 exit 1 fi + # Pre-existing drift can leave more than one ruleset with the same name; update the first and warn. + count="$(printf '%s' "$ids" | grep -c .)" + if [ "$count" -gt 1 ]; then + echo "Warning: $count rulesets named '$name' on $repo; updating the first (resolve the duplicates)." >&2 + fi + id="$(printf '%s\n' "$ids" | sed -n '1p')" if [ -n "$id" ]; then echo "Updating ruleset '$name' (id $id) on $repo" gh api --method PUT "repos/$repo/rulesets/$id" --input "$file" >/dev/null diff --git a/spec/validate.py b/spec/validate.py index 546eeba6..fedd935c 100644 --- a/spec/validate.py +++ b/spec/validate.py @@ -41,12 +41,22 @@ def main(): target_mech = secrets["targetMechanisms"] mechanisms = secrets["mechanisms"] - for repo in repos["repos"]: - name = repo["name"] - if repo["status"] == "backlog": + for i, repo in enumerate(repos["repos"]): + if not isinstance(repo, dict): + errors.append(f"repo #{i} is not an object") + continue + name = repo.get("name", f"#{i}") + status = repo.get("status") + if status is None: + errors.append(f"{name}: missing 'status'") + continue + if status == "backlog": if not repo.get("classificationPending"): errors.append(f"{name}: backlog repo without classificationPending") continue + if status != "cataloged": + errors.append(f"{name}: unknown status '{status}'") + continue repo_types = repo.get("types", []) if not repo_types: @@ -57,6 +67,9 @@ def main(): required = set(repo.get("requiredSecrets", [])) for pub in repo.get("publish", []): + if not isinstance(pub, dict) or "target" not in pub or "mechanism" not in pub: + errors.append(f"{name}: publish entry missing 'target'/'mechanism'") + continue target, mech = pub["target"], pub["mechanism"] if target not in target_mech: errors.append(f"{name}: publish target '{target}' unknown") @@ -87,8 +100,8 @@ def main(): for e in errors: print(f" - {e}") return 1 - cataloged = sum(1 for r in repos["repos"] if r["status"] == "cataloged") - backlog = sum(1 for r in repos["repos"] if r["status"] == "backlog") + cataloged = sum(1 for r in repos["repos"] if isinstance(r, dict) and r.get("status") == "cataloged") + backlog = sum(1 for r in repos["repos"] if isinstance(r, dict) and r.get("status") == "backlog") print(f"Spec validation OK: {cataloged} cataloged, {backlog} backlog repos classify cleanly.") return 0 From 38996f2abe040535ab3af4a7805561080fceea5a Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 4 Jul 2026 15:28:50 -0700 Subject: [PATCH 17/17] Resolve Copilot round 7: fix create path abort in configure.sh Guard the duplicate-name count on non-empty ids: grep -c exits non-zero on empty input, which under set -euo pipefail would abort the script in the common "no ruleset yet, create it" path. Now an empty match falls through to create. Co-Authored-By: Claude Opus 4.8 (1M context) --- repo-config/configure.sh | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/repo-config/configure.sh b/repo-config/configure.sh index 20263f0d..790bce58 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -19,12 +19,16 @@ for file in "$script_dir"/*.json; do echo "Failed to list rulesets for $repo (check auth and repo access)." >&2 exit 1 fi - # Pre-existing drift can leave more than one ruleset with the same name; update the first and warn. - count="$(printf '%s' "$ids" | grep -c .)" - if [ "$count" -gt 1 ]; then - echo "Warning: $count rulesets named '$name' on $repo; updating the first (resolve the duplicates)." >&2 + # Pre-existing drift can leave more than one ruleset with the same name; update the first and warn. Guard + # on non-empty so `grep -c` (which exits non-zero on empty input under `set -e`) can't abort the create path. + id="" + if [ -n "$ids" ]; then + count="$(printf '%s\n' "$ids" | grep -c .)" + if [ "$count" -gt 1 ]; then + echo "Warning: $count rulesets named '$name' on $repo; updating the first (resolve the duplicates)." >&2 + fi + id="$(printf '%s\n' "$ids" | sed -n '1p')" fi - id="$(printf '%s\n' "$ids" | sed -n '1p')" if [ -n "$id" ]; then echo "Updating ruleset '$name' (id $id) on $repo" gh api --method PUT "repos/$repo/rulesets/$id" --input "$file" >/dev/null