Skip to content

Commit 043994d

Browse files
authored
Merge pull request #9 from nojaf/auto-publish
Create automatic releases
2 parents 3447fc7 + 0149868 commit 043994d

File tree

4 files changed

+313
-4
lines changed

4 files changed

+313
-4
lines changed

.github/workflows/release.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
8+
env:
9+
DOTNET_NOLOGO: true
10+
DOTNET_CLI_TELEMETRY_OPTOUT: true
11+
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
12+
DOTNET_ROLL_FORWARD_TO_PRERELEASE: 1
13+
DOTNET_ROLL_FORWARD: LatestMajor
14+
15+
permissions:
16+
contents: read
17+
pages: write
18+
id-token: write
19+
20+
jobs:
21+
ci:
22+
runs-on: ubuntu-latest
23+
24+
steps:
25+
- uses: actions/checkout@v4
26+
27+
- name: Setup .NET
28+
uses: actions/setup-dotnet@v3
29+
30+
- name: Release
31+
run: dotnet fsi build.fsx -- -p Release
32+
env:
33+
NUGET_KEY: ${{ secrets.IONIDE_ANALYZER_NUGET_PUBLISH_KEY }}
34+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Changelog
22

3-
## Unreleased
3+
## 0.1.0 - 2023-11-07
44

5+
### Added
56
* Initial version

build.fsx

Lines changed: 260 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,29 @@
1-
#r "nuget: Fun.Build, 1.0.2"
1+
#r "nuget: Fun.Build, 1.0.3"
22
#r "nuget: Fake.IO.FileSystem, 6.0.0"
3+
#r "nuget: NuGet.Protocol, 6.7.0"
4+
#r "nuget: Ionide.KeepAChangelog, 0.1.8"
5+
#r "nuget: Humanizer.Core, 2.14.1"
36

7+
open System
48
open System.Text.Json
9+
open System.Threading
510
open Fake.IO
11+
open Fake.IO.FileSystemOperators
612
open Fake.IO.Globbing.Operators
713
open Fun.Build
14+
open Fun.Build.Internal
15+
open NuGet.Common
16+
open NuGet.Protocol
17+
open NuGet.Protocol.Core.Types
18+
open Ionide.KeepAChangelog
19+
open Ionide.KeepAChangelog.Domain
20+
open SemVersion
21+
open Humanizer
822

923
let cleanDirs globExpr = (!!globExpr) |> Shell.cleanDirs
1024

1125
/// Workaround for https://github.com/dotnet/sdk/issues/35989
12-
let restoreTools (ctx: Internal.StageContext) =
26+
let restoreTools (ctx: StageContext) =
1327
async {
1428
let json = File.readAsString ".config/dotnet-tools.json"
1529
let jsonDocument = JsonDocument.Parse(json)
@@ -39,6 +53,9 @@ let restoreTools (ctx: Internal.StageContext) =
3953
return 1
4054
}
4155

56+
let packStage =
57+
stage "pack" { run "dotnet pack ./src/Ionide.Analyzers/Ionide.Analyzers.fsproj -c Release -o bin" }
58+
4259
pipeline "Build" {
4360
workingDir __SOURCE_DIRECTORY__
4461
stage "clean" {
@@ -63,7 +80,7 @@ pipeline "Build" {
6380
run "dotnet build --no-restore -c Release ionide-analyzers.sln"
6481
}
6582
stage "test" { run "dotnet test --no-restore --no-build -c Release" }
66-
stage "pack" { run "dotnet pack ./src/Ionide.Analyzers/Ionide.Analyzers.fsproj -c Release -o bin" }
83+
packStage
6784
stage "docs" {
6885
envVars
6986
[|
@@ -89,4 +106,244 @@ pipeline "Docs" {
89106
runIfOnlySpecified true
90107
}
91108

109+
let getLatestPublishedNugetVersion packageName =
110+
task {
111+
let logger = NullLogger.Instance
112+
let cancellationToken = CancellationToken.None
113+
114+
let cache = new SourceCacheContext()
115+
let repository = Repository.Factory.GetCoreV3("https://api.nuget.org/v3/index.json")
116+
let! resource = repository.GetResourceAsync<FindPackageByIdResource>()
117+
let! versions = resource.GetAllVersionsAsync(packageName, cache, logger, cancellationToken)
118+
if Seq.isEmpty versions then
119+
return None
120+
else
121+
return versions |> Seq.max |> Some
122+
}
123+
124+
let getLatestChangeLogVersion () : SemanticVersion * DateTime * ChangelogData option =
125+
let changelog = System.IO.FileInfo(__SOURCE_DIRECTORY__ </> "CHANGELOG.md")
126+
let changeLogResult =
127+
match Parser.parseChangeLog changelog with
128+
| Error error -> failwithf "%A" error
129+
| Ok result -> result
130+
131+
changeLogResult.Releases
132+
|> List.sortByDescending (fun (_, d, _) -> d)
133+
|> List.head
134+
135+
type CommandRunner =
136+
abstract member LogWhenDryRun: string -> unit
137+
abstract member RunCommand: string -> Async<Result<unit, string>>
138+
abstract member RunCommandCaptureOutput: string -> Async<Result<string, string>>
139+
140+
/// Push *.nupkg
141+
let releaseNuGetPackage (ctx: CommandRunner) (version: SemanticVersion, _, _) =
142+
async {
143+
let key = Environment.GetEnvironmentVariable "NUGET_KEY"
144+
145+
let! result =
146+
ctx.RunCommand
147+
$"dotnet nuget push bin/Ionide.Analyzers.%s{string version}.nupkg --api-key {key} --source \"https://api.nuget.org/v3/index.json\""
148+
149+
match result with
150+
| Error _ -> return 1
151+
| Ok _ -> return 0
152+
}
153+
154+
type GithubRelease =
155+
{
156+
/// Is not suffixed with `v`
157+
Version: string
158+
Title: string
159+
Date: DateTime
160+
Draft: string
161+
}
162+
163+
let mapToGithubRelease (v: SemanticVersion, d: DateTime, cd: ChangelogData option) =
164+
match cd with
165+
| None -> failwith "Each Ionide.Analyzers release is expected to have at least one section."
166+
| Some cd ->
167+
168+
let version = $"{v.Major}.{v.Minor}.{v.Patch}"
169+
let title =
170+
let month = d.ToString("MMMM")
171+
let day = d.Day.Ordinalize()
172+
$"{month} {day} Release"
173+
174+
let sections =
175+
[
176+
"Added", cd.Added
177+
"Changed", cd.Changed
178+
"Fixed", cd.Fixed
179+
"Deprecated", cd.Deprecated
180+
"Removed", cd.Removed
181+
"Security", cd.Security
182+
yield! (Map.toList cd.Custom)
183+
]
184+
|> List.choose (fun (header, lines) ->
185+
if lines.IsEmpty then
186+
None
187+
else
188+
lines
189+
|> List.map (fun line -> line.TrimStart())
190+
|> String.concat "\n"
191+
|> sprintf "### %s\n%s" header
192+
|> Some
193+
)
194+
|> String.concat "\n\n"
195+
196+
let draft =
197+
$"""# {version}
198+
199+
{sections}"""
200+
201+
{
202+
Version = version
203+
Title = title
204+
Date = d
205+
Draft = draft
206+
}
207+
208+
let getReleaseNotes (ctx: CommandRunner) (currentRelease: GithubRelease) (previousReleaseDate: string option) =
209+
async {
210+
let closedFilter =
211+
match previousReleaseDate with
212+
| None -> ""
213+
| Some date -> $"closed:>%s{date}"
214+
215+
let! authorsStdOut =
216+
ctx.RunCommandCaptureOutput
217+
$"gh pr list -S \"state:closed base:main %s{closedFilter} -author:app/robot -author:app/dependabot\" --json author --jq \".[].author.login\""
218+
219+
let authorMsg =
220+
match authorsStdOut with
221+
| Error e -> failwithf $"Could not get authors: %s{e}"
222+
| Ok stdOut ->
223+
224+
let authors =
225+
stdOut.Split([| '\n' |], StringSplitOptions.RemoveEmptyEntries)
226+
|> Array.distinct
227+
|> Array.sort
228+
229+
if authors.Length = 1 then
230+
$"Special thanks to @%s{authors.[0]}!"
231+
else
232+
let lastAuthor = Array.last authors
233+
let otherAuthors =
234+
if authors.Length = 2 then
235+
$"@{authors.[0]}"
236+
else
237+
authors
238+
|> Array.take (authors.Length - 1)
239+
|> Array.map (sprintf "@%s")
240+
|> String.concat ", "
241+
$"Special thanks to %s{otherAuthors} and @%s{lastAuthor}!"
242+
243+
return
244+
$"""{currentRelease.Draft}
245+
246+
{authorMsg}
247+
248+
[https://www.nuget.org/packages/Ionide.Analyzers/{currentRelease.Version}](https://www.nuget.org/packages/Ionide.Analyzers/{currentRelease.Version})
249+
"""
250+
}
251+
252+
/// <summary>
253+
/// Create a GitHub release via the CLI.
254+
/// </summary>
255+
/// <param name="ctx"></param>
256+
/// <param name="currentVersion">From the ChangeLog file.</param>
257+
/// <param name="previousReleaseDate">Filter used to find the users involved in the release. This will be passed a parameter to the GitHub CLI.</param>
258+
let mkGitHubRelease
259+
(ctx: CommandRunner)
260+
(currentVersion: SemanticVersion * DateTime * ChangelogData option)
261+
(previousReleaseDate: string option)
262+
=
263+
async {
264+
let ghReleaseInfo = mapToGithubRelease currentVersion
265+
let! notes = getReleaseNotes ctx ghReleaseInfo previousReleaseDate
266+
ctx.LogWhenDryRun $"NOTES:\n%s{notes}"
267+
let noteFile = System.IO.Path.GetTempFileName()
268+
System.IO.File.WriteAllText(noteFile, notes)
269+
let file = $"./bin/Ionide.Analyzers.%s{ghReleaseInfo.Version}.nupkg"
270+
271+
let! releaseResult =
272+
ctx.RunCommand
273+
$"gh release create v%s{ghReleaseInfo.Version} {file} --title \"{ghReleaseInfo.Title}\" --notes-file \"{noteFile}\""
274+
275+
if System.IO.File.Exists noteFile then
276+
System.IO.File.Delete(noteFile)
277+
278+
match releaseResult with
279+
| Error _ -> return 1
280+
| Ok _ -> return 0
281+
}
282+
283+
pipeline "Release" {
284+
workingDir __SOURCE_DIRECTORY__
285+
stage "Release " {
286+
packStage
287+
run (fun ctx ->
288+
async {
289+
let commandRunner =
290+
match ctx.TryGetCmdArg "--dry-run" with
291+
| ValueNone ->
292+
{ new CommandRunner with
293+
member x.LogWhenDryRun _ = ()
294+
member x.RunCommand command = ctx.RunCommand command
295+
member x.RunCommandCaptureOutput command = ctx.RunCommandCaptureOutput command
296+
}
297+
| ValueSome _ ->
298+
{ new CommandRunner with
299+
member x.LogWhenDryRun msg = printfn "%s" msg
300+
member x.RunCommand command =
301+
async {
302+
printfn $"[dry-run]:{command}"
303+
return Ok()
304+
}
305+
member x.RunCommandCaptureOutput command =
306+
async {
307+
printfn $"[dry-run]:{command}"
308+
return Ok "nojaf\ndawedawe\nbaronfel"
309+
}
310+
}
311+
312+
let currentVersion = getLatestChangeLogVersion ()
313+
let currentVersionText, _, _ = currentVersion
314+
let! latestNugetVersion = getLatestPublishedNugetVersion "Ionide.Analyzers" |> Async.AwaitTask
315+
match latestNugetVersion with
316+
| None ->
317+
let! nugetResult = releaseNuGetPackage commandRunner currentVersion
318+
let! githubResult = mkGitHubRelease commandRunner currentVersion None
319+
return nugetResult + githubResult
320+
321+
| Some nugetVersion when (nugetVersion.OriginalVersion <> string currentVersionText) ->
322+
let! nugetResult = releaseNuGetPackage commandRunner currentVersion
323+
let! previousReleaseDate =
324+
ctx.RunCommandCaptureOutput
325+
$"gh release view v%s{nugetVersion.OriginalVersion} --json createdAt -t \"{{{{.createdAt}}}}\""
326+
327+
let previousReleaseDate =
328+
match previousReleaseDate with
329+
| Error e ->
330+
printfn "Unable to format previous release data, %s" e
331+
None
332+
| Ok d ->
333+
let output = d.Trim()
334+
let lastIdx = output.LastIndexOf("Z", StringComparison.Ordinal)
335+
Some(output.Substring(0, lastIdx))
336+
337+
let! githubResult = mkGitHubRelease commandRunner currentVersion previousReleaseDate
338+
return nugetResult + githubResult
339+
340+
| Some nugetVersion ->
341+
printfn "%s is already published" nugetVersion.OriginalVersion
342+
return 0
343+
}
344+
)
345+
}
346+
runIfOnlySpecified true
347+
}
348+
92349
tryPrintPipelineCommandHelp ()

docs/content/release-process.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
title: Release Process
3+
categoryindex: 1
4+
index: 2
5+
category: docs
6+
---
7+
# Release process
8+
9+
A new release happens automatically when the version in the [Changelog](https://github.com/ionide/ionide-analyzers/blob/main/CHANGELOG.md) was increased.
10+
We verify the next version doesn't exist yet on NuGet, and if that is the case, we publish the `*.nupkg` package and create a new GitHub release.
11+
12+
## Dry run
13+
14+
We use a custom pipeline in our `build.fsx` script to generate the release.
15+
You can safely dry-run this locally using:
16+
17+
> dotnet fsi build.fsx -- -p Release --dry-run

0 commit comments

Comments
 (0)