Created
May 18, 2025 22:48
-
-
Save jkone27/44c268c5826d716170e95b79aa753402 to your computer and use it in GitHub Desktop.
fsx script to bump package.json based on keep a changelog format using ionide keep a changelog
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#r "nuget: Ionide.KeepAChangelog" | |
#r "nuget: Newtonsoft.Json" | |
#r "nuget: Fli" | |
open System | |
open System.IO | |
open Newtonsoft.Json.Linq | |
open Ionide.KeepAChangelog | |
open Ionide.KeepAChangelog.Domain | |
open Fli | |
type BumpType = | |
| Major | |
| Minor | |
| Patch | |
static member Parse (bumpType: string) = | |
match bumpType.Trim().ToLower() with | |
| "major" -> Major | |
| "minor" -> Minor | |
| "patch" -> Patch | |
| _ -> failwith "Invalid bump type" | |
override this.ToString() = | |
match this with | |
| Major -> "major" | |
| Minor -> "minor" | |
| Patch -> "patch" | |
// Helper to render a single changelog section | |
let private renderSection (title: string) (content: string) = | |
if System.String.IsNullOrWhiteSpace(content) then "" | |
else $"### {title}\n{content}\n" | |
let private renderChangeLogData (header: string) (data: ChangelogData) = | |
[ renderSection "Added" data.Added | |
renderSection "Changed" data.Changed | |
renderSection "Deprecated" data.Deprecated | |
renderSection "Removed" data.Removed | |
renderSection "Fixed" data.Fixed | |
renderSection "Security" data.Security ] | |
|> List.filter (fun s -> not (System.String.IsNullOrWhiteSpace s)) | |
|> String.concat "\n\n" | |
|> fun body -> $"{header}\n\n{body}" | |
// Helper to render a single release | |
let private renderRelease (version: SemVersion.SemanticVersion, date: DateTime, dataOpt: ChangelogData option) = | |
let dateStr = date.ToString() | |
let header = $"## {version} - {dateStr}" | |
match dataOpt with | |
| None -> header | |
| Some data -> | |
let body = data |> renderChangeLogData header | |
// Remove trailing newlines for consistent spacing | |
body.TrimEnd() | |
// Helper to render unreleased changes | |
let private renderUnreleased (unreleased: ChangelogData option) = | |
let header = "## Unreleased" | |
match unreleased with | |
| None -> header | |
| Some data -> | |
data | |
|> renderChangeLogData header | |
|> _.TrimEnd() | |
type Changelogs with | |
member this.ToMarkDownString() : string = | |
let header = | |
"# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n" | |
let unreleased = | |
match this.Unreleased with | |
| None -> renderUnreleased None | |
| Some data -> renderUnreleased (Some data) | |
let releases = | |
this.Releases | |
|> List.map renderRelease | |
|> String.concat "\n\n" | |
|> fun s -> s.TrimEnd() | |
[header; unreleased; releases] | |
|> List.filter (fun s -> not (System.String.IsNullOrWhiteSpace s)) | |
|> String.concat "\n\n" | |
// --- Argument Parsing and Utilities --- | |
module Args = | |
let isDryRun = | |
fsi.CommandLineArgs | |
|> Array.exists (fun arg -> arg = "--dry-run") | |
let bumpType = | |
fsi.CommandLineArgs | |
|> Array.tryFind (fun arg -> arg.StartsWith("--bump-type=")) | |
|> Option.map (fun arg -> arg.Substring("--bump-type=".Length)) | |
|> Option.map BumpType.Parse | |
let isDebug = | |
fsi.CommandLineArgs | |
|> Array.exists (fun arg -> arg = "--debug") | |
let prompt (message: string) = | |
Console.Write(message) | |
Console.ReadLine() | |
// --- Version Handling --- | |
module Versioning = | |
let getCurrentPackageJsonVersion () = | |
let json = File.ReadAllText("package.json") | |
let j = JObject.Parse(json) | |
let versionStr = j.["version"].ToString() | |
SemVersion.SemanticVersion.Parse(versionStr) | |
let bumpVersion (current: SemVersion.SemanticVersion) (bumpType: BumpType) = | |
match bumpType with | |
| Major -> SemVersion.SemanticVersion( (current.Major.Value + 1), 0, 0) | |
| Minor -> SemVersion.SemanticVersion(current.Major, (current.Minor.Value + 1), 0) | |
| Patch -> SemVersion.SemanticVersion(current.Major, current.Minor, (current.Patch.Value + 1)) | |
let rec promptBumpType (currentVersion: SemVersion.SemanticVersion) = | |
let input = Args.prompt $"Enter bump type (major, minor, patch) [current: {currentVersion}]: " | |
try BumpType.Parse input | |
with _ -> | |
printfn "Invalid bump type. Please enter 'major', 'minor', or 'patch'." | |
promptBumpType currentVersion | |
let npmVersion (bumpType: BumpType) isDryRun = | |
if isDryRun then | |
$"[dry-run] Would run: npm version {bumpType}" | |
else | |
cli { | |
Exec "npm" | |
Arguments ["version"; bumpType.ToString()] | |
} | |
|> Command.execute | |
|> Output.toText | |
// --- Changelog Handling --- | |
module Changelog = | |
let changelogPath = "./CHANGELOG.md" | |
let getChangelog () = | |
let changelogFileInfo = new FileInfo(changelogPath) | |
if not changelogFileInfo.Exists then | |
failwith "error - CHANGELOG.md not found" | |
match Parser.parseChangeLog changelogFileInfo with | |
| Ok changelog -> | |
if Args.isDebug then | |
printfn "DEBUG: Parsed changelog: %A" changelog | |
changelog | |
| Error (msg, _) -> | |
printfn "Error parsing changelog: %s" msg | |
Environment.Exit(1) | |
failwith "Changelog parsing failed" | |
/// if unreleased section is empty, prompt for changes for new release | |
let promptNewReleaseChanges () : ChangelogData option = | |
let toSection entries = | |
entries | |
|> List.map (fun e -> "- " + e) |> String.concat "\n" | |
[ "Added"; "Changed"; "Fixed"; "Removed" ] | |
|> List.map (fun changeType -> | |
let entry = | |
Args.prompt $"Enter {changeType} changes (comma-separated, leave blank if none): " | |
let entries = | |
entry.Split(',') | |
|> Array.map _.Trim() | |
|> Array.filter (fun s -> s <> "") | |
|> List.ofArray | |
changeType, entries |> toSection | |
) | |
|> List.filter (fun (_, entries) -> entries.Length > 0) | |
|> List.fold (fun acc (changeType, entries) -> | |
match changeType with | |
| "Added" -> { acc with Added = entries } | |
| "Changed" -> { acc with Changed = entries } | |
| "Fixed" -> { acc with Fixed = entries } | |
| "Removed" -> { acc with Removed = entries } | |
| _ -> acc | |
) { Added = ""; Changed = ""; Fixed = ""; Removed = ""; Deprecated = ""; Security = ""; Custom = Map.empty } | |
|> Some | |
/// Prompt for changes if the unreleased section is empty | |
/// Insert a new release using Ionide.KeepAChangelog | |
let insertRelease (newVersion: SemVersion.SemanticVersion) (changelog: Changelogs) = | |
let release = (newVersion, DateTime.UtcNow, | |
changelog.Unreleased | |
|> Option.orElseWith promptNewReleaseChanges | |
) | |
// Prepend the new release to the releases list, preserving all previous releases | |
let updatedChangelog = { | |
{ changelog with Releases = release :: changelog.Releases } | |
with Unreleased = None | |
} | |
if Args.isDebug then | |
printfn "DEBUG: Updated changelog releases: %A" updatedChangelog.Releases | |
updatedChangelog.ToMarkDownString() | |
// --- Main Script Logic --- | |
open Versioning | |
open Changelog | |
let currentVersion = Versioning.getCurrentPackageJsonVersion () | |
printfn $"Current version: {currentVersion}" | |
let finalBumpType = | |
match Args.bumpType with | |
| None -> Versioning.promptBumpType currentVersion | |
| Some bumpType -> bumpType | |
let npmVersionOutput = Versioning.npmVersion finalBumpType Args.isDryRun | |
printfn "Updated package.json: %s" npmVersionOutput | |
let newVersion = | |
if Args.isDryRun then | |
Versioning.bumpVersion currentVersion finalBumpType | |
else | |
Versioning.getCurrentPackageJsonVersion () | |
printfn $"New version: {newVersion}" | |
let updatedChangelogText : string = | |
Changelog.getChangelog () | |
|> Changelog.insertRelease newVersion | |
if Args.isDryRun then | |
printfn "[dry-run] Would update CHANGELOG.md with:\n%s" updatedChangelogText | |
else | |
File.WriteAllText(Changelog.changelogPath, updatedChangelogText) | |
printfn "CHANGELOG.md updated." |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment