diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 000000000..96c07a339 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,17 @@ +# [Choice] Debian version (use bullseye on local arm64/Apple Silicon): bookworm, bullseye, buster +ARG VARIANT="bookworm" +FROM buildpack-deps:${VARIANT}-curl + + +ENV \ + # Enable detection of running in a container + DOTNET_RUNNING_IN_CONTAINER=true \ + DOTNET_ROOT=/usr/share/dotnet/ \ + DOTNET_NOLOGO=true \ + DOTNET_CLI_TELEMETRY_OPTOUT=false\ + DOTNET_USE_POLLING_FILE_WATCHER=true + + +# [Optional] Uncomment this section to install additional OS packages. +# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +# && apt-get -y install --no-install-recommends diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..8db0410a6 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,71 @@ +{ + "name": "dotnet", + // Set the build context one level higher so we can grab metadata like global.json + "context": "..", + "dockerFile": "Dockerfile", + "forwardPorts": [ + 0 + ], + "features": { + // https://github.com/devcontainers/features/blob/main/src/common-utils/README.md + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": true, + "installOhMyZshConfig": true, + "configureZshAsDefaultShell": true, + "username": "vscode", + "userUid": "1000", + "userGid": "1000", + "upgradePackages": true + }, + // https://github.com/devcontainers/features/blob/main/src/github-cli/README.md + "ghcr.io/devcontainers/features/github-cli:1": {}, + // https://github.com/devcontainers-contrib/features/blob/main/src/starship/README.md + "ghcr.io/devcontainers-contrib/features/starship:1": {}, + // https://github.com/devcontainers/features/blob/main/src/dotnet/README.md + "ghcr.io/devcontainers/features/dotnet:2": { + "version": "9.0.201" + } + }, + "overrideFeatureInstallOrder": [ + "ghcr.io/devcontainers/features/common-utils", + "ghcr.io/devcontainers/features/github-cli", + "ghcr.io/devcontainers-contrib/features/starship", + "ghcr.io/devcontainers/features/dotnet" + ], + "customizations": { + "vscode": { + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "ms-dotnettools.csharp", + "Ionide.Ionide-fsharp", + "tintoy.msbuild-project-tools", + "ionide.ionide-paket", + "usernamehw.errorlens", + "alefragnani.Bookmarks", + "oderwat.indent-rainbow", + "vscode-icons-team.vscode-icons", + "EditorConfig.EditorConfig", + "GitHub.vscode-pull-request-github", + "github.vscode-github-actions" + ], + "settings": { + "terminal.integrated.defaultProfile.linux": "zsh", + "csharp.suppressDotnetInstallWarning": true + } + } + }, + "remoteUser": "vscode", + "containerUser": "vscode", + "containerEnv": { + // Expose the local environment variable to the container + // They are used for releasing and publishing from the container + "GITHUB_TOKEN": "${localEnv:GITHUB_TOKEN}" + }, + "onCreateCommand": { + "enable-starship": "echo 'eval \"$(starship init zsh)\"' >> ~/.zshrc" + }, + "postAttachCommand": { + "restore": "dotnet tool restore && dotnet restore" + }, + "waitFor": "updateContentCommand" +} diff --git a/.github/workflows/build+test+deploy.yml b/.github/workflows/build+test+deploy.yml index bba91eaf2..140f1b6c8 100644 --- a/.github/workflows/build+test+deploy.yml +++ b/.github/workflows/build+test+deploy.yml @@ -36,11 +36,14 @@ jobs: - name: Restore dependencies run: dotnet restore - name: Build - run: dotnet fsi build.fsx -t Build + shell: pwsh + run: ./build.ps1 DotnetBuild - name: Run tests - run: dotnet fsi build.fsx -t Test + shell: pwsh + run: ./build.ps1 DotnetTest - name: Run FSharpLint on itself - run: dotnet fsi build.fsx -t SelfCheck + shell: pwsh + run: ./build.ps1 SelfCheck deployReleaseBinaries: @@ -56,46 +59,40 @@ jobs: - name: Restore tools run: dotnet tool restore - name: Build - run: dotnet fsi build.fsx + shell: pwsh + run: ./build.ps1 - name: Pack - run: dotnet fsi build.fsx -t Pack + shell: pwsh + run: ./build.ps1 DotnetPack - name: Publish binaries as artifact uses: actions/upload-artifact@v4 with: name: binaries - path: ./out/*.nupkg + path: ./dist/*.nupkg - name: Get Changelog Entry id: changelog_reader uses: mindsers/changelog-reader-action@v1 with: version: ${{ github.ref }} path: ./CHANGELOG.md - - name: Upload binaries to nuget (if nugetKey present) + - name: Publish (if tag) + if: startsWith(github.ref, 'refs/tags/') + shell: pwsh env: - nuget-key: ${{ secrets.NUGET_KEY }} + NUGET_TOKEN: ${{ secrets.NUGET_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: dotnet fsi build.fsx -t Push - - name: Create Release (if tag) - if: startsWith(github.ref, 'refs/tags/') - id: create_release - uses: actions/create-release@latest + FAKE_DETAILED_ERRORS: true + + run: ./build.ps1 Publish + + - name: PublishToGitHub (if master branch) + if: github.ref == 'refs/heads/master' + shell: pwsh env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token - with: - tag_name: ${{ github.ref }} - release_name: ${{ github.ref }} - body: ${{ steps.changelog_reader.outputs.log_entry }} - draft: false - prerelease: false - - name: Upload binaries to release (if tag) - if: startsWith(github.ref, 'refs/tags/') - uses: svenstaro/upload-release-action@v2 - with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - file: out/*.nupkg - tag: ${{ github.ref }} - overwrite: true - file_glob: true + NUGET_TOKEN: ${{ secrets.NUGET_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + FAKE_DETAILED_ERRORS: true + run: ./build.ps1 PublishToGitHub deployReleaseDocs: @@ -114,7 +111,8 @@ jobs: - name: Restore dependencies run: dotnet restore - name: Run Fornax - run: dotnet fsi build.fsx -t Docs + shell: pwsh + run: ./build.ps1 BuildDocs - name: Deploy (if tag) if: startsWith(github.ref, 'refs/tags/') uses: peaceiris/actions-gh-pages@v3 diff --git a/.gitignore b/.gitignore index 04d328f31..ae1bb7787 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,6 @@ x86/ [Ww][Ii][Nn]32/ [Aa][Rr][Mm]/ [Aa][Rr][Mm]64/ -build/ bld/ [Bb]in/ [Oo]bj/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..64c864b06 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,8 @@ +{ + "recommendations": [ + "ionide.ionide-fsharp", + "ionide.ionide-fake", + "ms-dotnettools.csharp", + "editorConfig.editorConfig" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..b98926a06 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "FSharp.fsacRuntime":"netcore", + "FSharp.enableAnalyzers": false, + "FSharp.analyzersPath": [ + "./packages/analyzers" + ] +} diff --git a/Directory.Packages.props b/Directory.Packages.props index 9a5a76174..0a183cb5f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,7 +3,7 @@ true - + @@ -14,16 +14,34 @@ - + + + + + + + + + + + + + + + + + + + + - - + \ No newline at end of file diff --git a/FSharpLint.slnf b/FSharpLint.slnf new file mode 100644 index 000000000..1fe23bff1 --- /dev/null +++ b/FSharpLint.slnf @@ -0,0 +1,13 @@ +{ + "solution": { + "path": "FSharpLint.slnx", + "projects": [ + "src\\FSharpLint.Console\\FSharpLint.Console.fsproj", + "src\\FSharpLint.Core\\FSharpLint.Core.fsproj", + "tests\\FSharpLint.Benchmarks\\FSharpLint.Benchmarks.fsproj", + "tests\\FSharpLint.Console.Tests\\FSharpLint.Console.Tests.fsproj", + "tests\\FSharpLint.Core.Tests\\FSharpLint.Core.Tests.fsproj", + "tests\\FSharpLint.FunctionalTest\\FSharpLint.FunctionalTest.fsproj" + ] + } +} \ No newline at end of file diff --git a/FSharpLint.slnx b/FSharpLint.slnx index 2d3d31087..1bc8d8250 100644 --- a/FSharpLint.slnx +++ b/FSharpLint.slnx @@ -5,7 +5,6 @@ - @@ -105,4 +104,5 @@ + diff --git a/build.cmd b/build.cmd new file mode 100644 index 000000000..abc47176f --- /dev/null +++ b/build.cmd @@ -0,0 +1 @@ +dotnet run --project ./build/build.fsproj -- --target %* diff --git a/build.fsx b/build.fsx deleted file mode 100644 index 3430f4ca5..000000000 --- a/build.fsx +++ /dev/null @@ -1,279 +0,0 @@ -// -------------------------------------------------------------------------------------- -// FAKE build script -// -------------------------------------------------------------------------------------- -#r "nuget: MSBuild.StructuredLogger" -#r "nuget: Fake.Core" -#r "nuget: Fake.Core.Target" -#r "nuget: Fake.Core.Process" -#r "nuget: Fake.DotNet.Cli" -#r "nuget: Fake.Core.ReleaseNotes" -#r "nuget: Fake.DotNet.AssemblyInfoFile" -#r "nuget: Fake.Tools.Git" -#r "nuget: Fake.Core.Environment" -#r "nuget: Fake.Core.UserInput" -#r "nuget: Fake.IO.FileSystem" -#r "nuget: Fake.DotNet.MsBuild" -#r "nuget: Fake.Api.GitHub" - -#if FAKE -#load ".fake/build.fsx/intellisense.fsx" -#else -// Boilerplate -System.Environment.GetCommandLineArgs() -|> Array.skip 2 // skip fsi.exe; build.fsx -|> Array.toList -|> Fake.Core.Context.FakeExecutionContext.Create false __SOURCE_FILE__ -|> Fake.Core.Context.RuntimeContext.Fake -|> Fake.Core.Context.setExecutionContext - -#endif - -open Fake.Core -open Fake.DotNet -open Fake.Tools -open Fake.IO -open Fake.IO.FileSystemOperators -open Fake.IO.Globbing.Operators -open Fake.Core.TargetOperators -open Fake.Api - -open System -open System.IO - -Target.initEnvironment() - -// -------------------------------------------------------------------------------------- -// Information about the project to be used at NuGet and in AssemblyInfo files -// -------------------------------------------------------------------------------------- - -let project = "FSharpLint" -let solutionFileName = "FSharpLint.slnx" - -let authors = "Matthew Mcveigh" - -let gitOwner = "fsprojects" -let gitName = "FSharpLint" -let gitHome = $"https://github.com/{gitOwner}" -let gitUrl = $"{gitHome}/{gitName}" - -// -------------------------------------------------------------------------------------- -// Helpers -// -------------------------------------------------------------------------------------- -let isNullOrWhiteSpace = System.String.IsNullOrWhiteSpace - -let exec cmd args dir = - let proc = - CreateProcess.fromRawCommandLine cmd args - |> CreateProcess.ensureExitCodeWithMessage $"Error while running '%s{cmd}' with args: %s{args}" - (if isNullOrWhiteSpace dir then proc - else proc |> CreateProcess.withWorkingDirectory dir) - |> Proc.run - |> ignore - -let getBuildParam var = - let value = Environment.environVar var - if String.IsNullOrWhiteSpace value then - None - else - Some value -let DoNothing = ignore - -// -------------------------------------------------------------------------------------- -// Build variables -// -------------------------------------------------------------------------------------- - -let buildDir = "./build/" -let nugetDir = "./out/" -let docsDir = "./docs/" -let rootDir = __SOURCE_DIRECTORY__ |> DirectoryInfo - -System.Environment.CurrentDirectory <- rootDir.FullName -let changelogFilename = "CHANGELOG.md" -let changelog = Changelog.load changelogFilename - -let githubRef = Environment.GetEnvironmentVariable "GITHUB_REF" -let tagPrefix = "refs/tags/" -let isTag = - if isNull githubRef then - false - else - githubRef.StartsWith tagPrefix - -let nugetVersion = - match (changelog.Unreleased, isTag) with - | (Some _unreleased, true) -> failwith "Shouldn't publish a git tag for changes outside a real release" - | (None, true) -> - changelog.LatestEntry.NuGetVersion - | (_, false) -> - let current = changelog.LatestEntry.NuGetVersion |> SemVer.parse - let bumped = { current with - Patch = current.Patch + 1u - Original = None - PreRelease = None } - let bumpedBaseVersion = string bumped - - let nugetPreRelease = Path.Combine(rootDir.FullName, "nugetPreRelease.fsx") - let procResult = - CreateProcess.fromRawCommand - "dotnet" - [ - "fsi" - nugetPreRelease - bumpedBaseVersion - ] - |> CreateProcess.redirectOutput - |> CreateProcess.ensureExitCode - |> Proc.run - procResult.Result.Output.Trim() - -let PackageReleaseNotes baseProps = - if isTag then - ("PackageReleaseNotes", $"%s{gitUrl}/blob/v%s{nugetVersion}/CHANGELOG.md")::baseProps - else - baseProps - -// -------------------------------------------------------------------------------------- -// Build Targets -// -------------------------------------------------------------------------------------- - -Target.create "Clean" (fun _ -> - Shell.cleanDirs [buildDir; nugetDir] -) - -Target.create "Build" (fun _ -> - DotNet.build id solutionFileName -) - -let filterPerformanceTests (p:DotNet.TestOptions) = { p with Filter = Some "\"TestCategory!=Performance\""; Configuration = DotNet.Release } - -Target.create "Test" (fun _ -> - DotNet.test filterPerformanceTests "tests/FSharpLint.Core.Tests" - DotNet.test filterPerformanceTests "tests/FSharpLint.Console.Tests" - DotNet.restore id "tests/FSharpLint.FunctionalTest.TestedProject/FSharpLint.FunctionalTest.TestedProject.sln" - DotNet.test filterPerformanceTests "tests/FSharpLint.FunctionalTest" -) - -Target.create "Docs" (fun _ -> - exec "dotnet" "fornax build" docsDir -) - -// -------------------------------------------------------------------------------------- -// Release Targets -// -------------------------------------------------------------------------------------- - -Target.create "BuildRelease" (fun _ -> - let properties = ("Version", nugetVersion) |> List.singleton |> PackageReleaseNotes - - DotNet.build (fun p -> - { p with - Configuration = DotNet.BuildConfiguration.Release - MSBuildParams = { p.MSBuildParams with Properties = properties } - } - ) solutionFileName -) - - -Target.create "Pack" (fun _ -> - let properties = PackageReleaseNotes ([ - ("Version", nugetVersion); - ("Authors", authors) - ("PackageProjectUrl", gitUrl) - ("RepositoryType", "git") - ("RepositoryUrl", gitUrl) - ("PackageLicenseExpression", "MIT") - ]) - - DotNet.pack (fun p -> - { p with - Configuration = DotNet.BuildConfiguration.Release - OutputPath = Some nugetDir - MSBuildParams = { p.MSBuildParams with Properties = properties } - } - ) solutionFileName -) - -Target.create "Push" (fun _ -> - let push key = - let distGlob = nugetDir "*.nupkg" - distGlob - |> DotNet.nugetPush (fun o -> { - o with - Common = { - o.Common with - CustomParams = Some "--skip-duplicate" - } - PushParams = { - o.PushParams with - Source = Some "https://api.nuget.org/v3/index.json" - ApiKey = Some key - } - }) - - let key = getBuildParam "nuget-key" - match getBuildParam "GITHUB_EVENT_NAME" with - | None -> - match key with - | None -> - let key = UserInput.getUserPassword "NuGet Key: " - push key - | Some key -> - push key - - | Some "push" -> - match key with - | None -> - Console.WriteLine "No nuget-key env var found, skipping..." - | Some key -> - if isTag then - push key - elif getBuildParam "GITHUB_REF_NAME" <> Some "master" then - Console.WriteLine "Not a push to master branch, skipping..." - else - match getBuildParam "GITHUB_SHA" with - | None -> - failwith "GITHUB_SHA should have been populated" - | Some commitHash -> - let gitArgs = $"describe --exact-match --tags %s{commitHash}" - let proc = - CreateProcess.fromRawCommandLine "git" gitArgs - |> Proc.run - if proc.ExitCode <> 0 then - // commit is not a tag, so go ahead pushing a prerelease - push key - else - Console.WriteLine "Commit mapped to a tag, skipping pushing prerelease..." - | _ -> - Console.WriteLine "Github event name not 'push', skipping..." - -) - - -Target.create "SelfCheck" (fun _ -> - let srcDir = Path.Combine(rootDir.FullName, "src") |> DirectoryInfo - - let consoleProj = Path.Combine(srcDir.FullName, "FSharpLint.Console", "FSharpLint.Console.fsproj") |> FileInfo - let sol = Path.Combine(rootDir.FullName, solutionFileName) |> FileInfo - exec "dotnet" $"run --framework net9.0 lint %s{sol.FullName}" consoleProj.Directory.FullName -) - -// -------------------------------------------------------------------------------------- -// Build order -// -------------------------------------------------------------------------------------- -Target.create "Default" DoNothing -Target.create "Release" DoNothing - -"Clean" - ==> "Build" - ==> "Test" - ==> "Default" - -"Clean" - ==> "BuildRelease" - ==> "Docs" - -"Default" - ==> "Pack" - ==> "Push" - ==> "Release" - -Target.runOrDefaultWithArguments "Default" diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 000000000..a1bab0202 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,5 @@ +$ErrorActionPreference = 'Stop' + +# Pass all arguments to dotnet run +$env:FAKE_DETAILED_ERRORS = 'true' +dotnet run --project ./build/build.fsproj -- --target $args diff --git a/build.sh b/build.sh new file mode 100755 index 000000000..2226032ab --- /dev/null +++ b/build.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -eu +set -o pipefail + +FAKE_DETAILED_ERRORS=true dotnet run --project ./build/build.fsproj -- --target "$@" diff --git a/build/Changelog.fs b/build/Changelog.fs new file mode 100644 index 000000000..6f3492c66 --- /dev/null +++ b/build/Changelog.fs @@ -0,0 +1,232 @@ +module Changelog + +open System +open Fake.Core +open Fake.IO + +let isEmptyChange = + function + | Changelog.Change.Added s + | Changelog.Change.Changed s + | Changelog.Change.Deprecated s + | Changelog.Change.Fixed s + | Changelog.Change.Removed s + | Changelog.Change.Security s + | Changelog.Change.Custom (_, s) -> String.IsNullOrWhiteSpace s.CleanedText + +let tagFromVersionNumber versionNumber = sprintf "v%s" versionNumber + +let failOnEmptyChangelog (latestEntry : Changelog.ChangelogEntry) = + let isEmpty = + (latestEntry.Changes |> Seq.forall isEmptyChange) + || latestEntry.Changes |> Seq.isEmpty + + if isEmpty then + failwith "No changes in CHANGELOG. Please add your changes under a heading specified in https://keepachangelog.com/" + +let mkLinkReference (newVersion : SemVerInfo) (changelog : Changelog.Changelog) (gitHubRepoUrl : string) = + if changelog.Entries |> List.isEmpty then + // No actual changelog entries yet: link reference will just point to the Git tag + sprintf "[%s]: %s/releases/tag/%s" newVersion.AsString gitHubRepoUrl (tagFromVersionNumber newVersion.AsString) + else + let versionTuple version = (version.Major, version.Minor, version.Patch) + // Changelog entries come already sorted, most-recent first, by the Changelog module + let prevEntry = + changelog.Entries + |> List.skipWhile (fun entry -> + entry.SemVer.PreRelease.IsSome + || versionTuple entry.SemVer = versionTuple newVersion + ) + |> List.tryHead + + let linkTarget = + match prevEntry with + | Some entry -> + sprintf + "%s/compare/%s...%s" + gitHubRepoUrl + (tagFromVersionNumber entry.SemVer.AsString) + (tagFromVersionNumber newVersion.AsString) + | None -> sprintf "%s/releases/tag/%s" gitHubRepoUrl (tagFromVersionNumber newVersion.AsString) + + sprintf "[%s]: %s" newVersion.AsString linkTarget + +let mkReleaseNotes changelog (latestEntry : Changelog.ChangelogEntry) gitHubRepoUrl = + let linkReference = mkLinkReference latestEntry.SemVer changelog gitHubRepoUrl + + if String.isNullOrEmpty linkReference then + latestEntry.ToString () + else + // Add link reference target to description before building release notes, since in main changelog file it's at the bottom of the file + let description = + match latestEntry.Description with + | None -> linkReference + | Some desc when desc.Contains (linkReference) -> desc + | Some desc -> sprintf "%s\n\n%s" (desc.Trim ()) linkReference + + { latestEntry with Description = Some description }.ToString () + +let getVersionNumber envVarName ctx = + let args = ctx.Context.Arguments + + let verArg = + args + |> List.tryHead + |> Option.defaultWith (fun () -> Environment.environVarOrDefault envVarName "") + + if SemVer.isValid verArg then + verArg + elif verArg.StartsWith ("v") && SemVer.isValid verArg.[1..] then + let target = ctx.Context.FinalTarget + + Trace.traceImportantfn + "Please specify a version number without leading 'v' next time, e.g. \"./build.sh %s %s\" rather than \"./build.sh %s %s\"" + target + verArg.[1..] + target + verArg + + verArg.[1..] + elif String.isNullOrEmpty verArg then + let target = ctx.Context.FinalTarget + + Trace.traceErrorfn + "Please specify a version number, either at the command line (\"./build.sh %s 1.0.0\") or in the %s environment variable" + target + envVarName + + failwith "No version number found" + else + Trace.traceErrorfn "Please specify a valid version number: %A could not be recognized as a version number" verArg + + failwith "Invalid version number" + +let updateChangelog changelogPath (changelog : Fake.Core.Changelog.Changelog) gitHubRepoUrl ctx = + + let verStr = ctx |> getVersionNumber "RELEASE_VERSION" + + let description, unreleasedChanges = + match changelog.Unreleased with + | None -> None, [] + | Some u -> u.Description, u.Changes + + let newVersion = SemVer.parse verStr + + changelog.Entries + |> List.tryFind (fun entry -> entry.SemVer = newVersion) + |> Option.iter (fun entry -> + Trace.traceErrorfn + "Version %s already exists in %s, released on %s" + verStr + changelogPath + (if entry.Date.IsSome then + entry.Date.Value.ToString ("yyyy-MM-dd") + else + "(no date specified)") + + failwith "Can't release with a duplicate version number" + ) + + changelog.Entries + |> List.tryFind (fun entry -> entry.SemVer > newVersion) + |> Option.iter (fun entry -> + Trace.traceErrorfn + "You're trying to release version %s, but a later version %s already exists, released on %s" + verStr + entry.SemVer.AsString + (if entry.Date.IsSome then + entry.Date.Value.ToString ("yyyy-MM-dd") + else + "(no date specified)") + + failwith "Can't release with a version number older than an existing release" + ) + + let versionTuple version = (version.Major, version.Minor, version.Patch) + + let prereleaseEntries = + changelog.Entries + |> List.filter (fun entry -> + entry.SemVer.PreRelease.IsSome + && versionTuple entry.SemVer = versionTuple newVersion + ) + + let prereleaseChanges = + prereleaseEntries + |> List.collect (fun entry -> entry.Changes |> List.filter (not << isEmptyChange)) + |> List.distinct + + let assemblyVersion, nugetVersion = Changelog.parseVersions newVersion.AsString + + let newEntry = + Changelog.ChangelogEntry.New ( + assemblyVersion.Value, + nugetVersion.Value, + Some System.DateTime.Today, + description, + unreleasedChanges @ prereleaseChanges, + false + ) + + let newChangelog = + Changelog.Changelog.New (changelog.Header, changelog.Description, None, newEntry :: changelog.Entries) + + // Save changelog to temporary file before making any edits + let changelogBackupFilename = System.IO.Path.GetTempFileName () + + changelogPath |> Shell.copyFile changelogBackupFilename + + Target.activateFinal "DeleteChangelogBackupFile" + + newChangelog |> Changelog.save changelogPath + + // Now update the link references at the end of the file + let linkReferenceForLatestEntry = mkLinkReference newVersion changelog gitHubRepoUrl + + let linkReferenceForUnreleased = + sprintf "[Unreleased]: %s/compare/%s...%s" gitHubRepoUrl (tagFromVersionNumber newVersion.AsString) "HEAD" + + let tailLines = File.read changelogPath |> List.ofSeq |> List.rev + + let isRef (line : string) = + System.Text.RegularExpressions.Regex.IsMatch (line, @"^\[.+?\]:\s?[a-z]+://.*$") + + let linkReferenceTargets = + tailLines + |> Seq.skipWhile String.isNullOrWhiteSpace + |> Seq.takeWhile isRef + |> Seq.rev // Now most recent entry is at the head of the list + |> Seq.toList + + let newLinkReferenceTargets = + match linkReferenceTargets with + | [] -> [ linkReferenceForUnreleased; linkReferenceForLatestEntry ] + | first :: rest when first |> String.startsWith "[Unreleased]:" -> + linkReferenceForUnreleased + :: linkReferenceForLatestEntry + :: rest + | first :: rest -> + linkReferenceForUnreleased + :: linkReferenceForLatestEntry + :: first + :: rest + + let blankLineCount = + tailLines + |> Seq.takeWhile String.isNullOrWhiteSpace + |> Seq.length + + let linkRefCount = linkReferenceTargets |> List.length + + let skipCount = blankLineCount + linkRefCount + + let updatedLines = + List.rev (tailLines |> List.skip skipCount) + @ newLinkReferenceTargets + + System.IO.File.WriteAllLines (changelogPath, updatedLines) + + // If build fails after this point but before we commit changes, undo our modifications + Target.activateBuildFailure "RevertChangelog" + + (newEntry, changelogBackupFilename) diff --git a/build/Fornax.fs b/build/Fornax.fs new file mode 100644 index 000000000..996eb50a4 --- /dev/null +++ b/build/Fornax.fs @@ -0,0 +1,139 @@ +namespace Fake.DotNet + +open System.IO +open Fake.Core +open Fake.IO +open Fake.IO.FileSystemOperators + +/// +/// Contains tasks to interact with Fornax static site generator +/// for F# documentation generation. +/// +[] +module Fornax = + + /// + /// Fornax build command parameters and options + /// + type BuildParams = { + /// Working directory to run Fornax from (default: docs directory) + WorkingDirectory : string option + + /// Timeout for the build process + Timeout : System.TimeSpan option + + /// Whether to fail if Fornax returns non-zero exit code + FailOnError : bool + } with + /// Parameter default values. + static member Default = { + WorkingDirectory = None + Timeout = None + FailOnError = true + } + + /// + /// Fornax watch command parameters and options + /// + type WatchParams = { + /// Working directory to run Fornax from (default: docs directory) + WorkingDirectory : string option + + /// Port to serve content on (default: 8080) + Port : int option + + /// Whether to fail if Fornax returns non-zero exit code + FailOnError : bool + } with + /// Parameter default values. + static member Default = { + WorkingDirectory = None + Port = None + FailOnError = true + } + + /// + /// Build documentation using Fornax + /// + /// + /// Function used to overwrite the build command default parameters. + /// + /// + /// + /// Fornax.build (fun p -> { p with WorkingDirectory = Some "./docs" }) + /// + /// + let build setBuildParams = + let buildParams = setBuildParams BuildParams.Default + + let processArgs = + CreateProcess.fromRawCommandLine "dotnet" "fornax build" + |> CreateProcess.withTimeout (buildParams.Timeout |> Option.defaultValue (System.TimeSpan.FromMinutes 10.0)) + |> (fun args -> + match buildParams.WorkingDirectory with + | Some dir -> CreateProcess.withWorkingDirectory dir args + | None -> args) + |> (fun args -> + if buildParams.FailOnError then + CreateProcess.ensureExitCode args + else + args) + + let result = processArgs |> Proc.run + + if buildParams.FailOnError && result.ExitCode <> 0 then + failwithf "Fornax build failed with exit code %d" result.ExitCode + else + result + + /// + /// Watch documentation using Fornax with hot reload + /// + /// + /// Function used to overwrite the watch command default parameters. + /// + /// + /// + /// Fornax.watch (fun p -> { p with Port = Some 3000; WorkingDirectory = Some "./docs" }) + /// + /// + let watch setWatchParams = + let watchParams = setWatchParams WatchParams.Default + + let args = + match watchParams.Port with + | Some port -> $"fornax watch --port {port}" + | None -> "fornax watch" + + let processArgs = + CreateProcess.fromRawCommandLine "dotnet" args + |> (fun args -> + match watchParams.WorkingDirectory with + | Some dir -> CreateProcess.withWorkingDirectory dir args + | None -> args) + |> (fun args -> + if watchParams.FailOnError then + CreateProcess.ensureExitCode args + else + args) + + let result = processArgs |> Proc.run + + if watchParams.FailOnError && result.ExitCode <> 0 then + failwithf "Fornax watch failed with exit code %d" result.ExitCode + else + result + + /// + /// Clean Fornax cache and generated files + /// + /// + /// Working directory where Fornax cache should be cleaned + let cleanCache workingDirectory = + let cacheDir = workingDirectory "_public" + let tempDir = workingDirectory "_temp" + + if Directory.Exists cacheDir then + Shell.cleanDir cacheDir + if Directory.Exists tempDir then + Shell.cleanDir tempDir diff --git a/build/Properties/launchSettings.json b/build/Properties/launchSettings.json new file mode 100644 index 000000000..294315631 --- /dev/null +++ b/build/Properties/launchSettings.json @@ -0,0 +1,28 @@ +{ + "profiles": { + "BuildAndTest": { + "commandName": "Project", + "commandLineArgs": "--target DotnetTest" + }, + "Publish": { + "commandName": "Project", + "commandLineArgs": "--target Publish" + }, + "PublishToGitHub": { + "commandName": "Project", + "commandLineArgs": "--target PublishToGitHub" + }, + "BuildDocs": { + "commandName": "Project", + "commandLineArgs": "--target BuildDocs" + }, + "SelfCheck": { + "commandName": "Project", + "commandLineArgs": "--target SelfCheck" + }, + "Release": { + "commandName": "Project", + "commandLineArgs": "--target Release 0.26.0" + } + } +} diff --git a/build/build.fs b/build/build.fs new file mode 100644 index 000000000..5f28dbcbb --- /dev/null +++ b/build/build.fs @@ -0,0 +1,772 @@ +open System +open System.Xml.Linq +open Fake.Core +open Fake.DotNet +open Fake.Tools +open Fake.IO +open Fake.IO.FileSystemOperators +open Fake.IO.Globbing.Operators +open Fake.Core.TargetOperators +open Fake.Api +open Fake.BuildServer +open Argu + +let environVarAsBoolOrDefault varName defaultValue = + let truthyConsts = [ "1"; "Y"; "YES"; "T"; "TRUE" ] + Environment.environVar varName + |> ValueOption.ofObj + |> ValueOption.map (fun envvar -> + truthyConsts + |> List.exists (fun ``const`` -> String.Equals (``const``, envvar, StringComparison.InvariantCultureIgnoreCase)) + ) + |> ValueOption.defaultValue defaultValue + +//----------------------------------------------------------------------------- +// Metadata and Configuration +//----------------------------------------------------------------------------- + +let rootDirectory = __SOURCE_DIRECTORY__ ".." + +let productName = "FSharpLint" + +let sln = rootDirectory "FSharpLint.slnf" + +let srcCodeGlob = + !!(rootDirectory "src/**/*.fs") + ++ (rootDirectory "src/**/*.fsx") + -- (rootDirectory "src/**/obj/**/*.fs") + +let testsCodeGlob = + !!(rootDirectory "tests/**/*.fs") + ++ (rootDirectory "tests/**/*.fsx") + -- (rootDirectory "tests/**/obj/**/*.fs") + +let srcGlob = rootDirectory "src/**/*.??proj" + +let testsGlob = rootDirectory "tests/**/*.??proj" + +let srcAndTest = !!srcGlob ++ testsGlob + +let distDir = rootDirectory "dist" + +let distGlob = distDir "*.nupkg" + +let docsDir = rootDirectory "docs" + +let docsSrcDir = rootDirectory "docsSrc" + +let temp = rootDirectory "temp" + +let watchDocsDir = temp "watch-docs" + +let gitOwner = "fsprojects" +let gitRepoName = "FSharpLint" + +let gitHubRepoUrl = $"https://github.com/%s{gitOwner}/%s{gitRepoName}" + +let documentationRootUrl = $"https://%s{gitOwner}.github.io/%s{gitRepoName}" + +let releaseBranch = "master" +let readme = "README.md" +let changelogFile = "CHANGELOG.md" + +// fsharplint:disable FL0046 +let READMElink = Uri (Uri (gitHubRepoUrl), $"blob/{releaseBranch}/{readme}") +let CHANGELOGlink = Uri (Uri (gitHubRepoUrl), $"blob/{releaseBranch}/{changelogFile}") +// fsharplint:enable FL0046 + +let changelogPath = rootDirectory changelogFile + +let changelog = lazy (Fake.Core.Changelog.load changelogPath) + +let mutable latestEntry = + if Seq.isEmpty changelog.Value.Entries then + Changelog.ChangelogEntry.New ("0.0.1", "0.0.1-alpha.1", Some DateTime.Today, None, [], false) + else + changelog.Value.LatestEntry + +let mutable changelogBackupFilename : string voption = ValueNone + +let publishUrl = "https://www.nuget.org" + +let githubToken = Environment.environVarOrNone "GITHUB_TOKEN" + +let nugetToken = Environment.environVarOrNone "NUGET_TOKEN" + +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +let isRelease (targets : Target list) = + targets + |> Seq.map (fun t -> t.Name) + |> Seq.exists (fun name -> + name = "PublishToNuGet" || name = "PublishToGitHub" || name = "BuildDocs" + ) + +let invokeAsync f = async { f () } + +let configuration (targets : Target list) = + let defaultVal = if isRelease targets then "Release" else "Debug" + + match Environment.environVarOrDefault "CONFIGURATION" defaultVal with + | "Debug" -> DotNet.BuildConfiguration.Debug + | "Release" -> DotNet.BuildConfiguration.Release + | config -> DotNet.BuildConfiguration.Custom config + +let failOnBadExitAndPrint (p : ProcessResult) = + if p.ExitCode <> 0 then + p.Errors |> Seq.iter Trace.traceError + + failwithf "failed with exitcode %d" p.ExitCode + +let isPublishToGitHub ctx = ctx.Context.FinalTarget = "PublishToGitHub" + +let isCI = lazy environVarAsBoolOrDefault "CI" false + +// CI Servers can have bizarre failures that have nothing to do with your code +let rec retryIfInCI times fn = + match isCI.Value with + | true -> + if times > 1 then + try + fn () + with _ -> + retryIfInCI (times - 1) fn + else + fn () + | _ -> fn () + +let failOnWrongBranch () = + if Git.Information.getBranchName "" <> releaseBranch then + failwithf "Not on %s. If you want to release please switch to this branch." releaseBranch + + +module dotnet = + let watch cmdParam program args = DotNet.exec cmdParam ($"watch %s{program}") args + + let run cmdParam args = DotNet.exec cmdParam "run" args + + let tool optionConfig (command : string) args = + DotNet.exec optionConfig command args + |> failOnBadExitAndPrint + + let sourcelink optionConfig args = tool optionConfig "sourcelink" args + + let fcswatch optionConfig args = tool optionConfig "fcswatch" args + + let fsharpAnalyzer optionConfig args = tool optionConfig "fsharp-analyzers" args + + let fantomas args = DotNet.exec id "fantomas" args + +module FSharpAnalyzers = + // fsharplint:disable FL0041 + type Arguments = + | Project of string + | Analyzers_Path of string + | Fail_On_Warnings of string list + | Ignore_Files of string list + | Verbose + // fsharplint:enable FL0041 + + interface IArgParserTemplate with + member s.Usage = "" + + +module DocsTool = + /// + /// Clean Fornax cache and generated files + /// + let cleanDocsCache () = Fornax.cleanCache docsDir + + /// + /// Build documentation using Fornax + /// + let build (configuration) = + let result = Fornax.build (fun p -> { p with WorkingDirectory = Some docsDir }) + result |> ignore + + /// + /// Watch documentation using Fornax with hot reload + /// + let watch (configuration) = + let result = Fornax.watch (fun p -> { p with WorkingDirectory = Some docsDir }) + result |> ignore + +module NuGetConfig = + /// + /// Add GitHub package source to NuGet configuration + /// + let addGitHubSource () = + let result = + DotNet.exec id "nuget" "add source --name \"github.com\" \"https://nuget.pkg.github.com/fsprojects/index.json\"" + + if not result.OK then + Trace.logf "Warning: Failed to add GitHub source: %A" result.Errors + + /// + /// Ensure NuGet package source mapping configuration + /// + let ensurePackageSourceMapping () = + let homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + let nugetConfigPath = homeDir ".nuget" "NuGet" "NuGet.Config" + + try + if IO.File.Exists nugetConfigPath then + let doc = XDocument.Load(nugetConfigPath) + + match doc.Root with + | null -> + Trace.logf "Warning: Invalid XML structure in %s" nugetConfigPath + | configRoot -> + // Remove existing packageSourceMapping if it exists + configRoot.Element(XName.Get("packageSourceMapping")) + |> ValueOption.ofObj + |> ValueOption.iter (fun existingMapping -> existingMapping.Remove()) + + // Create new packageSourceMapping element + let packageSourceMapping = XElement(XName.Get("packageSourceMapping")) + + // Create nuget.org source mapping + let nugetSource = XElement(XName.Get("packageSource")) + nugetSource.SetAttributeValue(XName.Get("key"), "nuget.org") + + let nugetPattern = XElement(XName.Get("package")) + nugetPattern.SetAttributeValue(XName.Get("pattern"), "*") + + nugetSource.Add(nugetPattern) + packageSourceMapping.Add(nugetSource) + configRoot.Add(packageSourceMapping) + + doc.Save(nugetConfigPath) + Trace.log "Successfully updated NuGet package source mapping" + else + Trace.logf "Warning: NuGet config file not found at %s" nugetConfigPath + with + | ex -> + Trace.logf "Warning: Failed to update NuGet package source mapping: %s" ex.Message + +let allReleaseChecks () = failOnWrongBranch () +//Changelog.failOnEmptyChangelog latestEntry + +let failOnLocalBuild () = + if not isCI.Value then + failwith "Not on CI. If you want to publish, please use CI." + +let failOnCIBuild () = + if isCI.Value then + failwith "On CI. If you want to run this target, please use a local build." + +let allPublishChecks () = failOnLocalBuild () +//Changelog.failOnEmptyChangelog latestEntry + +//----------------------------------------------------------------------------- +// Target Implementations +//----------------------------------------------------------------------------- + +/// So we don't require always being on the latest MSBuild.StructuredLogger +let disableBinLog (p : MSBuild.CliArguments) = { p with DisableInternalBinLog = true } + +let clean _ = + [ "bin"; "temp"; distDir ] + |> Shell.cleanDirs + + !!srcGlob ++ testsGlob + |> Seq.collect (fun p -> + [ "bin"; "obj" ] + |> Seq.map (fun sp -> IO.Path.GetDirectoryName p sp) + ) + |> Shell.cleanDirs + +let dotnetRestore _ = + [ sln ] + |> Seq.map (fun dir -> + fun () -> + let args = [] |> String.concat " " + + DotNet.restore + (fun c -> { + c with + MSBuildParams = disableBinLog c.MSBuildParams + Common = c.Common |> DotNet.Options.withCustomParams (Some (args)) + }) + dir + ) + |> Seq.iter (retryIfInCI 10) + +let dotnetToolRestore _ = + let result = + fun () -> DotNet.exec id "tool" "restore" + |> (retryIfInCI 10) + + if not result.OK then + failwithf "Failed to restore .NET tools: %A" result.Errors + +let updateChangelog ctx = + let newEntry, backupFilename = + if not <| isPublishToGitHub ctx then + let newEntry, backupFilename = Changelog.updateChangelog changelogPath changelog.Value gitHubRepoUrl ctx + (newEntry, ValueSome backupFilename) + elif Seq.isEmpty changelog.Value.Entries then + (latestEntry, ValueNone) + else + let latest = changelog.Value.LatestEntry + let semVer = { + latest.SemVer with + Original = None + Patch = latest.SemVer.Patch + 1u + PreRelease = PreRelease.TryParse "ci" + } + let entry = { + latest with + SemVer = semVer + NuGetVersion = semVer.AsString + AssemblyVersion = semVer.AsString + } + (entry, ValueNone) + + latestEntry <- newEntry + changelogBackupFilename <- backupFilename + +let revertChangelog _ = + changelogBackupFilename |> ValueOption.iter (Shell.copyFile changelogPath) + +let deleteChangelogBackupFile _ = + changelogBackupFilename |> ValueOption.iter Shell.rm + changelogBackupFilename <- ValueNone + +let getPackageVersionProperty publishToGitHub = + if publishToGitHub then + let runId = Environment.environVar "GITHUB_RUN_ID" + $"/p:PackageVersion=%s{latestEntry.NuGetVersion}-%s{runId}" + else + $"/p:PackageVersion=%s{latestEntry.NuGetVersion}" + +let dotnetBuild ctx = + + let publishToGitHub = isPublishToGitHub ctx + + let args = [ getPackageVersionProperty publishToGitHub; "--no-restore" ] + + DotNet.build + (fun c -> { + c with + Configuration = configuration (ctx.Context.AllExecutingTargets) + Common = c.Common |> DotNet.Options.withAdditionalArgs args + MSBuildParams = { + (disableBinLog c.MSBuildParams) with + Properties = [ + if publishToGitHub then + ("DebugType", "embedded") + ("EmbedAllSources", "true") + ] + } + }) + sln + +let fsharpAnalyzers _ = + let argParser = ArgumentParser.Create (programName = "fsharp-analyzers") + + !!srcGlob + |> Seq.iter (fun proj -> + let args = + [ + FSharpAnalyzers.Analyzers_Path (rootDirectory "packages/analyzers") + FSharpAnalyzers.Arguments.Project proj + FSharpAnalyzers.Arguments.Fail_On_Warnings [ "BDH0002" ] + FSharpAnalyzers.Arguments.Ignore_Files [ "*AssemblyInfo.fs" ] + FSharpAnalyzers.Verbose + ] + |> argParser.PrintCommandLineArgumentsFlat + + dotnet.fsharpAnalyzer id args + ) + +let dotnetTest ctx = + let args = [ "--no-build" ] + + // Filter performance tests like in build.fsx + let filterPerformanceTests (p : DotNet.TestOptions) = { + p with + Filter = Some "\"TestCategory!=Performance\"" + Configuration = configuration (ctx.Context.AllExecutingTargets) + } + + // Run the same test projects as in build.fsx + DotNet.test + (filterPerformanceTests + >> fun opts -> { + opts with + MSBuildParams = disableBinLog opts.MSBuildParams + Common = + opts.Common + |> DotNet.Options.withAdditionalArgs args + }) + (rootDirectory "tests/FSharpLint.Core.Tests") + + DotNet.test + (filterPerformanceTests + >> fun opts -> { + opts with + MSBuildParams = disableBinLog opts.MSBuildParams + Common = + opts.Common + |> DotNet.Options.withAdditionalArgs args + }) + (rootDirectory "tests/FSharpLint.Console.Tests") + + // Restore the functional test project like in build.fsx + DotNet.restore id (rootDirectory "tests/FSharpLint.FunctionalTest.TestedProject/FSharpLint.FunctionalTest.TestedProject.sln") + + DotNet.test + (filterPerformanceTests + >> fun opts -> { + opts with + MSBuildParams = disableBinLog opts.MSBuildParams + Common = + opts.Common + |> DotNet.Options.withAdditionalArgs args + }) + (rootDirectory "tests/FSharpLint.FunctionalTest") + +let watchTests _ = + !!testsGlob + |> Seq.map (fun proj -> + fun () -> + dotnet.watch + (fun opt -> + opt + |> DotNet.Options.withWorkingDirectory (IO.Path.GetDirectoryName proj) + ) + "test" + "" + |> ignore + ) + |> Seq.iter (invokeAsync >> Async.Catch >> Async.Ignore >> Async.Start) + + printfn "Press Ctrl+C (or Ctrl+Break) to stop..." + + let cancelEvent = + Console.CancelKeyPress + |> Async.AwaitEvent + |> Async.RunSynchronously + + cancelEvent.Cancel <- true + +let generateAssemblyInfo _ = + + let (|Fsproj|Csproj|Vbproj|) (projFileName : string) = + match projFileName with + | f when f.EndsWith ("fsproj") -> Fsproj + | f when f.EndsWith ("csproj") -> Csproj + | f when f.EndsWith ("vbproj") -> Vbproj + | _ -> failwith $"Project file %s{projFileName} not supported. Unknown project type." + + let releaseChannel = + match latestEntry.SemVer.PreRelease with + | Some pr -> pr.Name + | _ -> "release" + + let getAssemblyInfoAttributes projectName = [ + AssemblyInfo.Title (projectName) + AssemblyInfo.Product productName + AssemblyInfo.Version latestEntry.AssemblyVersion + AssemblyInfo.Metadata ("ReleaseDate", latestEntry.Date.Value.ToString ("o")) + AssemblyInfo.FileVersion latestEntry.AssemblyVersion + AssemblyInfo.InformationalVersion latestEntry.AssemblyVersion + AssemblyInfo.Metadata ("ReleaseChannel", releaseChannel) + AssemblyInfo.Metadata ("GitHash", Git.Information.getCurrentSHA1 (null)) + ] + + let getProjectDetails (projectPath : string) = + let projectName = IO.Path.GetFileNameWithoutExtension (projectPath) + + (projectPath, projectName, IO.Path.GetDirectoryName (projectPath), (getAssemblyInfoAttributes projectName)) + + !!srcGlob + |> Seq.map getProjectDetails + |> Seq.iter (fun (projFileName, _, folderName, attributes) -> + match projFileName with + | Fsproj -> AssemblyInfoFile.createFSharp (folderName "AssemblyInfo.fs") attributes + | Csproj -> AssemblyInfoFile.createCSharp ((folderName "Properties") "AssemblyInfo.cs") attributes + | Vbproj -> AssemblyInfoFile.createVisualBasic ((folderName "My Project") "AssemblyInfo.vb") attributes + ) + +let dotnetPack ctx = + // Get release notes with properly-linked version number + let releaseNotes = Changelog.mkReleaseNotes changelog.Value latestEntry gitHubRepoUrl + + let args = [ getPackageVersionProperty (isPublishToGitHub ctx); $"/p:PackageReleaseNotes=\"{releaseNotes}\"" ] + + DotNet.pack + (fun c -> { + c with + MSBuildParams = disableBinLog c.MSBuildParams + Configuration = configuration (ctx.Context.AllExecutingTargets) + OutputPath = Some distDir + Common = c.Common |> DotNet.Options.withAdditionalArgs args + }) + sln + +let sourceLinkTest _ = + !!distGlob + |> Seq.iter (fun nupkg -> dotnet.sourcelink id $"test %s{nupkg}") + +type PushSource = + | NuGet + | GitHub + +let publishTo (source : PushSource) _ = + allPublishChecks () + + distGlob + |> DotNet.nugetPush (fun o -> { + o with + Common = { + o.Common with + WorkingDirectory = "dist" + CustomParams = Some "--skip-duplicate" + } + PushParams = { + o.PushParams with + NoSymbols = source.IsGitHub + Source = + match source with + | NuGet -> Some "nuget.org" + | GitHub -> Some "github.com" + ApiKey = + match source with + | NuGet -> nugetToken + | GitHub -> githubToken + } + }) + +let gitRelease _ = + allReleaseChecks () + + let releaseNotesGitCommitFormat = latestEntry.ToString () + + Git.Staging.stageFile "" (rootDirectory "CHANGELOG.md") + |> ignore + + !!(rootDirectory "src/**/AssemblyInfo.fs") + ++ (rootDirectory "tests/**/AssemblyInfo.fs") + |> Seq.iter (Git.Staging.stageFile "" >> ignore) + + let msg = $"Bump version to `%s{latestEntry.NuGetVersion}`\n\n%s{releaseNotesGitCommitFormat}" + + Git.Commit.exec "" msg + + Target.deactivateBuildFailure "RevertChangelog" + + Git.Branches.push "" + + let tag = Changelog.tagFromVersionNumber latestEntry.NuGetVersion + + Git.Branches.tag "" tag + Git.Branches.pushTag "" "origin" tag + +let githubRelease _ = + allPublishChecks () + + let token = + match githubToken with + | Some s -> s + | _ -> failwith "please set the `GITHUB_TOKEN` environment variable to a github personal access token with repo access." + + let files = !!distGlob + // Get release notes with properly-linked version number + let releaseNotes = Changelog.mkReleaseNotes changelog.Value latestEntry gitHubRepoUrl + + GitHub.createClientWithToken token + |> GitHub.draftNewRelease + gitOwner + gitRepoName + (Changelog.tagFromVersionNumber latestEntry.NuGetVersion) + (latestEntry.SemVer.PreRelease <> None) + (releaseNotes |> Seq.singleton) + |> GitHub.uploadFiles files + |> GitHub.publishDraft + |> Async.RunSynchronously + +let formatCode _ = + let result = dotnet.fantomas $"{rootDirectory}" + + if not result.OK then + printfn "Errors while formatting all files: %A" result.Messages + +let checkFormatCode ctx = + let result = dotnet.fantomas $"{rootDirectory} --check" + + if result.ExitCode = 0 then + Trace.log "No files need formatting" + elif result.ExitCode = 99 then + failwith "Some files need formatting, check output for more info" + else + Trace.logf "Errors while formatting: %A" result.Errors + + +let cleanDocsCache _ = DocsTool.cleanDocsCache () + +let buildDocs ctx = + let configuration = configuration (ctx.Context.AllExecutingTargets) + + // Build only FSharpLint.Console project for documentation + DotNet.build + (fun c -> { + c with + Configuration = DotNet.BuildConfiguration.fromString (string configuration) + MSBuildParams = disableBinLog c.MSBuildParams + }) + (rootDirectory "src/FSharpLint.Console") + + DocsTool.build (string configuration) + +let watchDocs ctx = + let configuration = configuration (ctx.Context.AllExecutingTargets) + DocsTool.watch (string configuration) + +let configureNuGetForGitHub _ = + Trace.log "Configuring NuGet for GitHub package publishing..." + NuGetConfig.addGitHubSource () + NuGetConfig.ensurePackageSourceMapping () + +let selfCheck _ = + let srcDir = rootDirectory "src" + let consoleProj = srcDir "FSharpLint.Console" + let sol = sln + + DotNet.exec + (fun opts -> { opts with WorkingDirectory = consoleProj }) + "run" + $"--framework net9.0 lint %s{sol}" + |> failOnBadExitAndPrint + +let initTargets (ctx : Context.FakeExecutionContext) = + BuildServer.install [ GitHubActions.Installer ] + + let isPublishToGitHub = + ctx.Arguments + |> Seq.pairwise + |> Seq.exists (fun (arg, value) -> + (String.Equals (arg, "-t", StringComparison.OrdinalIgnoreCase) + || String.Equals (arg, "--target", StringComparison.OrdinalIgnoreCase)) + && String.Equals (value, "PublishToGitHub", StringComparison.OrdinalIgnoreCase) + ) + + /// Defines a dependency - y is dependent on x. Finishes the chain. + let (==>!) x y = x ==> y |> ignore + + /// Defines a soft dependency. x must run before y, if it is present, but y does not require x to be run. Finishes the chain. + let (?=>!) x y = x ?=> y |> ignore + //----------------------------------------------------------------------------- + // Hide Secrets in Logger + //----------------------------------------------------------------------------- + Option.iter (TraceSecrets.register "") githubToken + Option.iter (TraceSecrets.register "") nugetToken + //----------------------------------------------------------------------------- + // Target Declaration + //----------------------------------------------------------------------------- + + Target.create "Clean" clean + Target.create "DotnetRestore" dotnetRestore + Target.create "DotnetToolRestore" dotnetToolRestore + Target.create "ConfigureNuGetForGitHub" configureNuGetForGitHub + Target.create "UpdateChangelog" updateChangelog + Target.createBuildFailure "RevertChangelog" revertChangelog // Do NOT put this in the dependency chain + Target.createFinal "DeleteChangelogBackupFile" deleteChangelogBackupFile // Do NOT put this in the dependency chain + Target.create "DotnetBuild" dotnetBuild + Target.create "FSharpAnalyzers" fsharpAnalyzers + Target.create "DotnetTest" dotnetTest + Target.create "WatchTests" watchTests + Target.create "GenerateAssemblyInfo" generateAssemblyInfo + Target.create "DotnetPack" dotnetPack + Target.create "SourceLinkTest" sourceLinkTest + Target.create "PublishToNuGet" (publishTo NuGet) + Target.create "PublishToGitHub" (publishTo GitHub) + Target.create "GitRelease" gitRelease + Target.create "GitHubRelease" githubRelease + Target.create "FormatCode" formatCode + Target.create "CheckFormatCode" checkFormatCode + Target.create "Release" ignore // For local + Target.create "Publish" ignore //For CI + Target.create "CleanDocsCache" cleanDocsCache + Target.create "BuildDocs" buildDocs + Target.create "WatchDocs" watchDocs + Target.create "SelfCheck" selfCheck + + //----------------------------------------------------------------------------- + // Target Dependencies + //----------------------------------------------------------------------------- + + // Only call Clean if DotnetPack was in the call chain + // Ensure Clean is called before DotnetRestore + "Clean" ?=>! "DotnetRestore" + + "Clean" ==>! "DotnetPack" + + // Only call GenerateAssemblyInfo if GitRelease was in the call chain + // Ensure GenerateAssemblyInfo is called after DotnetRestore and before DotnetBuild + "DotnetRestore" ?=>! "GenerateAssemblyInfo" + + "GenerateAssemblyInfo" ?=>! "DotnetBuild" + + // Ensure UpdateChangelog is called after DotnetRestore + "DotnetRestore" ?=>! "UpdateChangelog" + + "UpdateChangelog" ?=>! "GenerateAssemblyInfo" + + "CleanDocsCache" ==>! "BuildDocs" + + // BuildDocs doesn't need DotnetBuild as it builds FSharpLint.Core itself + // "DotnetBuild" ?=>! "BuildDocs" + // "DotnetBuild" ==>! "BuildDocs" + + "DotnetBuild" ==>! "WatchDocs" + + "UpdateChangelog" + ==> "GenerateAssemblyInfo" + ==> "GitRelease" + ==>! "Release" + + + "DotnetRestore" =?> ("CheckFormatCode", isCI.Value) + ==> "DotnetBuild" + ==> "DotnetTest" + ==> "DotnetPack" + ==> "PublishToNuGet" + ==> "GitHubRelease" + ==>! "Publish" + + "DotnetRestore" + =?> ("CheckFormatCode", isCI.Value) + =?> ("GenerateAssemblyInfo", isPublishToGitHub) + =?> ("ConfigureNuGetForGitHub", isPublishToGitHub && isCI.Value && githubToken.IsSome) + ==> "DotnetBuild" + ==> "DotnetTest" + ==> "DotnetPack" + ==>! "PublishToGitHub" + + "DotnetRestore" ==>! "WatchTests" + + "DotnetToolRestore" ?=>! "DotnetRestore" + "DotnetToolRestore" ==>! "BuildDocs" + "DotnetToolRestore" ?=>! "CheckFormatCode" + "DotnetToolRestore" ?=>! "FormatCode" + +//----------------------------------------------------------------------------- +// Target Start +//----------------------------------------------------------------------------- +[] +let main argv = + + let ctx = + argv + |> Array.toList + |> Context.FakeExecutionContext.Create false "build.fsx" + + Context.setExecutionContext (Context.RuntimeContext.Fake ctx) + initTargets ctx + Target.runOrDefaultWithArguments "DotnetPack" + + 0 // return an integer exit code diff --git a/build/build.fsproj b/build/build.fsproj new file mode 100644 index 000000000..e5f85cef1 --- /dev/null +++ b/build/build.fsproj @@ -0,0 +1,37 @@ + + + Exe + net9.0 + 3390;$(WarnOn) + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/loaders/apirefloader.fsx b/docs/loaders/apirefloader.fsx index 8fb4f835d..d260c10f8 100644 --- a/docs/loaders/apirefloader.fsx +++ b/docs/loaders/apirefloader.fsx @@ -27,7 +27,6 @@ let rec collectModules pn pu nn nu (m: ApiDocEntity) = yield! m.NestedEntities |> List.collect (collectModules m.Name m.UrlBaseName nn nu) ] - let loader (projectRoot: string) (siteContet: SiteContents) = try // We need the console location as it contains all the dependencies @@ -36,15 +35,17 @@ let loader (projectRoot: string) (siteContet: SiteContents) = let projectName = "FSharpLint.Console" let projectArtifactName = "FSharpLint.Core.dll" // Try multiple possible locations for the assembly - let possiblePaths = [ - // Release build - Path.Combine(projectDir, "bin", "Release", dotNetMoniker, projectArtifactName) - // Debug build - Path.Combine(projectDir, "bin", "Debug", dotNetMoniker, projectArtifactName) - // Default build output (no custom output path) - Path.Combine(projectDir, "bin", "Release", projectArtifactName) - Path.Combine(projectDir, "bin", "Debug", projectArtifactName) - ] + let possiblePaths = + [ + // Release build + Path.Combine(projectDir, "bin", "Release", dotNetMoniker, projectArtifactName) + // Debug build + Path.Combine(projectDir, "bin", "Debug", dotNetMoniker, projectArtifactName) + // Default build output (no custom output path) + Path.Combine(projectDir, "bin", "Release", projectArtifactName) + Path.Combine(projectDir, "bin", "Debug", projectArtifactName) + ] + |> List.map Path.GetFullPath let foundDll = possiblePaths |> List.tryFind File.Exists diff --git a/src/FSharpLint.Core/AssemblyInfo.fs b/src/FSharpLint.Core/Attributes.fs similarity index 100% rename from src/FSharpLint.Core/AssemblyInfo.fs rename to src/FSharpLint.Core/Attributes.fs diff --git a/src/FSharpLint.Core/FSharpLint.Core.fsproj b/src/FSharpLint.Core/FSharpLint.Core.fsproj index ff228aaa4..0c7eeb8b2 100644 --- a/src/FSharpLint.Core/FSharpLint.Core.fsproj +++ b/src/FSharpLint.Core/FSharpLint.Core.fsproj @@ -7,6 +7,7 @@ true FSharpLint.Core false + false FSharpLint.Core API to programmatically run FSharpLint. @@ -15,7 +16,7 @@ - +