Skip to main content

CI Build System

Fable.Electron uses a modestly verbose but simple to manipulate build system that allows us to automate generation, packaging, versioning, and whatever else, for multiple projects that fall under the umbrella of the Fable.Electron core supported ecosystem.

Goals

  • Version based on ConventionalCommits, and a changelog as a single source of truth.
  • Publish automatically to nuget
  • Automatically generate and update electron bindings to the main repository, with exceptions:
    • DO NOT automatically merge MAJOR version changes in electron bindings
    • DO NOT automatically merge any version change that fails tests (due to generator failure or other)
  • If an exception for automatic generation occurs, create a PULL REQUEST to the DEVELOP branch

More goals may come to bear as we move along. Using this guide, hopefully you will understand how to manipulate the build system effectively.

note

Additionally, it also acts as a central abstraction for various operations.

As an example, you can run the docs in dev mode from the root of the repo by running

dotnet run -- docs [--npm-ci]

Use dotnet run -- --help to see what is available.

Architecture

FAKE

The core driver of the build system is the F# FAKE Framework.

Please see the documentation for FAKE regarding the particulars of its usage.

GH

We utilise the gh cli for navigating github and downloading release files programmatically.

GitNet

GitNet is a library for versioning F# Monorepos using conventional commits, and a flavor of semver that allows us to distinguish git tags for our monorepo packages.

Example

Imagine our repository, which contains 3 independently versioned packages: Fable.Electron, Fable.Electron.Forge, Fable.Electron.Remoting.

Normally we would create a tag for each version/release such as 'v38.8.0'.

Now imagine the following git tag history:

  • v38.8.0
  • v0.1.0
  • v38.8.1
  • v1.0.0

How can we tell which version relates to what package change?

In this flavor, a prefix is appended that distinguishes the version: _(ELECTRON)_38.8.0; _(FORGE)_8.3.0

GitNet essentially 'slices' the commit history for each project, based on which commits effect that project directory.

This allows granular automated versioning, without worrying about using footer tags to delineate between projects.

WDIO & Mocha

We use WDIO along with Mocha for testing.

Structure

ci
|-- Spec.fs
|-- lib
| |-- GhClient.fs
| |-- Electron.fs
| |-- GitNet.fs
| |-- Workers.fs
| |-- TargetOperators.fs
|-- Build.fs

Spec.fs

This is a specification file, with other generic helpers initialisers mixed in.

The significant aspects are described below.

File Navigation

We have compile time file system safety using EasyBuild.FileSystemProvider.

You can navigate the file system by accessing the static properties of the Root or VirtualRoot types.

type Root = AbsoluteFileSystem<_rootPath>

type VirtualRoot =
VirtualFileSystem<
_rootPath,
"""
fsdocs/
temp
electron-api.json
"""
>

Most of the essential folders/files you might want to navigate to are predefined:

module Projects =
module Folders =
type Remoting = Root.src.``Fable.Electron.Remoting``
type Generator = Root.src.``ElectronApi.Json.Parser``
type Build = Root.ci
type Electron = Root.src.``Fable.Electron``
type Forge = Root.src.``Fable.Electron.Forge``
type Tests = Root.tests

let Remoting = Folders.Remoting.``Fable.Electron.Remoting.fsproj``
let Generator = Folders.Generator.``ElectronApi.Json.Parser.fsproj``
let Build = Root.``Build.fsproj``
let Electron = Folders.Electron.``Fable.Electron.fsproj``
let Forge = Folders.Forge.``Fable.Electron.Forge.fsproj``

let Test =
Folders.Tests.``Fable.Electron.Remoting.Tests``.``Fable.Electron.Remoting.Tests.fsproj``

let BuildTest = Folders.Tests.``Build.Tests``.``Build.Tests.fsproj``

let Docs = Root.docs.``Docs.fsproj``

module Solutions =
let Electron = Root.``Fable.Electron.sln``

module Files =
let Api = VirtualRoot.temp.``electron-api.json``
let Cache = Root.ci.``cache.json``

Targets

FAKE Targets are defined as literals in the Ops module.

They are accessible via the build system directly using dotnet run -- run --target [TARGET].

Example snippet of Ops module
module Ops =
/// Clean directories from build material, and temporary files downloaded such as electron-api.json
[<Literal>]
let clean = "clean"

/// Clean directories from fable generated files
[<Literal>]
let fableClean = "fable-clean"

/// List releases from electron
[<Literal>]
let listReleases = "list-releases"
note

The Targets do not necessarily map 1-to-1 to our cli API though.

For instance, a command to run the test system might have a cleanup operation we want to run conditionally after the the test is completed. So the actual run target might need to be post-test.

Args

We define any flag literals in the Ops.Args module.

Example snippet of Args module

open EasyBuild.FileSystemProvider
open Fake.Core
open Fake.Core.Context

[<Literal>]
let _rootPath = __SOURCE_DIRECTORY__ + "/.."
type Root = AbsoluteFileSystem<_rootPath>

type VirtualRoot =
VirtualFileSystem<
_rootPath,
"""
fsdocs/
temp
electron-api.json
"""
>
module Projects =
module Folders =
type Remoting = Root.src.``Fable.Electron.Remoting``
type Generator = Root.src.``ElectronApi.Json.Parser``
type Build = Root.ci
type Electron = Root.src.``Fable.Electron``
type Forge = Root.src.``Fable.Electron.Forge``
type Tests = Root.tests

let Remoting = Folders.Remoting.``Fable.Electron.Remoting.fsproj``
let Generator = Folders.Generator.``ElectronApi.Json.Parser.fsproj``
let Build = Root.``Build.fsproj``
let Electron = Folders.Electron.``Fable.Electron.fsproj``
let Forge = Folders.Forge.``Fable.Electron.Forge.fsproj``

let Test =
Folders.Tests.``Fable.Electron.Remoting.Tests``.``Fable.Electron.Remoting.Tests.fsproj``

let BuildTest = Folders.Tests.``Build.Tests``.``Build.Tests.fsproj``

let Docs = Root.docs.``Docs.fsproj``

module Solutions =
let Electron = Root.``Fable.Electron.sln``

module Files =
let Api = VirtualRoot.temp.``electron-api.json``
let Cache = Root.ci.``cache.json``
module Ops =
/// Clean directories from build material, and temporary files downloaded such as electron-api.json
[<Literal>]
let clean = "clean"

/// Clean directories from fable generated files
[<Literal>]
let fableClean = "fable-clean"

/// List releases from electron
[<Literal>]

/// List releases from electron with details
[<Literal>]
let listDetailedReleases = "list-detailed-releases"
/// Download a specified release
[<Literal>]
let downloadApi = "download-api"

[<Literal>]
let downloadLatest = "download-latest"

/// Combines list releases and download api interactively
[<Literal>]

/// Post download cleanup
[<Literal>]
let postDownload = "post-download-clean"

/// Generate the Fable.Electron bindings
[<Literal>]
let generate = "generate"

[<Literal>]
let activateGitnet = "activate-gitnet"

/// Setup docs via npm i or npm ci
[<Literal>]
let setupDocs = "setup-docs"

/// Run docs in watch mode
[<Literal>]
let docs = "docs"

/// Build projects
[<Literal>]
let build = "build"

/// Pack projects
[<Literal>]
let pack = "pack"

/// Push to nuget
[<Literal>]
let push = "push"

/// Generate the API Docs (only to be run in an external repo)
[<Literal>]
let generateApiDocs = "generate-api-docs"

/// Does setup for tests by downloading deps with npm i or npm ci
[<Literal>]
let setupTest = "setup-test"

/// Run tests
[<Literal>]
let test = "test"

/// Do post test cleanup
[<Literal>]
let postTest = "post-test"

/// Restores tools in repo
[<Literal>]
let restore = "restore"

/// Formats files with fantomas
[<Literal>]
let format = "format"

/// Cron job for use by CI
[<Literal>]
let cron = "cron"

[<Literal>]
let loadCache = "load-cache"

[<Literal>]
let gitnet = "gitnet"

[<Literal>]
let downloadCache = "download-cache"

[<Literal>]
let buildTool = "build-tool"

module FlagArgs =
module Common =
[<Literal>]
let release = "--release"

[<Literal>]
let nugetApi = "--nuget-key"

[<Literal>]
let ghKey = "--gh-key"

module Run =
[<Literal>]
let target = "--target"

module Flags =
module Cron =
[<Literal>]
let downloadMinorOnly = "--only-minor"

[<Literal>]
let downloadPatchOnly = "--only-minor"

module Test =
[<Literal>]
let open' = "--open"

[<Literal>]
let watch = "--watch"

module Common =
[<Literal>]
let help = "--help"

[<Literal>]
let detailed = "--detailed"

[<Literal>]
let quick = "--quick"

[<Literal>]
let dry = "--dry-run"

These are set, and accessible from the Args type.

Example snippet of Args Type
type Args =
static let mutable args = None

static let hasFlag value =
args |> Option.exists (DocoptResult.hasFlag value)

static let getFlag value =
args |> Option.bind (DocoptResult.tryGetArgument value)

static member setArgs argsv =
args <- (Cli.parser: Docopt).Parse(argsv) |> Some

static member detailed = hasFlag Flags.Common.detailed
static member quick = hasFlag Flags.Common.quick
static member dryRun = hasFlag Flags.Common.dry

Commands

The commands map directly to our cli API. These literals are defined in the Commands module.

Example snippet of Commands module

module Commands =
[<Literal>]
let docs = "docs"

[<Literal>]
let test = "test"

[<Literal>]
let format = "format"

[<Literal>]
let generateApiDocs = "generate-api-docs"

[<Literal>]
let generate = "generate"

CLI

The CLI is then parsed from, and defined by, the Cli type:

Cli Type
and Cli =
static member spec =
$"""
Usage:
fable-electron [options]
fable-electron {Commands.docs} [options]
fable-electron {Commands.download} [options]
fable-electron {Commands.generate} [options]
fable-electron {Commands.generateApiDocs} [options]
fable-electron {Commands.pack} [options]
fable-electron {Commands.cron} [options] [crons]
fable-electron {Commands.run} [run] [options] [crons] [test]
fable-electron {Commands.test} [test] [options]
fable-electron {Commands.format} [options]
fable-electron {Commands.buildTool}

Cron Options [crons]:
--only-minor Only run a scheduled generation for minor releases of
the current electron semver. (can use together with patch)
--only-patch Only run a scheduled generation for patch releases of
the current electron semver. (can use together with minor)

Test Options [test]:
--open Will run the test application and open the app instead of
running the headless test suite.
--watch Will run the test application and open the app in watch mode
instead of running the headless test suite.

Run Options [run]:
--target <NAME> The target to run

Options [options]:
-h, --help Show this help message.
Note that the `cron` job should only be performed by the CI runners
-D, --detailed When printing release information, show all fields.
-Q, --quick Skip setup steps, such as installing dependencies (for local environments).
--dry-run Collect actions and print them at the end instead of pushing any changes.
--npm-ci `npm install` commands are run using `ci` (clean install) instead. Use this
if you are encountering 'module missing' errors for npm dependencies.
--release <RELEASE> Perform the actions for the specific release tag.
--skip-test Skip tests
--format Run fantomas
--nuget-key <API-KEY> The key used in authentication to push packages to NuGet.
--gh-key <PAT> Personal access token for GitHub to use instead of the CI runner.
--debug Shows the dependency list for the command and args
"""

static member parser = Docopt(Cli.spec)
note

While we inject the literals from the Commands module, doing the same for the flags would make formatting the details section annoying without string concatenation.

lib/GhClient.fs

This defines a Fake like module and types for using the gh cli tool similar to other tools in Fake.

lib/Electron.fs

Further abstractions over GhClient.fs for interacting directly with the electron/electron repository.

lib/GitNet.fs

Defines the configuration of, and initial runtime for, GitNet.

Ignored projects

Ensure that any .fsproj projects are ignored here, to prevent them having release notes generated for.

        Projects =
{ ProjectConfig.init with
IgnoredProjects =
[ Projects.Build
Projects.Generator
Projects.Test
Projects.Docs
Projects.BuildTest
Projects.Folders.Tests.``Tests.Common``.``Tests.Common.fsproj`` ]
|> List.map Path.GetFileNameWithoutExtension }

Other

Hopefully the rest of the configuration for GitNet is self-explanatory in the code.

You can explore the records with intellisense to see what configuration options are available.

lib/Workers.fs

Consider these as bindings to most of the operations that we might string together in a Target op to complete a part of our pipeline.

It is beneficial to partition ops into smaller parts - a good contribution would be doing this for the Ops.gitnet op.

lib/TargetOperators.fs

Additional operators to supplement Fake.Core.TargetOperators.

Build.fs

This is the meat of the Build system.

This is where we declare our Target operations. For the most part, they are fairly self explanatory, as they utilise the helpers defined in lib/Workers.fs.

Status


open Partas.Tools.SepochSemver
open Workers
open System.Text.Json
open Fake.Core
open Fake.IO
open Fake.Tools.Git
open Spec
open Fake.Tools
open Partas.GitNet
open GitNet

initializeContext ()


// Laundry
Target.create Ops.clean (ignore >> Laundry.clean)
Target.create Ops.fableClean (ignore >> Laundry.fableClean)
Target.create Ops.listReleases (fun _ -> Electron.listReleases true)
Target.create Ops.listDetailedReleases (fun _ -> Electron.listReleases false)

Target.create Ops.downloadApi
<| fun _ ->
match Args.release with
| Some value ->
Electron.tryGetReleaseFromString value
|> Option.orElseWith (fun () -> failwith $"Could not download a release matching the input '{value}'")
|> Option.iter (fun releaseInfo ->
Status.setRelease releaseInfo
Electron.downloadRelease releaseInfo)
| None -> failwithf $"Target %s{Ops.downloadApi} requires the argument '--release <RELEASE>' to be set"

Target.create Ops.downloadInput
<| fun _ ->
let getUserInput () =
let isQuit: string -> bool =
_.ToLowerInvariant()
>> function
| "q"
| "quit" -> true
| _ -> false

match UserInput.getUserInput "Choose a release or (q)uit:\n" with
| text when isQuit text -> failwith "User quit"
| text -> text

let rec run value =
match Electron.tryGetReleaseFromString value with
| None ->
Electron.listReleases true
getUserInput () |> run
| Some value ->
Status.setRelease value
Electron.downloadRelease value

Electron.listReleases true
getUserInput () |> run

Target.create Ops.downloadLatest
<| function
| _ when Args.downloadMinorOnly || Args.downloadPatchOnly ->
Target.runSimple Ops.loadCache [] |> ignore

let currentElectronVersion =
let tagName = Status.getCache().tagName.TrimStart('v')

tagName
|> tryParseSepochSemver
|> Option.map _.SemVer
|> function
| Some ver -> ver
| None ->
failwith
$"The `--only-minor` and `--only-patch` flags require the cache \
to have a compatible semver. Found {tagName} instead."

let parseTagName =
_.tagName.TrimStart('v') >> tryParseSepochSemver >> Option.map _.SemVer

Electron.getReleases ()
|> List.filter (_.isPrerelease >> not)
|> List.filter (
parseTagName
>> function
| Some value when Args.downloadMinorOnly && Args.downloadPatchOnly ->
currentElectronVersion.Major = value.Major
&& (currentElectronVersion.Minor < value.Minor
|| (currentElectronVersion.Minor = value.Minor
&& currentElectronVersion.Patch < value.Patch))
| Some value when Args.downloadMinorOnly ->
currentElectronVersion.Major = value.Major
&& currentElectronVersion.Minor < value.Minor
| Some value when Args.downloadPatchOnly ->
currentElectronVersion.Major = value.Major
&& currentElectronVersion.Minor = value.Minor
&& currentElectronVersion.Patch < value.Patch
| _ -> false
)
|> function
| [] -> Electron.tryGetReleaseFromString (Status.getCache().tagName)
| releases ->
releases
|> List.maxBy _.createdAt
|> _.tagName
|> Electron.tryGetReleaseFromString
|> function
| Some release ->
Status.setRelease release
Electron.downloadRelease release
| None -> failwith "Was not able to identify the latest release using the 'gh' cli."
| _ ->
Electron.tryGetRelease _.isLatest
|> function
| Some release ->
Status.setRelease release
Electron.downloadRelease release
| None -> failwith "Was not able to identify the latest release using the 'gh' cli."

Target.create Ops.postDownload (ignore >> Laundry.clean)

Target.create Ops.generate
<| fun _ ->
Electron.generate ()
|> Result.mapError (fun _ ->
failwith
"Attempted to generate from an electron-api.json, but none were downloaded.\n \
Either run target 'generate-release' or place the electron-api.json in the \
'/temp' folder at the root of the repository directory.")
|> ignore

Target.create Ops.setupDocs <| fun _ -> Docs.setup Args.npmCi
Target.create Ops.docs (ignore >> Docs.dev)
Target.create Ops.build (fun _ -> Project.build Project.Targets.All)
Target.create Ops.pack (fun _ -> Project.pack true Project.Targets.All)

Target.create Ops.push (fun _ ->
Project.push ()
Target.deactivateFinal Ops.gitnet)

Target.create Ops.generateApiDocs (ignore >> ApiDocs.validateDir >> ApiDocs.build)
Target.create Ops.setupTest (fun _ -> Electron.installTests Args.npmCi)

Target.create Ops.test
<| function
| _ when Args.watch -> Electron.watchTest ()
| _ when Args.open' -> Electron.openTest ()
| _ -> Electron.test ()

Target.create Ops.postTest (ignore >> Laundry.fableClean)
Target.create Ops.restore (ignore >> Laundry.restoreTools)
Target.create Ops.format (ignore >> Laundry.format)

// This target doesn't necessarily need to run anything itself. It acts to a sign post
// to target with a specific dependency list
Target.create Ops.cron ignore

Target.create Ops.loadCache
<| fun _ ->
if File.exists Files.Cache then
File.readAsString Files.Cache
|> JsonSerializer.Deserialize<ReleaseInfo>
|> Status.setCache

Target.create Ops.gitnet
<| fun para ->
if para.Context.IsRunningFinalTargets then
match para.Context.FinalTarget with
| Ops.gitnet -> Target.deactivateFinal Ops.gitnet
| _ -> ()

let projects, electronDeltaInfo = Versions.ElectronDelta.CreateFromContext()

let project =
{| electron = Project.Cracked.getProjectOrFail "Electron" projects
forge = Project.Cracked.getProjectOrFail "Forge" projects

let anyPackageUpdated =
electronDeltaInfo.IsElectronBump
|| getInitBumpRemoting.IsSome
|| getInitBumpForge.IsSome

let packageRequiresPull =
(electronDeltaInfo.DeltaKind.IsMajor && not electronDeltaInfo.IsProbablyPulled)
|| para.Context.HasError
// ====== Debug msg
printfn
$"
Summary of current status for GitNet:

Electron Cached Version: {electronDeltaInfo.Versions.CachedElectron.ToString()}
This is the electron release
information that is stored in
ci/cache.json

Is Probably Pulled: {electronDeltaInfo.IsProbablyPulled}
We can assume the repository
is being merged from a pull when
the electron cached version is higher
than the project files electron version

Current Electron Version: {electronDeltaInfo.Versions.FableElectronElectron.ToString()}
This is the project file electron version.
This is not updated except when being merged to main.

Current Package Version: {electronDeltaInfo.Versions.FableElectronPackage.ToString()}
This is the package version for Fable.Electron

Downloaded Version: {electronDeltaInfo.Versions.DownloadedElectron.ToString()}
This is the version of Electron that was
downloaded in this run.

If the major is updated, then we will submit a pull:
{electronDeltaInfo.DeltaKind}

Next version: {electronDeltaInfo.NextElectronVersion.ToString()}
This is the next calculated version
of Fable.Electron

Is Electron Package Updated: {electronDeltaInfo.IsElectronBump}
Whether or not the Electron package
is changed, regardless of whether the
'electron' version has changed.
This is caused by changes in the generator.

Is Any Package Updated: {anyPackageUpdated}
Whether any of our packages have
changed.

Next versions:
Remoting: {getInitBumpRemoting}
Forge: {getInitBumpForge}

Package Requires Pull: {packageRequiresPull}
Whether this run will result in a pull.
"
// ============= Action
match anyPackageUpdated, electronDeltaInfo.DeltaKind, packageRequiresPull with
| false, _, _ ->
// nothing to do
Trace.log "No changes during CI."
| true, Versions.Equal, true ->
// Electron package didnt update, but our other dependent packages failed
// which means we will not push this update at all.
failwith $"%A{para.Context.ErrorTargets}"
| _, (Versions.Major | Versions.Minor | Versions.Patch as deltaKind), requiresPull ->
// The message for the commit should still abide by ConventionalCommits.
let commitMessage =
Versions.makeCommitMessage ("Electron binding update to match " + Status.getRelease().tagName) deltaKind

let runOrDryLog message (fn: Lazy<_>) =
if not Args.dryRun then fn.Value else Trace.log $"[ACTION] "

let runOrDryLogItems messages (fn: Lazy<_>) =
if not Args.dryRun then
fn.Value
else
Trace.logItems "[ACTION] " messages

if requiresPull then
lazy
Branches.getRemoteBranches Root.``.``
|> List.exists ((=) $"ci/electron/{Status.getRelease().tagName}")
|> function
| true when not Args.dryRun -> failwith "A pull already exists for this release."
| false -> Laundry.createBranch $"ci/electron/{Status.getRelease().tagName}"
| _ -> ()
|> runOrDryLog $"[ACTION] Create branch: ci/electron/{Status.getRelease().tagName}"

lazy
(
// If we don't have to make a pull, then we'll change the versions in the project files
// Otherwise, this change should be delegated to when we actually merge.
// Exception for this is the cache release info. We'll use that as our guide post
// for the merge version.

// If the electron version is different, we also update the property in the project file
// to match this.
if
electronDeltaInfo.Versions.DownloadedElectron.Value
<> electronDeltaInfo.Versions.FableElectronElectron.Value
then
project.electron
|> CrackedProject.withFsProj (
CrackedProject.Document.withProperty
"ElectronVersion"
_.SetValue(electronDeltaInfo.Versions.DownloadedElectron.Value.ToString())
// Return Ok to overwrite the project file
// Return Error to prevent overwriting project file
>> ignore
>> Ok
)
|> ignore

let nextVersion = electronDeltaInfo.NextElectronVersion

[ project.electron, nextVersion.SemVer
match getInitBumpForge with
| ValueSome { SemVer = version } -> project.forge, version
| _ -> ()
match getInitBumpRemoting with
| ValueSome { SemVer = version } -> project.remoting, version
| _ -> () ]
|> List.iter (fun (proj, version) ->
let versionString = version.ToString()

proj
|> CrackedProject.withFsProj (
CrackedProject.Document.withPackageVersion _.SetValue(versionString)
>> CrackedProject.Document.withVersion _.SetValue(versionString)
>> ignore
>> Ok
)
|> ignore))
|> runOrDryLogItems
[ electronDeltaInfo.Versions.DownloadedElectron.ToString()
|> sprintf "Set Fable.Electron ElectronVersion: %s"
electronDeltaInfo.NextElectronVersion.SemVer.ToString()
|> sprintf "Set Fable.Electron Version: %s"

match getInitBumpForge with
| ValueSome { SemVer = version } -> version.ToString() |> sprintf "Set Fable.Electron.Forge Version: %s"
| _ -> ()
match getInitBumpRemoting with
| ValueSome { SemVer = version } ->
version.ToString() |> sprintf "Set Fable.Electron.Remoting Version: %s"
| _ -> () ]

// Write the version/release info to the cache that this generation was based off
lazy (Status.getRelease () |> Electron.writeToCache)
|> runOrDryLog $"Write to cache: {Status.getRelease ()}"

[ project.electron; project.forge; project.remoting ] // We collect all the compiled files for each project, the project files
|> List.collect (fun proj ->
CrackedProject.getCompiledFilePaths proj
|> List.map (Path.combine proj.ProjectDirectory)
|> List.append [ CrackedProject.projectFileName proj ])
// We also add the cache file
|> List.append [ Path.combine "ci" "cache.json" ]
// We stage the files and then commit
|> function
| files when Args.dryRun ->
Trace.log $"[ACTION] Stage files: {files}"
Trace.log $"[ACTION] Commit with Message: {commitMessage}"
| files ->
runtime.StageFiles files
runtime.CommitChanges(message = commitMessage, appendCommit = false)

// If we don't need to make a pull, then we can commit the tags.
// Otherwise, we'll leave that for when the pull is merged.
if not requiresPull then
let tags =
[ electronDeltaInfo.NextElectronVersion
if getInitBumpForge.IsSome then
getInitBumpForge.Value
if getInitBumpRemoting.IsSome then
getInitBumpRemoting.Value ]

lazy runtime.CommitTags tags
|> runOrDryLogItems (tags |> List.map (_.ToString() >> sprintf "Git Tag with: %s"))

lazy
// Once we have committed above, the markdown output will include the
// tags/commits, and we can generate the release notes
runtime.DryRun()
|> _.Markdown
// Instead of using WriteToOutputAndCommit, which automatically appends
// the message if a commit has been made - but also overwrites the commit
// message, we use WriteToOutputAndStage and then commit the changes
|> runtime.WriteToOutputAndStage

runtime.CommitChanges(appendCommit = false)
|> runOrDryLogItems [ "GENERATE RELEASE_NOTES"; "Stage release notes"; "Commit changes" ]

if not requiresPull then
// Before we do any pushing, we'll make sure the packages have no issues getting
// pushed to nuget if we're not doing a pull
Target.WithContext.run 1 (if Args.dryRun then Ops.pack else Ops.push) []
|> Target.raiseIfError

lazy
// This will push to main or push to the created branch
Laundry.pushCurrentBranch ()
|> runOrDryLog "Push to branch"
// If we have to make a pull, we'll generate the pull using GH CLI
if requiresPull then
let title =
if para.Context.HasError then "[GEN ERROR] For " else ""
+ "Electron "
+ Status.getRelease().tagName

let body =
if para.Context.HasError then
let rec addDetails (errors: (exn * Target) list) : string list =
match errors with
| [] -> []
| (e, target) :: rest ->
[ $"Error during '{target.Name}':"
""
"<details>"
"<summary>Error</summary>"
""
"```"
$"{e}"
"```"
"</details>"
"" ]
@ addDetails rest

addDetails para.Context.ErrorTargets
|> String.concat "\n"
|> sprintf
"""During the build process, I came across some errors.

Once these are corrected, please consider merging this to `develop`

%s"""
else
let release = Status.getRelease ()

$"""Bindings for electron {release.tagName} were generated successfully and passed tests.

This electron release was created on {release.createdAt}.

This pull must be merged to `main` for publishing to occur.

It is recommended to merge to `develop` for major electron versions first.
"""

lazy // Pulls are made to Devel rather than Main
Laundry.sendPullForDevel title body
|> runOrDryLog "Send pull to devel:\n{title}\n\n{body}"
| true, Versions.Equal, false when not Args.dryRun ->
// If electron package is the same, then we can just do a normal run
// and let everything fall into place
use runtime = createRuntime ()
runtime.Run() |> ignore
Target.WithContext.run 1 Ops.push [] |> Target.raiseIfError
| true, Versions.Equal, false ->
Trace.log $"[ACTION] Update Forge?: {getInitBumpForge}"
Trace.log $"[ACTION] Update Remoting?: {getInitBumpRemoting}"
Trace.log "[ACTION] Pushing to nuget"

Target.create Ops.activateGitnet
<| fun _ ->
Target.runSimple Ops.loadCache [] |> ignore
Target.activateFinal Ops.gitnet

Target.create Ops.downloadCache
<| fun _ -> Status.getCache () |> Electron.downloadRelease

Target.create Ops.buildTool
<| fun _ ->
Project.pack true (Project.Targets.One Projects.Build)
Trace.trace "Build.fsproj has been packed into a tool that can be used locally."

Trace.traceImportant
"""
Tool 'fable-electron' created in '/bin'.

With DotNet 10.0.100+ use `dnx build --source ./bin` to initialise.
Afterwards, you can run the build cli using `dnx build`.

Otherwise, you can locally install the tool using `dotnet tool install --source ./bin build`,
You can then run the tool using `dotnet fable-electron`.
# WARNING - do not commit/push your version of the dotnet-tools.json if you take this approach.
"""

open Fake.Core.TargetOperators
// ==========================================================
// CI entry point
[<EntryPoint>]
let main argsv =
let printHelp () = printfn $"%s{Cli.spec}"

if argsv |> Array.isEmpty then
printHelp ()
0
else
argsv |> Args.setArgs
// ==========================================================
// Set what operations of the CI must precede other operations
let dependencyMapping =
// Dependency on restore for any tool related actions
Ops.restore
===> [ Ops.clean ==> Ops.fableClean
Ops.downloadApi
Ops.downloadInput
Ops.downloadLatest
Ops.listDetailedReleases
Ops.listReleases
Ops.generate
Ops.generateApiDocs
Ops.test
Ops.format ]

Ops.gitnet <== [ Ops.loadCache ]

Ops.gitnet
<==? [ Ops.postDownload; Ops.postTest; Ops.test; Ops.fableClean; Ops.format ]
|> ignore

[
// define setup requirements
Ops.setupTest =?> (Ops.test, not Args.quick) ==> Ops.postTest
// If generate occurs, it is a soft dependency
// for multiple targets
Ops.generate
?==> [ Ops.test
Ops.format
Ops.generateApiDocs
Ops.build
Ops.pack
Ops.push
Ops.gitnet
Ops.postDownload ]
// On the other hand, generate has plenty of soft dependencies itself
Ops.generate <==? [ Ops.downloadApi; Ops.downloadInput; Ops.downloadLatest ]
Ops.setupDocs =?> (Ops.docs, not Args.quick)

Ops.loadCache ==> Ops.downloadCache


Ops.postDownload
<==? [ Ops.downloadApi
Ops.downloadInput
Ops.downloadLatest
Ops.generate
Ops.setupTest
Ops.test ] ]
let run =
if Args.debug then
Target.printDependencyGraph true
else
Target.runOrDefaultWithArguments

match argsv[0] with
| _ when Args.help -> printfn $"%s{Cli.spec}"
| Commands.buildTool -> run Ops.buildTool
| Commands.generateApiDocs -> run Ops.generateApiDocs
| Commands.docs -> run Ops.docs
| Commands.generate ->
if not <| File.exists Files.Api then
let dependencies =
[ Ops.downloadApi =?> (Ops.postDownload, Args.release.IsSome)
Ops.downloadInput =?> (Ops.postDownload, Args.release.IsNone)
Ops.generate ==> Ops.postDownload ]

run Ops.postDownload
else

run Ops.generate
| Commands.run ->
match Args.target with
| None -> failwith "No target supplied to '--target <NAME>'"
| Some target -> run target
| Commands.download -> run Ops.postDownload
| Commands.cron ->
let dependencies =
[ Ops.downloadLatest
==> Ops.generate
==> Ops.activateGitnet
==> Ops.build
==> Ops.test
==> Ops.postTest
==> Ops.postDownload
?==> [ Ops.gitnet; Ops.cron ]
==> Ops.cron
Ops.pack ==> Ops.push ]

run Ops.cron
| Commands.pack -> run Ops.pack
| Commands.test -> run (if Args.quick then Ops.test else Ops.postTest)
| maybeTarget -> run maybeTarget

0

The status module tracks two global values that are filled by different Target ops.

The Cache value is stored in ci/cache.json, and is a tracked copy of the release info JSON that was previously generated/operated on.

note

Workers can write to the cache, and the Target op load-cache will fill it.

On the other hand, the Release value is the current release that was downloaded. The Release value would be None if we were to not utilise one of the download target operations.

info

The three download targets are download-api, download-input and download-latest.

Spec.Ops module
    /// Download a specified release
[<Literal>]
let downloadApi = "download-api"

[<Literal>]
let downloadLatest = "download-latest"

/// Combines list releases and download api interactively
[<Literal>]
let downloadInput = "download-input"
  • download-api will download a release that is passed through the --release flag.
  • download-latest searches the releases for the one that is marked latest.
  • download-input will list the versions available, and you can choose a version to download.

Tracking Electron Release Versions

As mentioned, we do track the current downloaded release version, and previous release version through the Status.release and Status.cache values respectively.

We also locally track the current bound electron version in the project file for Fable.Electron directly.

important

We do not update the project files until we are packing and pushing to NuGet.

This allows us to distinguish between CI pipelines that create pulls for major versions, their eventual merges, and our normal build operations.

  • When creating a pull for a major version change, or because an error occurs in generation for a minor change (or other), we will update the cache.json with the release info.
  • This creates a context where the cache.json release info is higher than the project file electron version
  • When we merge the pull, this indicates that this was a CI generated pull/merge and we can publish the version change.
note

This approach uses a common operation for all merge/pull/push triggers, which is gitnet.

You could instead use .yaml workflows to target merge, pull, or push specifically (although the nuances with GitHub workflows have to be accounted for), and reduce the reliance on file values. You would have to make sure the yaml workflows trigger their appropriate targets.

Target gitnet

This target is essentially what was cooked up to serve as a single operation that would automatically control and version the different packages.

As this might likely call for changes, we will thoroughly disambiguate its operations.

note

While writing this, I found it easier to iterate by having the full operations splayed out, rather than partitioned into worker functions.

Not very idiomatic, and desperately could use better structure.


1. Load the projects
Target.create Ops.gitnet
<| fun para ->
if para.Context.IsRunningFinalTargets then
match para.Context.FinalTarget with
| Ops.gitnet -> Target.deactivateFinal Ops.gitnet
| _ -> ()

let projects, electronDeltaInfo = Versions.ElectronDelta.CreateFromContext()

let project =
{| electron = Project.Cracked.getProjectOrFail "Electron" projects
forge = Project.Cracked.getProjectOrFail "Forge" projects
remoting = Project.Cracked.getProjectOrFail "Remoting" projects |}

We first check that GitNet cracker finds the three projects we are interested in packaging.


2. Load the current versions

let anyPackageUpdated =
electronDeltaInfo.IsElectronBump
|| getInitBumpRemoting.IsSome
|| getInitBumpForge.IsSome

let packageRequiresPull =
(electronDeltaInfo.DeltaKind.IsMajor && not electronDeltaInfo.IsProbablyPulled)
|| para.Context.HasError
// ====== Debug msg
printfn
$"
Summary of current status for GitNet:

Electron Cached Version: {electronDeltaInfo.Versions.CachedElectron.ToString()}
This is the electron release
information that is stored in
ci/cache.json

Is Probably Pulled: {electronDeltaInfo.IsProbablyPulled}
We can assume the repository
is being merged from a pull when
the electron cached version is higher
than the project files electron version

Current Electron Version: {electronDeltaInfo.Versions.FableElectronElectron.ToString()}
This is the project file electron version.
This is not updated except when being merged to main.

Current Package Version: {electronDeltaInfo.Versions.FableElectronPackage.ToString()}
This is the package version for Fable.Electron

Downloaded Version: {electronDeltaInfo.Versions.DownloadedElectron.ToString()}
This is the version of Electron that was
downloaded in this run.

If the major is updated, then we will submit a pull:
{electronDeltaInfo.DeltaKind}

Next version: {electronDeltaInfo.NextElectronVersion.ToString()}
This is the next calculated version
of Fable.Electron

Is Electron Package Updated: {electronDeltaInfo.IsElectronBump}
Whether or not the Electron package
is changed, regardless of whether the
'electron' version has changed.
This is caused by changes in the generator.

Is Any Package Updated: {anyPackageUpdated}
Whether any of our packages have
changed.

Next versions:
Remoting: {getInitBumpRemoting}
Forge: {getInitBumpForge}

Package Requires Pull: {packageRequiresPull}
Whether this run will result in a pull.
"
// ============= Action
match anyPackageUpdated, electronDeltaInfo.DeltaKind, packageRequiresPull with
| false, _, _ ->
// nothing to do
Trace.log "No changes during CI."
| true, Versions.Equal, true ->
// Electron package didnt update, but our other dependent packages failed
// which means we will not push this update at all.
failwith $"%A{para.Context.ErrorTargets}"
| _, (Versions.Major | Versions.Minor | Versions.Patch as deltaKind), requiresPull ->
// The message for the commit should still abide by ConventionalCommits.
let commitMessage =
Versions.makeCommitMessage ("Electron binding update to match " + Status.getRelease().tagName) deltaKind

let runOrDryLog message (fn: Lazy<_>) =
if not Args.dryRun then fn.Value else Trace.log $"[ACTION] "

let runOrDryLogItems messages (fn: Lazy<_>) =
if not Args.dryRun then
fn.Value
else
Trace.logItems "[ACTION] " messages

if requiresPull then
lazy
Branches.getRemoteBranches Root.``.``
|> List.exists ((=) $"ci/electron/{Status.getRelease().tagName}")
|> function
| true when not Args.dryRun -> failwith "A pull already exists for this release."
| false -> Laundry.createBranch $"ci/electron/{Status.getRelease().tagName}"
| _ -> ()
|> runOrDryLog $"[ACTION] Create branch: ci/electron/{Status.getRelease().tagName}"

lazy
(
// If we don't have to make a pull, then we'll change the versions in the project files
// Otherwise, this change should be delegated to when we actually merge.
// Exception for this is the cache release info. We'll use that as our guide post
// for the merge version.

// If the electron version is different, we also update the property in the project file
// to match this.
if
electronDeltaInfo.Versions.DownloadedElectron.Value
<> electronDeltaInfo.Versions.FableElectronElectron.Value
then
project.electron
|> CrackedProject.withFsProj (
CrackedProject.Document.withProperty
"ElectronVersion"
_.SetValue(electronDeltaInfo.Versions.DownloadedElectron.Value.ToString())
// Return Ok to overwrite the project file
// Return Error to prevent overwriting project file
>> ignore
>> Ok
)
|> ignore

let nextVersion = electronDeltaInfo.NextElectronVersion

[ project.electron, nextVersion.SemVer
match getInitBumpForge with
| ValueSome { SemVer = version } -> project.forge, version
| _ -> ()
match getInitBumpRemoting with
| ValueSome { SemVer = version } -> project.remoting, version
| _ -> () ]
|> List.iter (fun (proj, version) ->
let versionString = version.ToString()

proj
|> CrackedProject.withFsProj (
CrackedProject.Document.withPackageVersion _.SetValue(versionString)
>> CrackedProject.Document.withVersion _.SetValue(versionString)
>> ignore
>> Ok
)
|> ignore))
|> runOrDryLogItems
[ electronDeltaInfo.Versions.DownloadedElectron.ToString()
|> sprintf "Set Fable.Electron ElectronVersion: %s"
electronDeltaInfo.NextElectronVersion.SemVer.ToString()
|> sprintf "Set Fable.Electron Version: %s"

match getInitBumpForge with
| ValueSome { SemVer = version } -> version.ToString() |> sprintf "Set Fable.Electron.Forge Version: %s"
| _ -> ()
match getInitBumpRemoting with
| ValueSome { SemVer = version } ->
version.ToString() |> sprintf "Set Fable.Electron.Remoting Version: %s"
| _ -> () ]

// Write the version/release info to the cache that this generation was based off
lazy (Status.getRelease () |> Electron.writeToCache)
|> runOrDryLog $"Write to cache: {Status.getRelease ()}"

[ project.electron; project.forge; project.remoting ] // We collect all the compiled files for each project, the project files
|> List.collect (fun proj ->
CrackedProject.getCompiledFilePaths proj
|> List.map (Path.combine proj.ProjectDirectory)
|> List.append [ CrackedProject.projectFileName proj ])
// We also add the cache file
|> List.append [ Path.combine "ci" "cache.json" ]
// We stage the files and then commit
|> function
| files when Args.dryRun ->
Trace.log $"[ACTION] Stage files: {files}"
Trace.log $"[ACTION] Commit with Message: {commitMessage}"
| files ->
runtime.StageFiles files
runtime.CommitChanges(message = commitMessage, appendCommit = false)

// If we don't need to make a pull, then we can commit the tags.
// Otherwise, we'll leave that for when the pull is merged.
if not requiresPull then
let tags =
[ electronDeltaInfo.NextElectronVersion
if getInitBumpForge.IsSome then
getInitBumpForge.Value
if getInitBumpRemoting.IsSome then
getInitBumpRemoting.Value ]

lazy runtime.CommitTags tags
|> runOrDryLogItems (tags |> List.map (_.ToString() >> sprintf "Git Tag with: %s"))

lazy
// Once we have committed above, the markdown output will include the
// tags/commits, and we can generate the release notes
runtime.DryRun()
|> _.Markdown
// Instead of using WriteToOutputAndCommit, which automatically appends
// the message if a commit has been made - but also overwrites the commit
// message, we use WriteToOutputAndStage and then commit the changes
|> runtime.WriteToOutputAndStage

runtime.CommitChanges(appendCommit = false)
|> runOrDryLogItems [ "GENERATE RELEASE_NOTES"; "Stage release notes"; "Commit changes" ]

if not requiresPull then
// Before we do any pushing, we'll make sure the packages have no issues getting
// pushed to nuget if we're not doing a pull
Target.WithContext.run 1 (if Args.dryRun then Ops.pack else Ops.push) []
|> Target.raiseIfError

lazy
// This will push to main or push to the created branch
Laundry.pushCurrentBranch ()
|> runOrDryLog "Push to branch"
// If we have to make a pull, we'll generate the pull using GH CLI
if requiresPull then
let title =
if para.Context.HasError then "[GEN ERROR] For " else ""
+ "Electron "
+ Status.getRelease().tagName

let body =
if para.Context.HasError then
let rec addDetails (errors: (exn * Target) list) : string list =
match errors with
| [] -> []
| (e, target) :: rest ->
[ $"Error during '{target.Name}':"
""
"<details>"
"<summary>Error</summary>"
""
"```"
$"{e}"
"```"
"</details>"
"" ]
@ addDetails rest

addDetails para.Context.ErrorTargets
|> String.concat "\n"
|> sprintf
"""During the build process, I came across some errors.

Once these are corrected, please consider merging this to `develop`

%s"""
else
let release = Status.getRelease ()

$"""Bindings for electron {release.tagName} were generated successfully and passed tests.

This electron release was created on {release.createdAt}.

This pull must be merged to `main` for publishing to occur.

It is recommended to merge to `develop` for major electron versions first.
"""

lazy // Pulls are made to Devel rather than Main
Laundry.sendPullForDevel title body
|> runOrDryLog "Send pull to devel:\n{title}\n\n{body}"
| true, Versions.Equal, false when not Args.dryRun ->
// If electron package is the same, then we can just do a normal run
// and let everything fall into place
use runtime = createRuntime ()
runtime.Run() |> ignore
Target.WithContext.run 1 Ops.push [] |> Target.raiseIfError
| true, Versions.Equal, false ->
Trace.log $"[ACTION] Update Forge?: {getInitBumpForge}"
Trace.log $"[ACTION] Update Remoting?: {getInitBumpRemoting}"
Trace.log "[ACTION] Pushing to nuget"

Target.create Ops.activateGitnet
<| fun _ ->
Target.runSimple Ops.loadCache [] |> ignore
Target.activateFinal Ops.gitnet

Target.create Ops.downloadCache
<| fun _ -> Status.getCache () |> Electron.downloadRelease

Target.create Ops.buildTool
<| fun _ ->
Project.pack true (Project.Targets.One Projects.Build)
Trace.trace "Build.fsproj has been packed into a tool that can be used locally."

Trace.traceImportant
"""
Tool 'fable-electron' created in '/bin'.

With DotNet 10.0.100+ use `dnx build --source ./bin` to initialise.
Afterwards, you can run the build cli using `dnx build`.

Otherwise, you can locally install the tool using `dotnet tool install --source ./bin build`,
You can then run the tool using `dotnet fable-electron`.
# WARNING - do not commit/push your version of the dotnet-tools.json if you take this approach.
"""

open Fake.Core.TargetOperators
// ==========================================================
// CI entry point
[<EntryPoint>]
let main argsv =
let printHelp () = printfn $"%s{Cli.spec}"

if argsv |> Array.isEmpty then
printHelp ()
0
else
argsv |> Args.setArgs
// ==========================================================
// Set what operations of the CI must precede other operations
let dependencyMapping =
// Dependency on restore for any tool related actions
Ops.restore
===> [ Ops.clean ==> Ops.fableClean
Ops.downloadApi
Ops.downloadInput
Ops.downloadLatest
Ops.listDetailedReleases
Ops.listReleases
Ops.generate
Ops.generateApiDocs
Ops.test
Ops.format ]

Ops.gitnet <== [ Ops.loadCache ]

Ops.gitnet
<==? [ Ops.postDownload; Ops.postTest; Ops.test; Ops.fableClean; Ops.format ]
|> ignore

[
// define setup requirements
Ops.setupTest =?> (Ops.test, not Args.quick) ==> Ops.postTest
// If generate occurs, it is a soft dependency
// for multiple targets
Ops.generate
?==> [ Ops.test
Ops.format
Ops.generateApiDocs
Ops.build
Ops.pack
Ops.push
Ops.gitnet
Ops.postDownload ]
// On the other hand, generate has plenty of soft dependencies itself
Ops.generate <==? [ Ops.downloadApi; Ops.downloadInput; Ops.downloadLatest ]
Ops.setupDocs =?> (Ops.docs, not Args.quick)

Ops.loadCache ==> Ops.downloadCache


Ops.postDownload
<==? [ Ops.downloadApi
Ops.downloadInput
Ops.downloadLatest
Ops.generate
Ops.setupTest
Ops.test ] ]
let run =
if Args.debug then
Target.printDependencyGraph true
else
Target.runOrDefaultWithArguments

match argsv[0] with
| _ when Args.help -> printfn $"%s{Cli.spec}"
| Commands.buildTool -> run Ops.buildTool
| Commands.generateApiDocs -> run Ops.generateApiDocs
| Commands.docs -> run Ops.docs
| Commands.generate ->
if not <| File.exists Files.Api then
let dependencies =
[ Ops.downloadApi =?> (Ops.postDownload, Args.release.IsSome)
Ops.downloadInput =?> (Ops.postDownload, Args.release.IsNone)
Ops.generate ==> Ops.postDownload ]

run Ops.postDownload
else

run Ops.generate
| Commands.run ->
match Args.target with
| None -> failwith "No target supplied to '--target <NAME>'"
| Some target -> run target
| Commands.download -> run Ops.postDownload
| Commands.cron ->
let dependencies =
[ Ops.downloadLatest
==> Ops.generate
==> Ops.activateGitnet
==> Ops.build
==> Ops.test
==> Ops.postTest
==> Ops.postDownload
?==> [ Ops.gitnet; Ops.cron ]
==> Ops.cron
Ops.pack ==> Ops.push ]

run Ops.cron
| Commands.pack -> run Ops.pack
| Commands.test -> run (if Args.quick then Ops.test else Ops.postTest)
| maybeTarget -> run maybeTarget

0
  • currentElectronVersion
  • CrackedProject.withFsProj
val withFsProj: fn: (XDocument -> Result<unit,'E>) -> proj: CrackedProject -> Result<unit,'E>

This provides us access to read or write to the project file.

If fn returns Ok, then the file is saved on completion of the function.

We can read values and return them in Error to access outside the function.

However, the other helper functions such as CrackedProject.Document.withProperty expect unit return functions, as they are primarily for writing over properties.

  • downloadedVersion

The use of Option.get implies that the CI expects, and requires, there to have been a download target preceding it.

Releasing this requirement would require proper handling.

  • getInitVersionElectron

This is a helper binding defined in lib/GitNet.fs. A dry run on initialisation provides us information including what bumps to expect, and the package initial versions.

Since GitNet does not assume these properties are set in the project files, they are options.


3. Calculate version deltas

open Partas.Tools.SepochSemver
open Workers
open System.Text.Json
open Fake.Core
open Fake.IO
open Fake.Tools.Git
open Spec
open Fake.Tools
open Partas.GitNet
open GitNet

initializeContext ()


// Laundry
Target.create Ops.clean (ignore >> Laundry.clean)
Target.create Ops.fableClean (ignore >> Laundry.fableClean)
Target.create Ops.listReleases (fun _ -> Electron.listReleases true)
Target.create Ops.listDetailedReleases (fun _ -> Electron.listReleases false)

Target.create Ops.downloadApi
<| fun _ ->
match Args.release with
| Some value ->
Electron.tryGetReleaseFromString value
|> Option.orElseWith (fun () -> failwith $"Could not download a release matching the input '{value}'")
|> Option.iter (fun releaseInfo ->
Status.setRelease releaseInfo
Electron.downloadRelease releaseInfo)
| None -> failwithf $"Target %s{Ops.downloadApi} requires the argument '--release <RELEASE>' to be set"

Target.create Ops.downloadInput
<| fun _ ->
let getUserInput () =
let isQuit: string -> bool =
_.ToLowerInvariant()
>> function
| "q"
| "quit" -> true
| _ -> false

match UserInput.getUserInput "Choose a release or (q)uit:\n" with
| text when isQuit text -> failwith "User quit"
| text -> text

let rec run value =
match Electron.tryGetReleaseFromString value with
| None ->
Electron.listReleases true
getUserInput () |> run
| Some value ->
Status.setRelease value
Electron.downloadRelease value

Electron.listReleases true
getUserInput () |> run

Target.create Ops.downloadLatest
<| function
| _ when Args.downloadMinorOnly || Args.downloadPatchOnly ->
Target.runSimple Ops.loadCache [] |> ignore

let currentElectronVersion =
let tagName = Status.getCache().tagName.TrimStart('v')

tagName
|> tryParseSepochSemver
|> Option.map _.SemVer
|> function
| Some ver -> ver
| None ->
failwith
$"The `--only-minor` and `--only-patch` flags require the cache \
to have a compatible semver. Found {tagName} instead."

let parseTagName =
_.tagName.TrimStart('v') >> tryParseSepochSemver >> Option.map _.SemVer

Electron.getReleases ()
|> List.filter (_.isPrerelease >> not)
|> List.filter (
parseTagName
>> function
| Some value when Args.downloadMinorOnly && Args.downloadPatchOnly ->
currentElectronVersion.Major = value.Major
&& (currentElectronVersion.Minor < value.Minor
|| (currentElectronVersion.Minor = value.Minor
&& currentElectronVersion.Patch < value.Patch))
| Some value when Args.downloadMinorOnly ->
currentElectronVersion.Major = value.Major
&& currentElectronVersion.Minor < value.Minor
| Some value when Args.downloadPatchOnly ->
currentElectronVersion.Major = value.Major
&& currentElectronVersion.Minor = value.Minor
&& currentElectronVersion.Patch < value.Patch
| _ -> false
)
|> function
| [] -> Electron.tryGetReleaseFromString (Status.getCache().tagName)
| releases ->
releases
|> List.maxBy _.createdAt
|> _.tagName
|> Electron.tryGetReleaseFromString
|> function
| Some release ->
Status.setRelease release
Electron.downloadRelease release
| None -> failwith "Was not able to identify the latest release using the 'gh' cli."
| _ ->
Electron.tryGetRelease _.isLatest
|> function
| Some release ->
Status.setRelease release
Electron.downloadRelease release
| None -> failwith "Was not able to identify the latest release using the 'gh' cli."

Target.create Ops.postDownload (ignore >> Laundry.clean)

Target.create Ops.generate
<| fun _ ->
Electron.generate ()
|> Result.mapError (fun _ ->
failwith
"Attempted to generate from an electron-api.json, but none were downloaded.\n \
Either run target 'generate-release' or place the electron-api.json in the \
'/temp' folder at the root of the repository directory.")
|> ignore

Target.create Ops.setupDocs <| fun _ -> Docs.setup Args.npmCi
Target.create Ops.docs (ignore >> Docs.dev)
Target.create Ops.build (fun _ -> Project.build Project.Targets.All)
Target.create Ops.pack (fun _ -> Project.pack true Project.Targets.All)

Target.create Ops.push (fun _ ->
Project.push ()
Target.deactivateFinal Ops.gitnet)

Target.create Ops.generateApiDocs (ignore >> ApiDocs.validateDir >> ApiDocs.build)
Target.create Ops.setupTest (fun _ -> Electron.installTests Args.npmCi)

Target.create Ops.test
<| function
| _ when Args.watch -> Electron.watchTest ()
| _ when Args.open' -> Electron.openTest ()
| _ -> Electron.test ()

Target.create Ops.postTest (ignore >> Laundry.fableClean)
Target.create Ops.restore (ignore >> Laundry.restoreTools)
Target.create Ops.format (ignore >> Laundry.format)

// This target doesn't necessarily need to run anything itself. It acts to a sign post
// to target with a specific dependency list
Target.create Ops.cron ignore

Target.create Ops.loadCache
<| fun _ ->
if File.exists Files.Cache then
File.readAsString Files.Cache
|> JsonSerializer.Deserialize<ReleaseInfo>
|> Status.setCache

Target.create Ops.gitnet
<| fun para ->
if para.Context.IsRunningFinalTargets then
match para.Context.FinalTarget with
| Ops.gitnet -> Target.deactivateFinal Ops.gitnet
| _ -> ()

let projects, electronDeltaInfo = Versions.ElectronDelta.CreateFromContext()

let project =
{| electron = Project.Cracked.getProjectOrFail "Electron" projects
forge = Project.Cracked.getProjectOrFail "Forge" projects

let anyPackageUpdated =
electronDeltaInfo.IsElectronBump
|| getInitBumpRemoting.IsSome
|| getInitBumpForge.IsSome

let packageRequiresPull =
(electronDeltaInfo.DeltaKind.IsMajor && not electronDeltaInfo.IsProbablyPulled)
|| para.Context.HasError
// ====== Debug msg
printfn
$"
Summary of current status for GitNet:

Electron Cached Version: {electronDeltaInfo.Versions.CachedElectron.ToString()}
This is the electron release
information that is stored in
ci/cache.json

Is Probably Pulled: {electronDeltaInfo.IsProbablyPulled}
We can assume the repository
is being merged from a pull when
the electron cached version is higher
than the project files electron version

Current Electron Version: {electronDeltaInfo.Versions.FableElectronElectron.ToString()}
This is the project file electron version.
This is not updated except when being merged to main.

Current Package Version: {electronDeltaInfo.Versions.FableElectronPackage.ToString()}
This is the package version for Fable.Electron

Downloaded Version: {electronDeltaInfo.Versions.DownloadedElectron.ToString()}
This is the version of Electron that was
downloaded in this run.

If the major is updated, then we will submit a pull:
{electronDeltaInfo.DeltaKind}

Next version: {electronDeltaInfo.NextElectronVersion.ToString()}
This is the next calculated version
of Fable.Electron

Is Electron Package Updated: {electronDeltaInfo.IsElectronBump}
Whether or not the Electron package
is changed, regardless of whether the
'electron' version has changed.
This is caused by changes in the generator.

Is Any Package Updated: {anyPackageUpdated}
Whether any of our packages have
changed.

Next versions:
Remoting: {getInitBumpRemoting}
Forge: {getInitBumpForge}

Package Requires Pull: {packageRequiresPull}
Whether this run will result in a pull.
"
// ============= Action
match anyPackageUpdated, electronDeltaInfo.DeltaKind, packageRequiresPull with
| false, _, _ ->
// nothing to do
Trace.log "No changes during CI."
| true, Versions.Equal, true ->
// Electron package didnt update, but our other dependent packages failed
// which means we will not push this update at all.
failwith $"%A{para.Context.ErrorTargets}"
| _, (Versions.Major | Versions.Minor | Versions.Patch as deltaKind), requiresPull ->
// The message for the commit should still abide by ConventionalCommits.
let commitMessage =
Versions.makeCommitMessage ("Electron binding update to match " + Status.getRelease().tagName) deltaKind

let runOrDryLog message (fn: Lazy<_>) =
if not Args.dryRun then fn.Value else Trace.log $"[ACTION] "

let runOrDryLogItems messages (fn: Lazy<_>) =
if not Args.dryRun then
fn.Value
else
Trace.logItems "[ACTION] " messages

if requiresPull then
lazy
Branches.getRemoteBranches Root.``.``
|> List.exists ((=) $"ci/electron/{Status.getRelease().tagName}")
|> function
| true when not Args.dryRun -> failwith "A pull already exists for this release."
| false -> Laundry.createBranch $"ci/electron/{Status.getRelease().tagName}"
| _ -> ()
|> runOrDryLog $"[ACTION] Create branch: ci/electron/{Status.getRelease().tagName}"

lazy
(
// If we don't have to make a pull, then we'll change the versions in the project files
// Otherwise, this change should be delegated to when we actually merge.
// Exception for this is the cache release info. We'll use that as our guide post
// for the merge version.

// If the electron version is different, we also update the property in the project file
// to match this.
if
electronDeltaInfo.Versions.DownloadedElectron.Value
<> electronDeltaInfo.Versions.FableElectronElectron.Value
then
project.electron
|> CrackedProject.withFsProj (
CrackedProject.Document.withProperty
"ElectronVersion"
_.SetValue(electronDeltaInfo.Versions.DownloadedElectron.Value.ToString())
// Return Ok to overwrite the project file
// Return Error to prevent overwriting project file
>> ignore
>> Ok
)
|> ignore

let nextVersion = electronDeltaInfo.NextElectronVersion

[ project.electron, nextVersion.SemVer
match getInitBumpForge with
| ValueSome { SemVer = version } -> project.forge, version
| _ -> ()
match getInitBumpRemoting with
| ValueSome { SemVer = version } -> project.remoting, version
| _ -> () ]
|> List.iter (fun (proj, version) ->
let versionString = version.ToString()

proj
|> CrackedProject.withFsProj (
CrackedProject.Document.withPackageVersion _.SetValue(versionString)
>> CrackedProject.Document.withVersion _.SetValue(versionString)
>> ignore
>> Ok
)
|> ignore))
|> runOrDryLogItems
[ electronDeltaInfo.Versions.DownloadedElectron.ToString()
|> sprintf "Set Fable.Electron ElectronVersion: %s"
electronDeltaInfo.NextElectronVersion.SemVer.ToString()
|> sprintf "Set Fable.Electron Version: %s"

match getInitBumpForge with
| ValueSome { SemVer = version } -> version.ToString() |> sprintf "Set Fable.Electron.Forge Version: %s"
| _ -> ()
match getInitBumpRemoting with
| ValueSome { SemVer = version } ->
version.ToString() |> sprintf "Set Fable.Electron.Remoting Version: %s"
| _ -> () ]

// Write the version/release info to the cache that this generation was based off
lazy (Status.getRelease () |> Electron.writeToCache)
|> runOrDryLog $"Write to cache: {Status.getRelease ()}"

[ project.electron; project.forge; project.remoting ] // We collect all the compiled files for each project, the project files
|> List.collect (fun proj ->
CrackedProject.getCompiledFilePaths proj
|> List.map (Path.combine proj.ProjectDirectory)
|> List.append [ CrackedProject.projectFileName proj ])
// We also add the cache file
|> List.append [ Path.combine "ci" "cache.json" ]
// We stage the files and then commit
|> function
| files when Args.dryRun ->
Trace.log $"[ACTION] Stage files: {files}"
Trace.log $"[ACTION] Commit with Message: {commitMessage}"
| files ->
runtime.StageFiles files
runtime.CommitChanges(message = commitMessage, appendCommit = false)

// If we don't need to make a pull, then we can commit the tags.
// Otherwise, we'll leave that for when the pull is merged.
if not requiresPull then
let tags =
[ electronDeltaInfo.NextElectronVersion
if getInitBumpForge.IsSome then
getInitBumpForge.Value
if getInitBumpRemoting.IsSome then
getInitBumpRemoting.Value ]

lazy runtime.CommitTags tags
|> runOrDryLogItems (tags |> List.map (_.ToString() >> sprintf "Git Tag with: %s"))

lazy
// Once we have committed above, the markdown output will include the
// tags/commits, and we can generate the release notes
runtime.DryRun()
|> _.Markdown
// Instead of using WriteToOutputAndCommit, which automatically appends
// the message if a commit has been made - but also overwrites the commit
// message, we use WriteToOutputAndStage and then commit the changes
|> runtime.WriteToOutputAndStage

runtime.CommitChanges(appendCommit = false)
|> runOrDryLogItems [ "GENERATE RELEASE_NOTES"; "Stage release notes"; "Commit changes" ]

if not requiresPull then
// Before we do any pushing, we'll make sure the packages have no issues getting
// pushed to nuget if we're not doing a pull
Target.WithContext.run 1 (if Args.dryRun then Ops.pack else Ops.push) []
|> Target.raiseIfError

lazy
// This will push to main or push to the created branch
Laundry.pushCurrentBranch ()
|> runOrDryLog "Push to branch"
// If we have to make a pull, we'll generate the pull using GH CLI
if requiresPull then
let title =
if para.Context.HasError then "[GEN ERROR] For " else ""
+ "Electron "
+ Status.getRelease().tagName

let body =
if para.Context.HasError then
let rec addDetails (errors: (exn * Target) list) : string list =
match errors with
| [] -> []
| (e, target) :: rest ->
[ $"Error during '{target.Name}':"
""
"<details>"
"<summary>Error</summary>"
""
"```"
$"{e}"
"```"
"</details>"
"" ]
@ addDetails rest

addDetails para.Context.ErrorTargets
|> String.concat "\n"
|> sprintf
"""During the build process, I came across some errors.

Once these are corrected, please consider merging this to `develop`

%s"""
else
let release = Status.getRelease ()

$"""Bindings for electron {release.tagName} were generated successfully and passed tests.

This electron release was created on {release.createdAt}.

This pull must be merged to `main` for publishing to occur.

It is recommended to merge to `develop` for major electron versions first.
"""

lazy // Pulls are made to Devel rather than Main
Laundry.sendPullForDevel title body
|> runOrDryLog "Send pull to devel:\n{title}\n\n{body}"
| true, Versions.Equal, false when not Args.dryRun ->
// If electron package is the same, then we can just do a normal run
// and let everything fall into place
use runtime = createRuntime ()
runtime.Run() |> ignore
Target.WithContext.run 1 Ops.push [] |> Target.raiseIfError
| true, Versions.Equal, false ->
Trace.log $"[ACTION] Update Forge?: {getInitBumpForge}"
Trace.log $"[ACTION] Update Remoting?: {getInitBumpRemoting}"
Trace.log "[ACTION] Pushing to nuget"

Target.create Ops.activateGitnet
<| fun _ ->
Target.runSimple Ops.loadCache [] |> ignore
Target.activateFinal Ops.gitnet

Target.create Ops.downloadCache
<| fun _ -> Status.getCache () |> Electron.downloadRelease

Target.create Ops.buildTool
<| fun _ ->
Project.pack true (Project.Targets.One Projects.Build)
Trace.trace "Build.fsproj has been packed into a tool that can be used locally."

Trace.traceImportant
"""
Tool 'fable-electron' created in '/bin'.

With DotNet 10.0.100+ use `dnx build --source ./bin` to initialise.
Afterwards, you can run the build cli using `dnx build`.

Otherwise, you can locally install the tool using `dotnet tool install --source ./bin build`,
You can then run the tool using `dotnet fable-electron`.
# WARNING - do not commit/push your version of the dotnet-tools.json if you take this approach.
"""

open Fake.Core.TargetOperators
// ==========================================================
// CI entry point
[<EntryPoint>]
let main argsv =
let printHelp () = printfn $"%s{Cli.spec}"

if argsv |> Array.isEmpty then
printHelp ()
0
else
argsv |> Args.setArgs
// ==========================================================
// Set what operations of the CI must precede other operations
let dependencyMapping =
// Dependency on restore for any tool related actions
Ops.restore
===> [ Ops.clean ==> Ops.fableClean
Ops.downloadApi
Ops.downloadInput
Ops.downloadLatest
Ops.listDetailedReleases
Ops.listReleases
Ops.generate
Ops.generateApiDocs
Ops.test
Ops.format ]

Ops.gitnet <== [ Ops.loadCache ]

Ops.gitnet
<==? [ Ops.postDownload; Ops.postTest; Ops.test; Ops.fableClean; Ops.format ]
|> ignore

[
// define setup requirements
Ops.setupTest =?> (Ops.test, not Args.quick) ==> Ops.postTest
// If generate occurs, it is a soft dependency
// for multiple targets
Ops.generate
?==> [ Ops.test
Ops.format
Ops.generateApiDocs
Ops.build
Ops.pack
Ops.push
Ops.gitnet
Ops.postDownload ]
// On the other hand, generate has plenty of soft dependencies itself
Ops.generate <==? [ Ops.downloadApi; Ops.downloadInput; Ops.downloadLatest ]
Ops.setupDocs =?> (Ops.docs, not Args.quick)

Ops.loadCache ==> Ops.downloadCache


Ops.postDownload
<==? [ Ops.downloadApi
Ops.downloadInput
Ops.downloadLatest
Ops.generate
Ops.setupTest
Ops.test ] ]
let run =
if Args.debug then
Target.printDependencyGraph true
else
Target.runOrDefaultWithArguments

match argsv[0] with
| _ when Args.help -> printfn $"%s{Cli.spec}"
| Commands.buildTool -> run Ops.buildTool
| Commands.generateApiDocs -> run Ops.generateApiDocs
| Commands.docs -> run Ops.docs
| Commands.generate ->
if not <| File.exists Files.Api then
let dependencies =
[ Ops.downloadApi =?> (Ops.postDownload, Args.release.IsSome)
Ops.downloadInput =?> (Ops.postDownload, Args.release.IsNone)
Ops.generate ==> Ops.postDownload ]

run Ops.postDownload
else

run Ops.generate
| Commands.run ->
match Args.target with
| None -> failwith "No target supplied to '--target <NAME>'"
| Some target -> run target
| Commands.download -> run Ops.postDownload
| Commands.cron ->
let dependencies =
[ Ops.downloadLatest
==> Ops.generate
==> Ops.activateGitnet
==> Ops.build
==> Ops.test
==> Ops.postTest
==> Ops.postDownload
?==> [ Ops.gitnet; Ops.cron ]
==> Ops.cron
Ops.pack ==> Ops.push ]

run Ops.cron
| Commands.pack -> run Ops.pack
| Commands.test -> run (if Args.quick then Ops.test else Ops.postTest)
| maybeTarget -> run maybeTarget

0
  • isLocalBindingDirty

We use a lib/Workers.fs helper to determine if the Fable.Electron/Program.fs (our bindings) file has changed during generation.

In the scenario where we modify our generator, we would expect the output to change, but the electron version deltas would be 0.

This boolean would indicate that we would still need to bump and publish the package.


4. Next version, and compound booleans

open Partas.Tools.SepochSemver
open Workers
open System.Text.Json
open Fake.Core
open Fake.IO
open Fake.Tools.Git
open Spec
open Fake.Tools
open Partas.GitNet
open GitNet

initializeContext ()


// Laundry
Target.create Ops.clean (ignore >> Laundry.clean)
Target.create Ops.fableClean (ignore >> Laundry.fableClean)
Target.create Ops.listReleases (fun _ -> Electron.listReleases true)
Target.create Ops.listDetailedReleases (fun _ -> Electron.listReleases false)

Target.create Ops.downloadApi
<| fun _ ->
match Args.release with
| Some value ->
Electron.tryGetReleaseFromString value
|> Option.orElseWith (fun () -> failwith $"Could not download a release matching the input '{value}'")
|> Option.iter (fun releaseInfo ->
Status.setRelease releaseInfo
Electron.downloadRelease releaseInfo)
| None -> failwithf $"Target %s{Ops.downloadApi} requires the argument '--release <RELEASE>' to be set"

Target.create Ops.downloadInput
<| fun _ ->
let getUserInput () =
let isQuit: string -> bool =
_.ToLowerInvariant()
>> function
| "q"
| "quit" -> true
| _ -> false

match UserInput.getUserInput "Choose a release or (q)uit:\n" with
| text when isQuit text -> failwith "User quit"
| text -> text

let rec run value =
match Electron.tryGetReleaseFromString value with
| None ->
Electron.listReleases true
getUserInput () |> run
| Some value ->
Status.setRelease value
Electron.downloadRelease value

Electron.listReleases true
getUserInput () |> run

Target.create Ops.downloadLatest
<| function
| _ when Args.downloadMinorOnly || Args.downloadPatchOnly ->
Target.runSimple Ops.loadCache [] |> ignore

let currentElectronVersion =
let tagName = Status.getCache().tagName.TrimStart('v')

tagName
|> tryParseSepochSemver
|> Option.map _.SemVer
|> function
| Some ver -> ver
| None ->
failwith
$"The `--only-minor` and `--only-patch` flags require the cache \
to have a compatible semver. Found {tagName} instead."

let parseTagName =
_.tagName.TrimStart('v') >> tryParseSepochSemver >> Option.map _.SemVer

Electron.getReleases ()
|> List.filter (_.isPrerelease >> not)
|> List.filter (
parseTagName
>> function
| Some value when Args.downloadMinorOnly && Args.downloadPatchOnly ->
currentElectronVersion.Major = value.Major
&& (currentElectronVersion.Minor < value.Minor
|| (currentElectronVersion.Minor = value.Minor
&& currentElectronVersion.Patch < value.Patch))
| Some value when Args.downloadMinorOnly ->
currentElectronVersion.Major = value.Major
&& currentElectronVersion.Minor < value.Minor
| Some value when Args.downloadPatchOnly ->
currentElectronVersion.Major = value.Major
&& currentElectronVersion.Minor = value.Minor
&& currentElectronVersion.Patch < value.Patch
| _ -> false
)
|> function
| [] -> Electron.tryGetReleaseFromString (Status.getCache().tagName)
| releases ->
releases
|> List.maxBy _.createdAt
|> _.tagName
|> Electron.tryGetReleaseFromString
|> function
| Some release ->
Status.setRelease release
Electron.downloadRelease release
| None -> failwith "Was not able to identify the latest release using the 'gh' cli."
| _ ->
Electron.tryGetRelease _.isLatest
|> function
| Some release ->
Status.setRelease release
Electron.downloadRelease release
| None -> failwith "Was not able to identify the latest release using the 'gh' cli."

Target.create Ops.postDownload (ignore >> Laundry.clean)

Target.create Ops.generate
<| fun _ ->
Electron.generate ()
|> Result.mapError (fun _ ->
failwith
"Attempted to generate from an electron-api.json, but none were downloaded.\n \
Either run target 'generate-release' or place the electron-api.json in the \
'/temp' folder at the root of the repository directory.")
|> ignore

Target.create Ops.setupDocs <| fun _ -> Docs.setup Args.npmCi
Target.create Ops.docs (ignore >> Docs.dev)
Target.create Ops.build (fun _ -> Project.build Project.Targets.All)
Target.create Ops.pack (fun _ -> Project.pack true Project.Targets.All)

Target.create Ops.push (fun _ ->
Project.push ()
Target.deactivateFinal Ops.gitnet)

Target.create Ops.generateApiDocs (ignore >> ApiDocs.validateDir >> ApiDocs.build)
Target.create Ops.setupTest (fun _ -> Electron.installTests Args.npmCi)

Target.create Ops.test
<| function
| _ when Args.watch -> Electron.watchTest ()
| _ when Args.open' -> Electron.openTest ()
| _ -> Electron.test ()

Target.create Ops.postTest (ignore >> Laundry.fableClean)
Target.create Ops.restore (ignore >> Laundry.restoreTools)
Target.create Ops.format (ignore >> Laundry.format)

// This target doesn't necessarily need to run anything itself. It acts to a sign post
// to target with a specific dependency list
Target.create Ops.cron ignore

Target.create Ops.loadCache
<| fun _ ->
if File.exists Files.Cache then
File.readAsString Files.Cache
|> JsonSerializer.Deserialize<ReleaseInfo>
|> Status.setCache

Target.create Ops.gitnet
<| fun para ->
if para.Context.IsRunningFinalTargets then
match para.Context.FinalTarget with
| Ops.gitnet -> Target.deactivateFinal Ops.gitnet
| _ -> ()

let projects, electronDeltaInfo = Versions.ElectronDelta.CreateFromContext()

let project =
{| electron = Project.Cracked.getProjectOrFail "Electron" projects
forge = Project.Cracked.getProjectOrFail "Forge" projects

let anyPackageUpdated =
electronDeltaInfo.IsElectronBump
|| getInitBumpRemoting.IsSome
|| getInitBumpForge.IsSome

let packageRequiresPull =
(electronDeltaInfo.DeltaKind.IsMajor && not electronDeltaInfo.IsProbablyPulled)
|| para.Context.HasError
// ====== Debug msg
printfn
$"
Summary of current status for GitNet:

Electron Cached Version: {electronDeltaInfo.Versions.CachedElectron.ToString()}
This is the electron release
information that is stored in
ci/cache.json

Is Probably Pulled: {electronDeltaInfo.IsProbablyPulled}
We can assume the repository
is being merged from a pull when
the electron cached version is higher
than the project files electron version

Current Electron Version: {electronDeltaInfo.Versions.FableElectronElectron.ToString()}
This is the project file electron version.
This is not updated except when being merged to main.

Current Package Version: {electronDeltaInfo.Versions.FableElectronPackage.ToString()}
This is the package version for Fable.Electron

Downloaded Version: {electronDeltaInfo.Versions.DownloadedElectron.ToString()}
This is the version of Electron that was
downloaded in this run.

If the major is updated, then we will submit a pull:
{electronDeltaInfo.DeltaKind}

Next version: {electronDeltaInfo.NextElectronVersion.ToString()}
This is the next calculated version
of Fable.Electron

Is Electron Package Updated: {electronDeltaInfo.IsElectronBump}
Whether or not the Electron package
is changed, regardless of whether the
'electron' version has changed.
This is caused by changes in the generator.

Is Any Package Updated: {anyPackageUpdated}
Whether any of our packages have
changed.

Next versions:
Remoting: {getInitBumpRemoting}
Forge: {getInitBumpForge}

Package Requires Pull: {packageRequiresPull}
Whether this run will result in a pull.
"
// ============= Action
match anyPackageUpdated, electronDeltaInfo.DeltaKind, packageRequiresPull with
| false, _, _ ->
// nothing to do
Trace.log "No changes during CI."
| true, Versions.Equal, true ->
// Electron package didnt update, but our other dependent packages failed
// which means we will not push this update at all.
failwith $"%A{para.Context.ErrorTargets}"
| _, (Versions.Major | Versions.Minor | Versions.Patch as deltaKind), requiresPull ->
// The message for the commit should still abide by ConventionalCommits.
let commitMessage =
Versions.makeCommitMessage ("Electron binding update to match " + Status.getRelease().tagName) deltaKind

let runOrDryLog message (fn: Lazy<_>) =
if not Args.dryRun then fn.Value else Trace.log $"[ACTION] "

let runOrDryLogItems messages (fn: Lazy<_>) =
if not Args.dryRun then
fn.Value
else
Trace.logItems "[ACTION] " messages

if requiresPull then
lazy
Branches.getRemoteBranches Root.``.``
|> List.exists ((=) $"ci/electron/{Status.getRelease().tagName}")
|> function
| true when not Args.dryRun -> failwith "A pull already exists for this release."
| false -> Laundry.createBranch $"ci/electron/{Status.getRelease().tagName}"
| _ -> ()
|> runOrDryLog $"[ACTION] Create branch: ci/electron/{Status.getRelease().tagName}"

lazy
(
// If we don't have to make a pull, then we'll change the versions in the project files
// Otherwise, this change should be delegated to when we actually merge.
// Exception for this is the cache release info. We'll use that as our guide post
// for the merge version.

// If the electron version is different, we also update the property in the project file
// to match this.
if
electronDeltaInfo.Versions.DownloadedElectron.Value
<> electronDeltaInfo.Versions.FableElectronElectron.Value
then
project.electron
|> CrackedProject.withFsProj (
CrackedProject.Document.withProperty
"ElectronVersion"
_.SetValue(electronDeltaInfo.Versions.DownloadedElectron.Value.ToString())
// Return Ok to overwrite the project file
// Return Error to prevent overwriting project file
>> ignore
>> Ok
)
|> ignore

let nextVersion = electronDeltaInfo.NextElectronVersion

[ project.electron, nextVersion.SemVer
match getInitBumpForge with
| ValueSome { SemVer = version } -> project.forge, version
| _ -> ()
match getInitBumpRemoting with
| ValueSome { SemVer = version } -> project.remoting, version
| _ -> () ]
|> List.iter (fun (proj, version) ->
let versionString = version.ToString()

proj
|> CrackedProject.withFsProj (
CrackedProject.Document.withPackageVersion _.SetValue(versionString)
>> CrackedProject.Document.withVersion _.SetValue(versionString)
>> ignore
>> Ok
)
|> ignore))
|> runOrDryLogItems
[ electronDeltaInfo.Versions.DownloadedElectron.ToString()
|> sprintf "Set Fable.Electron ElectronVersion: %s"
electronDeltaInfo.NextElectronVersion.SemVer.ToString()
|> sprintf "Set Fable.Electron Version: %s"

match getInitBumpForge with
| ValueSome { SemVer = version } -> version.ToString() |> sprintf "Set Fable.Electron.Forge Version: %s"
| _ -> ()
match getInitBumpRemoting with
| ValueSome { SemVer = version } ->
version.ToString() |> sprintf "Set Fable.Electron.Remoting Version: %s"
| _ -> () ]

// Write the version/release info to the cache that this generation was based off
lazy (Status.getRelease () |> Electron.writeToCache)
|> runOrDryLog $"Write to cache: {Status.getRelease ()}"

[ project.electron; project.forge; project.remoting ] // We collect all the compiled files for each project, the project files
|> List.collect (fun proj ->
CrackedProject.getCompiledFilePaths proj
|> List.map (Path.combine proj.ProjectDirectory)
|> List.append [ CrackedProject.projectFileName proj ])
// We also add the cache file
|> List.append [ Path.combine "ci" "cache.json" ]
// We stage the files and then commit
|> function
| files when Args.dryRun ->
Trace.log $"[ACTION] Stage files: {files}"
Trace.log $"[ACTION] Commit with Message: {commitMessage}"
| files ->
runtime.StageFiles files
runtime.CommitChanges(message = commitMessage, appendCommit = false)

// If we don't need to make a pull, then we can commit the tags.
// Otherwise, we'll leave that for when the pull is merged.
if not requiresPull then
let tags =
[ electronDeltaInfo.NextElectronVersion
if getInitBumpForge.IsSome then
getInitBumpForge.Value
if getInitBumpRemoting.IsSome then
getInitBumpRemoting.Value ]

lazy runtime.CommitTags tags
|> runOrDryLogItems (tags |> List.map (_.ToString() >> sprintf "Git Tag with: %s"))

lazy
// Once we have committed above, the markdown output will include the
// tags/commits, and we can generate the release notes
runtime.DryRun()
|> _.Markdown
// Instead of using WriteToOutputAndCommit, which automatically appends
// the message if a commit has been made - but also overwrites the commit
// message, we use WriteToOutputAndStage and then commit the changes
|> runtime.WriteToOutputAndStage

runtime.CommitChanges(appendCommit = false)
|> runOrDryLogItems [ "GENERATE RELEASE_NOTES"; "Stage release notes"; "Commit changes" ]

if not requiresPull then
// Before we do any pushing, we'll make sure the packages have no issues getting
// pushed to nuget if we're not doing a pull
Target.WithContext.run 1 (if Args.dryRun then Ops.pack else Ops.push) []
|> Target.raiseIfError

lazy
// This will push to main or push to the created branch
Laundry.pushCurrentBranch ()
|> runOrDryLog "Push to branch"
// If we have to make a pull, we'll generate the pull using GH CLI
if requiresPull then
let title =
if para.Context.HasError then "[GEN ERROR] For " else ""
+ "Electron "
+ Status.getRelease().tagName

let body =
if para.Context.HasError then
let rec addDetails (errors: (exn * Target) list) : string list =
match errors with
| [] -> []
| (e, target) :: rest ->
[ $"Error during '{target.Name}':"
""
"<details>"
"<summary>Error</summary>"
""
"```"
$"{e}"
"```"
"</details>"
"" ]
@ addDetails rest

addDetails para.Context.ErrorTargets
|> String.concat "\n"
|> sprintf
"""During the build process, I came across some errors.

Once these are corrected, please consider merging this to `develop`

%s"""
else
let release = Status.getRelease ()

$"""Bindings for electron {release.tagName} were generated successfully and passed tests.

This electron release was created on {release.createdAt}.

This pull must be merged to `main` for publishing to occur.

It is recommended to merge to `develop` for major electron versions first.
"""

lazy // Pulls are made to Devel rather than Main
Laundry.sendPullForDevel title body
|> runOrDryLog "Send pull to devel:\n{title}\n\n{body}"
| true, Versions.Equal, false when not Args.dryRun ->
// If electron package is the same, then we can just do a normal run
// and let everything fall into place
use runtime = createRuntime ()
runtime.Run() |> ignore
Target.WithContext.run 1 Ops.push [] |> Target.raiseIfError
| true, Versions.Equal, false ->
Trace.log $"[ACTION] Update Forge?: {getInitBumpForge}"
Trace.log $"[ACTION] Update Remoting?: {getInitBumpRemoting}"
Trace.log "[ACTION] Pushing to nuget"

Target.create Ops.activateGitnet
<| fun _ ->
Target.runSimple Ops.loadCache [] |> ignore
Target.activateFinal Ops.gitnet

Target.create Ops.downloadCache
<| fun _ -> Status.getCache () |> Electron.downloadRelease

Target.create Ops.buildTool
<| fun _ ->
Project.pack true (Project.Targets.One Projects.Build)
Trace.trace "Build.fsproj has been packed into a tool that can be used locally."

Trace.traceImportant
"""
Tool 'fable-electron' created in '/bin'.

With DotNet 10.0.100+ use `dnx build --source ./bin` to initialise.
Afterwards, you can run the build cli using `dnx build`.

Otherwise, you can locally install the tool using `dotnet tool install --source ./bin build`,
You can then run the tool using `dotnet fable-electron`.
# WARNING - do not commit/push your version of the dotnet-tools.json if you take this approach.
"""

open Fake.Core.TargetOperators
// ==========================================================
// CI entry point
[<EntryPoint>]
let main argsv =
let printHelp () = printfn $"%s{Cli.spec}"

if argsv |> Array.isEmpty then
printHelp ()
0
else
argsv |> Args.setArgs
// ==========================================================
// Set what operations of the CI must precede other operations
let dependencyMapping =
// Dependency on restore for any tool related actions
Ops.restore
===> [ Ops.clean ==> Ops.fableClean
Ops.downloadApi
Ops.downloadInput
Ops.downloadLatest
Ops.listDetailedReleases
Ops.listReleases
Ops.generate
Ops.generateApiDocs
Ops.test
Ops.format ]

Ops.gitnet <== [ Ops.loadCache ]

Ops.gitnet
<==? [ Ops.postDownload; Ops.postTest; Ops.test; Ops.fableClean; Ops.format ]
|> ignore

[
// define setup requirements
Ops.setupTest =?> (Ops.test, not Args.quick) ==> Ops.postTest
// If generate occurs, it is a soft dependency
// for multiple targets
Ops.generate
?==> [ Ops.test
Ops.format
Ops.generateApiDocs
Ops.build
Ops.pack
Ops.push
Ops.gitnet
Ops.postDownload ]
// On the other hand, generate has plenty of soft dependencies itself
Ops.generate <==? [ Ops.downloadApi; Ops.downloadInput; Ops.downloadLatest ]
Ops.setupDocs =?> (Ops.docs, not Args.quick)

Ops.loadCache ==> Ops.downloadCache


Ops.postDownload
<==? [ Ops.downloadApi
Ops.downloadInput
Ops.downloadLatest
Ops.generate
Ops.setupTest
Ops.test ] ]
let run =
if Args.debug then
Target.printDependencyGraph true
else
Target.runOrDefaultWithArguments

match argsv[0] with
| _ when Args.help -> printfn $"%s{Cli.spec}"
| Commands.buildTool -> run Ops.buildTool
| Commands.generateApiDocs -> run Ops.generateApiDocs
| Commands.docs -> run Ops.docs
| Commands.generate ->
if not <| File.exists Files.Api then
let dependencies =
[ Ops.downloadApi =?> (Ops.postDownload, Args.release.IsSome)
Ops.downloadInput =?> (Ops.postDownload, Args.release.IsNone)
Ops.generate ==> Ops.postDownload ]

run Ops.postDownload
else

run Ops.generate
| Commands.run ->
match Args.target with
| None -> failwith "No target supplied to '--target <NAME>'"
| Some target -> run target
| Commands.download -> run Ops.postDownload
| Commands.cron ->
let dependencies =
[ Ops.downloadLatest
==> Ops.generate
==> Ops.activateGitnet
==> Ops.build
==> Ops.test
==> Ops.postTest
==> Ops.postDownload
?==> [ Ops.gitnet; Ops.cron ]
==> Ops.cron
Ops.pack ==> Ops.push ]

run Ops.cron
| Commands.pack -> run Ops.pack
| Commands.test -> run (if Args.quick then Ops.test else Ops.postTest)
| maybeTarget -> run maybeTarget

0

Because a normal GitNet run would bump versions depending on their commits, we need to manually re-create the workflow so that we can programmatically control the version of electron that we would bump to.

5. Branching workflow based on compound booleans
    // ============= Action
match anyPackageUpdated, electronDeltaInfo.DeltaKind, packageRequiresPull with
| false, _, _ ->
// nothing to do
Trace.log "No changes during CI."
| true, Versions.Equal, true ->
// Electron package didnt update, but our other dependent packages failed
// which means we will not push this update at all.
failwith $"%A{para.Context.ErrorTargets}"
| _, (Versions.Major | Versions.Minor | Versions.Patch as deltaKind), requiresPull ->
// The message for the commit should still abide by ConventionalCommits.
let commitMessage =
Versions.makeCommitMessage ("Electron binding update to match " + Status.getRelease().tagName) deltaKind

let runOrDryLog message (fn: Lazy<_>) =
if not Args.dryRun then fn.Value else Trace.log $"[ACTION] "

let runOrDryLogItems messages (fn: Lazy<_>) =
if not Args.dryRun then
fn.Value
else
Trace.logItems "[ACTION] " messages

if requiresPull then
lazy
Branches.getRemoteBranches Root.``.``
|> List.exists ((=) $"ci/electron/{Status.getRelease().tagName}")
|> function
| true when not Args.dryRun -> failwith "A pull already exists for this release."
| false -> Laundry.createBranch $"ci/electron/{Status.getRelease().tagName}"
| _ -> ()
|> runOrDryLog $"[ACTION] Create branch: ci/electron/{Status.getRelease().tagName}"

lazy
(
// If we don't have to make a pull, then we'll change the versions in the project files
// Otherwise, this change should be delegated to when we actually merge.
// Exception for this is the cache release info. We'll use that as our guide post
// for the merge version.

// If the electron version is different, we also update the property in the project file
// to match this.
if
electronDeltaInfo.Versions.DownloadedElectron.Value
<> electronDeltaInfo.Versions.FableElectronElectron.Value
then
project.electron
|> CrackedProject.withFsProj (
CrackedProject.Document.withProperty
"ElectronVersion"
_.SetValue(electronDeltaInfo.Versions.DownloadedElectron.Value.ToString())
// Return Ok to overwrite the project file
// Return Error to prevent overwriting project file
>> ignore
>> Ok
)
|> ignore

let nextVersion = electronDeltaInfo.NextElectronVersion

[ project.electron, nextVersion.SemVer
match getInitBumpForge with
| ValueSome { SemVer = version } -> project.forge, version
| _ -> ()
match getInitBumpRemoting with
| ValueSome { SemVer = version } -> project.remoting, version
| _ -> () ]
|> List.iter (fun (proj, version) ->
let versionString = version.ToString()

proj
|> CrackedProject.withFsProj (
CrackedProject.Document.withPackageVersion _.SetValue(versionString)
>> CrackedProject.Document.withVersion _.SetValue(versionString)
>> ignore
>> Ok
)
|> ignore))
|> runOrDryLogItems
[ electronDeltaInfo.Versions.DownloadedElectron.ToString()
|> sprintf "Set Fable.Electron ElectronVersion: %s"
electronDeltaInfo.NextElectronVersion.SemVer.ToString()
|> sprintf "Set Fable.Electron Version: %s"

match getInitBumpForge with
| ValueSome { SemVer = version } -> version.ToString() |> sprintf "Set Fable.Electron.Forge Version: %s"
| _ -> ()
match getInitBumpRemoting with
| ValueSome { SemVer = version } ->
version.ToString() |> sprintf "Set Fable.Electron.Remoting Version: %s"
| _ -> () ]

// Write the version/release info to the cache that this generation was based off
lazy (Status.getRelease () |> Electron.writeToCache)
|> runOrDryLog $"Write to cache: {Status.getRelease ()}"

[ project.electron; project.forge; project.remoting ] // We collect all the compiled files for each project, the project files
|> List.collect (fun proj ->
CrackedProject.getCompiledFilePaths proj
|> List.map (Path.combine proj.ProjectDirectory)
|> List.append [ CrackedProject.projectFileName proj ])
// We also add the cache file
|> List.append [ Path.combine "ci" "cache.json" ]
// We stage the files and then commit
|> function
| files when Args.dryRun ->
Trace.log $"[ACTION] Stage files: {files}"
Trace.log $"[ACTION] Commit with Message: {commitMessage}"
| files ->
runtime.StageFiles files
runtime.CommitChanges(message = commitMessage, appendCommit = false)

// If we don't need to make a pull, then we can commit the tags.
// Otherwise, we'll leave that for when the pull is merged.
if not requiresPull then
let tags =
[ electronDeltaInfo.NextElectronVersion
if getInitBumpForge.IsSome then
getInitBumpForge.Value
if getInitBumpRemoting.IsSome then
getInitBumpRemoting.Value ]

lazy runtime.CommitTags tags
|> runOrDryLogItems (tags |> List.map (_.ToString() >> sprintf "Git Tag with: %s"))

lazy
// Once we have committed above, the markdown output will include the
// tags/commits, and we can generate the release notes
runtime.DryRun()
|> _.Markdown
// Instead of using WriteToOutputAndCommit, which automatically appends
// the message if a commit has been made - but also overwrites the commit
// message, we use WriteToOutputAndStage and then commit the changes
|> runtime.WriteToOutputAndStage

runtime.CommitChanges(appendCommit = false)
|> runOrDryLogItems [ "GENERATE RELEASE_NOTES"; "Stage release notes"; "Commit changes" ]

if not requiresPull then
// Before we do any pushing, we'll make sure the packages have no issues getting
// pushed to nuget if we're not doing a pull
Target.WithContext.run 1 (if Args.dryRun then Ops.pack else Ops.push) []
|> Target.raiseIfError

lazy
// This will push to main or push to the created branch
Laundry.pushCurrentBranch ()
|> runOrDryLog "Push to branch"
// If we have to make a pull, we'll generate the pull using GH CLI
if requiresPull then
let title =
if para.Context.HasError then "[GEN ERROR] For " else ""
+ "Electron "
+ Status.getRelease().tagName

let body =
if para.Context.HasError then
let rec addDetails (errors: (exn * Target) list) : string list =
match errors with
| [] -> []
| (e, target) :: rest ->
[ $"Error during '{target.Name}':"
""
"<details>"
"<summary>Error</summary>"
""
"```"
$"{e}"
"```"
"</details>"
"" ]
@ addDetails rest

addDetails para.Context.ErrorTargets
|> String.concat "\n"
|> sprintf
"""During the build process, I came across some errors.

Once these are corrected, please consider merging this to `develop`

%s"""
else
let release = Status.getRelease ()

$"""Bindings for electron {release.tagName} were generated successfully and passed tests.

This electron release was created on {release.createdAt}.

This pull must be merged to `main` for publishing to occur.

It is recommended to merge to `develop` for major electron versions first.
"""

lazy // Pulls are made to Devel rather than Main
Laundry.sendPullForDevel title body
|> runOrDryLog "Send pull to devel:\n{title}\n\n{body}"
| true, Versions.Equal, false when not Args.dryRun ->
// If electron package is the same, then we can just do a normal run
// and let everything fall into place
use runtime = createRuntime ()
runtime.Run() |> ignore
Target.WithContext.run 1 Ops.push [] |> Target.raiseIfError
| true, Versions.Equal, false ->
Trace.log $"[ACTION] Update Forge?: {getInitBumpForge}"
Trace.log $"[ACTION] Update Remoting?: {getInitBumpRemoting}"
Trace.log "[ACTION] Pushing to nuget"
tip

Running the gitnet target with the flag --dry-run can be helpful to see what operations would run without actually committing to any changes.

You can also see here, repeated usage of, git operations such as file staging, committing, and tagging via methods on the GitNet runtime.

GitNet uses LibGit2Sharp rather than the git cli.

Target Dependencies

Defining Target Dependencies
        // ==========================================================
// Set what operations of the CI must precede other operations
let dependencyMapping =
// Dependency on restore for any tool related actions
Ops.restore
===> [ Ops.clean ==> Ops.fableClean
Ops.downloadApi
Ops.downloadInput
Ops.downloadLatest
Ops.listDetailedReleases
Ops.listReleases
Ops.generate
Ops.generateApiDocs
Ops.test
Ops.format ]

Ops.gitnet <== [ Ops.loadCache ]

Ops.gitnet
<==? [ Ops.postDownload; Ops.postTest; Ops.test; Ops.fableClean; Ops.format ]
|> ignore

[
// define setup requirements
Ops.setupTest =?> (Ops.test, not Args.quick) ==> Ops.postTest
// If generate occurs, it is a soft dependency
// for multiple targets
Ops.generate
?==> [ Ops.test
Ops.format
Ops.generateApiDocs
Ops.build
Ops.pack
Ops.push
Ops.gitnet
Ops.postDownload ]
// On the other hand, generate has plenty of soft dependencies itself
Ops.generate <==? [ Ops.downloadApi; Ops.downloadInput; Ops.downloadLatest ]
Ops.setupDocs =?> (Ops.docs, not Args.quick)

Ops.loadCache ==> Ops.downloadCache


Ops.postDownload
<==? [ Ops.downloadApi
Ops.downloadInput
Ops.downloadLatest
Ops.generate
Ops.setupTest
Ops.test ] ]

See the guide FAKE Targets.

OperatorDescription
x ==> yy requires x
x ?=> yif x is run, then it must occur before y (soft dependency)
x =?> (y, predicate)y requires x if the predicate is true.
x <== y listx requires each y
x ===> y listEach y requires x
x <==? y listIf any of y occur, they must occur before x
x ?==> y listEach y would require x to occur first, if x was being run
x ?=?> (y, predicate) listFor each y, they require x if predicate is true
x <?=? (y, predicate) listFor each y, x is dependent on y if predicate is true