Skip to content

Instantly share code, notes, and snippets.

@jkone27
Created May 18, 2025 22:48
Show Gist options
  • Save jkone27/44c268c5826d716170e95b79aa753402 to your computer and use it in GitHub Desktop.
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
#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