From 0a12ece5f7226c5232c65462cbd077d55732e646 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 17 May 2026 13:14:13 +0200 Subject: [PATCH] Move version calculation to Plan job and ship built artifact (#326) --- .github/workflows/Build-Module.yml | 14 ++- .../workflows/{Get-Settings.yml => Plan.yml} | 53 +++++++++-- .github/workflows/Publish-Module.yml | 13 +-- .github/workflows/workflow.yml | 90 ++++++++++--------- README.md | 35 ++++++-- 5 files changed, 134 insertions(+), 71 deletions(-) rename .github/workflows/{Get-Settings.yml => Plan.yml} (50%) diff --git a/.github/workflows/Build-Module.yml b/.github/workflows/Build-Module.yml index a49da7de..fa59f96a 100644 --- a/.github/workflows/Build-Module.yml +++ b/.github/workflows/Build-Module.yml @@ -12,6 +12,16 @@ on: description: Name of the artifact to upload. required: false default: module + ModuleVersion: + type: string + description: The Major.Minor.Patch version to stamp into the built manifest. Empty falls back to '999.0.0'. + required: false + default: '' + ModulePrerelease: + type: string + description: Optional prerelease tag to stamp into the built manifest. + required: false + default: '' permissions: contents: read # to checkout the repository @@ -30,8 +40,10 @@ jobs: fetch-depth: 0 - name: Build module - uses: PSModule/Build-PSModule@345728124d201f371a8b0f1aacb98f89000a06dc # v4.0.14 + uses: PSModule/Build-PSModule@main with: Name: ${{ fromJson(inputs.Settings).Name }} + Version: ${{ inputs.ModuleVersion }} + Prerelease: ${{ inputs.ModulePrerelease }} ArtifactName: ${{ inputs.ArtifactName }} WorkingDirectory: ${{ fromJson(inputs.Settings).WorkingDirectory }} diff --git a/.github/workflows/Get-Settings.yml b/.github/workflows/Plan.yml similarity index 50% rename from .github/workflows/Get-Settings.yml rename to .github/workflows/Plan.yml index 860fb436..a01754ef 100644 --- a/.github/workflows/Get-Settings.yml +++ b/.github/workflows/Plan.yml @@ -1,4 +1,10 @@ -name: Get-Settings +name: Plan + +# The Plan job is the single decision point for the workflow. +# It runs two steps: +# 1. Get-PSModuleSettings - loads and resolves configuration +# 2. Resolve-PSModuleVersion - calculates the next version from settings + PR labels +# All downstream jobs consume the Settings JSON and the resolved version outputs from this job. on: workflow_call: @@ -45,19 +51,39 @@ on: outputs: Settings: - description: The complete settings object including test suites - value: ${{ jobs.Get-Settings.outputs.Settings }} + description: The complete settings object including test suites. + value: ${{ jobs.Plan.outputs.Settings }} + ModuleVersion: + description: The Major.Minor.Patch part of the next version. + value: ${{ jobs.Plan.outputs.ModuleVersion }} + ModulePrerelease: + description: The prerelease tag, empty string when not a prerelease. + value: ${{ jobs.Plan.outputs.ModulePrerelease }} + ModuleFullVersion: + description: The full version string including prefix and prerelease tag (for example v1.4.0). + value: ${{ jobs.Plan.outputs.ModuleFullVersion }} + ReleaseType: + description: The release type - Release, Prerelease, or None. + value: ${{ jobs.Plan.outputs.ReleaseType }} + CreateRelease: + description: 'true when a release/prerelease should actually be created.' + value: ${{ jobs.Plan.outputs.CreateRelease }} permissions: contents: read # to checkout the repo - pull-requests: write # to add labels to PRs + pull-requests: write # to add labels / comments to PRs jobs: - Get-Settings: - name: Get-Settings + Plan: + name: Plan runs-on: ubuntu-latest outputs: Settings: ${{ steps.Get-Settings.outputs.Settings }} + ModuleVersion: ${{ steps.Resolve-Version.outputs.Version }} + ModulePrerelease: ${{ steps.Resolve-Version.outputs.Prerelease }} + ModuleFullVersion: ${{ steps.Resolve-Version.outputs.FullVersion }} + ReleaseType: ${{ steps.Resolve-Version.outputs.ReleaseType }} + CreateRelease: ${{ steps.Resolve-Version.outputs.CreateRelease }} steps: - name: Checkout Code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -76,3 +102,18 @@ jobs: Version: ${{ inputs.Version }} WorkingDirectory: ${{ inputs.WorkingDirectory }} ImportantFilePatterns: ${{ inputs.ImportantFilePatterns }} + + - name: Resolve-Version + # Resolve only when the workflow is going to create a release/prerelease. + if: fromJson(steps.Get-Settings.outputs.Settings).Publish.Module.ReleaseType != 'None' + uses: PSModule/Resolve-PSModuleVersion@main + id: Resolve-Version + env: + GH_TOKEN: ${{ github.token }} + with: + Settings: ${{ steps.Get-Settings.outputs.Settings }} + Debug: ${{ inputs.Debug }} + Prerelease: ${{ inputs.Prerelease }} + Verbose: ${{ inputs.Verbose }} + Version: ${{ inputs.Version }} + WorkingDirectory: ${{ inputs.WorkingDirectory }} diff --git a/.github/workflows/Publish-Module.yml b/.github/workflows/Publish-Module.yml index 6a67831f..2edc731a 100644 --- a/.github/workflows/Publish-Module.yml +++ b/.github/workflows/Publish-Module.yml @@ -13,7 +13,7 @@ on: required: true permissions: - contents: write # to checkout the repo and create releases + contents: write # to checkout the repo, create releases, and upload release artifacts pull-requests: write # to comment on PRs jobs: @@ -30,7 +30,7 @@ jobs: fetch-depth: 0 - name: Publish module - uses: PSModule/Publish-PSModule@8917aed588dae1bd1aa2873b1caec1c50c20d255 # v2.2.4 + uses: PSModule/Publish-PSModule@main env: GH_TOKEN: ${{ github.token }} with: @@ -39,15 +39,6 @@ jobs: APIKey: ${{ secrets.APIKEY }} WhatIf: ${{ github.repository == 'PSModule/Process-PSModule' }} AutoCleanup: ${{ fromJson(inputs.Settings).Publish.Module.AutoCleanup }} - AutoPatching: ${{ fromJson(inputs.Settings).Publish.Module.AutoPatching }} - DatePrereleaseFormat: ${{ fromJson(inputs.Settings).Publish.Module.DatePrereleaseFormat }} - IgnoreLabels: ${{ fromJson(inputs.Settings).Publish.Module.IgnoreLabels }} - ReleaseType: ${{ fromJson(inputs.Settings).Publish.Module.ReleaseType }} - IncrementalPrerelease: ${{ fromJson(inputs.Settings).Publish.Module.IncrementalPrerelease }} - MajorLabels: ${{ fromJson(inputs.Settings).Publish.Module.MajorLabels }} - MinorLabels: ${{ fromJson(inputs.Settings).Publish.Module.MinorLabels }} - PatchLabels: ${{ fromJson(inputs.Settings).Publish.Module.PatchLabels }} - VersionPrefix: ${{ fromJson(inputs.Settings).Publish.Module.VersionPrefix }} UsePRTitleAsReleaseName: ${{ fromJson(inputs.Settings).Publish.Module.UsePRTitleAsReleaseName }} UsePRBodyAsReleaseNotes: ${{ fromJson(inputs.Settings).Publish.Module.UsePRBodyAsReleaseNotes }} UsePRTitleAsNotesHeading: ${{ fromJson(inputs.Settings).Publish.Module.UsePRTitleAsNotesHeading }} diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 9a4bc6af..9e65a67f 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -82,8 +82,8 @@ jobs: # - ✅ Merged PR - Always runs to load configuration # - ✅ Abandoned PR - Always runs to load configuration # - ✅ Manual run - Always runs to load configuration - Get-Settings: - uses: ./.github/workflows/Get-Settings.yml + Plan: + uses: ./.github/workflows/Plan.yml with: SettingsPath: ${{ inputs.SettingsPath }} Debug: ${{ inputs.Debug }} @@ -99,12 +99,12 @@ jobs: # - ❌ Abandoned PR - No need to lint abandoned changes # - ❌ Manual run - Only runs for PR events Lint-Repository: - if: fromJson(needs.Get-Settings.outputs.Settings).Run.LintRepository + if: fromJson(needs.Plan.outputs.Settings).Run.LintRepository needs: - - Get-Settings + - Plan uses: ./.github/workflows/Lint-Repository.yml with: - Settings: ${{ needs.Get-Settings.outputs.Settings }} + Settings: ${{ needs.Plan.outputs.Settings }} # Runs on: # - ✅ Open/Updated PR - Builds module for testing @@ -112,12 +112,14 @@ jobs: # - ❌ Abandoned PR - Skips building abandoned changes # - ✅ Manual run - Builds module when manually triggered Build-Module: - if: fromJson(needs.Get-Settings.outputs.Settings).Run.BuildModule + if: fromJson(needs.Plan.outputs.Settings).Run.BuildModule uses: ./.github/workflows/Build-Module.yml needs: - - Get-Settings + - Plan with: - Settings: ${{ needs.Get-Settings.outputs.Settings }} + Settings: ${{ needs.Plan.outputs.Settings }} + ModuleVersion: ${{ needs.Plan.outputs.ModuleVersion }} + ModulePrerelease: ${{ needs.Plan.outputs.ModulePrerelease }} # Runs on: # - ✅ Open/Updated PR - Tests source code changes @@ -125,12 +127,12 @@ jobs: # - ❌ Abandoned PR - Skips testing abandoned changes # - ✅ Manual run - Tests source code when manually triggered Test-SourceCode: - if: fromJson(needs.Get-Settings.outputs.Settings).Run.TestSourceCode + if: fromJson(needs.Plan.outputs.Settings).Run.TestSourceCode needs: - - Get-Settings + - Plan uses: ./.github/workflows/Test-SourceCode.yml with: - Settings: ${{ needs.Get-Settings.outputs.Settings }} + Settings: ${{ needs.Plan.outputs.Settings }} # Runs on: # - ✅ Open/Updated PR - Lints source code changes @@ -138,12 +140,12 @@ jobs: # - ❌ Abandoned PR - Skips linting abandoned changes # - ✅ Manual run - Lints source code when manually triggered Lint-SourceCode: - if: fromJson(needs.Get-Settings.outputs.Settings).Run.LintSourceCode + if: fromJson(needs.Plan.outputs.Settings).Run.LintSourceCode needs: - - Get-Settings + - Plan uses: ./.github/workflows/Lint-SourceCode.yml with: - Settings: ${{ needs.Get-Settings.outputs.Settings }} + Settings: ${{ needs.Plan.outputs.Settings }} # Runs on: # - ✅ Open/Updated PR - Tests built module @@ -151,13 +153,13 @@ jobs: # - ❌ Abandoned PR - Skips testing abandoned changes # - ✅ Manual run - Tests built module when manually triggered Test-Module: - if: fromJson(needs.Get-Settings.outputs.Settings).Run.TestModule && needs.Build-Module.result == 'success' && !cancelled() + if: fromJson(needs.Plan.outputs.Settings).Run.TestModule && needs.Build-Module.result == 'success' && !cancelled() needs: - Build-Module - - Get-Settings + - Plan uses: ./.github/workflows/Test-Module.yml with: - Settings: ${{ needs.Get-Settings.outputs.Settings }} + Settings: ${{ needs.Plan.outputs.Settings }} # Runs on: # - ✅ Open/Updated PR - Runs setup scripts before local module tests @@ -165,7 +167,7 @@ jobs: # - ❌ Abandoned PR - Skips setup for abandoned changes # - ✅ Manual run - Runs setup scripts when manually triggered BeforeAll-ModuleLocal: - if: fromJson(needs.Get-Settings.outputs.Settings).Run.BeforeAllModuleLocal && needs.Build-Module.result == 'success' && !cancelled() + if: fromJson(needs.Plan.outputs.Settings).Run.BeforeAllModuleLocal && needs.Build-Module.result == 'success' && !cancelled() uses: ./.github/workflows/BeforeAll-ModuleLocal.yml secrets: TEST_APP_ENT_CLIENT_ID: ${{ secrets.TEST_APP_ENT_CLIENT_ID }} @@ -177,9 +179,9 @@ jobs: TEST_USER_PAT: ${{ secrets.TEST_USER_PAT }} needs: - Build-Module - - Get-Settings + - Plan with: - Settings: ${{ needs.Get-Settings.outputs.Settings }} + Settings: ${{ needs.Plan.outputs.Settings }} # Runs on: # - ✅ Open/Updated PR - Tests module in local environment @@ -187,10 +189,10 @@ jobs: # - ❌ Abandoned PR - Skips testing abandoned changes # - ✅ Manual run - Tests module in local environment when manually triggered Test-ModuleLocal: - if: fromJson(needs.Get-Settings.outputs.Settings).Run.TestModuleLocal && needs.Build-Module.result == 'success' && !cancelled() + if: fromJson(needs.Plan.outputs.Settings).Run.TestModuleLocal && needs.Build-Module.result == 'success' && !cancelled() needs: - Build-Module - - Get-Settings + - Plan - BeforeAll-ModuleLocal uses: ./.github/workflows/Test-ModuleLocal.yml secrets: @@ -202,7 +204,7 @@ jobs: TEST_USER_USER_FG_PAT: ${{ secrets.TEST_USER_USER_FG_PAT }} TEST_USER_PAT: ${{ secrets.TEST_USER_PAT }} with: - Settings: ${{ needs.Get-Settings.outputs.Settings }} + Settings: ${{ needs.Plan.outputs.Settings }} # Runs on: # - ✅ Open/Updated PR - Runs teardown scripts after local module tests @@ -210,7 +212,7 @@ jobs: # - ✅ Abandoned PR - Runs teardown if tests were started (cleanup) # - ✅ Manual run - Runs teardown scripts after local module tests AfterAll-ModuleLocal: - if: fromJson(needs.Get-Settings.outputs.Settings).Run.AfterAllModuleLocal && needs.Test-ModuleLocal.result != 'skipped' && always() + if: fromJson(needs.Plan.outputs.Settings).Run.AfterAllModuleLocal && needs.Test-ModuleLocal.result != 'skipped' && always() uses: ./.github/workflows/AfterAll-ModuleLocal.yml secrets: TEST_APP_ENT_CLIENT_ID: ${{ secrets.TEST_APP_ENT_CLIENT_ID }} @@ -221,10 +223,10 @@ jobs: TEST_USER_USER_FG_PAT: ${{ secrets.TEST_USER_USER_FG_PAT }} TEST_USER_PAT: ${{ secrets.TEST_USER_PAT }} needs: - - Get-Settings + - Plan - Test-ModuleLocal with: - Settings: ${{ needs.Get-Settings.outputs.Settings }} + Settings: ${{ needs.Plan.outputs.Settings }} # Runs on: # - ✅ Open/Updated PR - Collects and reports test results @@ -232,16 +234,16 @@ jobs: # - ❌ Abandoned PR - Skips collecting results for abandoned changes # - ✅ Manual run - Collects and reports test results when manually triggered Get-TestResults: - if: fromJson(needs.Get-Settings.outputs.Settings).Run.GetTestResults && needs.Get-Settings.result == 'success' && always() && !cancelled() + if: fromJson(needs.Plan.outputs.Settings).Run.GetTestResults && needs.Plan.result == 'success' && always() && !cancelled() needs: - - Get-Settings + - Plan - Test-SourceCode - Lint-SourceCode - Test-Module - Test-ModuleLocal uses: ./.github/workflows/Get-TestResults.yml with: - Settings: ${{ needs.Get-Settings.outputs.Settings }} + Settings: ${{ needs.Plan.outputs.Settings }} # Runs on: # - ✅ Open/Updated PR - Calculates and reports code coverage @@ -249,14 +251,14 @@ jobs: # - ❌ Abandoned PR - Skips coverage for abandoned changes # - ✅ Manual run - Calculates and reports code coverage when manually triggered Get-CodeCoverage: - if: fromJson(needs.Get-Settings.outputs.Settings).Run.GetCodeCoverage && needs.Get-Settings.result == 'success' && always() && !cancelled() + if: fromJson(needs.Plan.outputs.Settings).Run.GetCodeCoverage && needs.Plan.result == 'success' && always() && !cancelled() needs: - - Get-Settings + - Plan - Test-Module - Test-ModuleLocal uses: ./.github/workflows/Get-CodeCoverage.yml with: - Settings: ${{ needs.Get-Settings.outputs.Settings }} + Settings: ${{ needs.Plan.outputs.Settings }} # Runs on: # - ✅ Open/Updated PR - Only with prerelease label: publishes prerelease version @@ -264,17 +266,17 @@ jobs: # - ✅ Abandoned PR - Cleans up prereleases for the abandoned branch (no version published) # - ❌ Manual run - Only runs for PR events Publish-Module: - if: fromJson(needs.Get-Settings.outputs.Settings).Run.PublishModule && needs.Get-Settings.result == 'success' && !cancelled() && (needs.Get-TestResults.result == 'success' || needs.Get-TestResults.result == 'skipped') && (needs.Get-CodeCoverage.result == 'success' || needs.Get-CodeCoverage.result == 'skipped') && (needs.Build-Site.result == 'success' || needs.Build-Site.result == 'skipped') + if: fromJson(needs.Plan.outputs.Settings).Run.PublishModule && needs.Plan.result == 'success' && !cancelled() && (needs.Get-TestResults.result == 'success' || needs.Get-TestResults.result == 'skipped') && (needs.Get-CodeCoverage.result == 'success' || needs.Get-CodeCoverage.result == 'skipped') && (needs.Build-Site.result == 'success' || needs.Build-Site.result == 'skipped') uses: ./.github/workflows/Publish-Module.yml secrets: APIKey: ${{ secrets.APIKey }} needs: - - Get-Settings + - Plan - Get-TestResults - Get-CodeCoverage - Build-Site with: - Settings: ${{ needs.Get-Settings.outputs.Settings }} + Settings: ${{ needs.Plan.outputs.Settings }} # Runs on: # - ✅ Open/Updated PR - Builds documentation for review @@ -282,13 +284,13 @@ jobs: # - ❌ Abandoned PR - Skips building docs for abandoned changes # - ✅ Manual run - Builds documentation when manually triggered Build-Docs: - if: fromJson(needs.Get-Settings.outputs.Settings).Run.BuildDocs + if: fromJson(needs.Plan.outputs.Settings).Run.BuildDocs needs: - - Get-Settings + - Plan - Build-Module uses: ./.github/workflows/Build-Docs.yml with: - Settings: ${{ needs.Get-Settings.outputs.Settings }} + Settings: ${{ needs.Plan.outputs.Settings }} # Runs on: # - ✅ Open/Updated PR - Builds site for preview @@ -296,13 +298,13 @@ jobs: # - ❌ Abandoned PR - Skips building site for abandoned changes # - ✅ Manual run - Builds site when manually triggered Build-Site: - if: fromJson(needs.Get-Settings.outputs.Settings).Run.BuildSite + if: fromJson(needs.Plan.outputs.Settings).Run.BuildSite needs: - - Get-Settings + - Plan - Build-Docs uses: ./.github/workflows/Build-Site.yml with: - Settings: ${{ needs.Get-Settings.outputs.Settings }} + Settings: ${{ needs.Plan.outputs.Settings }} # Runs on: # - ❌ Open/Updated PR - Site not published for PRs in progress @@ -310,12 +312,12 @@ jobs: # - ❌ Abandoned PR - Site not published for abandoned changes # - ❌ Manual run - Only publishes on merged PRs to default branch Publish-Site: - if: fromJson(needs.Get-Settings.outputs.Settings).Run.PublishSite && needs.Get-TestResults.result == 'success' && needs.Get-CodeCoverage.result == 'success' && needs.Build-Site.result == 'success' && !cancelled() + if: fromJson(needs.Plan.outputs.Settings).Run.PublishSite && needs.Get-TestResults.result == 'success' && needs.Get-CodeCoverage.result == 'success' && needs.Build-Site.result == 'success' && !cancelled() uses: ./.github/workflows/Publish-Site.yml needs: - - Get-Settings + - Plan - Get-TestResults - Get-CodeCoverage - Build-Site with: - Settings: ${{ needs.Get-Settings.outputs.Settings }} + Settings: ${{ needs.Plan.outputs.Settings }} diff --git a/README.md b/README.md index c74caadb..f14103e4 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,9 @@ Depending on the labels in the pull requests, the [workflow will result in diffe - [How to get started](#how-to-get-started) - [How it works](#how-it-works) - [Workflow overview](#workflow-overview) - - [Get-Settings](#get-settings) + - [Plan](#plan) - [Lint-Repository](#lint-repository) - - [Get settings](#get-settings-1) + - [Plan job](#plan-job) - [Build module](#build-module) - [Test source code](#test-source-code) - [Lint source code](#lint-source-code) @@ -106,20 +106,35 @@ Depending on the labels in the pull requests, the [workflow will result in diffe - [Colocation of concerns](#colocation-of-concerns) - [Compatibility](#compatibility) -### Get-Settings +### Plan -[workflow](./.github/workflows/Get-Settings.yml) +[workflow](./.github/workflows/Plan.yml) + +The Plan job is the single decision point of the workflow. It runs two steps in sequence: + +1. **Get-PSModuleSettings** — loads the settings file (`.github/PSModule.yml`) and emits a fully resolved + `Settings` JSON object that every downstream job consumes. +2. **Resolve-PSModuleVersion** — calculates the next module version from the resolved settings and the + labels on the current pull request. Emits `ModuleVersion`, `ModulePrerelease`, `ModuleFullVersion`, + `ReleaseType`, and `CreateRelease` as job outputs. + +The resolved version is passed into `Build-Module` so the manifest is stamped with the final version **before** +the test stages run. The same artifact is then published unchanged by `Publish-Module`, which also uploads the +zipped module as a GitHub Release asset. The bytes that are tested are the bytes that ship to the PowerShell +Gallery and to GitHub Releases. ### Lint-Repository [workflow](./.github/workflows/Lint-Repository.yml) -### Get settings +### Plan job -[workflow](#get-settings) -- Reads the settings file `github/PSModule.yml` in the module repository to configure the workflow. +[workflow](#plan) +- Reads the settings file `.github/PSModule.yml` in the module repository to configure the workflow. - Gathers context for the process from GitHub and the repo files, configuring what tests to run, if and what kind of release to create, and whether to setup testing infrastructure and what operating systems to run the tests on. +- Calculates the next module version from PR labels and existing releases, then publishes it as job outputs so Build-Module can stamp the manifest + before the artifact is tested. ### Build module @@ -317,6 +332,8 @@ The [PSModule - Module tests](./scripts/tests/Module/PSModule/PSModule.Tests.ps1 [workflow](./.github/workflows/Publish-Module.yml) - Publishes the module to the PowerShell Gallery. - Creates a release on the GitHub repository. +- Attaches the built module as a `.zip` asset on the GitHub Release so consumers can download the exact bytes + that were tested and pushed to the PowerShell Gallery. - **Abandoned PR cleanup**: When a PR is closed without merging (abandoned), the workflow automatically cleans up any prerelease versions and tags that were created for that PR. This ensures that abandoned work doesn't leave orphaned prereleases in the PowerShell Gallery or repository. This behavior is controlled by the `Publish.Module.AutoCleanup` @@ -430,7 +447,7 @@ This table shows when each job runs based on the trigger scenario: | Job | Open/Updated PR | Merged PR | Abandoned PR | Manual Run | | ------------------------- | --------------- | ---------- | ------------ | ---------- | -| **Get-Settings** | ✅ Always | ✅ Always | ✅ Always | ✅ Always | +| **Plan** | ✅ Always | ✅ Always | ✅ Always | ✅ Always | | **Lint-Repository** | ✅ Yes | ❌ No | ❌ No | ❌ No | | **Build-Module** | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes | | **Build-Docs** | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes | @@ -677,7 +694,7 @@ Test: ### Example 2 - Rapid testing -This example ends up running Get-Settings, Build-Module and Test-Module (tests from the module repo) on **ubuntu-latest** only. +This example ends up running Plan, Build-Module and Test-Module (tests from the module repo) on **ubuntu-latest** only. ```yaml Test: