Skip to content

Commit 27a224d

Browse files
authored
Automate releases (#2872)
* WIP: See what CI says. * Create draft GitHub release. * Combine NuGet push and GitHub release. * Remove extracted getNuGetPackages function. * Use GitHub last publish date.
1 parent 1227fca commit 27a224d

File tree

2 files changed

+175
-14
lines changed

2 files changed

+175
-14
lines changed

.github/workflows/main.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,10 @@ jobs:
3232
uses: peaceiris/actions-gh-pages@v3
3333
with:
3434
github_token: ${{ secrets.GITHUB_TOKEN }}
35-
publish_dir: ./output
35+
publish_dir: ./output
36+
- name: "Publish"
37+
if: matrix.os == 'windows-latest' && github.ref == 'refs/heads/main'
38+
env:
39+
NUGET_KEY: ${{ secrets.NUGET_KEY }}
40+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
41+
run: dotnet fsi build.fsx -p Release

build.fsx

Lines changed: 168 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
#r "nuget: Fun.Build, 0.3.8"
22
#r "nuget: CliWrap, 3.5.0"
33
#r "nuget: FSharp.Data, 5.0.2"
4+
#r "nuget: Ionide.KeepAChangelog, 0.1.8"
5+
#r "nuget: Humanizer.Core, 2.14.1"
46

7+
open System
58
open System.IO
69
open Fun.Build
710
open CliWrap
811
open CliWrap.Buffered
912
open FSharp.Data
1013
open System.Xml.Linq
1114
open System.Xml.XPath
15+
open Ionide.KeepAChangelog
16+
open Ionide.KeepAChangelog.Domain
17+
open SemVersion
18+
open Humanizer
1219

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

@@ -39,7 +46,7 @@ let semanticVersioning =
3946

4047
let pushPackage nupkg =
4148
async {
42-
let key = System.Environment.GetEnvironmentVariable("NUGET_KEY")
49+
let key = Environment.GetEnvironmentVariable("NUGET_KEY")
4350
let! result =
4451
Cli
4552
.Wrap("dotnet")
@@ -77,18 +84,6 @@ pipeline "Build" {
7784
run
7885
$"dotnet fsdocs build --clean --properties Configuration=Release --fscoptions \" -r:{semanticVersioning}\" --eval --strict --nonpublic"
7986
}
80-
stage "Push" {
81-
whenCmdArg "--push"
82-
run (fun _ ->
83-
async {
84-
let! exitCodes =
85-
Directory.EnumerateFiles("bin", "*.nupkg", SearchOption.TopDirectoryOnly)
86-
|> Seq.filter (fun nupkg -> not (nupkg.Contains("Fantomas.Client")))
87-
|> Seq.map pushPackage
88-
|> Async.Sequential
89-
return Seq.max exitCodes
90-
})
91-
}
9287
runIfOnlySpecified false
9388
}
9489

@@ -313,3 +308,163 @@ pipeline "Init" {
313308
}
314309
runIfOnlySpecified true
315310
}
311+
312+
type GithubRelease =
313+
{ Version: string
314+
Title: string
315+
Date: DateTime
316+
PublishedDate: string option
317+
Draft: string }
318+
319+
let mkGithubRelease (v: SemanticVersion, d: DateTime, cd: ChangelogData option) =
320+
match cd with
321+
| None -> failwith "Each Fantomas release is expected to have at least one section."
322+
| Some cd ->
323+
let version = $"{v.Major}.{v.Minor}.{v.Patch}"
324+
let title =
325+
let month = d.ToString("MMMM")
326+
let day = d.Day.Ordinalize()
327+
$"{month} {day} Release"
328+
329+
let prefixedVersion = $"v{version}"
330+
let publishDate =
331+
let cmdResult =
332+
Cli
333+
.Wrap("gh")
334+
.WithArguments($"release view {prefixedVersion} --json publishedAt -t \"{{{{.publishedAt}}}}\"")
335+
.WithValidation(CommandResultValidation.None)
336+
.ExecuteBufferedAsync()
337+
.Task.Result
338+
if cmdResult.ExitCode <> 0 then
339+
None
340+
else
341+
let output = cmdResult.StandardOutput.Trim()
342+
let lastIdx = output.LastIndexOf("Z")
343+
Some(output.Substring(0, lastIdx))
344+
345+
let sections =
346+
[ "Added", cd.Added
347+
"Changed", cd.Changed
348+
"Fixed", cd.Fixed
349+
"Deprecated", cd.Deprecated
350+
"Removed", cd.Removed
351+
"Security", cd.Security
352+
yield! (Map.toList cd.Custom) ]
353+
|> List.choose (fun (header, lines) ->
354+
if lines.IsEmpty then
355+
None
356+
else
357+
lines
358+
|> List.map (fun line -> line.TrimStart())
359+
|> String.concat "\n"
360+
|> sprintf "### %s\n%s" header
361+
|> Some)
362+
|> String.concat "\n\n"
363+
364+
let draft =
365+
$"""# {version}
366+
367+
{sections}"""
368+
369+
{ Version = version
370+
Title = title
371+
Date = d
372+
PublishedDate = publishDate
373+
Draft = draft }
374+
375+
let getReleaseNotes currentRelease (lastRelease: GithubRelease) =
376+
let date = lastRelease.PublishedDate.Value
377+
let authorMsg =
378+
let authors =
379+
Cli
380+
.Wrap("gh")
381+
.WithArguments(
382+
$"pr list -S \"state:closed base:main closed:>{date} -author:app/robot\" --json author --jq \".[].author.login\""
383+
)
384+
.ExecuteBufferedAsync()
385+
.Task.Result.StandardOutput.Split([| '\n' |], StringSplitOptions.RemoveEmptyEntries)
386+
|> Array.distinct
387+
|> Array.sort
388+
389+
if authors.Length = 1 then
390+
$"Special thanks to %s{authors.[0]}!"
391+
else
392+
let lastAuthor = Array.last authors
393+
let otherAuthors =
394+
if authors.Length = 2 then
395+
$"@{authors.[0]}"
396+
else
397+
authors
398+
|> Array.take (authors.Length - 1)
399+
|> Array.map (sprintf "@%s")
400+
|> String.concat ", "
401+
$"Special thanks to %s{otherAuthors} and @%s{lastAuthor}!"
402+
403+
$"""{currentRelease.Draft}
404+
405+
{authorMsg}
406+
407+
[https://www.nuget.org/packages/fantomas/{currentRelease.Version}](https://www.nuget.org/packages/fantomas/{currentRelease.Version})
408+
"""
409+
410+
let getCurrentAndLastReleaseFromChangelog () =
411+
let changelog = FileInfo(__SOURCE_DIRECTORY__ </> "CHANGELOG.md")
412+
let changeLogResult =
413+
match Parser.parseChangeLog changelog with
414+
| Error error -> failwithf "%A" error
415+
| Ok result -> result
416+
417+
let lastReleases =
418+
changeLogResult.Releases
419+
|> List.filter (fun (v, _, _) -> String.IsNullOrEmpty v.Prerelease)
420+
|> List.sortByDescending (fun (_, d, _) -> d)
421+
|> List.take 2
422+
423+
match lastReleases with
424+
| [ current; last ] -> mkGithubRelease current, mkGithubRelease last
425+
| _ -> failwith "Could not find the current and last release from CHANGELOG.md"
426+
427+
pipeline "Release" {
428+
workingDir __SOURCE_DIRECTORY__
429+
stage "Release" {
430+
run (fun _ ->
431+
async {
432+
let currentRelease, lastRelease = getCurrentAndLastReleaseFromChangelog ()
433+
434+
if Option.isSome currentRelease.PublishedDate then
435+
return 0
436+
else
437+
// Push packages to NuGet
438+
let nugetPackages =
439+
Directory.EnumerateFiles("bin", "*.nupkg", SearchOption.TopDirectoryOnly)
440+
|> Seq.filter (fun nupkg -> not (nupkg.Contains("Fantomas.Client")))
441+
|> Seq.toArray
442+
443+
let! nugetExitCodes = nugetPackages |> Array.map pushPackage |> Async.Sequential
444+
445+
let notes = getReleaseNotes currentRelease lastRelease
446+
let noteFile = Path.GetTempFileName()
447+
File.WriteAllText(noteFile, notes)
448+
let files = nugetPackages |> String.concat " "
449+
450+
// We create a draft release that requires a manual publish.
451+
// This is to allow us to add additional release notes when it makes sense.
452+
let! draftResult =
453+
Cli
454+
.Wrap("gh")
455+
.WithArguments(
456+
$"release create v{currentRelease.Version} {files} --draft --title \"{currentRelease.Title}\" --notes-file \"{noteFile}\""
457+
)
458+
.WithValidation(CommandResultValidation.None)
459+
.ExecuteAsync()
460+
.Task
461+
|> Async.AwaitTask
462+
463+
if File.Exists noteFile then
464+
File.Delete(noteFile)
465+
466+
return Seq.max [| yield! nugetExitCodes; yield draftResult.ExitCode |]
467+
})
468+
}
469+
runIfOnlySpecified true
470+
}

0 commit comments

Comments
 (0)