Skip to content

Commit daffe46

Browse files
committed
release: move release-gating logic from workflow into FAKE
The release workflow used to parse RELEASE_NOTES.md, run the (Unreleased) skip and tag-exists skip, and chain steps with if: steps.notes.outputs.skip == 'false' — all in YAML. None of that is testable, reviewable, or reachable from a local build. Move the entire flow into build/ReleaseFromNotesTask.fs as a new `releaseFromNotes` FAKE target that owns: * Reading the top `### <version>` header from RELEASE_NOTES.md. * Skipping when the entry is marked `(Unreleased)` or when a git tag for the parsed version already exists. * Clean / build / test / pack / push / tag when the gate passes. Git interactions use Fake.Tools.Git (runGitCommand for the tag-exists check, runSimpleGitCommand + Branches.pushTag for the annotated tag + push) rather than raw System.Diagnostics.Process. The workflow shrinks to one step: `./build.sh releaseFromNotes` with CI=true and NUGET_API_KEY in env. Also document the rule in AGENTS.md ("CI is a thin shell around FAKE") so the next round of release tweaks lands in the build project, not the YAML.
1 parent e152453 commit daffe46

5 files changed

Lines changed: 168 additions & 61 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 10 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,11 @@
11
name: Release
22

33
# Trigger: a push to main that touches RELEASE_NOTES.md.
4-
# Behavior:
5-
# 1. Parse the topmost version header from RELEASE_NOTES.md.
6-
# 2. Skip if the entry is marked `(Unreleased)` — author iterates with that
7-
# marker, drops it (and ideally adds a date) to ship.
8-
# 3. Skip if a git tag for that version already exists (idempotent re-runs).
9-
# 4. Otherwise: run `./build.sh publishNuget`, which chains
10-
# clean -> buildSolution -> runTests -> pack -> publishNuget. Version,
11-
# release notes, and pkg metadata all flow from RELEASE_NOTES.md and the
12-
# project files. The CI env var auto-accepts the FAKE prompts.
13-
# 5. Tag the commit with the released version.
4+
# All release logic — parsing the top version, the (Unreleased) skip, the
5+
# "tag already exists" skip, pack, publish, tag — lives in the FAKE target
6+
# `releaseFromNotes` (build/ReleaseFromNotesTask.fs). This workflow is
7+
# intentionally thin: any change to release behavior should go in the
8+
# build project, not here. See AGENTS.md → "CI is a thin shell around FAKE".
149
on:
1510
push:
1611
branches: [main]
@@ -26,60 +21,17 @@ jobs:
2621
steps:
2722
- uses: actions/checkout@v4
2823
with:
29-
fetch-depth: 0 # full history so we can check for an existing tag
24+
fetch-depth: 0 # full history + tags so the gate's tag-exists check works
3025

3126
- name: Setup .NET
3227
uses: actions/setup-dotnet@v4
3328
with:
3429
global-json-file: global.json
3530

36-
- name: Parse top entry from RELEASE_NOTES.md
37-
id: notes
38-
run: |
39-
set -euo pipefail
40-
header=$(grep -m1 -E '^### ' RELEASE_NOTES.md || true)
41-
if [ -z "$header" ]; then
42-
echo "::error file=RELEASE_NOTES.md::No '### <version>' header found."
43-
exit 1
44-
fi
45-
version=$(printf '%s' "$header" | sed -E 's/^### ([^ ]+).*/\1/')
46-
echo "version=$version" >> "$GITHUB_OUTPUT"
47-
echo "Top entry: $header"
48-
echo "Parsed version: $version"
49-
if printf '%s' "$header" | grep -qiE '\(unreleased\)'; then
50-
echo "Top entry is marked (Unreleased) — skipping release."
51-
echo "skip=true" >> "$GITHUB_OUTPUT"
52-
exit 0
53-
fi
54-
if git rev-parse "refs/tags/$version" >/dev/null 2>&1; then
55-
echo "Tag $version already exists — skipping release."
56-
echo "skip=true" >> "$GITHUB_OUTPUT"
57-
exit 0
58-
fi
59-
echo "skip=false" >> "$GITHUB_OUTPUT"
60-
61-
- name: Make build.sh executable
62-
if: steps.notes.outputs.skip == 'false'
63-
run: chmod u+x build.sh
64-
65-
- name: Build, test, pack, and publish via FAKE
66-
if: steps.notes.outputs.skip == 'false'
31+
- name: Run releaseFromNotes
6732
env:
6833
CI: 'true' # auto-accepts FAKE promptYesNo gates
69-
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} # consumed by ReleaseTasks.publishNuget
70-
run: |
71-
if [ -z "${NUGET_API_KEY:-}" ]; then
72-
echo "::error::NUGET_API_KEY secret is not set."
73-
exit 1
74-
fi
75-
./build.sh publishNuget
76-
77-
- name: Tag the release
78-
if: steps.notes.outputs.skip == 'false'
79-
env:
80-
VERSION: ${{ steps.notes.outputs.version }}
34+
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} # consumed by ReleaseFromNotesTask
8135
run: |
82-
git config user.name 'github-actions[bot]'
83-
git config user.email 'github-actions[bot]@users.noreply.github.com'
84-
git tag -a "$VERSION" -m "Release $VERSION"
85-
git push origin "$VERSION"
36+
chmod u+x build.sh
37+
./build.sh releaseFromNotes

‎AGENTS.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,18 @@ dotnet tool restore # installs the pinned dotnet-xscgen
7676
- Rule keys must match xscgen's default name exactly. When in doubt, regenerate without `--tnsf` once locally to read off the defaults, then write rules against those.
7777
- The substitution file is not regex-based; every rule is a literal type-name rename.
7878

79+
## CI is a thin shell around FAKE
80+
81+
The `.github/workflows/` files exist to set up a runner, restore the SDK, and **invoke a single FAKE target**. Any non-trivial logic — version parsing, gate checks, conditional skips, packaging, tagging — belongs in the build project under [`build/`](build/), not in the YAML.
82+
83+
Concretely:
84+
85+
- The release CI calls `./build.sh releaseFromNotes`; everything that flow does (parsing the topmost `### <version>` header from `RELEASE_NOTES.md`, the `(Unreleased)` skip, the "tag already exists" skip, clean/build/test/pack/push/tag) is implemented in [`build/ReleaseFromNotesTask.fs`](build/ReleaseFromNotesTask.fs).
86+
- Interactive `promptYesNo` gates inside FAKE targets auto-accept when the `CI` env var is `true` (see [`build/MessagePrompts.fs`](build/MessagePrompts.fs)). CI sets this; humans get prompted.
87+
- The NuGet API key is read from the `NUGET_API_KEY` env var by FAKE; CI passes it through from the `NUGET_API_KEY` GitHub Actions secret.
88+
89+
**When changing release behavior:** edit the FAKE task, not the workflow. If a workflow file starts growing shell logic (`grep`/`sed`/`awk` against repo files, conditional `if: ...` chains around build steps), that logic should move into a FAKE target.
90+
7991
## Things to avoid
8092

8193
- Do not add an fsdocs / FsDocs site here — usage examples live in the base BioFSharp docs.

‎build/Build.fs‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ open TestTasks
1616
open PackageTasks
1717
open DocumentationTasks
1818
open ReleaseTasks
19+
open ReleaseFromNotesTask
1920

2021
/// Full release of nuget package, git tag, and documentation for the stable version.
2122
let _release =
@@ -36,11 +37,16 @@ let _releaseNoDocs =
3637
[clean; buildSolution; runTests; pack; createTag; publishNuget;]
3738

3839
/// Full release of nuget package for the prerelease version.
39-
let _preReleaseNoDocs =
40-
BuildTask.createEmpty
41-
"PreReleaseNoDocs"
40+
let _preReleaseNoDocs =
41+
BuildTask.createEmpty
42+
"PreReleaseNoDocs"
4243
[setPrereleaseTag; clean; buildSolution; runTests; packPrerelease; createPrereleaseTag; publishNugetPrerelease]
4344

45+
// Force ReleaseFromNotesTask to be initialized so its BuildTask.create call
46+
// registers the target. Without a reference here F# would skip the module's
47+
// top-level bindings (target wouldn't be discoverable from the CLI).
48+
let _releaseFromNotes = releaseFromNotes
49+
4450
[<EntryPoint>]
4551
let main args =
4652
runOrDefault buildSolution args

‎build/ReleaseFromNotesTask.fs‎

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
module ReleaseFromNotesTask
2+
3+
open BlackFox.Fake
4+
open Fake.Core
5+
open Fake.DotNet
6+
open Fake.IO
7+
open Fake.IO.Globbing.Operators
8+
open Fake.Tools
9+
10+
open ProjectInfo
11+
12+
open System.Text.RegularExpressions
13+
14+
// Owns *all* logic for the "release on RELEASE_NOTES.md change" CI flow:
15+
// parse the topmost ### header, decide whether to skip (Unreleased / tag
16+
// already exists), and otherwise run the full release pipeline (clean →
17+
// build → test → pack → push → tag). The GitHub Actions workflow stays
18+
// thin and only invokes this target.
19+
//
20+
// Why duplicate a few lines from PackageTasks / ReleaseTasks instead of
21+
// chaining their build-tasks? Those targets gate on interactive prompts
22+
// (auto-accepted via CI=true) and unconditionally chain clean + build +
23+
// test, which we want to skip entirely when the gate says no release.
24+
// Keeping a self-contained body lets the gate short-circuit cleanly.
25+
26+
/// First "### <version>" line from RELEASE_NOTES.md (raw, so we can detect
27+
/// the (Unreleased) marker — Fake.Core.ReleaseNotes.load strips it).
28+
let private firstHeaderLine () =
29+
System.IO.File.ReadLines "RELEASE_NOTES.md"
30+
|> Seq.tryFind (fun line -> line.StartsWith "### ")
31+
32+
let private tagExists (tag: string) =
33+
// `git rev-parse --verify --quiet refs/tags/<tag>` exits 0 iff the ref resolves.
34+
let ok, _, _ =
35+
Git.CommandHelper.runGitCommand "" (sprintf "rev-parse --verify --quiet refs/tags/%s" tag)
36+
ok
37+
38+
let private cleanArtifacts () =
39+
!! "src/**/bin"
40+
++ "src/**/obj"
41+
++ "tests/**/bin"
42+
++ "tests/**/obj"
43+
++ pkgDir
44+
|> Shell.cleanDirs
45+
46+
let private buildAndTest () =
47+
solutionFile
48+
|> DotNet.build (fun p ->
49+
{ p with
50+
Configuration = DotNet.BuildConfiguration.fromString configuration
51+
MSBuildParams = { p.MSBuildParams with DisableInternalBinLog = true } }
52+
|> DotNet.Options.withCustomParams (Some "-tl"))
53+
54+
for testProject in testProjects do
55+
testProject
56+
|> DotNet.test (fun p ->
57+
{ p with
58+
Configuration = DotNet.BuildConfiguration.fromString configuration
59+
NoBuild = true
60+
MSBuildParams = { p.MSBuildParams with DisableInternalBinLog = true } }
61+
|> DotNet.Options.withCustomParams (Some "-tl"))
62+
63+
// Mirrors PackageTasks.replaceCommitLink — keep PackageReleaseNotes free of
64+
// the `[[#sha](url)]` link prefixes some release notes carry.
65+
let private commitLinkPattern = Regex @"\[\[#[a-z0-9]*\]\(.*\)\] "
66+
67+
let private packAll (version: string) =
68+
let notes =
69+
release.Notes
70+
|> List.map (fun line -> commitLinkPattern.Replace(line, ""))
71+
|> String.concat "\r\n"
72+
73+
!! "src/**/*.*proj"
74+
-- "src/bin/*"
75+
|> Seq.iter (DotNet.pack (fun p ->
76+
let msb =
77+
{ p.MSBuildParams with
78+
Properties =
79+
[ "Version", version
80+
"PackageReleaseNotes", notes ]
81+
@ p.MSBuildParams.Properties
82+
DisableInternalBinLog = true }
83+
{ p with
84+
Configuration = DotNet.BuildConfiguration.fromString configuration
85+
MSBuildParams = msb
86+
OutputPath = Some pkgDir
87+
NoBuild = true }
88+
|> DotNet.Options.withCustomParams (Some "-tl")))
89+
90+
let private pushNupkgs () =
91+
let apikey =
92+
match Environment.environVarOrNone "NUGET_API_KEY" with
93+
| Some k when k <> "" -> k
94+
| _ -> failwith "NUGET_API_KEY env var is not set."
95+
let source = "https://api.nuget.org/v3/index.json"
96+
let artifacts = !! (sprintf "%s/*.*pkg" pkgDir) |> Seq.toList
97+
if List.isEmpty artifacts then
98+
failwithf "No packages found under %s/ to push." pkgDir
99+
for artifact in artifacts do
100+
let result = DotNet.exec id "nuget" (sprintf "push -s %s -k %s %s --skip-duplicate" source apikey artifact)
101+
if not result.OK then
102+
failwithf "dotnet nuget push failed for %s" artifact
103+
104+
let private pushReleaseTag (version: string) =
105+
// GitHub Actions checkout doesn't preconfigure committer identity; set a
106+
// bot identity here so the annotated tag has author metadata. No FAKE
107+
// wrapper for `git config`, so use runSimpleGitCommand (throws on failure).
108+
Git.CommandHelper.runSimpleGitCommand "" "config user.name 'github-actions[bot]'" |> ignore
109+
Git.CommandHelper.runSimpleGitCommand "" "config user.email 'github-actions[bot]@users.noreply.github.com'" |> ignore
110+
// `Git.Branches.tag` creates a *lightweight* tag — we want an annotated
111+
// one so the release carries author + message metadata. Drop to the raw
112+
// git command for that, then use Git.Branches.pushTag for the push.
113+
Git.CommandHelper.runSimpleGitCommand "" (sprintf "tag -a %s -m \"Release %s\"" version version) |> ignore
114+
Git.Branches.pushTag "" "origin" version
115+
116+
let releaseFromNotes = BuildTask.create "ReleaseFromNotes" [] {
117+
match firstHeaderLine () with
118+
| None ->
119+
failwith "RELEASE_NOTES.md has no '### <version>' header — cannot determine version to release."
120+
| Some header ->
121+
let version = stableVersionTag
122+
Trace.log (sprintf "Top RELEASE_NOTES.md entry: %s" header)
123+
Trace.log (sprintf "Parsed version: %s" version)
124+
if Regex.IsMatch(header, @"\(unreleased\)", RegexOptions.IgnoreCase) then
125+
Trace.log (sprintf "Entry is marked (Unreleased) — skipping release of %s." version)
126+
elif tagExists version then
127+
Trace.log (sprintf "Git tag %s already exists — skipping release." version)
128+
else
129+
Trace.log (sprintf "Releasing %s..." version)
130+
cleanArtifacts ()
131+
buildAndTest ()
132+
packAll version
133+
pushNupkgs ()
134+
pushReleaseTag version
135+
Trace.log (sprintf "Released %s." version)
136+
}

‎build/build.fsproj‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
<Compile Include="PackageTasks.fs" />
1616
<Compile Include="DocumentationTasks.fs" />
1717
<Compile Include="ReleaseTasks.fs" />
18+
<Compile Include="ReleaseFromNotesTask.fs" />
1819
<Compile Include="Build.fs" />
1920
</ItemGroup>
2021

0 commit comments

Comments
 (0)