diff --git a/CHANGELOG.md b/CHANGELOG.md index 17837967c..c1a1417a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [8.0.0-alpha-001] - 2025-12-12 + +### Changed + +- Update FCS to 'Remove LetOrUseKeyword from SynExprLetOrUseTrivia', commit 43932b4c7984d6562e91e5f1484868cd4f5befcf [#3167](https://github.com/fsprojects/fantomas/pull/3167) + ## [7.0.5] - 2025-12-06 ### Fixed diff --git a/build.fsx b/build.fsx index d6090d53e..c52c4f479 100644 --- a/build.fsx +++ b/build.fsx @@ -19,6 +19,10 @@ open Humanizer let () a b = Path.Combine(a, b) +let isDryRun = + let args = fsi.CommandLineArgs + Array.exists (fun arg -> arg = "--dry-run") args + let cleanFolders (input: string seq) = async { input @@ -40,15 +44,19 @@ let semanticVersioning = let pushPackage nupkg = async { - let key = Environment.GetEnvironmentVariable("NUGET_KEY") - let! result = - Cli - .Wrap("dotnet") - .WithArguments($"nuget push {nupkg} --api-key {key} --source https://api.nuget.org/v3/index.json") - .ExecuteAsync() - .Task - |> Async.AwaitTask - return result.ExitCode + if isDryRun then + printfn $"[DRY-RUN] Would push package: {nupkg}" + return 0 + else + let key = Environment.GetEnvironmentVariable("NUGET_KEY") + let! result = + Cli + .Wrap("dotnet") + .WithArguments($"nuget push {nupkg} --api-key {key} --source https://api.nuget.org/v3/index.json") + .ExecuteAsync() + .Task + |> Async.AwaitTask + return result.ExitCode } let analysisReportsDir = "analysisreports" @@ -321,23 +329,35 @@ pipeline "Init" { } type GithubRelease = - { Version: string - Title: string - Date: DateTime - PublishedDate: string option - Draft: string } + { + Version: string + Title: string + Date: DateTime + /// Optional because new releases don't have a published date yet + PublishedDate: string option + Draft: string + } let mkGithubRelease (v: SemanticVersion, d: DateTime, cd: ChangelogData option) = match cd with | None -> failwith "Each Fantomas release is expected to have at least one section." | Some cd -> - let version = $"{v.Major}.{v.Minor}.{v.Patch}" + let version = + if String.IsNullOrEmpty v.Prerelease then + $"{v.Major}.{v.Minor}.{v.Patch}" + else + $"{v.Major}.{v.Minor}.{v.Patch}-{v.Prerelease}" + + printfn $"Parsing release version: {version} (prerelease: {not (String.IsNullOrEmpty v.Prerelease)})" + let title = let month = d.ToString("MMMM") let day = d.Day.Ordinalize() $"{month} {day} Release" let prefixedVersion = $"v{version}" + printfn $"Checking if release {prefixedVersion} already exists on GitHub..." + let publishDate = let cmdResult = Cli @@ -347,11 +367,14 @@ let mkGithubRelease (v: SemanticVersion, d: DateTime, cd: ChangelogData option) .ExecuteBufferedAsync() .Task.Result if cmdResult.ExitCode <> 0 then + printfn $"Release {prefixedVersion} does not exist yet" None else let output = cmdResult.StandardOutput.Trim() let lastIdx = output.LastIndexOf("Z", StringComparison.Ordinal) - Some(output.Substring(0, lastIdx)) + let dateStr = output.Substring(0, lastIdx) + printfn $"Release {prefixedVersion} already exists, published at: {dateStr}" + Some(dateStr) let sections = [ "Added", cd.Added @@ -384,32 +407,139 @@ let mkGithubRelease (v: SemanticVersion, d: DateTime, cd: ChangelogData option) Draft = draft } let getReleaseNotes currentRelease (lastRelease: GithubRelease) = - let date = lastRelease.PublishedDate.Value + let date = + match lastRelease.PublishedDate with + | Some d -> + printfn $"Using last release published date for author attribution: {d}" + d + | None -> + // Query GitHub for the most recent published release + printfn "Last release has no published date, querying GitHub for most recent release..." + let ghReleaseResult = + Cli + .Wrap("gh") + .WithArguments("release list --limit 1 --json createdAt") + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync() + .Task.Result + + if + ghReleaseResult.ExitCode = 0 + && not (String.IsNullOrWhiteSpace(ghReleaseResult.StandardOutput.Trim())) + then + let jsonOutput = ghReleaseResult.StandardOutput.Trim() + let jsonValue = FSharp.Data.JsonValue.Parse(jsonOutput) + let releases = jsonValue.AsArray() + if releases.Length > 0 then + match releases.[0].TryGetProperty("createdAt") with + | Some createdAtJson -> + let createdAt = createdAtJson.AsString() + // Parse ISO 8601 date and convert back to string format for the query + let dateTime = + DateTime + .Parse(createdAt, null, System.Globalization.DateTimeStyles.RoundtripKind) + .ToUniversalTime() + let ghDate = dateTime.ToString("yyyy-MM-ddTHH:mm:ss") + printfn $"Using most recent GitHub release date for author attribution: {ghDate}" + ghDate + | None -> + let fallbackDate = DateTime.UtcNow.ToString("yyyy-MM-dd") + printfn $"GitHub release missing createdAt, using current date: {fallbackDate}" + fallbackDate + else + let fallbackDate = DateTime.UtcNow.ToString("yyyy-MM-dd") + printfn $"No GitHub releases found, using current date: {fallbackDate}" + fallbackDate + else + let fallbackDate = DateTime.UtcNow.ToString("yyyy-MM-dd") + printfn $"Could not query GitHub releases, using current date: {fallbackDate}" + fallbackDate + + printfn $"Querying PRs closed after {date} for author attribution..." + let authorMsg = - let authors = + let queryResult = Cli .Wrap("gh") - .WithArguments( - $"pr list -S \"state:closed base:main closed:>{date} -author:app/robot\" --json author --jq \".[].author.login\"" - ) + .WithArguments($"pr list -S \"state:closed base:main closed:>{date}\" --json commits,mergedAt") + .WithValidation(CommandResultValidation.None) .ExecuteBufferedAsync() - .Task.Result.StandardOutput.Split([| '\n' |], StringSplitOptions.RemoveEmptyEntries) - |> Array.distinct - |> Array.sort + .Task.Result - if authors.Length = 1 then - $"Special thanks to @%s{authors.[0]}!" + if queryResult.ExitCode <> 0 then + printfn $"Warning: Failed to query PRs for author attribution (exit code: {queryResult.ExitCode})" + String.Empty else - let lastAuthor = Array.last authors - let otherAuthors = - if authors.Length = 2 then - $"@{authors.[0]}" - else - authors - |> Array.take (authors.Length - 1) - |> Array.map (sprintf "@%s") - |> String.concat ", " - $"Special thanks to %s{otherAuthors} and @%s{lastAuthor}!" + let jsonOutput = queryResult.StandardOutput.Trim() + + // Parse JSON to filter by mergedAt timestamp + let jsonValue = FSharp.Data.JsonValue.Parse(jsonOutput) + let prs = jsonValue.AsArray() + + // Parse the date as ISO 8601 format (GitHub always returns dates in this format: "2025-08-02T10:25:30Z") + let cutoffTimestamp = + DateTime.Parse(date, null, System.Globalization.DateTimeStyles.RoundtripKind).ToUniversalTime() + + printfn $"Filtering PRs merged after: {cutoffTimestamp:O}" + + let authors = + prs + |> Array.collect (fun (pr: FSharp.Data.JsonValue) -> + let mergedAtOpt = + match pr.TryGetProperty("mergedAt") with + | Some mergedAtJson -> + let mergedAtStr = mergedAtJson.AsString() + match + DateTime.TryParse(mergedAtStr, null, System.Globalization.DateTimeStyles.RoundtripKind) + with + | true, dt -> Some(dt.ToUniversalTime()) + | false, _ -> None + | None -> None + + match mergedAtOpt with + | Some mergedAt when mergedAt > cutoffTimestamp -> + match pr.TryGetProperty("commits") with + | Some commitsJson -> + let commits = commitsJson.AsArray() + commits + |> Array.collect (fun (commit: FSharp.Data.JsonValue) -> + match commit.TryGetProperty("authors") with + | Some authorsJson -> + let commitAuthors = authorsJson.AsArray() + commitAuthors + |> Array.choose (fun (author: FSharp.Data.JsonValue) -> + match author.TryGetProperty("login") with + | Some loginJson -> + let login = loginJson.AsString() + // Filter out bots + if login.EndsWith("[bot]", StringComparison.Ordinal) then + None + else + Some(login) + | None -> None) + | None -> [||]) + | None -> [||] + | _ -> [||]) + |> Array.distinct + |> Array.sort + + printfn $"Found {authors.Length} contributors for this release" + + if authors.Length = 0 then + String.Empty + elif authors.Length = 1 then + $"Special thanks to @%s{authors.[0]}!" + else + let lastAuthor = Array.last authors + let otherAuthors = + if authors.Length = 2 then + $"@{authors.[0]}" + else + authors + |> Array.take (authors.Length - 1) + |> Array.map (sprintf "@%s") + |> String.concat ", " + $"Special thanks to %s{otherAuthors} and @%s{lastAuthor}!" $"""{currentRelease.Draft} @@ -419,20 +549,38 @@ let getReleaseNotes currentRelease (lastRelease: GithubRelease) = """ let getCurrentAndLastReleaseFromChangelog () = + printfn "Parsing CHANGELOG.md to find current and last release..." let changelog = FileInfo(__SOURCE_DIRECTORY__ "CHANGELOG.md") let changeLogResult = match Parser.parseChangeLog changelog with - | Error error -> failwithf "%A" error - | Ok result -> result + | Error error -> failwithf "Failed to parse changelog: %A" error + | Ok result -> + printfn $"Found {result.Releases.Length} releases in changelog" + result let lastReleases = changeLogResult.Releases - |> List.filter (fun (v, _, _) -> String.IsNullOrEmpty v.Prerelease) |> List.sortByDescending (fun (_, d, _) -> d) |> List.take 2 match lastReleases with - | [ current; last ] -> mkGithubRelease current, mkGithubRelease last + | [ current; last ] -> + let currentVersion, _, _ = current + let lastVersion, _, _ = last + let currentPrerelease = + if String.IsNullOrEmpty currentVersion.Prerelease then + "" + else + $" (prerelease: {currentVersion.Prerelease})" + let lastPrerelease = + if String.IsNullOrEmpty lastVersion.Prerelease then + "" + else + $" (prerelease: {lastVersion.Prerelease})" + printfn + $"Current release: {currentVersion.Major}.{currentVersion.Minor}.{currentVersion.Patch}{currentPrerelease}" + printfn $"Last release: {lastVersion.Major}.{lastVersion.Minor}.{lastVersion.Patch}{lastPrerelease}" + mkGithubRelease current, mkGithubRelease last | _ -> failwith "Could not find the current and last release from CHANGELOG.md" pipeline "Release" { @@ -443,45 +591,112 @@ pipeline "Release" { stage "Release" { run (fun _ -> async { + if isDryRun then + printfn "[DRY-RUN] Starting release pipeline in dry-run mode" + else + printfn "Starting release pipeline" + let currentRelease, lastRelease = getCurrentAndLastReleaseFromChangelog () if Option.isSome currentRelease.PublishedDate then + printfn $"Release {currentRelease.Version} already exists on GitHub. Skipping release process." return 0 else + printfn $"Release {currentRelease.Version} does not exist yet. Proceeding with release process." + + // Determine if this is a prerelease + let isPrerelease = currentRelease.Version.Contains("-") + if isPrerelease then + printfn $"Detected prerelease version: {currentRelease.Version}" + // Push packages to NuGet let nugetPackages = Directory.EnumerateFiles("artifacts/package/release", "*.nupkg", SearchOption.TopDirectoryOnly) |> Seq.filter (fun nupkg -> not (nupkg.Contains("Fantomas.Client"))) |> Seq.toArray + printfn $"Found {nugetPackages.Length} packages to push to NuGet:" + nugetPackages |> Array.iter (fun pkg -> printfn $" - {Path.GetFileName(pkg)}") + let! nugetExitCodes = nugetPackages |> Array.map pushPackage |> Async.Sequential + let nugetSuccess = nugetExitCodes |> Array.forall (fun code -> code = 0) + if nugetSuccess then + printfn "All NuGet packages pushed successfully" + else + let exitCodesStr = nugetExitCodes |> Array.map string |> String.concat ", " + printfn $"Warning: Some NuGet packages failed to push. Exit codes: {exitCodesStr}" + let notes = getReleaseNotes currentRelease lastRelease + printfn "Release notes that will be used:" + printfn "---" + printfn "%s" notes + printfn "---" let noteFile = Path.GetTempFileName() File.WriteAllText(noteFile, notes) let files = nugetPackages |> String.concat " " // We create a draft release for minor and majors. Those that requires a manual publish. // This is to allow us to add additional release notes when it makes sense. - let! draftResult = - let isDraft = - let isRevision = lastRelease.Version.Split('.') |> Array.last |> int |> (<>) 0 - if isRevision then String.Empty else "--draft" - - Cli - .Wrap("gh") - .WithArguments( - $"release create v{currentRelease.Version} {files} {isDraft} --title \"{currentRelease.Title}\" --notes-file \"{noteFile}\"" - ) - .WithValidation(CommandResultValidation.None) - .ExecuteAsync() - .Task - |> Async.AwaitTask + // Extract patch version from currentRelease.Version (handle prerelease format) + let versionParts = currentRelease.Version.Split('-') + let mainVersion = versionParts.[0] + let patchVersion = + let parts = mainVersion.Split('.') + if parts.Length >= 3 then + match Int32.TryParse(parts.[2]) with + | true, p -> p + | _ -> 0 + else + 0 + + let isRevision = patchVersion <> 0 + // Draft only for stable minor/major releases (patch = 0 and not prerelease) + let isDraftFlag = + if isRevision || isPrerelease then + String.Empty + else + "--draft" + let prereleaseFlag = if isPrerelease then "--prerelease" else String.Empty + + let releaseType = + if isPrerelease then "prerelease (published)" + elif isRevision then "revision (published)" + else "minor/major (draft)" + printfn $"Release type: {releaseType}" + if isPrerelease then + printfn "This is a prerelease version" + + let releaseCommand = + $"release create v{currentRelease.Version} {files} {isDraftFlag} {prereleaseFlag} --title \"{currentRelease.Title}\" --notes-file \"{noteFile}\"" + + let! draftExitCode = + if isDryRun then + printfn $"[DRY-RUN] Would execute: gh {releaseCommand}" + async { return 0 } + else + printfn $"Creating GitHub release: v{currentRelease.Version}" + async { + let! result = + Cli + .Wrap("gh") + .WithArguments(releaseCommand) + .WithValidation(CommandResultValidation.None) + .ExecuteAsync() + .Task + |> Async.AwaitTask + return result.ExitCode + } if File.Exists noteFile then File.Delete(noteFile) - return Seq.max [| yield! nugetExitCodes; yield draftResult.ExitCode |] + if draftExitCode = 0 then + printfn $"Successfully created GitHub release: v{currentRelease.Version}" + else + printfn $"Warning: GitHub release creation returned exit code: {draftExitCode}" + + return Seq.max [| yield! nugetExitCodes; yield draftExitCode |] }) } runIfOnlySpecified true diff --git a/docs/docs/contributors/Releases.md b/docs/docs/contributors/Releases.md index debf86f62..7ce2452f0 100644 --- a/docs/docs/contributors/Releases.md +++ b/docs/docs/contributors/Releases.md @@ -5,7 +5,12 @@ index: 14 --- # Releases -Releases in Fantomas are not automated and require some manual steps by the maintainers. +Releases in Fantomas are automated via GitHub Actions. When a new release entry is added to the [CHANGELOG.md](https://github.com/fsprojects/fantomas/blob/main/CHANGELOG.md) and pushed to the `main` branch, the release workflow will automatically: + +1. Build and test the project +2. Create NuGet packages +3. Publish packages to NuGet +4. Create a GitHub release with release notes ## Preparation @@ -15,62 +20,81 @@ The [CHANGELOG.md](https://github.com/fsprojects/fantomas/blob/main/CHANGELOG.md ## [5.1.0] - 2022-11-04 ``` -It is custom to have the next version merged into the `main` branch and locally publish from there. - -Verify that all recent PRs and closed issues are listed in the changelog. Normally, this should be ok as we require a changelog entry before we merge a PR. +For prerelease versions, include the prerelease suffix: -## NuGet push - -To publish the new versions to NuGet: +```md +## [8.0.0-alpha-001] - 2024-12-12 +``` -> dotnet fsi build.fsx --push +Verify that all recent PRs and closed issues are listed in the changelog. Normally, this should be ok as we require a changelog entry before we merge a PR. -The `--push` will try and publish the `*.nupkg` files created by the build in the `bin` folder. -`Fantomas.Client` will be excluded and requires a specific pipeline to publish. +Once the changelog entry is merged into `main`, the release workflow will automatically trigger and handle the release process. -The pipeline does assume that the `NUGET_KEY` environment variable is set with a valid NuGet key. +## Testing Releases Locally -## GitHub release +You can test the release process locally using the `--dry-run` flag. This will perform all validation and generate release notes without actually publishing to NuGet or creating a GitHub release: -A new GitHub release entry should be created for official versions. This will notify users who have subscribed to releases. -In the past some alpha or beta releases have had a GitHub release, it depends on the occasion. +```bash +dotnet fsi build.fsx -- -p Release --dry-run +``` -### Tag +This is useful for: +- Verifying the release notes before publishing +- Checking that the changelog is parsed correctly +- Testing author attribution +- Ensuring the release pipeline works as expected -Create a new tag based on the main branch. The assumption is that the `CHANGELOG` file contains the tag version you are about to create. +## Automated Release Process -### Release title +The release pipeline (`build.fsx -p Release`) performs the following steps: -For a revision release you can use the current date (example: `October 13th Release`), for a minor or major releas pick the month name (example: `September`). +1. **Parses the changelog** to find the current and last release +2. **Checks if the release already exists** on GitHub (skips if already published) +3. **Builds and tests** the project +4. **Creates NuGet packages** for all projects (except `Fantomas.Client`) +5. **Publishes packages to NuGet** +6. **Generates release notes** including: + - Changelog sections (Added, Changed, Fixed, etc.) + - Contributor attribution (from PR commits merged since the last release) + - Link to NuGet package +7. **Creates GitHub release**: + - Draft releases for stable minor/major versions (patch = 0) + - Published releases for revisions (patch > 0) and all prereleases + - Includes `--prerelease` flag for alpha/beta versions -### Description +### Release Types -Some parts of the description are fixed, some depend on the occasions: +- **Stable minor/major** (e.g., `7.0.0`, `8.0.0`): Created as draft releases, requiring manual publish +- **Stable revisions** (e.g., `7.0.5`, `8.1.2`): Published immediately +- **Prereleases** (e.g., `8.0.0-alpha-001`, `8.0.0-beta-001`): Always published immediately -```md -# Title +### Author Attribution - +The release notes automatically include contributor attribution by: +- Querying PRs merged since the last release +- Extracting authors from all commits in those PRs +- Filtering out bots (e.g., `dependabot[bot]`) +- Generating a "Special thanks to..." message - +## Manual Steps - -Special thanks to @x, @y and @z! +The only manual step required is for **minor and major releases**: - -[https://www.nuget.org/packages/fantomas/5.0.4](https://www.nuget.org/packages/fantomas/5.0.4) -``` +### Adding Release Nicknames -The format of the title is `# version` (example: `# 5.0.5`). This differs from the notation used in the changelog file! -For minors and majors a codename (inside a `` tag) is used. All codenames so far have been song titles by the band Ghost (example `# 5.1.0 Kaisarion - 11/2022`). -With the exception of `v5, "Get Back"`. +Minor and major releases are created as **draft releases** on GitHub. This allows maintainers to add a cool nickname to the release title before publishing. -The list of people to thank is compiled by cross referencing the changelog entries. The author of the GitHub release is omitted. -Don't be shy to include other names of people who have contributed in alternative ways when the occasion calls for it. +The nickname is typically a song name from the band Ghost. For example: +- `# 5.1.0 Kaisarion - 11/2022` +- `# 7.0.0 Year Zero - 01/2025` -### Artifacts +To add a nickname: +1. Go to the draft release on GitHub +2. Edit the release title to add the nickname in a `` tag +3. Review the release notes (they're already generated automatically) +4. Publish the release -Upload the `*.nupkg` files to the release artifacts. +**Note**: `Fantomas.Client` requires a separate pipeline (`build.fsx -p PushClient`) and is not included in the automated release. ## Spread the word