├── .editorconfig ├── .github └── workflows │ ├── main.yml │ └── release.yml ├── .gitignore ├── .idea └── .idea.Generaptor │ └── .idea │ ├── dictionaries │ └── fried.xml │ └── inspectionProfiles │ └── Project_Default.xml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Directory.Build.props ├── Generaptor.Library ├── Actions.fs ├── Generaptor.Library.fsproj └── Patterns.fs ├── Generaptor.Tests ├── ActionsClientTests.fs ├── Generaptor.Tests.fsproj ├── GeneratorTests.fs ├── RegeneratorTests.BasicRegeneratorWorkflow.verified.fsx ├── RegeneratorTests.StepEnvGenerator.verified.fsx ├── RegeneratorTests.StrategyGenerator.verified.fsx ├── RegeneratorTests.fs ├── TestFramework.fs └── VerifierTests.fs ├── Generaptor.sln ├── Generaptor.sln.DotSettings ├── Generaptor.sln.license ├── Generaptor ├── ActionsClient.fs ├── AssemblyInfo.fs ├── EntryPoint.fs ├── Generaptor.fsproj ├── GitHubActions.fs ├── ScriptGenerator.fs ├── Serializers.fs └── Verifier.fs ├── Infrastructure └── GitHubActions │ ├── GitHubActions.fsproj │ └── Program.fs ├── LICENSE.md ├── LICENSES ├── CC-BY-4.0.txt └── MIT.txt ├── MAINTAINERSHIP.md ├── README.md ├── REUSE.toml ├── Scripts └── Get-Version.ps1 ├── renovate.json └── renovate.json.license /.editorconfig: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-2025 Friedrich von Never 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | indent_style = space 11 | indent_size = 4 12 | 13 | [*.yml] 14 | indent_size = 2 15 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-2025 Friedrich von Never 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | # This file is auto-generated. 6 | name: Main 7 | on: 8 | push: 9 | branches: 10 | - main 11 | pull_request: 12 | branches: 13 | - main 14 | schedule: 15 | - cron: 0 0 * * 6 16 | workflow_dispatch: 17 | jobs: 18 | main: 19 | strategy: 20 | matrix: 21 | image: 22 | - macos-14 23 | - ubuntu-24.04 24 | - windows-2025 25 | fail-fast: false 26 | runs-on: ${{ matrix.image }} 27 | env: 28 | DOTNET_CLI_TELEMETRY_OPTOUT: 1 29 | DOTNET_NOLOGO: 1 30 | NUGET_PACKAGES: ${{ github.workspace }}/.github/nuget-packages 31 | steps: 32 | - uses: actions/checkout@v4 33 | - name: Set up .NET SDK 34 | uses: actions/setup-dotnet@v4 35 | with: 36 | dotnet-version: 8.0.x 37 | - name: NuGet cache 38 | uses: actions/cache@v4 39 | with: 40 | key: ${{ runner.os }}.nuget.${{ hashFiles('**/*.fsproj') }} 41 | path: ${{ env.NUGET_PACKAGES }} 42 | - name: Build 43 | run: dotnet build 44 | - name: Test 45 | run: dotnet test --filter Category!=SkipOnCI 46 | timeout-minutes: 10 47 | verify-workflows: 48 | runs-on: ubuntu-24.04 49 | env: 50 | DOTNET_CLI_TELEMETRY_OPTOUT: 1 51 | DOTNET_NOLOGO: 1 52 | NUGET_PACKAGES: ${{ github.workspace }}/.github/nuget-packages 53 | steps: 54 | - uses: actions/checkout@v4 55 | - uses: actions/setup-dotnet@v4 56 | - uses: actions/cache@v4 57 | with: 58 | key: ${{ runner.os }}.nuget.${{ hashFiles('**/*.fsproj') }} 59 | path: ${{ env.NUGET_PACKAGES }} 60 | - run: dotnet run --project Infrastructure/GitHubActions -- verify 61 | licenses: 62 | runs-on: ubuntu-24.04 63 | steps: 64 | - uses: actions/checkout@v4 65 | - uses: fsfe/reuse-action@v5 66 | encodings: 67 | runs-on: ubuntu-24.04 68 | steps: 69 | - uses: actions/checkout@v4 70 | - shell: pwsh 71 | run: Install-Module VerifyEncoding -Repository PSGallery -RequiredVersion 2.2.0 -Force && Test-Encoding 72 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-2025 Friedrich von Never 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | # This file is auto-generated. 6 | name: Release 7 | on: 8 | push: 9 | branches: 10 | - main 11 | tags: 12 | - v* 13 | pull_request: 14 | branches: 15 | - main 16 | schedule: 17 | - cron: 0 0 * * 6 18 | workflow_dispatch: 19 | jobs: 20 | nuget: 21 | permissions: 22 | contents: write 23 | runs-on: ubuntu-24.04 24 | steps: 25 | - uses: actions/checkout@v4 26 | - id: version 27 | name: Get version 28 | shell: pwsh 29 | run: echo "version=$(Scripts/Get-Version.ps1 -RefName $env:GITHUB_REF)" >> $env:GITHUB_OUTPUT 30 | - run: dotnet pack --configuration Release -p:Version=${{ steps.version.outputs.version }} 31 | - name: Read changelog 32 | uses: ForNeVeR/ChangelogAutomation.action@v2 33 | with: 34 | output: ./release-notes.md 35 | - name: Upload artifacts 36 | uses: actions/upload-artifact@v4 37 | with: 38 | path: |- 39 | ./release-notes.md 40 | ./Generaptor/bin/Release/Generaptor.${{ steps.version.outputs.version }}.nupkg 41 | ./Generaptor/bin/Release/Generaptor.${{ steps.version.outputs.version }}.snupkg 42 | ./Generaptor.Library/bin/Release/Generaptor.Library.${{ steps.version.outputs.version }}.nupkg 43 | ./Generaptor.Library/bin/Release/Generaptor.Library.${{ steps.version.outputs.version }}.snupkg 44 | - if: startsWith(github.ref, 'refs/tags/v') 45 | name: Create a release 46 | uses: softprops/action-gh-release@v2 47 | with: 48 | body_path: ./release-notes.md 49 | files: |- 50 | ./Generaptor/bin/Release/Generaptor.${{ steps.version.outputs.version }}.nupkg 51 | ./Generaptor/bin/Release/Generaptor.${{ steps.version.outputs.version }}.snupkg 52 | ./Generaptor.Library/bin/Release/Generaptor.Library.${{ steps.version.outputs.version }}.nupkg 53 | ./Generaptor.Library/bin/Release/Generaptor.Library.${{ steps.version.outputs.version }}.snupkg 54 | name: Generaptor ${{ steps.version.outputs.version }} 55 | - if: startsWith(github.ref, 'refs/tags/v') 56 | name: Push artifact to NuGet 57 | run: dotnet nuget push ./Generaptor/bin/Release/Generaptor.${{ steps.version.outputs.version }}.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_TOKEN }} 58 | - if: startsWith(github.ref, 'refs/tags/v') 59 | name: Push artifact to NuGet 60 | run: dotnet nuget push ./Generaptor.Library/bin/Release/Generaptor.Library.${{ steps.version.outputs.version }}.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_TOKEN }} 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-2025 Generaptor contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | /.idea/ 6 | /.vs/ 7 | 8 | bin/ 9 | obj/ 10 | 11 | *.received.* 12 | *.user 13 | -------------------------------------------------------------------------------- /.idea/.idea.Generaptor/.idea/dictionaries/fried.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | andivionian 5 | ventis 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/.idea.Generaptor/.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 6 | Changelog 7 | ========= 8 | All notable changes to this project will be documented in this file. 9 | 10 | The format is based on [Keep a Changelog][keep-a-changelog], and this project adheres to [Semantic Versioning v2.0.0][semver]. See [the README file][docs.readme] for more details on how it is versioned. 11 | 12 | ## [1.6.1] - 2025-05-18 13 | ### Fixed 14 | - Correct reading of command-line arguments when invoked from `.fsx`. 15 | 16 | ### Changed 17 | - Bump dependencies' versions. 18 | 19 | ## [1.6.0] - 2025-05-17 20 | ### Added 21 | - New command-line option: `regenerate` to prepare a Generaptor script from an already existing YAML workflow. 22 | - New argument for `step`: `usesSpec` to automatically derive action's version from the externally updated YAML (or even use latest available on GitHub). 23 | - New command-line option: `verify` to check that the content of the YAML file corresponds to what would be generated by Generaptor. 24 | - New workflow function `header` to override the generated file header comment (useful for storing license information). 25 | 26 | ### Changed 27 | - `Actions.prepareChangelog` now uses the new `Auto` version. 28 | 29 | ## [1.5.0] - 2024-05-16 30 | ### Added 31 | - `Commands.step`: new parameter `env` that gets transformed into `env` on the corresponding action step. 32 | 33 | ## [1.4.0] - 2024-05-15 34 | ### Added 35 | - `Commands.step`: new parameter `condition` that gets transformed into `if` on the corresponding action step. 36 | 37 | ## [1.3.0] - 2024-05-14 38 | ### Added 39 | - `Commands.needs`: support `needs` entry of the job definition. 40 | 41 | ## [1.2.0] - 2024-03-29 42 | ### Changed 43 | - `Actions.createRelease`: update the action to v2, allow overriding the version 44 | - `Actions.uploadArtifacts`: update the action to v4, allow overriding the version 45 | 46 | ## [1.1.0] - 2024-02-23 47 | ### Added 48 | - Better support for execution from `.fsx` files. 49 | 50 | This was technically supported in 1.0.0 already, but from this version, we now support a direct list of arguments from `fsi.CommandLineArgs` as an argument for the `EntryPoint.Process`. 51 | 52 | Thanks to @kant2002 for the contribution! 53 | 54 | ## [1.0.0] - 2024-02-17 55 | ### Added 56 | The initial release of this package. Main features: 57 | - low-level features to set up GitHub action workflows and jobs; 58 | - a set of actions to work with .NET projects, including build, test, and release actions. 59 | 60 | [docs.readme]: README.md 61 | [keep-a-changelog]: https://keepachangelog.com/en/1.0.0/ 62 | [semver]: https://semver.org/spec/v2.0.0.html 63 | 64 | [1.0.0]: https://github.com/ForNeVeR/Generaptor/releases/tag/v1.0.0 65 | [1.1.0]: https://github.com/ForNeVeR/Generaptor/compare/v1.0.0...v1.1.0 66 | [1.2.0]: https://github.com/ForNeVeR/Generaptor/compare/v1.1.0...v1.2.0 67 | [1.3.0]: https://github.com/ForNeVeR/Generaptor/compare/v1.2.0...v1.3.0 68 | [1.4.0]: https://github.com/ForNeVeR/Generaptor/compare/v1.3.0...v1.4.0 69 | [1.5.0]: https://github.com/ForNeVeR/Generaptor/compare/v1.4.0...v1.5.0 70 | [1.6.0]: https://github.com/ForNeVeR/Generaptor/compare/v1.5.0...v1.6.0 71 | [1.6.0]: https://github.com/ForNeVeR/Generaptor/compare/v1.6.0...v1.6.1 72 | [Unreleased]: https://github.com/ForNeVeR/Generaptor/compare/v1.6.1...HEAD 73 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Contributor Covenant Code of Conduct 8 | 9 | ## Our Pledge 10 | 11 | We as members, contributors, and leaders pledge to make participation in our 12 | community a harassment-free experience for everyone, regardless of age, body 13 | size, visible or invisible disability, ethnicity, sex characteristics, gender 14 | identity and expression, level of experience, education, socio-economic status, 15 | nationality, personal appearance, race, caste, color, religion, or sexual 16 | identity and orientation. 17 | 18 | We pledge to act and interact in ways that contribute to an open, welcoming, 19 | diverse, inclusive, and healthy community. 20 | 21 | ## Our Standards 22 | 23 | Examples of behavior that contributes to a positive environment for our 24 | community include: 25 | 26 | * Demonstrating empathy and kindness toward other people 27 | * Being respectful of differing opinions, viewpoints, and experiences 28 | * Giving and gracefully accepting constructive feedback 29 | * Accepting responsibility and apologizing to those affected by our mistakes, 30 | and learning from the experience 31 | * Focusing on what is best not just for us as individuals, but for the overall 32 | community 33 | 34 | Examples of unacceptable behavior include: 35 | 36 | * The use of sexualized language or imagery, and sexual attention or advances of 37 | any kind 38 | * Trolling, insulting or derogatory comments, and personal or political attacks 39 | * Public or private harassment 40 | * Publishing others' private information, such as a physical or email address, 41 | without their explicit permission 42 | * Other conduct which could reasonably be considered inappropriate in a 43 | professional setting 44 | 45 | ## Enforcement Responsibilities 46 | 47 | Community leaders are responsible for clarifying and enforcing our standards of 48 | acceptable behavior and will take appropriate and fair corrective action in 49 | response to any behavior that they deem inappropriate, threatening, offensive, 50 | or harmful. 51 | 52 | Community leaders have the right and responsibility to remove, edit, or reject 53 | comments, commits, code, wiki edits, issues, and other contributions that are 54 | not aligned to this Code of Conduct, and will communicate reasons for moderation 55 | decisions when appropriate. 56 | 57 | ## Scope 58 | 59 | This Code of Conduct applies within all community spaces, and also applies when 60 | an individual is officially representing the community in public spaces. 61 | Examples of representing our community include using an official e-mail address, 62 | posting via an official social media account, or acting as an appointed 63 | representative at an online or offline event. 64 | 65 | ## Enforcement 66 | 67 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 68 | reported to the community leaders responsible for enforcement at 69 | [friedrich@fornever.me](mailto:friedrich@fornever.me). 70 | All complaints will be reviewed and investigated promptly and fairly. 71 | 72 | All community leaders are obligated to respect the privacy and security of the 73 | reporter of any incident. 74 | 75 | ## Enforcement Guidelines 76 | 77 | Community leaders will follow these Community Impact Guidelines in determining 78 | the consequences for any action they deem in violation of this Code of Conduct: 79 | 80 | ### 1. Correction 81 | 82 | **Community Impact**: Use of inappropriate language or other behavior deemed 83 | unprofessional or unwelcome in the community. 84 | 85 | **Consequence**: A private, written warning from community leaders, providing 86 | clarity around the nature of the violation and an explanation of why the 87 | behavior was inappropriate. A public apology may be requested. 88 | 89 | ### 2. Warning 90 | 91 | **Community Impact**: A violation through a single incident or series of 92 | actions. 93 | 94 | **Consequence**: A warning with consequences for continued behavior. No 95 | interaction with the people involved, including unsolicited interaction with 96 | those enforcing the Code of Conduct, for a specified period of time. This 97 | includes avoiding interactions in community spaces as well as external channels 98 | like social media. Violating these terms may lead to a temporary or permanent 99 | ban. 100 | 101 | ### 3. Temporary Ban 102 | 103 | **Community Impact**: A serious violation of community standards, including 104 | sustained inappropriate behavior. 105 | 106 | **Consequence**: A temporary ban from any sort of interaction or public 107 | communication with the community for a specified period of time. No public or 108 | private interaction with the people involved, including unsolicited interaction 109 | with those enforcing the Code of Conduct, is allowed during this period. 110 | Violating these terms may lead to a permanent ban. 111 | 112 | ### 4. Permanent Ban 113 | 114 | **Community Impact**: Demonstrating a pattern of violation of community 115 | standards, including sustained inappropriate behavior, harassment of an 116 | individual, or aggression toward or disparagement of classes of individuals. 117 | 118 | **Consequence**: A permanent ban from any sort of public interaction within the 119 | community. 120 | 121 | ## Attribution 122 | 123 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 124 | version 2.1, available at 125 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 126 | 127 | Community Impact Guidelines were inspired by 128 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 129 | 130 | For answers to common questions about this code of conduct, see the FAQ at 131 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 132 | [https://www.contributor-covenant.org/translations][translations]. 133 | 134 | [homepage]: https://www.contributor-covenant.org 135 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 136 | [Mozilla CoC]: https://github.com/mozilla/diversity 137 | [FAQ]: https://www.contributor-covenant.org/faq 138 | [translations]: https://www.contributor-covenant.org/translations 139 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | Contributor Guide 8 | ================= 9 | 10 | File Encoding Changes 11 | --------------------- 12 | If the automation asks you to update the file encoding (line endings or UTF-8 BOM) in certain files, run the following PowerShell script ([PowerShell Core][powershell] is recommended to run this script): 13 | ```console 14 | $ Install-Module VerifyEncoding -Repository PSGallery && Test-Encoding -AutoFix 15 | ``` 16 | 17 | The `-AutoFix` switch will automatically fix the encoding issues, and you'll only need to commit and push the changes. 18 | 19 | License Automation 20 | ------------------ 21 | 22 | If the CI asks you to update the file licenses, follow one of these: 23 | 1. Update the headers manually (look at the existing files), something like this: 24 | ```fsharp 25 | // SPDX-FileCopyrightText: %year% %your name% <%your contact info, e.g. email%> 26 | // 27 | // SPDX-License-Identifier: MIT 28 | ``` 29 | (accommodate to the file's comment style if required). 30 | 2. Alternately, use [REUSE][reuse] tool: 31 | ```console 32 | $ reuse annotate --license MIT --copyright '%your name% <%your contact info, e.g. email%>' %file names to annotate% 33 | ``` 34 | 35 | (Feel free to attribute the changes to the "Generaptor contributors " instead of your name in a multi-author file, or if you don't want your name to be mentioned in the project's source: this doesn't mean you'll lose the copyright.) 36 | 37 | 38 | [dotnet-sdk]: https://dotnet.microsoft.com/en-us/download 39 | [powershell]: https://github.com/PowerShell/PowerShell 40 | [reuse]: https://reuse.software/ 41 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 1.6.1 10 | 11 | ForNeVeR 12 | https://github.com/ForNeVeR/Generaptor 13 | MIT 14 | https://github.com/ForNeVeR/Generaptor.git 15 | ci;github-actions 16 | 17 | true 18 | true 19 | true 20 | snupkg 21 | 22 | README.md 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /Generaptor.Library/Actions.fs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024-2025 Friedrich von Never 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | namespace Generaptor.Library 6 | 7 | open Generaptor.GitHubActions 8 | open type Generaptor.GitHubActions.Commands 9 | 10 | type Actions = 11 | static member checkout: JobCreationCommand = step(uses = "actions/checkout@v4") 12 | 13 | static member dotNetPack(version: string): JobCreationCommand = 14 | step( 15 | run = $"dotnet pack --configuration Release -p:Version={version}" 16 | ) 17 | 18 | static member getVersionWithScript(stepId: string, scriptPath: string): JobCreationCommand = 19 | step( 20 | id = stepId, 21 | name = "Get version", 22 | shell = "pwsh", 23 | run = $"""echo "version=$({scriptPath} -RefName $env:GITHUB_REF)" >> $env:GITHUB_OUTPUT""" 24 | ) 25 | 26 | static member prepareChangelog(outputPath: string): JobCreationCommand = 27 | step( 28 | name = "Read changelog", 29 | usesSpec = Auto "ForNeVeR/ChangelogAutomation.action", 30 | options = Map.ofList [ 31 | "output", outputPath 32 | ] 33 | ) 34 | 35 | static member uploadArtifacts(artifacts: string seq, ?actionVersion: string): JobCreationCommand = 36 | let version = defaultArg actionVersion "v4" 37 | step( 38 | name = "Upload artifacts", 39 | uses = $"actions/upload-artifact@{version}", 40 | options = Map.ofList [ 41 | "path", String.concat "\n" artifacts 42 | ] 43 | ) 44 | 45 | static member createRelease(name: string, releaseNotesPath: string, files: string seq, ?actionVersion: string): JobCreationCommand = 46 | let version = defaultArg actionVersion "v2" 47 | step( 48 | name = "Create a release", 49 | uses = $"softprops/action-gh-release@{version}", 50 | options = Map.ofList [ 51 | "name", name 52 | "body_path", releaseNotesPath 53 | "files", String.concat "\n" files 54 | ] 55 | ) 56 | 57 | static member pushToNuGetOrg (nuGetApiKeyId: string) (artifacts: string seq) : JobCreationCommand seq = 58 | artifacts 59 | |> Seq.map (fun artifact -> 60 | step ( 61 | name = "Push artifact to NuGet", 62 | run = 63 | $"dotnet nuget push {artifact} --source https://api.nuget.org/v3/index.json --api-key " 64 | + "${{ secrets." + nuGetApiKeyId + " }}" 65 | )) 66 | -------------------------------------------------------------------------------- /Generaptor.Library/Generaptor.Library.fsproj: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | net8.0 11 | true 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /Generaptor.Library/Patterns.fs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024-2025 Friedrich von Never 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | namespace Generaptor.Library 6 | 7 | open Generaptor 8 | open Generaptor.GitHubActions 9 | open type GitHubActions.Commands 10 | 11 | #nowarn "25" 12 | 13 | type Patterns = 14 | static member addMatrix (images: string seq) (AddJob job): WorkflowCreationCommand = 15 | AddJob { job with 16 | RunsOn = Some "${{ matrix.image }}" 17 | Strategy = Some { 18 | Matrix = Map.ofSeq [ "image", images ] 19 | FailFast = Some false 20 | } 21 | } 22 | 23 | static member dotNetBuildAndTest(?sdkVersion: string, ?projectFileExtensions: string seq): JobCreationCommand seq = 24 | let sdkVersion = defaultArg sdkVersion "8.0.x" 25 | let projectFileExtensions = defaultArg projectFileExtensions [ ".fsproj" ] 26 | [ 27 | setEnv "DOTNET_NOLOGO" "1" 28 | setEnv "DOTNET_CLI_TELEMETRY_OPTOUT" "1" 29 | setEnv "NUGET_PACKAGES" "${{ github.workspace }}/.github/nuget-packages" 30 | 31 | step( 32 | name = "Set up .NET SDK", 33 | uses = "actions/setup-dotnet@v4", 34 | options = Map.ofList [ 35 | "dotnet-version", sdkVersion 36 | ] 37 | ) 38 | let hashFiles = 39 | projectFileExtensions 40 | |> Seq.map (fun ext -> $"'**/*{ext}'") 41 | |> String.concat ", " 42 | step( 43 | name = "NuGet cache", 44 | uses = "actions/cache@v4", 45 | options = Map.ofList [ 46 | "path", "${{ env.NUGET_PACKAGES }}" 47 | "key", "${{ runner.os }}.nuget.${{ hashFiles(" + hashFiles + ") }}" 48 | ] 49 | ) 50 | step( 51 | name = "Build", 52 | run = "dotnet build" 53 | ) 54 | step( 55 | name = "Test", 56 | run = "dotnet test", 57 | timeoutMin = 10 58 | ) 59 | ] 60 | 61 | static member ifCalledOnTagPush(steps: JobCreationCommand seq): JobCreationCommand seq = 62 | steps |> 63 | Seq.map( 64 | function 65 | | AddStep ({ Condition = None } as step) -> 66 | AddStep { step with 67 | Condition = Some "startsWith(github.ref, 'refs/tags/v')" 68 | } 69 | | AddStep step -> failwith $"Step {step} has a condition already." 70 | | x -> x 71 | ) 72 | -------------------------------------------------------------------------------- /Generaptor.Tests/ActionsClientTests.fs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Friedrich von Never 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | module Generaptor.Tests.ActionsClientTests 6 | 7 | open System.Threading.Tasks 8 | open Generaptor 9 | open Xunit 10 | 11 | [] 12 | [] 13 | let ``ChangelogAutomation.action last version``(): Task = 14 | let client = ActionsClient() :> IActionsClient 15 | task { 16 | let! version = client.GetLastActionVersion "ForNeVeR/ChangelogAutomation.action" 17 | Assert.Equal(ActionVersion "v2", version) 18 | } 19 | 20 | [] 21 | let ``BestVersions for empty list``(): unit = 22 | Assert.Equal(None, ActionsClient.SelectBestVersion []) 23 | 24 | [] 25 | let ``BestVersions for non-prefixed versions``(): unit = 26 | Assert.Equal(Some "1", ActionsClient.SelectBestVersion ["1.1"; "1"; "0.9"]) 27 | 28 | [] 29 | let ``BestVersions for prefixed versions``(): unit = 30 | Assert.Equal(Some "v2", ActionsClient.SelectBestVersion ["v1.1"; "v2"; "v0.9"]) 31 | 32 | [] 33 | let ``BestVersions when no major-only exists``(): unit = 34 | Assert.Equal(Some "v2.1", ActionsClient.SelectBestVersion ["v1.1"; "v2.1"; "v1"]) 35 | 36 | [] 37 | let ``BestVersions for mixed versions``(): unit = 38 | Assert.Equal(Some "10.1", ActionsClient.SelectBestVersion ["10.1"; "v2"; "v0.9"]) 39 | -------------------------------------------------------------------------------- /Generaptor.Tests/Generaptor.Tests.fsproj: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | net8.0 11 | 12 | false 13 | true 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | runtime; build; native; contentfiles; analyzers; buildtransitive 31 | all 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /Generaptor.Tests/GeneratorTests.fs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024-2025 Friedrich von Never 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | module Generaptor.Tests.GeneratorTests 6 | 7 | open Xunit 8 | 9 | open Generaptor 10 | open Generaptor.GitHubActions 11 | open type Generaptor.GitHubActions.Commands 12 | 13 | let private doTest (expected: string) wf = 14 | let actual = Serializers.Stringify wf Map.empty TestFramework.MockActionsClient 15 | Assert.Equal(expected.ReplaceLineEndings "\n", actual) 16 | 17 | let private doTestOnExistingWorkflow (existing: string) (expected: string) wf = 18 | let existingVersions = Serializers.ExtractVersions existing 19 | let actual = Serializers.Stringify wf existingVersions TestFramework.MockActionsClient 20 | Assert.Equal(expected.ReplaceLineEndings "\n", actual) 21 | 22 | [] 23 | let ``Basic workflow gets generated``(): unit = 24 | let wf = workflow("wf") [| 25 | name "Main" 26 | onPushTo "main" 27 | 28 | job "main" [| 29 | needs "another" 30 | runsOn "ubuntu-latest" 31 | step(uses = "actions/checkout@v4") 32 | |] 33 | |] 34 | let expected = """name: Main 35 | on: 36 | push: 37 | branches: 38 | - main 39 | jobs: 40 | main: 41 | needs: 42 | - another 43 | runs-on: ubuntu-latest 44 | steps: 45 | - uses: actions/checkout@v4 46 | """ 47 | doTest expected wf 48 | 49 | [] 50 | let ``Condition tests``(): unit = 51 | let wf = workflow("wf") [| 52 | onPushTo "main" 53 | job "main" [| 54 | step(uses = "aaa") 55 | step(uses = "bbb", condition = "goes here") 56 | |] 57 | |] 58 | let expected = """on: 59 | push: 60 | branches: 61 | - main 62 | jobs: 63 | main: 64 | steps: 65 | - uses: aaa 66 | - if: goes here 67 | uses: bbb 68 | """ 69 | doTest expected wf 70 | 71 | [] 72 | let ``Environment tests``(): unit = 73 | let wf = workflow("wf") [| 74 | onPushTo "main" 75 | job "main" [| 76 | step(uses = "aaa") 77 | step(uses = "bbb", env = Map.ofList [ "foo", "bar" ]) 78 | |] 79 | |] 80 | let expected = """on: 81 | push: 82 | branches: 83 | - main 84 | jobs: 85 | main: 86 | steps: 87 | - uses: aaa 88 | - uses: bbb 89 | env: 90 | foo: bar 91 | """ 92 | doTest expected wf 93 | 94 | [] 95 | let ``Strategy test``(): unit = 96 | let wf = workflow "wf" [ 97 | job "main" [| 98 | strategy(failFast = false, matrix = [| 99 | "image", [ 100 | "macos-latest" 101 | "ubuntu-latest" 102 | "windows-latest" 103 | ] 104 | |]) 105 | |] 106 | ] 107 | let expected = """on: {} 108 | jobs: 109 | main: 110 | strategy: 111 | matrix: 112 | image: 113 | - macos-latest 114 | - ubuntu-latest 115 | - windows-latest 116 | fail-fast: false 117 | """ 118 | doTest expected wf 119 | 120 | [] 121 | let ``Complex strategy test``(): unit = 122 | let wf = workflow "wf" [ 123 | job "main" [| 124 | strategy(matrix = [| 125 | "config", [ 126 | Map.ofList [ 127 | "name", "macos" 128 | "image", "macos-latest" 129 | ] 130 | Map.ofList [ 131 | "name", "linux" 132 | "image", "ubuntu-24.04" 133 | ] 134 | Map.ofList [ 135 | "name", "windows" 136 | "image", "windows-2022" 137 | ] 138 | ] 139 | |]) 140 | |] 141 | ] 142 | let expected = """on: {} 143 | jobs: 144 | main: 145 | strategy: 146 | matrix: 147 | config: 148 | - image: macos-latest 149 | name: macos 150 | - image: ubuntu-24.04 151 | name: linux 152 | - image: windows-2022 153 | name: windows 154 | """ 155 | doTest expected wf 156 | 157 | [] 158 | let ``Autogenerated version fetching test``(): unit = 159 | let wf = workflow "wf" [ 160 | job "main" [| 161 | step(usesSpec = Auto "ForNeVeR/ChangelogAutomation.action") 162 | |] 163 | ] 164 | let expected = """on: {} 165 | jobs: 166 | main: 167 | steps: 168 | - uses: ForNeVeR/ChangelogAutomation.action@v10 169 | """ 170 | doTest expected wf 171 | 172 | [] 173 | let ``Existing version fetching test``(): unit = 174 | let wf = workflow "wf" [ 175 | job "main" [| 176 | step(usesSpec = Auto "ForNeVeR/ChangelogAutomation.action") 177 | step(usesSpec = Auto "ForNeVeR/ChangelogAutomation.action") 178 | |] 179 | ] 180 | let existing = """on: {} 181 | jobs: 182 | main: 183 | steps: 184 | - uses: ForNeVeR/ChangelogAutomation.action@v1 185 | """ 186 | let expected = """on: {} 187 | jobs: 188 | main: 189 | steps: 190 | - uses: ForNeVeR/ChangelogAutomation.action@v1 191 | - uses: ForNeVeR/ChangelogAutomation.action@v1 192 | """ 193 | doTestOnExistingWorkflow existing expected wf 194 | 195 | [] 196 | let ``Extract several versions``(): unit = 197 | let existing = """on: {} 198 | jobs: 199 | main: 200 | steps: 201 | - uses: ForNeVeR/ChangelogAutomation@v1 202 | - uses: ForNeVeR/ChangelogAutomation@v2 203 | - uses: ForNeVeR/ChangelogAutomation@v3 204 | - uses: ForNeVeR/ChangelogAutomation@nonsense 205 | """ 206 | Assert.Equivalent( 207 | Map.ofArray [| "ForNeVeR/ChangelogAutomation", ActionVersion "v3" |], 208 | Serializers.ExtractVersions existing 209 | ) 210 | 211 | [] 212 | let ``Unparseable version ok with no alternative``(): unit = 213 | let existing = """on: {} 214 | jobs: 215 | main: 216 | steps: 217 | - uses: ForNeVeR/ChangelogAutomation@nonsense 218 | """ 219 | Assert.Equivalent( 220 | Map.ofArray [| "ForNeVeR/ChangelogAutomation", ActionVersion "nonsense" |], 221 | Serializers.ExtractVersions existing 222 | ) 223 | 224 | [] 225 | let ``Unparseable version error``(): unit = 226 | let existing = """on: {} 227 | jobs: 228 | main: 229 | steps: 230 | - uses: ForNeVeR/ChangelogAutomation@nonsense1 231 | - uses: ForNeVeR/ChangelogAutomation@nonsense2 232 | """ 233 | let ex = Assert.Throws(fun() -> Serializers.ExtractVersions existing |> ignore) 234 | Assert.Equal("Cannot determine any parseable version for action ForNeVeR/ChangelogAutomation.", ex.Message) 235 | -------------------------------------------------------------------------------- /Generaptor.Tests/RegeneratorTests.BasicRegeneratorWorkflow.verified.fsx: -------------------------------------------------------------------------------- 1 | #r "nuget: Generaptor.Library, " 2 | open Generaptor 3 | open Generaptor.GitHubActions 4 | open type Generaptor.GitHubActions.Commands 5 | let workflows = [ 6 | workflow "1" [ 7 | name "Main" 8 | onPushTo "main" 9 | onPullRequestTo "main" 10 | onSchedule "0 0 * * 6" 11 | onWorkflowDispatch 12 | job "main" [ 13 | strategy(failFast = false, matrix = [ 14 | "image", [ 15 | "macos-latest" 16 | "ubuntu-latest" 17 | "windows-latest" 18 | ] 19 | ]) 20 | runsOn "${{ matrix.image }}" 21 | setEnv "DOTNET_CLI_TELEMETRY_OPTOUT" "1" 22 | setEnv "DOTNET_NOLOGO" "1" 23 | setEnv "NUGET_PACKAGES" "${{ github.workspace }}/.github/nuget-packages" 24 | step( 25 | uses = "actions/checkout@v4" 26 | ) 27 | step( 28 | name = "Set up .NET SDK", 29 | uses = "actions/setup-dotnet@v4", 30 | options = Map.ofList [ 31 | "dotnet-version", "8.0.x" 32 | ] 33 | ) 34 | step( 35 | name = "NuGet cache", 36 | uses = "actions/cache@v4", 37 | options = Map.ofList [ 38 | "key", "${{ runner.os }}.nuget.${{ hashFiles('**/*.fsproj') }}" 39 | "path", "${{ env.NUGET_PACKAGES }}" 40 | ] 41 | ) 42 | step( 43 | name = "Test", 44 | run = "dotnet test", 45 | timeoutMin = 10 46 | ) 47 | ] 48 | ] 49 | workflow "2" [ 50 | name "Release" 51 | onPushTo "main" 52 | onPushTags "v*" 53 | onPullRequestTo "main" 54 | onSchedule "0 0 * * 6" 55 | onWorkflowDispatch 56 | job "nuget" [ 57 | writeContentPermissions 58 | runsOn "ubuntu-latest" 59 | step( 60 | uses = "actions/checkout@v4" 61 | ) 62 | step( 63 | id = "version", 64 | name = "Get version", 65 | shell = "pwsh", 66 | run = "echo \"version=$(Scripts/Get-Version.ps1 -RefName $env:GITHUB_REF)\" >> $env:GITHUB_OUTPUT" 67 | ) 68 | step( 69 | run = "dotnet pack --configuration Release -p:Version=${{ steps.version.outputs.version }}" 70 | ) 71 | step( 72 | name = "Read changelog", 73 | uses = "ForNeVeR/ChangelogAutomation.action@v1", 74 | options = Map.ofList [ 75 | "output", "./release-notes.md" 76 | ] 77 | ) 78 | step( 79 | name = "Upload artifacts", 80 | uses = "actions/upload-artifact@v4", 81 | options = Map.ofList [ 82 | "path", "./release-notes.md\n./Generaptor/bin/Release/Generaptor.${{ steps.version.outputs.version }}.nupkg\n./Generaptor/bin/Release/Generaptor.${{ steps.version.outputs.version }}.snupkg\n./Generaptor.Library/bin/Release/Generaptor.Library.${{ steps.version.outputs.version }}.nupkg\n./Generaptor.Library/bin/Release/Generaptor.Library.${{ steps.version.outputs.version }}.snupkg" 83 | ] 84 | ) 85 | ] 86 | ] 87 | ] 88 | EntryPoint.Process fsi.CommandLineArgs workflows -------------------------------------------------------------------------------- /Generaptor.Tests/RegeneratorTests.StepEnvGenerator.verified.fsx: -------------------------------------------------------------------------------- 1 | #r "nuget: Generaptor.Library, " 2 | open Generaptor 3 | open Generaptor.GitHubActions 4 | open type Generaptor.GitHubActions.Commands 5 | let workflows = [ 6 | workflow "1" [ 7 | job "main" [ 8 | step( 9 | name = "Create release", 10 | condition = "startsWith(github.ref, 'refs/tags/v')", 11 | id = "release", 12 | uses = "actions/create-release@v1", 13 | env = Map.ofList [ 14 | "GITHUB_TOKEN", "${{ secrets.GITHUB_TOKEN }}" 15 | ], 16 | options = Map.ofList [ 17 | "tag_name", "${{ github.ref }}" 18 | "release_name", "ChangelogAutomation v${{ steps.version.outputs.version }}" 19 | "body_path", "./release-data.md" 20 | ] 21 | ) 22 | ] 23 | ] 24 | ] 25 | EntryPoint.Process fsi.CommandLineArgs workflows -------------------------------------------------------------------------------- /Generaptor.Tests/RegeneratorTests.StrategyGenerator.verified.fsx: -------------------------------------------------------------------------------- 1 | #r "nuget: Generaptor.Library, " 2 | open Generaptor 3 | open Generaptor.GitHubActions 4 | open type Generaptor.GitHubActions.Commands 5 | let workflows = [ 6 | workflow "1" [ 7 | job "main" [ 8 | strategy(failFast = false, matrix = [ 9 | "config", [ 10 | Map.ofList [ 11 | "name", "macos" 12 | "image", "macos-14" 13 | ] 14 | Map.ofList [ 15 | "name", "linux" 16 | "image", "ubuntu-24.04" 17 | ] 18 | Map.ofList [ 19 | "name", "windows" 20 | "image", "windows-2022" 21 | ] 22 | ] 23 | ]) 24 | jobName "main.${{ matrix.config.name }}" 25 | ] 26 | ] 27 | ] 28 | EntryPoint.Process fsi.CommandLineArgs workflows -------------------------------------------------------------------------------- /Generaptor.Tests/RegeneratorTests.fs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Friedrich von Never 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | module Generaptor.Tests.RegeneratorTests 6 | 7 | open System 8 | open System.IO 9 | open System.Threading.Tasks 10 | open Generaptor 11 | open TruePath 12 | open VerifyXunit 13 | open Xunit 14 | 15 | let private InitializeVerify() = 16 | Environment.SetEnvironmentVariable("DiffEngine_Disabled", "true") 17 | Environment.SetEnvironmentVariable("Verify_DisableClipboard", "true") 18 | 19 | let private DoTest(files: (string * string) seq): Task = 20 | let tempDir = 21 | let path = Path.GetTempFileName() 22 | File.Delete path 23 | Directory.CreateDirectory path |> ignore 24 | AbsolutePath path 25 | try 26 | for fileName, fileContent in files do 27 | let filePath = tempDir / fileName 28 | File.WriteAllText(filePath.Value, fileContent) 29 | let actualScript = 30 | ScriptGenerator.GenerateFrom(LocalPath tempDir) 31 | .Replace( 32 | $"nuget: Generaptor.Library, {ScriptGenerator.PackageVersion()}", 33 | "nuget: Generaptor.Library, ") 34 | 35 | InitializeVerify() 36 | Verifier.Verify(actualScript, extension = "fsx").ToTask() 37 | finally 38 | Directory.Delete(tempDir.Value, true) 39 | 40 | [] 41 | let BasicRegeneratorWorkflow(): Task = 42 | let files = [| 43 | "1.yml", """ 44 | name: Main 45 | on: 46 | push: 47 | branches: 48 | - main 49 | pull_request: 50 | branches: 51 | - main 52 | schedule: 53 | - cron: 0 0 * * 6 54 | workflow_dispatch: 55 | jobs: 56 | main: 57 | strategy: 58 | matrix: 59 | image: 60 | - macos-latest 61 | - ubuntu-latest 62 | - windows-latest 63 | fail-fast: false 64 | runs-on: ${{ matrix.image }} 65 | env: 66 | DOTNET_CLI_TELEMETRY_OPTOUT: 1 67 | DOTNET_NOLOGO: 1 68 | NUGET_PACKAGES: ${{ github.workspace }}/.github/nuget-packages 69 | steps: 70 | - uses: actions/checkout@v4 71 | - name: Set up .NET SDK 72 | uses: actions/setup-dotnet@v4 73 | with: 74 | dotnet-version: 8.0.x 75 | - name: NuGet cache 76 | uses: actions/cache@v4 77 | with: 78 | key: ${{ runner.os }}.nuget.${{ hashFiles('**/*.fsproj') }} 79 | path: ${{ env.NUGET_PACKAGES }} 80 | - name: Test 81 | run: dotnet test 82 | timeout-minutes: 10 83 | """ 84 | "2.yml", """ 85 | name: Release 86 | on: 87 | push: 88 | branches: 89 | - main 90 | tags: 91 | - v* 92 | pull_request: 93 | branches: 94 | - main 95 | schedule: 96 | - cron: 0 0 * * 6 97 | workflow_dispatch: 98 | jobs: 99 | nuget: 100 | permissions: 101 | contents: write 102 | runs-on: ubuntu-latest 103 | steps: 104 | - uses: actions/checkout@v4 105 | - id: version 106 | name: Get version 107 | shell: pwsh 108 | run: echo "version=$(Scripts/Get-Version.ps1 -RefName $env:GITHUB_REF)" >> $env:GITHUB_OUTPUT 109 | - run: dotnet pack --configuration Release -p:Version=${{ steps.version.outputs.version }} 110 | - name: Read changelog 111 | uses: ForNeVeR/ChangelogAutomation.action@v1 112 | with: 113 | output: ./release-notes.md 114 | - name: Upload artifacts 115 | uses: actions/upload-artifact@v4 116 | with: 117 | path: |- 118 | ./release-notes.md 119 | ./Generaptor/bin/Release/Generaptor.${{ steps.version.outputs.version }}.nupkg 120 | ./Generaptor/bin/Release/Generaptor.${{ steps.version.outputs.version }}.snupkg 121 | ./Generaptor.Library/bin/Release/Generaptor.Library.${{ steps.version.outputs.version }}.nupkg 122 | ./Generaptor.Library/bin/Release/Generaptor.Library.${{ steps.version.outputs.version }}.snupkg 123 | """ 124 | |] 125 | DoTest files 126 | 127 | [] 128 | let StrategyGenerator(): Task = 129 | let files = [ 130 | "1.yml", """ 131 | jobs: 132 | main: 133 | strategy: 134 | fail-fast: false 135 | matrix: 136 | config: 137 | - name: 'macos' 138 | image: 'macos-14' 139 | - name: 'linux' 140 | image: 'ubuntu-24.04' 141 | - name: 'windows' 142 | image: 'windows-2022' 143 | 144 | name: main.${{ matrix.config.name }} 145 | """ 146 | ] 147 | DoTest files 148 | 149 | [] 150 | let StepEnvGenerator(): Task = 151 | let files = [ 152 | "1.yml", """ 153 | jobs: 154 | main: 155 | steps: 156 | - name: Create release 157 | if: startsWith(github.ref, 'refs/tags/v') 158 | id: release 159 | uses: actions/create-release@v1 160 | env: 161 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 162 | with: 163 | tag_name: ${{ github.ref }} 164 | release_name: ChangelogAutomation v${{ steps.version.outputs.version }} 165 | body_path: ./release-data.md 166 | """ 167 | ] 168 | DoTest files 169 | -------------------------------------------------------------------------------- /Generaptor.Tests/TestFramework.fs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Friedrich von Never 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | module Generaptor.Tests.TestFramework 6 | 7 | open System.Threading.Tasks 8 | open Generaptor 9 | 10 | let MockActionsClient = { 11 | new IActionsClient with 12 | member this.GetLastActionVersion(ownerAndName) = 13 | Task.FromResult( 14 | match ownerAndName with 15 | | "ForNeVeR/ChangelogAutomation.action" -> ActionVersion "v10" 16 | | _ -> failwithf $"Not allowed to fetch action version for {ownerAndName}." 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /Generaptor.Tests/VerifierTests.fs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Friedrich von Never 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | module Generaptor.Tests.VerifierTests 6 | 7 | open System.IO 8 | open System.Threading.Tasks 9 | open Generaptor.GitHubActions 10 | open type Generaptor.GitHubActions.Commands 11 | open Generaptor.Verifier 12 | open TruePath 13 | open Xunit 14 | 15 | let private DoTest(files: (string * string) seq, workflows): Task = task { 16 | let tempDir = 17 | let path = Path.GetTempFileName() 18 | File.Delete path 19 | Directory.CreateDirectory path |> ignore 20 | AbsolutePath path 21 | try 22 | for fileName, fileContent in files do 23 | let filePath = tempDir / fileName 24 | File.WriteAllText(filePath.Value, fileContent) 25 | 26 | let! result = VerifyWorkflows(LocalPath tempDir, workflows, TestFramework.MockActionsClient) 27 | return tempDir, result 28 | finally 29 | Directory.Delete(tempDir.Value, true) 30 | } 31 | 32 | [] 33 | let ``Verification success``(): Task = 34 | let files = [| 35 | "wf.yml", """# This file is auto-generated. 36 | on: {} 37 | jobs: 38 | main: 39 | strategy: 40 | matrix: 41 | config: 42 | - image: macos-latest 43 | name: macos 44 | - image: ubuntu-24.04 45 | name: linux 46 | - image: windows-2022 47 | name: windows 48 | """ 49 | |] 50 | let wf = workflow "wf" [ 51 | job "main" [| 52 | strategy(matrix = [| 53 | "config", [ 54 | Map.ofList [ 55 | "name", "macos" 56 | "image", "macos-latest" 57 | ] 58 | Map.ofList [ 59 | "name", "linux" 60 | "image", "ubuntu-24.04" 61 | ] 62 | Map.ofList [ 63 | "name", "windows" 64 | "image", "windows-2022" 65 | ] 66 | ] 67 | |]) 68 | |] 69 | ] 70 | task { 71 | let! _, content = DoTest(files, [|wf|]) 72 | Assert.Equal({ 73 | Errors = Array.empty 74 | }, content) 75 | } 76 | 77 | [] 78 | let ``Verification failure: content not equal``(): Task = 79 | let files = [| 80 | "wf.yml", """# incorrect content 81 | jobs: 82 | main: 83 | strategy: 84 | matrix: 85 | config: 86 | - image: macos-latest 87 | name: macos 88 | - image: ubuntu-24.04 89 | name: linux 90 | - image: windows-2022 91 | name: windows 92 | """ 93 | |] 94 | let wf = workflow "wf" [ 95 | job "main" [| 96 | strategy(matrix = [| 97 | "config", [ 98 | Map.ofList [ 99 | "name", "macos" 100 | "image", "macos-latest" 101 | ] 102 | Map.ofList [ 103 | "name", "linux" 104 | "image", "ubuntu-24.04" 105 | ] 106 | Map.ofList [ 107 | "name", "windows" 108 | "image", "windows-2022" 109 | ] 110 | ] 111 | |]) 112 | |] 113 | ] 114 | task { 115 | let! (path, content) = DoTest(files, [|wf|]) 116 | let file = path / (wf.Id + ".yml") 117 | Assert.Equal({ 118 | Errors = [| 119 | $"The content of the file \"{file.Value}\" differs from the generated content for the workflow \"wf\"." 120 | |] 121 | }, content) 122 | } 123 | 124 | [] 125 | let ``Verification failure: file is absent``(): Task = 126 | let wf = workflow "wf" [] 127 | task { 128 | let! (path, content) = DoTest(Array.empty, [|wf|]) 129 | let file = path / (wf.Id + ".yml") 130 | Assert.Equal({ 131 | Errors = [| 132 | $"File for the workflow \"{wf.Id}\" doesn't exist: \"{file.Value}\"." 133 | |] 134 | }, content) 135 | } 136 | 137 | [] 138 | let ``Verification failure: redundant file``(): Task = 139 | let files = [| 140 | "wf.yml", "# this is a redundant file" 141 | |] 142 | task { 143 | let! (path, content) = DoTest(files, Array.empty) 144 | let file = path / "wf.yml" 145 | Assert.Equal({ 146 | Errors = [| 147 | $"File \"{file}\" does not correspond to any generated workflow." 148 | |] 149 | }, content) 150 | } 151 | -------------------------------------------------------------------------------- /Generaptor.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31903.59 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{D7083C7E-9C8E-4052-952F-F9403F4C073B}" 7 | ProjectSection(SolutionItems) = preProject 8 | .editorconfig = .editorconfig 9 | .gitignore = .gitignore 10 | LICENSE.md = LICENSE.md 11 | README.md = README.md 12 | CODE_OF_CONDUCT.md = CODE_OF_CONDUCT.md 13 | MAINTAINERSHIP.md = MAINTAINERSHIP.md 14 | Directory.Build.props = Directory.Build.props 15 | CHANGELOG.md = CHANGELOG.md 16 | CONTRIBUTING.md = CONTRIBUTING.md 17 | REUSE.toml = REUSE.toml 18 | Generaptor.sln.license = Generaptor.sln.license 19 | Generaptor.sln.DotSettings = Generaptor.sln.DotSettings 20 | renovate.json = renovate.json 21 | renovate.json.license = renovate.json.license 22 | EndProjectSection 23 | EndProject 24 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Generaptor", "Generaptor\Generaptor.fsproj", "{DECF408B-82A4-4EF2-971E-56840AA72F6D}" 25 | EndProject 26 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Infrastructure", "Infrastructure", "{FC1DB59B-C7D2-4D7B-BBD9-8183E2317EA1}" 27 | EndProject 28 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "GitHubActions", "Infrastructure\GitHubActions\GitHubActions.fsproj", "{B1CF5868-2797-46C1-8ADD-E76D46127842}" 29 | EndProject 30 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Generaptor.Tests", "Generaptor.Tests\Generaptor.Tests.fsproj", "{56E337A3-37CC-430B-87E2-3CAFE6FFEBAB}" 31 | EndProject 32 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{3AB1C8E3-61F0-437B-9BE5-B7098E7EFF2E}" 33 | EndProject 34 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{38B19542-0515-4A04-89E9-27D8375303BD}" 35 | ProjectSection(SolutionItems) = preProject 36 | .github\workflows\main.yml = .github\workflows\main.yml 37 | .github\workflows\release.yml = .github\workflows\release.yml 38 | EndProjectSection 39 | EndProject 40 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Generaptor.Library", "Generaptor.Library\Generaptor.Library.fsproj", "{D814D8A7-76C1-43E9-931F-4A9F4CBAAED6}" 41 | EndProject 42 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Scripts", "Scripts", "{21699C58-41F1-459C-B993-A2F83846F62A}" 43 | ProjectSection(SolutionItems) = preProject 44 | Scripts\Get-Version.ps1 = Scripts\Get-Version.ps1 45 | EndProjectSection 46 | EndProject 47 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "LICENSES", "LICENSES", "{3248E38A-4F37-4A23-9E28-C74B2F26C89F}" 48 | ProjectSection(SolutionItems) = preProject 49 | LICENSES\MIT.txt = LICENSES\MIT.txt 50 | LICENSES\CC-BY-4.0.txt = LICENSES\CC-BY-4.0.txt 51 | EndProjectSection 52 | EndProject 53 | Global 54 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 55 | Debug|Any CPU = Debug|Any CPU 56 | Release|Any CPU = Release|Any CPU 57 | EndGlobalSection 58 | GlobalSection(SolutionProperties) = preSolution 59 | HideSolutionNode = FALSE 60 | EndGlobalSection 61 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 62 | {DECF408B-82A4-4EF2-971E-56840AA72F6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 63 | {DECF408B-82A4-4EF2-971E-56840AA72F6D}.Debug|Any CPU.Build.0 = Debug|Any CPU 64 | {DECF408B-82A4-4EF2-971E-56840AA72F6D}.Release|Any CPU.ActiveCfg = Release|Any CPU 65 | {DECF408B-82A4-4EF2-971E-56840AA72F6D}.Release|Any CPU.Build.0 = Release|Any CPU 66 | {B1CF5868-2797-46C1-8ADD-E76D46127842}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 67 | {B1CF5868-2797-46C1-8ADD-E76D46127842}.Debug|Any CPU.Build.0 = Debug|Any CPU 68 | {B1CF5868-2797-46C1-8ADD-E76D46127842}.Release|Any CPU.ActiveCfg = Release|Any CPU 69 | {B1CF5868-2797-46C1-8ADD-E76D46127842}.Release|Any CPU.Build.0 = Release|Any CPU 70 | {56E337A3-37CC-430B-87E2-3CAFE6FFEBAB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 71 | {56E337A3-37CC-430B-87E2-3CAFE6FFEBAB}.Debug|Any CPU.Build.0 = Debug|Any CPU 72 | {56E337A3-37CC-430B-87E2-3CAFE6FFEBAB}.Release|Any CPU.ActiveCfg = Release|Any CPU 73 | {56E337A3-37CC-430B-87E2-3CAFE6FFEBAB}.Release|Any CPU.Build.0 = Release|Any CPU 74 | {D814D8A7-76C1-43E9-931F-4A9F4CBAAED6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 75 | {D814D8A7-76C1-43E9-931F-4A9F4CBAAED6}.Debug|Any CPU.Build.0 = Debug|Any CPU 76 | {D814D8A7-76C1-43E9-931F-4A9F4CBAAED6}.Release|Any CPU.ActiveCfg = Release|Any CPU 77 | {D814D8A7-76C1-43E9-931F-4A9F4CBAAED6}.Release|Any CPU.Build.0 = Release|Any CPU 78 | EndGlobalSection 79 | GlobalSection(NestedProjects) = preSolution 80 | {B1CF5868-2797-46C1-8ADD-E76D46127842} = {FC1DB59B-C7D2-4D7B-BBD9-8183E2317EA1} 81 | {38B19542-0515-4A04-89E9-27D8375303BD} = {3AB1C8E3-61F0-437B-9BE5-B7098E7EFF2E} 82 | EndGlobalSection 83 | EndGlobal 84 | -------------------------------------------------------------------------------- /Generaptor.sln.DotSettings: -------------------------------------------------------------------------------- 1 |  2 | True 3 | True 4 | True 5 | True 6 | True 7 | True 8 | True -------------------------------------------------------------------------------- /Generaptor.sln.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2024-2025 Friedrich von Never 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /Generaptor/ActionsClient.fs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Friedrich von Never 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | namespace Generaptor 6 | 7 | open System 8 | open System.Globalization 9 | open System.Threading.Tasks 10 | open Octokit 11 | 12 | type ActionVersion = ActionVersion of string 13 | 14 | type IActionsClient = 15 | abstract member GetLastActionVersion: ownerAndName: string -> Task 16 | 17 | type internal NumericVersion = 18 | | NumericVersion of major: int * minor: int option * patch: int option 19 | 20 | member this.Major = let (NumericVersion(m, _, _)) = this in m 21 | 22 | static member TryParse(s: string) = 23 | let (|Number|_|) (s: string): int option = 24 | match Int32.TryParse(s, CultureInfo.InvariantCulture) with 25 | | true, n -> Some n 26 | | false, _ -> None 27 | 28 | let strippedPrefix = if s.StartsWith 'v' then s.Substring 1 else s 29 | match strippedPrefix.Split '.' with 30 | | [| Number(m) |] -> Some <| NumericVersion(m, None, None) 31 | | [| Number(major); Number(minor) |] -> Some <| NumericVersion(major, Some minor, None) 32 | | [| Number(major); Number(minor); Number(patch) |] -> Some <| NumericVersion(major, Some minor, Some patch) 33 | | _ -> None 34 | 35 | type ActionsClient() = 36 | 37 | static member SelectBestVersion(versions: string seq): string option = 38 | let parsedVersions = 39 | versions 40 | |> Seq.choose(fun v -> NumericVersion.TryParse v |> Option.map(fun n -> v, n)) 41 | |> Seq.cache 42 | 43 | let latestVersion = 44 | parsedVersions 45 | |> Seq.sortByDescending snd 46 | |> Seq.tryHead 47 | 48 | latestVersion 49 | |> Option.map(fun(string, version) -> 50 | let versionsWithSameMajor = parsedVersions |> Seq.filter(fun(_, x) -> x.Major = version.Major) 51 | let majorOnlyVersion = 52 | versionsWithSameMajor 53 | |> Seq.filter(fun(_, x) -> x = NumericVersion(version.Major, None, None)) 54 | |> Seq.tryHead 55 | match majorOnlyVersion with 56 | | Some(s, _) -> s 57 | | None -> string 58 | ) 59 | 60 | interface IActionsClient with 61 | member this.GetLastActionVersion(ownerAndName) = 62 | let client = GitHubClient(ProductHeaderValue("generaptor")) 63 | let owner, name = 64 | match ownerAndName.Split '/' with 65 | | [| o; n |] -> o, n 66 | | _ -> failwithf $"Invalid repository owner/name: {ownerAndName}." 67 | task { 68 | let! tags = client.Repository.GetAllTags(owner, name) 69 | let versions = tags |> Seq.map _.Name 70 | return 71 | match ActionsClient.SelectBestVersion versions with 72 | | Some v -> ActionVersion v 73 | | None -> failwithf $"Cannot find any version for action {ownerAndName}." 74 | } 75 | -------------------------------------------------------------------------------- /Generaptor/AssemblyInfo.fs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024-2025 Friedrich von Never 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | namespace Generaptor 6 | 7 | open System.Runtime.CompilerServices 8 | 9 | [] 10 | () 11 | -------------------------------------------------------------------------------- /Generaptor/EntryPoint.fs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024-2025 Generaptor contributors 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | module Generaptor.EntryPoint 6 | 7 | open System.IO 8 | open System.Threading.Tasks 9 | open Generaptor.GitHubActions 10 | open TruePath 11 | 12 | let private printUsage() = 13 | printfn "%s" ("""Possible arguments: 14 | generate - generate GitHub Actions workflows in .github/workflows subdirectory of the current directory 15 | regenerate - generate Generaptor script from .github/workflows subdirectory of the current directory 16 | """.ReplaceLineEndings "\n") 17 | 18 | let private generateWorkflows(workflows: Workflow seq): Task = 19 | let dir = LocalPath ".github/workflows" 20 | let actionsClient = ActionsClient() 21 | task { 22 | for wf in workflows do 23 | Directory.CreateDirectory dir.Value |> ignore 24 | let yaml = dir / (wf.Id + ".yml") 25 | let! content = Serializers.GenerateWorkflowContent(yaml, wf, actionsClient) 26 | do! File.WriteAllTextAsync(yaml.Value, content) 27 | } 28 | 29 | let private regenerate(fileName: LocalPath) = 30 | let dir = LocalPath(Path.Combine(".github", "workflows")) 31 | let script = ScriptGenerator.GenerateFrom dir 32 | File.WriteAllText(fileName.Value, script) 33 | 34 | let private runSynchronously(t: Task) = 35 | t.GetAwaiter().GetResult() 36 | 37 | module ExitCodes = 38 | let Success = 0 39 | let ArgumentsNotRecognized = 1 40 | let VerificationError = 2 41 | 42 | let private Verify workflows = 43 | (task { 44 | let actionsClient = ActionsClient() 45 | let dir = LocalPath ".github/workflows" 46 | let! result = Verifier.VerifyWorkflows(dir, workflows, actionsClient) 47 | for error in result.Errors do 48 | eprintfn $"%s{error}" 49 | return if result.Success then ExitCodes.Success else ExitCodes.VerificationError 50 | }).GetAwaiter().GetResult() 51 | 52 | let Process(args: string seq) (workflows: Workflow seq): int = 53 | let args = Seq.toArray args 54 | let args = 55 | if args.Length > 0 && args[0].EndsWith ".fsx" 56 | then Array.skip 1 args 57 | else args 58 | 59 | match args with 60 | | [||] | [|"generate"|] -> runSynchronously <| generateWorkflows workflows; ExitCodes.Success 61 | | [|"regenerate"; fileName|] -> regenerate(LocalPath fileName); ExitCodes.Success 62 | | [|"verify"|] -> Verify workflows 63 | | _ -> printUsage(); ExitCodes.ArgumentsNotRecognized 64 | -------------------------------------------------------------------------------- /Generaptor/Generaptor.fsproj: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | net8.0 11 | true 12 | true 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /Generaptor/GitHubActions.fs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024-2025 Friedrich von Never 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | module Generaptor.GitHubActions 6 | 7 | open System 8 | open System.Collections.Immutable 9 | 10 | type Triggers = { 11 | Push: PushTrigger 12 | PullRequest: PullRequestTrigger 13 | Schedule: string option 14 | WorkflowDispatch: bool 15 | } 16 | and PushTrigger = { 17 | Branches: ImmutableArray 18 | Tags: ImmutableArray 19 | } 20 | and PullRequestTrigger = { 21 | Branches: ImmutableArray 22 | } 23 | 24 | type TriggerCreationCommand = 25 | | OnPushBranches of string seq 26 | | OnPushTags of string seq 27 | | OnPullRequestToBranches of string seq 28 | | OnSchedule of string 29 | | OnWorkflowDispatch 30 | 31 | type Permission = ContentWrite 32 | 33 | type ActionSpec = 34 | | ActionWithVersion of nameWithVersion: string 35 | /// This will read the highest major action version from the YAML file if it exists. If not found, will fall back 36 | /// to the latest available action tag on GitHub. 37 | | Auto of name: string 38 | 39 | type Job = { 40 | Id: string 41 | Name: string option 42 | Permissions: Set 43 | Needs: ImmutableArray 44 | Strategy: Strategy option 45 | RunsOn: string option 46 | Environment: Map 47 | Steps: ImmutableArray 48 | } 49 | and Strategy = { 50 | Matrix: Map 51 | FailFast: bool option 52 | } 53 | and Step = { 54 | Condition: string option 55 | Id: string option 56 | Name: string option 57 | Uses: ActionSpec option 58 | Shell: string option 59 | Run: string option 60 | Options: Map 61 | Environment: Map 62 | TimeoutMin: int option 63 | } 64 | 65 | type Workflow = { 66 | Id: string 67 | Header: string option 68 | Name: string option 69 | Triggers: Triggers 70 | Jobs: ImmutableArray 71 | } 72 | 73 | type JobCreationCommand = 74 | | Name of string 75 | | AddPermissions of Permission 76 | | Needs of string 77 | | RunsOn of string 78 | | AddStep of Step 79 | | SetEnv of string * string 80 | | AddStrategy of Strategy 81 | 82 | type WorkflowCreationCommand = 83 | | SetHeader of string 84 | | SetName of string 85 | | AddTrigger of TriggerCreationCommand 86 | | AddJob of Job 87 | 88 | let private addTrigger wf = function 89 | | OnPushBranches branches -> { wf with Workflow.Triggers.Push.Branches = wf.Triggers.Push.Branches.AddRange(branches) } 90 | | OnPushTags tags -> { wf with Workflow.Triggers.Push.Tags = wf.Triggers.Push.Tags.AddRange(tags) } 91 | | OnPullRequestToBranches branches -> { wf with Workflow.Triggers.PullRequest.Branches = wf.Triggers.PullRequest.Branches.AddRange(branches) } 92 | | OnSchedule cron -> { wf with Workflow.Triggers.Schedule = Some cron } 93 | | OnWorkflowDispatch -> { wf with Workflow.Triggers.WorkflowDispatch = true } 94 | 95 | let private createJob id commands = 96 | let mutable job = { 97 | Id = id 98 | Name = None 99 | Strategy = None 100 | Permissions = Set.empty 101 | Needs = ImmutableArray.Empty 102 | RunsOn = None 103 | Environment = Map.empty 104 | Steps = ImmutableArray.Empty 105 | } 106 | for command in commands do 107 | job <- 108 | match command with 109 | | Name n -> { job with Name = Some n } 110 | | AddPermissions p -> { job with Permissions = Set.add p job.Permissions } 111 | | Needs needs -> { job with Needs = job.Needs.Add needs } 112 | | RunsOn runsOn -> { job with RunsOn = Some runsOn } 113 | | AddStep step -> { job with Steps = job.Steps.Add(step) } 114 | | SetEnv (key, value) -> { job with Environment = Map.add key value job.Environment} 115 | | AddStrategy s -> { job with Strategy = Some s } 116 | job 117 | 118 | let workflow (id: string) (commands: WorkflowCreationCommand seq): Workflow = 119 | let mutable wf = { 120 | Id = id 121 | Header = None 122 | Name = None 123 | Triggers = { 124 | Push = { 125 | Branches = ImmutableArray.Empty 126 | Tags = ImmutableArray.Empty 127 | } 128 | PullRequest = { 129 | Branches = ImmutableArray.Empty 130 | } 131 | Schedule = None 132 | WorkflowDispatch = false 133 | } 134 | Jobs = ImmutableArray.Empty 135 | } 136 | for command in commands do 137 | wf <- 138 | match command with 139 | | SetHeader header -> { wf with Header = Some header } 140 | | SetName name -> { wf with Name = Some name } 141 | | AddTrigger trigger -> addTrigger wf trigger 142 | | AddJob job -> { wf with Jobs = wf.Jobs.Add job } 143 | wf 144 | 145 | type Commands = 146 | static member name(name: string): WorkflowCreationCommand = 147 | SetName name 148 | static member header(headerText: string): WorkflowCreationCommand = 149 | SetHeader headerText 150 | 151 | static member onPushTo(branchName: string): WorkflowCreationCommand = 152 | AddTrigger(OnPushBranches [| branchName |]) 153 | static member onPushTags(tagName: string): WorkflowCreationCommand= 154 | AddTrigger(OnPushTags [| tagName |]) 155 | static member onPullRequestTo(branchName: string): WorkflowCreationCommand = 156 | AddTrigger(OnPullRequestToBranches [| branchName |]) 157 | static member onSchedule(cron: string): WorkflowCreationCommand = 158 | AddTrigger(OnSchedule cron) 159 | static member onSchedule(day: DayOfWeek): WorkflowCreationCommand = 160 | AddTrigger(OnSchedule $"0 0 * * {int day}") 161 | static member onWorkflowDispatch: WorkflowCreationCommand = 162 | AddTrigger OnWorkflowDispatch 163 | 164 | static member job (id: string) (commands: JobCreationCommand seq): WorkflowCreationCommand = 165 | AddJob(createJob id commands) 166 | 167 | static member jobName(name: string): JobCreationCommand = 168 | Name name 169 | static member writeContentPermissions: JobCreationCommand = 170 | AddPermissions(ContentWrite) 171 | static member needs(jobId: string): JobCreationCommand = 172 | Needs jobId 173 | static member runsOn(image: string): JobCreationCommand = 174 | RunsOn image 175 | static member setEnv (key: string) (value: string): JobCreationCommand = 176 | SetEnv(key, value) 177 | static member step(?id: string, 178 | ?condition: string, 179 | ?name: string, 180 | ?uses: string, 181 | ?usesSpec: ActionSpec, 182 | ?shell: string, 183 | ?run: string, 184 | ?options: Map, 185 | ?env: Map, 186 | ?timeoutMin: int): JobCreationCommand = 187 | let actionSpec = 188 | match uses, usesSpec with 189 | | Some nameWithVersion, None -> Some <| ActionWithVersion nameWithVersion 190 | | None, Some spec -> Some spec 191 | | None, None -> None 192 | | Some nameWithVersion, Some spec -> 193 | failwithf $"Invalid action spec: both {nameWithVersion} and {spec} are specified." 194 | AddStep { 195 | Condition = condition 196 | Id = id 197 | Name = name 198 | Uses = actionSpec 199 | Shell = shell 200 | Run = run 201 | Options = defaultArg options Map.empty 202 | Environment = defaultArg env Map.empty 203 | TimeoutMin = timeoutMin 204 | } 205 | static member strategy(matrix: seq, ?failFast: bool): JobCreationCommand = 206 | AddStrategy { 207 | FailFast = failFast 208 | Matrix = Map.ofSeq matrix 209 | } 210 | -------------------------------------------------------------------------------- /Generaptor/ScriptGenerator.fs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024-2025 Friedrich von Never 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | module Generaptor.ScriptGenerator 6 | 7 | open System 8 | open System.Collections.Generic 9 | open System.Collections 10 | open System.IO 11 | open System.Reflection 12 | open System.Runtime.CompilerServices 13 | open System.Text 14 | open TruePath 15 | open YamlDotNet.Serialization 16 | 17 | [] 18 | let PackageVersion(): string = 19 | let assembly = Assembly.GetExecutingAssembly() 20 | assembly.GetName().Version.ToString 3 21 | 22 | let private ParseYaml(file: LocalPath): Dictionary = 23 | let deserializer = DeserializerBuilder().Build() 24 | deserializer.Deserialize<_>(File.ReadAllText file.Value) 25 | 26 | let private StringLiteral(x: obj) = 27 | let s = x :?> string 28 | let s = s.Replace("\"", "\\\"").Replace("\n", "\\n") 29 | $"\"{s}\"" 30 | 31 | let private Indent(spaces: int) () = String(' ', spaces) 32 | 33 | let private AddIndent(existing: unit -> string, level: int) (): string = 34 | existing() + String(' ', level) 35 | 36 | let private SerializeOn(data: Dictionary): string = 37 | let builder = StringBuilder() 38 | let append v = builder.AppendLine $" {v}" |> ignore 39 | for kvp in data do 40 | let key = kvp.Key :?> string 41 | let value = kvp.Value 42 | match key with 43 | | "push" -> 44 | let children = value :?> Dictionary 45 | children.GetValueOrDefault "branches" |> Option.ofObj |> Option.iter(fun branches -> 46 | branches :?> obj seq 47 | |> Seq.iter(fun branch -> 48 | append $"onPushTo {StringLiteral branch}" 49 | ) 50 | ) 51 | children.GetValueOrDefault "tags" |> Option.ofObj |> Option.iter(fun tags -> 52 | tags :?> _ seq 53 | |> Seq.iter(fun tag -> 54 | append $"onPushTags {StringLiteral tag}" 55 | ) 56 | ) 57 | | "pull_request" -> 58 | let children = value :?> Dictionary 59 | children.GetValueOrDefault "branches" |> Option.ofObj |> Option.iter(fun branches -> 60 | branches :?> _ seq 61 | |> Seq.iter(fun branch -> 62 | append $"onPullRequestTo {StringLiteral branch}" 63 | ) 64 | ) 65 | | "schedule" -> 66 | value :?> obj seq |> Seq.iter(fun entry -> 67 | let cron = (entry :?> Dictionary)["cron"] 68 | append $"onSchedule {StringLiteral(cron :?> string)}" 69 | ) 70 | | "workflow_dispatch" -> append "onWorkflowDispatch" 71 | | other -> failwithf $"Unknown key in the 'on' section: \"{other}\"." 72 | builder.ToString() 73 | 74 | let private SerializeStrategyMatrix (m: IDictionary) = 75 | let rec serializeItem(item: obj, indent: unit -> string) = 76 | let builder = StringBuilder() 77 | let append(x: string) = builder.Append x |> ignore 78 | match item with 79 | | :? string as s -> append $"{indent()}{StringLiteral s}" 80 | | :? Dictionary as map -> 81 | append $"{indent()}Map.ofList [\n" 82 | for kvp in map do 83 | append $"{indent()} {StringLiteral kvp.Key}, {StringLiteral kvp.Value}\n" 84 | append $"{indent()}]" 85 | | :? IEnumerable as collection -> 86 | append "[\n" 87 | for x in collection do 88 | append $"{serializeItem(x, AddIndent(indent, 4))}\n" 89 | append $"{indent()}]" 90 | | unknown -> failwithf $"Unknown element of strategy's matrix: \"{unknown}\"." 91 | builder.ToString() 92 | 93 | let builder = StringBuilder().AppendLine("[") 94 | let append (x: string) = builder.Append x |> ignore 95 | for kvp in m do 96 | let kvp = kvp :?> DictionaryEntry 97 | append $" \"{kvp.Key}\", {serializeItem(kvp.Value, Indent 16)}\n" 98 | builder.Append(" ]").ToString() 99 | 100 | let private SerializeStrategy(data: obj): string = 101 | let map = data :?> Dictionary 102 | let builder = StringBuilder().Append "(" 103 | let mutable hasArguments = false 104 | let append k v = 105 | if hasArguments then builder.Append ", " |> ignore 106 | builder.Append $"{k} = {v}" |> ignore 107 | hasArguments <- true 108 | 109 | // fail-fast should go first 110 | match map.GetValueOrDefault "fail-fast" with 111 | | null -> () 112 | | v -> append "failFast" v 113 | 114 | for kvp in map do 115 | let key = kvp.Key :?> string 116 | let value = kvp.Value 117 | match key with 118 | | "fail-fast" -> () // already processed 119 | | "matrix" -> append "matrix" (SerializeStrategyMatrix(value :?> IDictionary)) 120 | | other -> failwithf $"Unknown key in the 'strategy' section: \"{other}\"." 121 | builder.Append(")").ToString() 122 | 123 | let private SerializeEnv(data: obj, indent: unit -> string): string = 124 | let result = StringBuilder() 125 | let append(x: string) = result.AppendLine $"{indent()}{x}" |> ignore 126 | let data = data :?> Dictionary 127 | for kvp in data do 128 | let key = kvp.Key :?> string 129 | let value = kvp.Value 130 | append $"setEnv {StringLiteral key} {StringLiteral value}" 131 | result.ToString() 132 | 133 | let private SerializeStringMap(map: obj, indent: unit -> string) = 134 | let map = map :?> Dictionary 135 | let result = StringBuilder().AppendLine("Map.ofList [") 136 | for kvp in map do 137 | result.AppendLine $"{indent()} {StringLiteral kvp.Key}, {StringLiteral kvp.Value}" |> ignore 138 | result.Append($"{indent()}]").ToString() 139 | 140 | let private SerializeSteps(data: obj, indent: unit -> string): string = 141 | let builder = StringBuilder() 142 | let append(x: string) = builder.AppendLine $"{indent()}{x}" |> ignore 143 | let data = data :?> obj seq 144 | for step in data do 145 | let step = step :?> Dictionary 146 | append "step(" 147 | let mutable first = true 148 | for kvp in step do 149 | let key = kvp.Key :?> string 150 | let value = kvp.Value 151 | let appendArg k v = 152 | if first then 153 | first <- false 154 | builder.Append $"{indent()} {k} = {v}" 155 | else 156 | builder.Append $",\n{indent()} {k} = {v}" 157 | |> ignore 158 | 159 | match key with 160 | | "if" -> appendArg "condition" <| StringLiteral value 161 | | "id" -> appendArg "id" <| StringLiteral value 162 | | "name" -> appendArg "name" <| StringLiteral value 163 | | "uses" -> appendArg "uses" <| StringLiteral value 164 | | "shell" -> appendArg "shell" <| StringLiteral value 165 | | "run" -> appendArg "run" <| StringLiteral value 166 | | "with" -> appendArg "options" <| SerializeStringMap(value, Indent 16) 167 | | "env" -> appendArg "env" <| SerializeStringMap(value, Indent 16) 168 | | "timeout-minutes" -> appendArg "timeoutMin" value 169 | | other -> failwithf $"Unknown key in the 'steps' section: \"{other}\"." 170 | builder.AppendLine $"\n{indent()})" |> ignore 171 | builder.ToString() 172 | 173 | let private SerializePermissions(value: obj, indent: unit -> string) = 174 | let permissions = value :?> Dictionary 175 | let builder = StringBuilder() 176 | let append v = builder.AppendLine $"{indent()}{v}" |> ignore 177 | for kvp in permissions do 178 | let key = kvp.Key :?> string 179 | let value = kvp.Value :?> string 180 | match key with 181 | | "contents" -> 182 | match value with 183 | | "write" -> append "writeContentPermissions" 184 | | other -> failwithf $"Unknown value in the 'permissions' section: \"{other}\"." 185 | | other -> failwithf $"Unknown key in the 'permissions' section: \"{other}\"." 186 | 187 | builder.ToString() 188 | 189 | let private SerializeJobs(jobs: obj): string = 190 | let builder = StringBuilder() 191 | let append v = builder.AppendLine $" {v}" |> ignore 192 | 193 | let jobs = jobs :?> Dictionary 194 | for kvp in jobs do 195 | let name = kvp.Key 196 | let content = kvp.Value :?> Dictionary 197 | 198 | append $"job \"{name}\" [" 199 | let append v = append $" {v}" 200 | for kvp in content do 201 | let key = kvp.Key :?> string 202 | let value = kvp.Value 203 | match key with 204 | | "name" -> append $"jobName {StringLiteral value}" 205 | | "strategy" -> append <| $"strategy{SerializeStrategy value}" 206 | | "runs-on" -> append $"runsOn {StringLiteral value}" 207 | | "env" -> builder.Append(SerializeEnv(value, Indent 12)) |> ignore 208 | | "steps" -> builder.Append(SerializeSteps(value, Indent 12)) |> ignore 209 | | "permissions" -> builder.Append(SerializePermissions(value, Indent 12)) |> ignore 210 | | other -> failwithf $"Unknown key in the 'jobs' section: \"{other}\"." 211 | builder.AppendLine " ]" |> ignore 212 | 213 | builder.ToString() 214 | 215 | let private SerializeWorkflow (name: string) (content: Dictionary): string = 216 | let builder = StringBuilder().AppendLine $" workflow \"{name}\" [" 217 | let append v = builder.AppendLine $" {v}" |> ignore 218 | let appendSection(v: string) = builder.Append v |> ignore 219 | for kvp in content do 220 | let key = kvp.Key 221 | let value = kvp.Value 222 | match key with 223 | | "name" -> append $"name {StringLiteral(value :?> string)}" 224 | | "on" -> appendSection <| SerializeOn(value :?> Dictionary) 225 | | "jobs" -> appendSection <| SerializeJobs value 226 | | other -> failwithf $"Unknown key at the root level of the workflow \"{name}\": \"{other}\"." 227 | builder.Append(" ]").ToString() 228 | 229 | let GenerateFrom(workflowDirectory: LocalPath): string = 230 | let files = Directory.GetFiles(workflowDirectory.Value, "*.yml") |> Seq.map LocalPath 231 | let workflows = 232 | files 233 | |> Seq.sortBy _.Value 234 | |> Seq.map(fun path -> 235 | let name = path.GetFilenameWithoutExtension() 236 | let content = ParseYaml path 237 | SerializeWorkflow name content 238 | ) 239 | |> String.concat "\n" 240 | $"""#r "nuget: Generaptor.Library, {PackageVersion()}" 241 | open Generaptor 242 | open Generaptor.GitHubActions 243 | open type Generaptor.GitHubActions.Commands 244 | let workflows = [ 245 | {workflows} 246 | ] 247 | EntryPoint.Process fsi.CommandLineArgs workflows""".ReplaceLineEndings "\n" 248 | -------------------------------------------------------------------------------- /Generaptor/Serializers.fs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024-2025 Friedrich von Never 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | module internal Generaptor.Serializers 6 | 7 | open System.Collections 8 | open System.Collections.Generic 9 | 10 | open System.IO 11 | open System.Threading.Tasks 12 | open TruePath 13 | open YamlDotNet.Core 14 | open YamlDotNet.Serialization 15 | open YamlDotNet.Serialization.EventEmitters 16 | 17 | open Generaptor.GitHubActions 18 | 19 | let private convertTriggers(triggers: Triggers) = 20 | let map = Dictionary() 21 | 22 | let push = triggers.Push 23 | if push.Branches.Length > 0 || push.Tags.Length > 0 then 24 | map.Add("push", Map.ofArray [| 25 | if push.Branches.Length > 0 then 26 | "branches", push.Branches 27 | if push.Tags.Length > 0 then 28 | "tags", push.Tags 29 | |]) 30 | if triggers.PullRequest.Branches.Length > 0 then 31 | map.Add("pull_request", Map.ofArray [| "branches", triggers.PullRequest.Branches |]) 32 | match triggers.Schedule with 33 | | None -> () 34 | | Some cron -> map.Add("schedule", [| Map.ofArray [| "cron", cron |] |]) 35 | 36 | if triggers.WorkflowDispatch then 37 | map.Add("workflow_dispatch", null) 38 | 39 | map 40 | 41 | let private addOptional (map: Dictionary) (key: string) value = 42 | match value with 43 | | Some v -> map.Add(key, v) 44 | | None -> () 45 | 46 | let private getActionUsesSpec (existingVersions: Map, client: IActionsClient) = function 47 | | ActionWithVersion av -> av 48 | | Auto name -> 49 | (task { 50 | let! version = 51 | match Map.tryFind name existingVersions with 52 | | Some version -> Task.FromResult version 53 | | None -> client.GetLastActionVersion name 54 | 55 | let (ActionVersion versionString) = version 56 | return $"{name}@{versionString}" 57 | }).GetAwaiter().GetResult() 58 | 59 | let private convertSteps(steps, existingVersions, client) = 60 | steps |> Seq.map (fun (step: Step) -> 61 | let map = Dictionary() 62 | addOptional map "if" step.Condition 63 | addOptional map "id" step.Id 64 | addOptional map "name" step.Name 65 | 66 | let uses = step.Uses |> Option.map(getActionUsesSpec(existingVersions, client)) 67 | addOptional map "uses" uses 68 | 69 | addOptional map "shell" step.Shell 70 | addOptional map "run" step.Run 71 | if not <| Map.isEmpty step.Options then 72 | map.Add("with", step.Options) 73 | if not <| Map.isEmpty step.Environment then 74 | map.Add("env", step.Environment) 75 | addOptional map "timeout-minutes" step.TimeoutMin 76 | map 77 | ) 78 | 79 | let private convertStrategy(strategy: Strategy) = 80 | let map = Dictionary() 81 | map.Add("matrix", strategy.Matrix) 82 | match strategy.FailFast with 83 | | None -> () 84 | | Some v -> map.Add("fail-fast", v) 85 | map 86 | 87 | let private convertPermissions permissions = 88 | Map.ofArray [| 89 | if Set.contains ContentWrite permissions then 90 | "contents", "write" 91 | |] 92 | 93 | let private convertJobBody(job: Job, existingVersions, client) = 94 | let mutable map = Dictionary() 95 | match job.Name with 96 | | None -> () 97 | | Some n -> map.Add("name", n) 98 | match job.Strategy with 99 | | None -> () 100 | | Some s -> map.Add("strategy", convertStrategy s) 101 | if not job.Permissions.IsEmpty then 102 | map.Add("permissions", convertPermissions job.Permissions) 103 | if not job.Needs.IsEmpty then 104 | map.Add("needs", job.Needs) 105 | addOptional map "runs-on" job.RunsOn 106 | if job.Environment.Count > 0 then 107 | map.Add("env", job.Environment) 108 | if job.Steps.Length > 0 then 109 | map.Add("steps", convertSteps(job.Steps, existingVersions, client)) 110 | map 111 | 112 | let private convertJobs(jobs: Job seq, existingVersions, client) = 113 | let map = Dictionary() 114 | for job in jobs do 115 | map.Add(job.Id, convertJobBody(job, existingVersions, client)) 116 | map 117 | 118 | let private convertWorkflow(wf: Workflow, existingVersions, client) = 119 | let mutable map = Dictionary() 120 | addOptional map "name" wf.Name 121 | map.Add("on", convertTriggers wf.Triggers) 122 | map.Add("jobs", convertJobs(wf.Jobs, existingVersions, client)) 123 | map 124 | 125 | let ExtractVersions(content: string): Map = 126 | let document = 127 | let deserializer = DeserializerBuilder().Build() 128 | deserializer.Deserialize> content 129 | let getValue (m: obj) k = 130 | match m with 131 | | :? IDictionary as m when m.Contains k -> Some m[k] 132 | | _ -> None 133 | let getSubdictionary m k = 134 | match getValue m k with 135 | | Some(:? IDictionary as s) -> Some s 136 | | _ -> None 137 | let jobs = 138 | getSubdictionary document "jobs" 139 | |> Option.map(fun x -> x.Values |> Seq.cast) 140 | |> Option.defaultValue Seq.empty 141 | let allSteps = jobs |> Seq.collect (fun j -> 142 | getValue j "steps" 143 | |> Option.bind(function | :? seq as s -> Some s | _ -> None) 144 | |> Option.defaultValue Seq.empty 145 | ) 146 | let allUsesClauses = 147 | allSteps 148 | |> Seq.choose(fun s -> 149 | match s with 150 | | :? IDictionary as d when d.Contains "uses" -> 151 | match d["uses"] with 152 | | :? string as u -> Some u 153 | | _ -> None 154 | | _ -> None 155 | ) 156 | allUsesClauses 157 | |> Seq.choose(fun v -> match v.Split('@', 2) with | [| n; v |] -> Some(n, v) | _ -> None) 158 | |> Seq.groupBy fst 159 | |> Seq.map(fun(k, xs) -> k, Seq.map snd xs) 160 | |> Seq.map(fun (name, allVersions) -> 161 | let distinctVersions = Seq.distinct allVersions |> Array.ofSeq 162 | let version = 163 | match distinctVersions.Length with 164 | | 1 -> Array.exactlyOne distinctVersions 165 | | _ -> distinctVersions 166 | |> Seq.choose(fun v -> NumericVersion.TryParse v |> Option.map (fun n -> v, n)) 167 | |> Seq.sortByDescending snd 168 | |> Seq.map fst 169 | |> Seq.tryHead 170 | |> Option.defaultWith( 171 | fun() -> failwithf $"Cannot determine any parseable version for action {name}." 172 | ) 173 | name, ActionVersion version 174 | ) 175 | |> Map.ofSeq 176 | 177 | let internal Stringify(wf: Workflow) (existingVersions: Map) (client: IActionsClient): string = 178 | let serializer = 179 | SerializerBuilder() 180 | .WithNewLine("\n") 181 | .WithEventEmitter(fun nextEmitter -> 182 | { new ChainedEventEmitter(nextEmitter) with 183 | override this.Emit(eventInfo: ScalarEventInfo, emitter: IEmitter): unit = 184 | if eventInfo.Source.Type = typeof 185 | && (eventInfo.Source.Value :?> string).Contains "\n" then 186 | eventInfo.Style <- ScalarStyle.Literal 187 | nextEmitter.Emit(eventInfo, emitter) 188 | }) 189 | .Build() 190 | let data = convertWorkflow(wf, existingVersions, client) 191 | serializer.Serialize data 192 | 193 | let GenerateWorkflowContent(yaml: LocalPath, wf: Workflow, client: IActionsClient): Task = task { 194 | printfn $"Generating workflow {wf.Id}…" 195 | let! existingVersions = 196 | if File.Exists yaml.Value 197 | then task { 198 | let! content = File.ReadAllTextAsync yaml.Value 199 | return ExtractVersions content 200 | } 201 | else Task.FromResult Map.empty 202 | 203 | let mutable header = defaultArg wf.Header "# This file is auto-generated.\n" 204 | if not(header.EndsWith "\n") then header <- $"{header}\n" 205 | 206 | return header + Stringify wf existingVersions client 207 | } 208 | -------------------------------------------------------------------------------- /Generaptor/Verifier.fs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Friedrich von Never 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | module Generaptor.Verifier 6 | 7 | open System.Collections.Generic 8 | open System.IO 9 | open System.Threading.Tasks 10 | open Generaptor.GitHubActions 11 | open TruePath 12 | 13 | type VerificationResult = 14 | { 15 | Errors: string[] 16 | } 17 | member this.Success = this.Errors.Length = 0 18 | 19 | let private NormalizeText(s: string): string = 20 | s.Trim().Split "\n" |> Seq.map _.TrimEnd() |> String.concat "\n" 21 | 22 | let VerifyWorkflows( 23 | workflowDir: LocalPath, 24 | workflows: Workflow seq, 25 | client: IActionsClient 26 | ): Task = task { 27 | let files = HashSet(Directory.GetFiles(workflowDir.Value, "*.yml") |> Seq.map LocalPath) 28 | let errors = ResizeArray() 29 | for wf in workflows do 30 | printfn $"Verifying workflow {wf.Id}…" 31 | let yaml = workflowDir / (wf.Id + ".yml") 32 | let! newContent = Serializers.GenerateWorkflowContent(yaml, wf, client) 33 | let! oldContent = 34 | if File.Exists yaml.Value 35 | then task { 36 | let! content = File.ReadAllTextAsync(yaml.Value) 37 | return Some content 38 | } 39 | else Task.FromResult None 40 | match oldContent, newContent with 41 | | None, _ -> errors.Add $"File for the workflow \"{wf.Id}\" doesn't exist: \"{yaml.Value}\"." 42 | | Some x, y when NormalizeText x = NormalizeText y -> () 43 | | Some _, _ -> 44 | errors.Add ( 45 | $"The content of the file \"{yaml.Value}\" differs " + 46 | $"from the generated content for the workflow \"{wf.Id}\"." 47 | ) 48 | 49 | files.Remove yaml |> ignore 50 | 51 | for remaining in files do 52 | errors.Add $"File \"{remaining.Value}\" does not correspond to any generated workflow." 53 | 54 | return { 55 | Errors = Array.ofSeq errors 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Infrastructure/GitHubActions/GitHubActions.fsproj: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | Exe 11 | net8.0 12 | false 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /Infrastructure/GitHubActions/Program.fs: -------------------------------------------------------------------------------- 1 | let licenseHeader = """ 2 | # SPDX-FileCopyrightText: 2024-2025 Friedrich von Never 3 | # 4 | # SPDX-License-Identifier: MIT 5 | 6 | # This file is auto-generated.""".Trim() 7 | 8 | open System 9 | 10 | open Generaptor 11 | open Generaptor.GitHubActions 12 | open type Generaptor.GitHubActions.Commands 13 | open type Generaptor.Library.Actions 14 | open type Generaptor.Library.Patterns 15 | 16 | let mainBranch = "main" 17 | let linuxImage = "ubuntu-24.04" 18 | let images = [ 19 | "macos-14" 20 | linuxImage 21 | "windows-2025" 22 | ] 23 | 24 | let workflows = [ 25 | let workflow name body = 26 | workflow name [ 27 | header licenseHeader 28 | yield! body 29 | ] 30 | 31 | let mainTriggers = [ 32 | onPushTo mainBranch 33 | onPullRequestTo mainBranch 34 | onSchedule(day = DayOfWeek.Saturday) 35 | onWorkflowDispatch 36 | ] 37 | 38 | workflow "main" [ 39 | name "Main" 40 | yield! mainTriggers 41 | job "main" [ 42 | checkout 43 | 44 | let sdkVersion = "8.0.x" 45 | let projectFileExtensions = [ ".fsproj" ] 46 | 47 | setEnv "DOTNET_NOLOGO" "1" 48 | setEnv "DOTNET_CLI_TELEMETRY_OPTOUT" "1" 49 | setEnv "NUGET_PACKAGES" "${{ github.workspace }}/.github/nuget-packages" 50 | 51 | step( 52 | name = "Set up .NET SDK", 53 | uses = "actions/setup-dotnet@v4", 54 | options = Map.ofList [ 55 | "dotnet-version", sdkVersion 56 | ] 57 | ) 58 | let hashFiles = 59 | projectFileExtensions 60 | |> Seq.map (fun ext -> $"'**/*{ext}'") 61 | |> String.concat ", " 62 | step( 63 | name = "NuGet cache", 64 | uses = "actions/cache@v4", 65 | options = Map.ofList [ 66 | "path", "${{ env.NUGET_PACKAGES }}" 67 | "key", "${{ runner.os }}.nuget.${{ hashFiles(" + hashFiles + ") }}" 68 | ] 69 | ) 70 | step( 71 | name = "Build", 72 | run = "dotnet build" 73 | ) 74 | step( 75 | name = "Test", 76 | run = "dotnet test --filter Category!=SkipOnCI", 77 | timeoutMin = 10 78 | ) 79 | ] |> addMatrix images 80 | 81 | job "verify-workflows" [ 82 | runsOn "ubuntu-24.04" 83 | setEnv "DOTNET_CLI_TELEMETRY_OPTOUT" "1" 84 | setEnv "DOTNET_NOLOGO" "1" 85 | setEnv "NUGET_PACKAGES" "${{ github.workspace }}/.github/nuget-packages" 86 | step( 87 | uses = "actions/checkout@v4" 88 | ) 89 | step( 90 | uses = "actions/setup-dotnet@v4" 91 | ) 92 | step( 93 | uses = "actions/cache@v4", 94 | options = Map.ofList [ 95 | "key", "${{ runner.os }}.nuget.${{ hashFiles('**/*.fsproj') }}" 96 | "path", "${{ env.NUGET_PACKAGES }}" 97 | ] 98 | ) 99 | step( 100 | run = "dotnet run --project Infrastructure/GitHubActions -- verify" 101 | ) 102 | ] 103 | 104 | job "licenses" [ 105 | runsOn linuxImage 106 | step(usesSpec = Auto "actions/checkout") 107 | step(usesSpec = Auto "fsfe/reuse-action") 108 | ] 109 | 110 | job "encodings" [ 111 | runsOn linuxImage 112 | step(uses = "actions/checkout@v4") 113 | let verifyEncodingVersion = "2.2.0" 114 | step( 115 | shell = "pwsh", 116 | run = "Install-Module VerifyEncoding " + 117 | "-Repository PSGallery " + 118 | $"-RequiredVersion {verifyEncodingVersion} " + 119 | "-Force && Test-Encoding" 120 | ) 121 | ] 122 | ] 123 | workflow "release" [ 124 | name "Release" 125 | yield! mainTriggers 126 | onPushTags "v*" 127 | job "nuget" [ 128 | runsOn linuxImage 129 | checkout 130 | writeContentPermissions 131 | 132 | let configuration = "Release" 133 | 134 | let versionStepId = "version" 135 | let versionField = "${{ steps." + versionStepId + ".outputs.version }}" 136 | getVersionWithScript(stepId = versionStepId, scriptPath = "Scripts/Get-Version.ps1") 137 | dotNetPack(version = versionField) 138 | 139 | let releaseNotes = "./release-notes.md" 140 | prepareChangelog(releaseNotes) 141 | let artifacts projectName includeSNuPkg = [ 142 | $"./{projectName}/bin/{configuration}/{projectName}.{versionField}.nupkg" 143 | if includeSNuPkg then $"./{projectName}/bin/{configuration}/{projectName}.{versionField}.snupkg" 144 | ] 145 | let allArtifacts = [ 146 | yield! artifacts "Generaptor" true 147 | yield! artifacts "Generaptor.Library" true 148 | ] 149 | uploadArtifacts [ 150 | releaseNotes 151 | yield! allArtifacts 152 | ] 153 | yield! ifCalledOnTagPush [ 154 | createRelease( 155 | name = $"Generaptor {versionField}", 156 | releaseNotesPath = releaseNotes, 157 | files = allArtifacts 158 | ) 159 | yield! pushToNuGetOrg "NUGET_TOKEN" [ 160 | yield! artifacts "Generaptor" false 161 | yield! artifacts "Generaptor.Library" false 162 | ] 163 | ] 164 | ] 165 | ] 166 | ] 167 | 168 | [] 169 | let main(args: string[]): int = 170 | EntryPoint.Process args workflows 171 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License 2 | =============== 3 | Copyright (C) 2024-2025 Generaptor contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /LICENSES/CC-BY-4.0.txt: -------------------------------------------------------------------------------- 1 | Creative Commons Attribution 4.0 International 2 | 3 | Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible. 4 | 5 | Using Creative Commons Public Licenses 6 | 7 | Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses. 8 | 9 | Considerations for licensors: Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. More considerations for licensors. 10 | 11 | Considerations for the public: By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor’s permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. Although not required by our licenses, you are encouraged to respect those requests where reasonable. More considerations for the public. 12 | 13 | Creative Commons Attribution 4.0 International Public License 14 | 15 | By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions. 16 | 17 | Section 1 – Definitions. 18 | 19 | a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image. 20 | 21 | b. Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License. 22 | 23 | c. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights. 24 | 25 | d. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements. 26 | 27 | e. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material. 28 | 29 | f. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License. 30 | 31 | g. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license. 32 | 33 | h. Licensor means the individual(s) or entity(ies) granting rights under this Public License. 34 | 35 | i. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them. 36 | 37 | j. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world. 38 | 39 | k. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning. 40 | 41 | Section 2 – Scope. 42 | 43 | a. License grant. 44 | 45 | 1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to: 46 | 47 | A. reproduce and Share the Licensed Material, in whole or in part; and 48 | 49 | B. produce, reproduce, and Share Adapted Material. 50 | 51 | 2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions. 52 | 53 | 3. Term. The term of this Public License is specified in Section 6(a). 54 | 55 | 4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material. 56 | 57 | 5. Downstream recipients. 58 | 59 | A. Offer from the Licensor – Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License. 60 | 61 | B. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material. 62 | 63 | 6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i). 64 | 65 | b. Other rights. 66 | 67 | 1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise. 68 | 69 | 2. Patent and trademark rights are not licensed under this Public License. 70 | 71 | 3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties. 72 | 73 | Section 3 – License Conditions. 74 | 75 | Your exercise of the Licensed Rights is expressly made subject to the following conditions. 76 | 77 | a. Attribution. 78 | 79 | 1. If You Share the Licensed Material (including in modified form), You must: 80 | 81 | A. retain the following if it is supplied by the Licensor with the Licensed Material: 82 | 83 | i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated); 84 | 85 | ii. a copyright notice; 86 | 87 | iii. a notice that refers to this Public License; 88 | 89 | iv. a notice that refers to the disclaimer of warranties; 90 | 91 | v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable; 92 | 93 | B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and 94 | 95 | C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License. 96 | 97 | 2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information. 98 | 99 | 3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable. 100 | 101 | 4. If You Share Adapted Material You produce, the Adapter's License You apply must not prevent recipients of the Adapted Material from complying with this Public License. 102 | 103 | Section 4 – Sui Generis Database Rights. 104 | 105 | Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material: 106 | 107 | a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database; 108 | 109 | b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and 110 | 111 | c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database. 112 | For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights. 113 | 114 | Section 5 – Disclaimer of Warranties and Limitation of Liability. 115 | 116 | a. Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You. 117 | 118 | b. To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You. 119 | 120 | c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability. 121 | 122 | Section 6 – Term and Termination. 123 | 124 | a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically. 125 | 126 | b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates: 127 | 128 | 1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or 129 | 130 | 2. upon express reinstatement by the Licensor. 131 | 132 | c. For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License. 133 | 134 | d. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License. 135 | 136 | e. Sections 1, 5, 6, 7, and 8 survive termination of this Public License. 137 | 138 | Section 7 – Other Terms and Conditions. 139 | 140 | a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed. 141 | 142 | b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License. 143 | 144 | Section 8 – Interpretation. 145 | 146 | a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License. 147 | 148 | b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions. 149 | 150 | c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor. 151 | 152 | d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority. 153 | 154 | Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at creativecommons.org/policies, Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses. 155 | 156 | Creative Commons may be contacted at creativecommons.org. 157 | -------------------------------------------------------------------------------- /LICENSES/MIT.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 6 | associated documentation files (the "Software"), to deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all copies or substantial 12 | portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT 15 | LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 16 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE 18 | USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /MAINTAINERSHIP.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | Maintainer Guide 8 | ================ 9 | 10 | Publish a New Version 11 | --------------------- 12 | 1. Update the `LICENSE.md` file, if required. 13 | 2. Prepare a corresponding entry in the `CHANGELOG.md` file (usually by renaming the "Unreleased" section). 14 | 3. Set `` in the `Directory.Build.props` file. 15 | 4. Merge the aforementioned changes via a pull request. 16 | 5. Check if the NuGet key is still valid (see the **Rotate NuGet Publishing Key** section if it isn't). 17 | 6. Push a tag in form of `v`, e.g. `v0.0.1`. GitHub Actions will do the rest (push a NuGet package). 18 | 19 | Rotate NuGet Publishing Key 20 | --------------------------- 21 | CI relies on NuGet API key being added to the secrets. From time to time, this key requires maintenance: it will become obsolete and will have to be updated. 22 | 23 | To update the key: 24 | 25 | 1. Sign in onto nuget.org. 26 | 2. Go to the [API keys][nuget.api-keys] section. 27 | 3. Update the existing or create a new key named `generaptor.github` with a permission to **Push only new package versions** and only allowed to publish the following packages: 28 | - **Generaptor**, 29 | - **Generaptor.Library**. 30 | 4. Paste the generated key to the `NUGET_TOKEN` variable on the [action secrets][github.secrets] section of GitHub settings. 31 | 32 | [github.secrets]: https://github.com/ForNeVeR/Generaptor/settings/secrets/actions 33 | [nuget.api-keys]: https://www.nuget.org/account/apikeys 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | 🦖 Generaptor [![Status Ventis][status-ventis]][andivionian-status-classifier] 8 | ============ 9 | 10 | Generaptor helps you to maintain GitHub actions for your project. It can generate the YAML files, according to the specification defined in your code. 11 | 12 | Now you can manage your action definitions via NuGet packages, and port the whole workflows between repositories. 13 | A bit of strong typing will also help to avoid mistakes! 14 | 15 | NuGet package links: 16 | - [![Generaptor][nuget.badge.generaptor]][nuget.generaptor] 17 | - [![Generaptor.Library][nuget.badge.generaptor-library]][nuget.generaptor-library] 18 | 19 | Showcase 20 | -------- 21 | Consider this F# program (this is actually used in this very repository): 22 | ```fsharp 23 | let mainBranch = "main" 24 | let images = [ 25 | "macos-12" 26 | "ubuntu-22.04" 27 | "windows-2022" 28 | ] 29 | 30 | let workflows = [ 31 | workflow "main" [ 32 | name "Main" 33 | onPushTo mainBranch 34 | onPullRequestTo mainBranch 35 | onSchedule(day = DayOfWeek.Saturday) 36 | onWorkflowDispatch 37 | job "main" [ 38 | checkout 39 | yield! dotNetBuildAndTest() 40 | ] |> addMatrix images 41 | ] 42 | ] 43 | 44 | [] 45 | let main(args: string[]): int = 46 | EntryPoint.Process args workflows 47 | ``` 48 | 49 | (See the actual example with all the imports in [the main program file][example.main].) 50 | 51 | It will generate the following GitHub action configuration: 52 | ```yaml 53 | name: Main 54 | on: 55 | push: 56 | branches: 57 | - main 58 | pull_request: 59 | branches: 60 | - main 61 | schedule: 62 | - cron: 0 0 * * 6 63 | workflow_dispatch: 64 | jobs: 65 | main: 66 | strategy: 67 | matrix: 68 | image: 69 | - macos-12 70 | - ubuntu-22.04 71 | - windows-2022 72 | fail-fast: false 73 | runs-on: ${{ matrix.image }} 74 | env: 75 | DOTNET_CLI_TELEMETRY_OPTOUT: 1 76 | DOTNET_NOLOGO: 1 77 | NUGET_PACKAGES: ${{ github.workspace }}/.github/nuget-packages 78 | steps: 79 | - uses: actions/checkout@v4 80 | - name: Set up .NET SDK 81 | uses: actions/setup-dotnet@v4 82 | with: 83 | dotnet-version: 8.0.x 84 | - name: NuGet cache 85 | uses: actions/cache@v4 86 | with: 87 | key: ${{ runner.os }}.nuget.${{ hashFiles('**/*.fsproj') }} 88 | path: ${{ env.NUGET_PACKAGES }} 89 | - name: Build 90 | run: dotnet build 91 | - name: Test 92 | run: dotnet test 93 | timeout-minutes: 10 94 | ``` 95 | 96 | How to Use 97 | ---------- 98 | We recommend two main modes of execution for Generaptor: from a .NET project and from a script file. 99 | 100 | ### .NET Project 101 | This integration is useful if you already have a solution file, and it's more convenient for you to have your infrastructure in a new project in that solution. Follow this instruction. 102 | 103 | 1. Create a new F# project in your solution. The location doesn't matter, but we recommend calling it `GitHubActions` and put inside the `Infrastructure` solution folder, to not mix it with the main code. 104 | 2. Install the `Generaptor.Library` NuGet package. 105 | 3. Call the `Generaptor.EntryPoint.Process` method with the arguments passed to the `main` function and the list of workflows you want to generate. 106 | 4. Run the program from the repository root folder in your shell, for example: 107 | ```console 108 | $ cd 109 | $ dotnet run --project ./Infrastructure/GitHubActions 110 | ``` 111 | 112 | See the **Command-Line Arguments** section for more details. 113 | 114 | ### Script File 115 | As an alternative execution mode, we also support execution from an F# script file. 116 | 117 | Put your code (see an example below) into an `.fsx` file (say, `github-actions.fsx`), and run it with the following shell command: 118 | 119 | ```console 120 | $ dotnet fsi github-actions.fsx [optional parameters may go here] 121 | ``` 122 | 123 | The script file example: 124 | ```fsharp 125 | #r "nuget: Generaptor.Library, 1.1.0" 126 | open System 127 | 128 | open Generaptor 129 | open Generaptor.GitHubActions 130 | open type Generaptor.GitHubActions.Commands 131 | open type Generaptor.Library.Actions 132 | open type Generaptor.Library.Patterns 133 | 134 | let mainBranch = "main" 135 | let images = [ 136 | "macos-12" 137 | "ubuntu-22.04" 138 | "windows-2022" 139 | ] 140 | 141 | let workflows = [ 142 | workflow "main" [ 143 | name "Main" 144 | onPushTo mainBranch 145 | onPullRequestTo mainBranch 146 | onSchedule(day = DayOfWeek.Saturday) 147 | onWorkflowDispatch 148 | job "main" [ 149 | checkout 150 | yield! dotNetBuildAndTest() 151 | ] |> addMatrix images 152 | ] 153 | ] 154 | 155 | exit <| EntryPoint.Process fsi.CommandLineArgs workflows 156 | ``` 157 | 158 | ### Command-Line Arguments 159 | Generaptor supports the following command-line arguments: 160 | - no arguments or `generate` — (re-)generate the workflow files in the `.github/workflows` folder, relatively to the current directory; 161 | - `verify` — read the current workflows form `.github/workflows` and compare them with the script contents. If any are different, print diagnostic message and exit with non-zero exit code. 162 | - `regenerate [path to fsx file]` — generate the `.fsx` script file from the `.yml` workflows in the repository (the `.github/workflows` folder, relative to the current directory). 163 | 164 | ### Automatic Version Extraction 165 | For cases when you manage your action versions separately (using tools like Dependabot or Renovate), you can set up Generaptor to read the action versions from your YAML definitions. This way, it will read the versions, then regenerate the file, and apply the versions read previously — thus preserving the flow you have with external tools. 166 | 167 | To use it, define steps using the `Auto` notation: 168 | ```fsharp 169 | let workflows = [ 170 | workflow "main" [ 171 | job "main" [ 172 | // Obsolete way, will not auto-update: 173 | // step(uses = "actions/checkout@v4") 174 | // New way, will work well with external update: 175 | step(usesSpec = Auto "actions/checkout") 176 | ] 177 | ] 178 | ] 179 | ``` 180 | 181 | `Auto` notation will try to guess the latest used major action version from the corresponding `.yml` file; failing that, will find the latest used minor version, and failing that — will fetch the latest version from the corresponding action's repository. 182 | 183 | It supports version tags in form of `[v]X[.Y[.Z]]`, where X, Y, and Z are numbers. 184 | 185 | ### Library Features 186 | For basic GitHub Action support (workflow and step DSL), see [the `GitHubActions.fs` file][api.github-actions]. The basic actions are in the main **Generaptor** package. 187 | 188 | For advanced patterns and action commands ready for use, see [Actions][api.library-actions] and [Patterns][api.library-patterns] files. These are in the auxiliary **Generaptor.Library** package. 189 | 190 | Feel free to create your own actions and patterns, and either send a PR to this repository, or publish your own NuGet packages! 191 | 192 | ### GitHub Actions Usage 193 | Example usage to set up script verification on CI: 194 | ```fsharp 195 | job "verify-workflows" [ 196 | runsOn "ubuntu-latest" 197 | 198 | setEnv "DOTNET_CLI_TELEMETRY_OPTOUT" "1" 199 | setEnv "DOTNET_NOLOGO" "1" 200 | setEnv "NUGET_PACKAGES" "${{ github.workspace }}/.github/nuget-packages" 201 | step( 202 | uses = "actions/checkout@v4" 203 | ) 204 | step( 205 | uses = "actions/setup-dotnet@v4" 206 | ) 207 | step( 208 | run = "dotnet fsi ./scripts/github-actions.fsx verify" 209 | ) 210 | ] 211 | ``` 212 | 213 | Versioning Notes 214 | ---------------- 215 | This project's versioning follows the [Semantic Versioning 2.0.0][semver] specification. 216 | 217 | When considering compatible changes, we currently only consider the source compatibility with the user scripts, not binary compatibility. This may be subject to change in the future. 218 | 219 | Documentation 220 | ------------- 221 | - [Changelog][docs.changelog] 222 | - [License (MIT)][docs.license] 223 | - [Contributor Guide][docs.contributing] 224 | - [Maintainer Guide][docs.maintainer-guide] 225 | - [Code of Conduct (adapted from the Contributor Covenant)][docs.code-of-conduct] 226 | 227 | License 228 | ------- 229 | The project is distributed under the terms of [the MIT license][docs.license]. 230 | 231 | The license indication in the project's sources is compliant with the [REUSE specification v3.3][reuse.spec]. 232 | 233 | [andivionian-status-classifier]: https://andivionian.fornever.me/v1/#status-ventis- 234 | [api.github-actions]: ./Generaptor/GitHubActions.fs 235 | [api.library-actions]: ./Generaptor.Library/Actions.fs 236 | [api.library-patterns]: ./Generaptor.Library/Patterns.fs 237 | [docs.changelog]: ./CHANGELOG.md 238 | [docs.code-of-conduct]: ./CODE_OF_CONDUCT.md 239 | [docs.contributing]: CONTRIBUTING.md 240 | [docs.license]: ./LICENSE.md 241 | [docs.maintainer-guide]: ./MAINTAINERSHIP.md 242 | [example.main]: ./Infrastructure/GitHubActions/Program.fs 243 | [nuget.badge.generaptor-library]: https://img.shields.io/nuget/v/Generaptor.Library?label=Generaptor.Library 244 | [nuget.badge.generaptor]: https://img.shields.io/nuget/v/Generaptor?label=Generaptor 245 | [nuget.generaptor-library]: https://www.nuget.org/packages/Generaptor.Library 246 | [nuget.generaptor]: https://www.nuget.org/packages/Generaptor 247 | [semver]: https://semver.org/spec/v2.0.0.html 248 | [status-ventis]: https://img.shields.io/badge/status-ventis-yellow.svg 249 | -------------------------------------------------------------------------------- /REUSE.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | SPDX-PackageName = "Generaptor" 3 | SPDX-PackageSupplier = "Generaptor contributors " 4 | SPDX-PackageDownloadLocation = "https://github.com/ForNeVeR/Generaptor" 5 | 6 | [[annotations]] 7 | path = ".idea/**/**" 8 | precedence = "aggregate" 9 | SPDX-FileCopyrightText = "2024-2025 Friedrich von Never " 10 | SPDX-License-Identifier = "MIT" 11 | 12 | [[annotations]] 13 | path = "**.DotSettings" 14 | precedence = "aggregate" 15 | SPDX-FileCopyrightText = "2024 Friedrich von Never " 16 | SPDX-License-Identifier = "MIT" 17 | 18 | [[annotations]] 19 | path = "Generaptor.Tests/*.verified.fsx" 20 | precedence = "override" 21 | SPDX-FileCopyrightText = "2025 Friedrich von Never " 22 | SPDX-License-Identifier = "MIT" 23 | -------------------------------------------------------------------------------- /Scripts/Get-Version.ps1: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-2025 Friedrich von Never 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | param( 6 | [string] $RefName, 7 | [string] $RepositoryRoot = "$PSScriptRoot/.." 8 | ) 9 | 10 | $ErrorActionPreference = 'Stop' 11 | Set-StrictMode -Version Latest 12 | 13 | Write-Host "Determining version from ref `"$RefName`"…" 14 | if ($RefName -match '^refs/tags/v') { 15 | $version = $RefName -replace '^refs/tags/v', '' 16 | Write-Host "Pushed ref is a version tag, version: $version" 17 | } else { 18 | $propsFilePath = "$RepositoryRoot/Directory.Build.props" 19 | [xml] $props = Get-Content $propsFilePath 20 | foreach ($group in $props.Project.PropertyGroup) { 21 | if ($group.Label -eq 'Packaging') { 22 | $version = $group.Version 23 | break 24 | } 25 | } 26 | Write-Host "Pushed ref is a not version tag, got version from $($propsFilePath): $version" 27 | } 28 | 29 | Write-Output $version 30 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /renovate.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2025 Friedrich von Never 2 | 3 | SPDX-License-Identifier: MIT 4 | --------------------------------------------------------------------------------