Skip to content

Commit

Permalink
Automate releases (#2872)
Browse files Browse the repository at this point in the history
* WIP: See what CI says.

* Create draft GitHub release.

* Combine NuGet push and GitHub release.

* Remove extracted getNuGetPackages function.

* Use GitHub last publish date.
  • Loading branch information
nojaf authored May 5, 2023
1 parent 1227fca commit 27a224d
Show file tree
Hide file tree
Showing 2 changed files with 175 additions and 14 deletions.
8 changes: 7 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,10 @@ jobs:
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./output
publish_dir: ./output
- name: "Publish"
if: matrix.os == 'windows-latest' && github.ref == 'refs/heads/main'
env:
NUGET_KEY: ${{ secrets.NUGET_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: dotnet fsi build.fsx -p Release
181 changes: 168 additions & 13 deletions build.fsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
#r "nuget: Fun.Build, 0.3.8"
#r "nuget: CliWrap, 3.5.0"
#r "nuget: FSharp.Data, 5.0.2"
#r "nuget: Ionide.KeepAChangelog, 0.1.8"
#r "nuget: Humanizer.Core, 2.14.1"

open System
open System.IO
open Fun.Build
open CliWrap
open CliWrap.Buffered
open FSharp.Data
open System.Xml.Linq
open System.Xml.XPath
open Ionide.KeepAChangelog
open Ionide.KeepAChangelog.Domain
open SemVersion
open Humanizer

let (</>) a b = Path.Combine(a, b)

Expand Down Expand Up @@ -39,7 +46,7 @@ let semanticVersioning =

let pushPackage nupkg =
async {
let key = System.Environment.GetEnvironmentVariable("NUGET_KEY")
let key = Environment.GetEnvironmentVariable("NUGET_KEY")
let! result =
Cli
.Wrap("dotnet")
Expand Down Expand Up @@ -77,18 +84,6 @@ pipeline "Build" {
run
$"dotnet fsdocs build --clean --properties Configuration=Release --fscoptions \" -r:{semanticVersioning}\" --eval --strict --nonpublic"
}
stage "Push" {
whenCmdArg "--push"
run (fun _ ->
async {
let! exitCodes =
Directory.EnumerateFiles("bin", "*.nupkg", SearchOption.TopDirectoryOnly)
|> Seq.filter (fun nupkg -> not (nupkg.Contains("Fantomas.Client")))
|> Seq.map pushPackage
|> Async.Sequential
return Seq.max exitCodes
})
}
runIfOnlySpecified false
}

Expand Down Expand Up @@ -313,3 +308,163 @@ pipeline "Init" {
}
runIfOnlySpecified true
}

type GithubRelease =
{ Version: string
Title: string
Date: DateTime
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 title =
let month = d.ToString("MMMM")
let day = d.Day.Ordinalize()
$"{month} {day} Release"

let prefixedVersion = $"v{version}"
let publishDate =
let cmdResult =
Cli
.Wrap("gh")
.WithArguments($"release view {prefixedVersion} --json publishedAt -t \"{{{{.publishedAt}}}}\"")
.WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync()
.Task.Result
if cmdResult.ExitCode <> 0 then
None
else
let output = cmdResult.StandardOutput.Trim()
let lastIdx = output.LastIndexOf("Z")
Some(output.Substring(0, lastIdx))

let sections =
[ "Added", cd.Added
"Changed", cd.Changed
"Fixed", cd.Fixed
"Deprecated", cd.Deprecated
"Removed", cd.Removed
"Security", cd.Security
yield! (Map.toList cd.Custom) ]
|> List.choose (fun (header, lines) ->
if lines.IsEmpty then
None
else
lines
|> List.map (fun line -> line.TrimStart())
|> String.concat "\n"
|> sprintf "### %s\n%s" header
|> Some)
|> String.concat "\n\n"

let draft =
$"""# {version}
{sections}"""

{ Version = version
Title = title
Date = d
PublishedDate = publishDate
Draft = draft }

let getReleaseNotes currentRelease (lastRelease: GithubRelease) =
let date = lastRelease.PublishedDate.Value
let authorMsg =
let authors =
Cli
.Wrap("gh")
.WithArguments(
$"pr list -S \"state:closed base:main closed:>{date} -author:app/robot\" --json author --jq \".[].author.login\""
)
.ExecuteBufferedAsync()
.Task.Result.StandardOutput.Split([| '\n' |], StringSplitOptions.RemoveEmptyEntries)
|> Array.distinct
|> Array.sort

if 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}
{authorMsg}
[https://www.nuget.org/packages/fantomas/{currentRelease.Version}](https://www.nuget.org/packages/fantomas/{currentRelease.Version})
"""

let getCurrentAndLastReleaseFromChangelog () =
let changelog = FileInfo(__SOURCE_DIRECTORY__ </> "CHANGELOG.md")
let changeLogResult =
match Parser.parseChangeLog changelog with
| Error error -> failwithf "%A" error
| Ok result -> 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
| _ -> failwith "Could not find the current and last release from CHANGELOG.md"

pipeline "Release" {
workingDir __SOURCE_DIRECTORY__
stage "Release" {
run (fun _ ->
async {
let currentRelease, lastRelease = getCurrentAndLastReleaseFromChangelog ()

if Option.isSome currentRelease.PublishedDate then
return 0
else
// Push packages to NuGet
let nugetPackages =
Directory.EnumerateFiles("bin", "*.nupkg", SearchOption.TopDirectoryOnly)
|> Seq.filter (fun nupkg -> not (nupkg.Contains("Fantomas.Client")))
|> Seq.toArray

let! nugetExitCodes = nugetPackages |> Array.map pushPackage |> Async.Sequential

let notes = getReleaseNotes currentRelease lastRelease
let noteFile = Path.GetTempFileName()
File.WriteAllText(noteFile, notes)
let files = nugetPackages |> String.concat " "

// We create a draft release that requires a manual publish.
// This is to allow us to add additional release notes when it makes sense.
let! draftResult =
Cli
.Wrap("gh")
.WithArguments(
$"release create v{currentRelease.Version} {files} --draft --title \"{currentRelease.Title}\" --notes-file \"{noteFile}\""
)
.WithValidation(CommandResultValidation.None)
.ExecuteAsync()
.Task
|> Async.AwaitTask

if File.Exists noteFile then
File.Delete(noteFile)

return Seq.max [| yield! nugetExitCodes; yield draftResult.ExitCode |]
})
}
runIfOnlySpecified true
}

0 comments on commit 27a224d

Please sign in to comment.