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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
### Added
- SemverPolicy.MaxImpact cap: block fragments/releases that would force a Major bump unless --allow-major is passed (new + release)
- Sample workspace samples/maximpact-gate demonstrating the MaxImpact cap (run-demo.sh)
- Dogfood the API Surface Gate on ChangeSharp itself: committed baselines (CLI help, MCP tools, library public API) + update script + api-surface CI job + PublicApiBaselineTests
- Expose the safety gates on MCP tools: validate_fragments apiMinLevel, perform_release allowMajor/apiMinLevel
- Unify safety-gate orchestration in the library (GetCreateFragmentError, GetReleaseGateResult) so the CLI and MCP share the same gate sequence
- Record explicit --allow-major decisions in release output (audit trail, CLI + MCP)

### Fixed
- Reduce CodeFactor cognitive-complexity findings in the interactive category menu and version-bump computation (behavior-preserving refactor)
52 changes: 52 additions & 0 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,58 @@ jobs:
if: github.event_name == 'push'
run: changesharp validate

api-surface:
# Dogfoods the API Surface Gate on ChangeSharp's own public surfaces
# (CLI help, MCP tools, library public API). See docs/features/ApiSurfaceGate.md.
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0

- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x

- name: Regenerate public-surface baselines
run: scripts/update-public-api.sh

- name: Committed baselines must match the regenerated surface
run: |
if ! git diff --exit-code -- tests/public-api/; then
echo "::error::Public-surface baselines are out of date. Run scripts/update-public-api.sh and commit the result."
exit 1
fi

- name: Derive API impact from the baseline diff vs main
id: surface
run: |
git fetch origin main --depth=1
DIFF=$(git diff --unified=0 origin/main -- tests/public-api/ || true)
if [ -z "$DIFF" ]; then
LEVEL=patch
else
ADDED=$(echo "$DIFF" | grep -c '^+[^+]' || true)
REMOVED=$(echo "$DIFF" | grep -c '^-[^-]' || true)
if [ "$REMOVED" -gt 0 ]; then LEVEL=major; else LEVEL=minor; fi
fi
echo "Detected API impact level: $LEVEL"
echo "level=$LEVEL" >> "$GITHUB_OUTPUT"

- name: Pack ChangeSharp CLI
run: dotnet pack ChangeSharp.Cli/ChangeSharp.Cli.csproj -o nupkg --nologo

- name: Install ChangeSharp CLI
run: dotnet tool install --global --add-source ./nupkg ChangeSharp.Cli

- name: Enforce fragments against the API surface impact
run: |
export PATH="$PATH:$HOME/.dotnet/tools"
changesharp validate --api-min-level ${{ steps.surface.outputs.level }}

analysis:
# SonarCloud analyzes both main pushes and pull requests. For PRs the scanner
# needs the pull-request parameters plus GITHUB_TOKEN (used to decorate the PR
Expand Down
222 changes: 125 additions & 97 deletions ChangeSharp.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,13 @@ static async Task<int> Main(string[] args)
var breakingOption = new Option<bool>("--breaking") { Description = "Mark change as Breaking Changes." };

var fileOption = new Option<string>("--file") { Description = "Read the change description from a file instead of the message argument, stdin, or a prompt." };
var allowMajorOption = new Option<bool>("--allow-major") { Description = "Allow a fragment whose impact exceeds SemverPolicy.MaxImpact." };

var newCommand = new Command("new", "Create a new unreleased changelog fragment.")
{
messageArgument, addedOption, changedOption, fixedOption,
removedOption, deprecatedOption, securityOption, breakingOption,
fileOption, jsonOption,
fileOption, allowMajorOption, jsonOption,
};

newCommand.SetAction(parseResult =>
Expand Down Expand Up @@ -121,40 +122,54 @@ static async Task<int> Main(string[] args)
bool deprecated = parseResult.GetValue(deprecatedOption);
bool security = parseResult.GetValue(securityOption);
bool breaking = parseResult.GetValue(breakingOption);
bool allowMajor = parseResult.GetValue(allowMajorOption);

bool anyCategoryOptionProvided = added || changed || fixedOpt || removed || deprecated || security || breaking;

if (anyCategoryOptionProvided)
while (true)
{
category = breaking ? "Breaking Changes"
: removed ? "Removed"
: changed ? "Changed"
: deprecated ? "Deprecated"
: fixedOpt ? "Fixed"
: security ? "Security"
: "Added";
}
else if (Console.IsInputRedirected)
{
return o.Err("Category is required when non-interactive. Use one of --added, --changed, --fixed, --removed, --deprecated, --security, --breaking.", ExitCodeValidationError);
}
else
{
category = PromptForCategory();
}
if (anyCategoryOptionProvided)
{
category = breaking ? "Breaking Changes"
: removed ? "Removed"
: changed ? "Changed"
: deprecated ? "Deprecated"
: fixedOpt ? "Fixed"
: security ? "Security"
: "Added";
}
else if (Console.IsInputRedirected)
{
return o.Err("Category is required when non-interactive. Use one of --added, --changed, --fixed, --removed, --deprecated, --security, --breaking.", ExitCodeValidationError);
}
else
{
category = PromptForCategory(allowMajor);
}

try
{
var manager = new WorkspaceManager();
string filePath = manager.CreateFragment(message, category);
return o.Ok(new
try
{
filename = Path.GetFileName(filePath),
category,
path = filePath
}, () => Console.WriteLine($"Created fragment: {Path.GetFileName(filePath)} under category '{category}'"));
var manager = new WorkspaceManager();
string? blockReason = manager.GetCreateFragmentError(category, allowMajor);
if (blockReason != null)
{
if (anyCategoryOptionProvided || Console.IsInputRedirected)
return o.Err(blockReason, ExitCodeValidationError);
Console.WriteLine();
Console.WriteLine($" {blockReason}");
Console.WriteLine(" Choose another category, or rerun with --allow-major.");
continue;
}
string filePath = manager.CreateFragment(message, category);
return o.Ok(new
{
filename = Path.GetFileName(filePath),
category,
path = filePath
}, () => Console.WriteLine($"Created fragment: {Path.GetFileName(filePath)} under category '{category}'"));
}
catch (Exception ex) { return o.Err(ex.Message); }
}
catch (Exception ex) { return o.Err(ex.Message); }
});
rootCommand.Add(newCommand);

Expand Down Expand Up @@ -238,8 +253,20 @@ static async Task<int> Main(string[] args)

if (!hasErrors)
{
int? apiResult = CheckApiMinLevel(parseResult, manager, apiMinLevelOption, apiMinLevelWarnOption, o);
if (apiResult.HasValue) return apiResult.Value;
string? apiMinLevelValue = parseResult.GetValue(apiMinLevelOption);
if (apiMinLevelValue != null)
{
bool warnOnly = parseResult.GetValue(apiMinLevelWarnOption);
var (pass, maxImpact, maxLevelName) = manager.CheckApiMinLevel(apiMinLevelValue);
if (!pass)
{
string message = $"API surface requires at least a '{apiMinLevelValue}' bump, but fragments only reach '{maxLevelName}' (level {maxImpact}).";
if (warnOnly)
o.Warn(message);
else
return o.Err(message, ExitCodeValidationError);
}
}
}

var jsonResults = results.Select(r => new { file = r.FilePath, valid = r.IsValid, errors = r.Errors }).ToList();
Expand Down Expand Up @@ -283,10 +310,11 @@ static async Task<int> Main(string[] args)
var dryRunOption = new Option<bool>("--dry-run") { Description = "Display what would happen without making any changes." };
var allowEmptyOption = new Option<bool>("--allow-empty") { Description = "Exit with success even if no unreleased fragments are found." };
var requireApprovalOption = new Option<bool>("--require-approval") { Description = "Require explicit approval (CHANGESHARP_ALLOW_UNSAFE_RELEASE) to proceed." };
var allowMajorReleaseOption = new Option<bool>("--allow-major") { Description = "Allow a release whose impact exceeds SemverPolicy.MaxImpact." };
var releaseCommand = new Command("release", "Aggregate fragments, bump version, update CHANGELOG.md, and clean up.")
{
dryRunOption, allowEmptyOption, requireApprovalOption,
apiMinLevelOption, apiMinLevelWarnOption, jsonOption
apiMinLevelOption, apiMinLevelWarnOption, allowMajorReleaseOption, jsonOption
};
releaseCommand.SetAction(parseResult =>
{
Expand Down Expand Up @@ -368,13 +396,26 @@ static async Task<int> Main(string[] args)
});
}

int? apiResult = CheckApiMinLevel(parseResult, manager, apiMinLevelOption, apiMinLevelWarnOption, o);
if (apiResult.HasValue) return apiResult.Value;
string? apiMinLevelValue = parseResult.GetValue(apiMinLevelOption);
bool warnOnly = parseResult.GetValue(apiMinLevelWarnOption);
bool allowMajor = parseResult.GetValue(allowMajorReleaseOption);

var gate = manager.GetReleaseGateResult(apiMinLevelValue, allowMajor);
if (gate.Blocked)
{
if (gate.CapExceeded || !warnOnly)
return o.Err(gate.Message, ExitCodeValidationError);
o.Warn(gate.Message);
}

var (nextVersion, releaseWarnings) = manager.Release(DateTime.Today, dryRun);
foreach (var w in releaseWarnings)
var allWarnings = releaseWarnings.Append(gate.CapExceeded ? "Major bump explicitly allowed via --allow-major." : null)
.Where(w => w != null)
.Cast<string>()
.ToList();
foreach (var w in allWarnings)
Console.Error.WriteLine($"Warning: {w}");
return o.Ok(new { releasedVersion = nextVersion, warnings = releaseWarnings },
return o.Ok(new { releasedVersion = nextVersion, warnings = allWarnings },
() => Console.WriteLine($"Release successful! New version: {nextVersion}"));
}
catch (InvalidOperationException ex) when (ex.Message.Contains("Conflict"))
Expand Down Expand Up @@ -581,35 +622,13 @@ static async Task<int> Main(string[] args)
private static Output Out(ParseResult pr, Option<bool> jsonOption) =>
new(pr.GetValue(jsonOption));

private static int? CheckApiMinLevel(ParseResult parseResult, WorkspaceManager manager,
Option<string> apiMinLevelOption, Option<bool> apiMinLevelWarnOption, Output o)
{
string? minLevel = parseResult.GetValue(apiMinLevelOption);
if (minLevel == null) return null;

bool warnOnly = parseResult.GetValue(apiMinLevelWarnOption);
var (pass, maxImpact, maxLevelName) = manager.CheckApiMinLevel(minLevel);

if (pass) return null;

string message = $"API surface requires at least a '{minLevel}' bump, but fragments only reach '{maxLevelName}' (level {maxImpact}).";

if (warnOnly)
{
o.Warn(message);
return null;
}

return o.Err(message, ExitCodeValidationError);
}

private static string? PromptForMessage()
{
Console.Write("Enter a description for the change: ");
return Console.ReadLine();
}

private static string PromptForCategory()
private static string PromptForCategory(bool allowMajor)
{
var categories = new (string Name, string Description)[]
{
Expand All @@ -622,67 +641,76 @@ private static string PromptForCategory()
("Breaking Changes", "Backward-incompatible change")
};

Dictionary<string, string>? impacts = null;
SemverPolicyConfig? policy = null;
try
{
impacts = new WorkspaceManager().LoadConfig().SemverPolicy.Mappings;
policy = new WorkspaceManager().LoadConfig().SemverPolicy;
}
catch
{
// impact display is best-effort
}

int maxAllowed = policy == null ? 3 : NextVersionComputer.ParseImpact(policy.MaxImpact);

string? impactOf(string name) =>
policy?.Mappings.TryGetValue(name, out var v) == true ? v : null;

bool isBlocked(string name) =>
!allowMajor && impactOf(name) is { } impact && NextVersionComputer.ParseImpact(impact) > maxAllowed;

int selected = 0;
Console.WriteLine("Select a category (↑/↓ to navigate, Enter to confirm, Esc to cancel, 1-7 to jump):");
while (true)
{
for (int i = 0; i < categories.Length; i++)
{
Console.CursorLeft = 0;
string impact = impacts != null && impacts.TryGetValue(categories[i].Name, out var v) ? $" ({v})" : "";
if (i == selected)
{
Console.Write("> ");
Console.BackgroundColor = ConsoleColor.DarkBlue;
Console.ForegroundColor = ConsoleColor.White;
Console.Write(categories[i].Name.PadRight(18));
Console.ResetColor();
Console.WriteLine($"{impact} — {categories[i].Description}");
}
else
{
Console.WriteLine($" {categories[i].Name.PadRight(18)}{impact} — {categories[i].Description}");
}
}

RenderCategoryMenu(categories, selected, impactOf, isBlocked);
var key = Console.ReadKey(true);
if (key.Key == ConsoleKey.UpArrow && selected > 0)
{
selected--;
}
else if (key.Key == ConsoleKey.DownArrow && selected < categories.Length - 1)
{
selected++;
}
else if (key.Key == ConsoleKey.Enter)
var next = ApplyCategoryKey(key, categories.Length, selected);
if (next.IsFinal)
{
selected = next.Selected;
break;
}
else if (key.Key == ConsoleKey.Escape)
selected = next.Selected;
Console.CursorTop -= categories.Length;
}

return categories[selected].Name;
}

private static void RenderCategoryMenu(
(string Name, string Description)[] categories, int selected,
Func<string, string?> impactOf, Func<string, bool> isBlocked)
{
for (int i = 0; i < categories.Length; i++)
{
Console.CursorLeft = 0;
string impact = impactOf(categories[i].Name) is { } v ? $" ({v})" : "";
string blocked = isBlocked(categories[i].Name) ? " ⚠ blocked (MaxImpact)" : "";
if (i == selected)
{
selected = 0;
break;
Console.Write("> ");
Console.BackgroundColor = ConsoleColor.DarkBlue;
Console.ForegroundColor = ConsoleColor.White;
Console.Write(categories[i].Name.PadRight(18));
Console.ResetColor();
Console.WriteLine($"{impact}{blocked} — {categories[i].Description}");
}
else if (key.Key >= ConsoleKey.D1 && key.Key <= ConsoleKey.D7)
else
{
selected = key.Key - ConsoleKey.D1;
break;
Console.WriteLine($" {categories[i].Name.PadRight(18)}{impact}{blocked} — {categories[i].Description}");
}

Console.CursorTop -= categories.Length;
}
}

return categories[selected].Name;
private static (bool IsFinal, int Selected) ApplyCategoryKey(ConsoleKeyInfo key, int count, int selected)
{
if (key.Key == ConsoleKey.UpArrow && selected > 0) return (false, selected - 1);
if (key.Key == ConsoleKey.DownArrow && selected < count - 1) return (false, selected + 1);
if (key.Key == ConsoleKey.Escape) return (true, 0);
if (key.Key >= ConsoleKey.D1 && key.Key <= ConsoleKey.D7) return (true, key.Key - ConsoleKey.D1);
if (key.Key == ConsoleKey.Enter) return (true, selected);
return (false, selected);
}
}

Expand Down
Loading
Loading