Orchestrate Release #28
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Orchestrate Release | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| lib_version: | |
| description: 'Library version (e.g. 1.5.0 or 1.5.0-beta.1)' | |
| required: true | |
| backend_version: | |
| description: 'Backend version (leave empty to skip)' | |
| required: false | |
| default: '' | |
| console_version: | |
| description: 'PayrollConsole version (leave empty to skip)' | |
| required: false | |
| default: '' | |
| webapp_version: | |
| description: 'WebApp version (leave empty to skip)' | |
| required: false | |
| default: '' | |
| mcpserver_version: | |
| description: 'McpServer version (leave empty to skip)' | |
| required: false | |
| default: '' | |
| update_wiki: | |
| description: 'Update Wiki release notes (deprecated)' | |
| type: boolean | |
| default: false | |
| dry_run: | |
| description: 'Dry run (test pipeline, draft releases)' | |
| type: boolean | |
| default: false | |
| permissions: | |
| contents: write | |
| packages: read | |
| env: | |
| ORG: Payroll-Engine | |
| DOTNET_VERSION: '10.0.x' | |
| jobs: | |
| # ─── Prepare parameters ─────────────────────────────── | |
| prepare: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| lib_version: ${{ steps.resolve.outputs.lib_version }} | |
| lib_prerelease: ${{ steps.resolve.outputs.lib_prerelease }} | |
| build_backend: ${{ steps.resolve.outputs.build_backend }} | |
| build_console: ${{ steps.resolve.outputs.build_console }} | |
| build_webapp: ${{ steps.resolve.outputs.build_webapp }} | |
| build_mcpserver: ${{ steps.resolve.outputs.build_mcpserver }} | |
| backend_prerelease: ${{ steps.resolve.outputs.backend_prerelease }} | |
| console_prerelease: ${{ steps.resolve.outputs.console_prerelease }} | |
| webapp_prerelease: ${{ steps.resolve.outputs.webapp_prerelease }} | |
| mcpserver_prerelease: ${{ steps.resolve.outputs.mcpserver_prerelease }} | |
| any_prerelease: ${{ steps.resolve.outputs.any_prerelease }} | |
| dry_run: ${{ steps.resolve.outputs.dry_run }} | |
| steps: | |
| - name: Resolve parameters | |
| id: resolve | |
| run: | | |
| DRY_RUN="${{ inputs.dry_run }}" | |
| echo "dry_run=${DRY_RUN}" >> $GITHUB_OUTPUT | |
| if [ "${DRY_RUN}" = "true" ]; then | |
| LIB_VERSION="0.0.0-dryrun.${{ github.run_number }}" | |
| echo "⚠️ Dry-run mode: using version ${LIB_VERSION}" | |
| else | |
| LIB_VERSION="${{ inputs.lib_version }}" | |
| fi | |
| echo "lib_version=${LIB_VERSION}" >> $GITHUB_OUTPUT | |
| if [[ "$LIB_VERSION" == *-* ]]; then | |
| echo "lib_prerelease=true" >> $GITHUB_OUTPUT | |
| else | |
| echo "lib_prerelease=false" >> $GITHUB_OUTPUT | |
| fi | |
| ANY_PRE="false" | |
| for APP in backend console webapp mcpserver; do | |
| case $APP in | |
| backend) RAW_VERSION="${{ inputs.backend_version }}" ;; | |
| console) RAW_VERSION="${{ inputs.console_version }}" ;; | |
| webapp) RAW_VERSION="${{ inputs.webapp_version }}" ;; | |
| mcpserver) RAW_VERSION="${{ inputs.mcpserver_version }}" ;; | |
| esac | |
| if [ "${DRY_RUN}" = "true" ]; then | |
| RAW_VERSION="0.0.0-dryrun.${{ github.run_number }}" | |
| fi | |
| if [ -n "${RAW_VERSION}" ]; then | |
| echo "build_${APP}=true" >> $GITHUB_OUTPUT | |
| else | |
| echo "build_${APP}=false" >> $GITHUB_OUTPUT | |
| fi | |
| if [[ "${RAW_VERSION}" == *-* ]]; then | |
| echo "${APP}_prerelease=true" >> $GITHUB_OUTPUT | |
| ANY_PRE="true" | |
| else | |
| echo "${APP}_prerelease=false" >> $GITHUB_OUTPUT | |
| fi | |
| done | |
| if [[ "$LIB_VERSION" == *-* ]]; then | |
| ANY_PRE="true" | |
| fi | |
| echo "any_prerelease=${ANY_PRE}" >> $GITHUB_OUTPUT | |
| - name: Print release plan | |
| run: | | |
| echo "═══════════════════════════════════════════" | |
| echo " Release Plan" | |
| echo "═══════════════════════════════════════════" | |
| echo " Dry run: ${{ steps.resolve.outputs.dry_run }}" | |
| echo " Libraries: ${{ steps.resolve.outputs.lib_version }} (prerelease: ${{ steps.resolve.outputs.lib_prerelease }})" | |
| echo " Backend: ${{ inputs.backend_version || '(skip)' }} (prerelease: ${{ steps.resolve.outputs.backend_prerelease }})" | |
| echo " Console: ${{ inputs.console_version || '(skip)' }} (prerelease: ${{ steps.resolve.outputs.console_prerelease }})" | |
| echo " WebApp: ${{ inputs.webapp_version || '(skip)' }} (prerelease: ${{ steps.resolve.outputs.webapp_prerelease }})" | |
| echo " McpServer: ${{ inputs.mcpserver_version || '(skip)' }} (prerelease: ${{ steps.resolve.outputs.mcpserver_prerelease }})" | |
| echo " Wiki update: ${{ inputs.update_wiki }}" | |
| echo "═══════════════════════════════════════════" | |
| # ═══════════════════════════════════════════════════════ | |
| # VERSION GUARD (upfront check across all repos) | |
| # ═══════════════════════════════════════════════════════ | |
| version-guard: | |
| needs: prepare | |
| if: needs.prepare.outputs.dry_run == 'false' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Check all versions across repos | |
| uses: actions/github-script@v8 | |
| with: | |
| github-token: ${{ secrets.PAT_DISPATCH }} | |
| script: | | |
| const org = '${{ env.ORG }}'; | |
| const libVersion = '${{ needs.prepare.outputs.lib_version }}'; | |
| const libTag = `v${libVersion}`; | |
| const errors = []; | |
| const libRepos = [ | |
| 'PayrollEngine.Core', 'PayrollEngine.Serilog', 'PayrollEngine.Document', | |
| 'PayrollEngine.Client.Core', 'PayrollEngine.Client.Scripting', | |
| 'PayrollEngine.Client.Test', 'PayrollEngine.Client.Services' | |
| ]; | |
| console.log(`\n📦 Checking library version: ${libVersion}\n`); | |
| for (const repo of libRepos) { | |
| try { | |
| await github.rest.git.getRef({ owner: org, repo, ref: `tags/${libTag}` }); | |
| errors.push(`${repo}: Git tag '${libTag}' already exists`); | |
| } catch (e) { if (e.status !== 404) throw e; } | |
| try { | |
| await github.rest.repos.getReleaseByTag({ owner: org, repo, tag: libTag }); | |
| errors.push(`${repo}: GitHub Release '${libTag}' already exists`); | |
| } catch (e) { if (e.status !== 404) throw e; } | |
| try { | |
| const packages = await github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg({ | |
| package_type: 'nuget', package_name: repo.toLowerCase(), org, per_page: 100 | |
| }); | |
| if (packages.data.some(p => p.name === libVersion)) | |
| errors.push(`${repo}: NuGet version '${libVersion}' already exists on GitHub Packages`); | |
| } catch (e) { if (e.status !== 404) console.warn(` ⚠️ ${repo}: ${e.message}`); } | |
| if (!errors.some(e => e.startsWith(repo))) console.log(` ✅ ${repo}`); | |
| } | |
| const apps = [ | |
| { repo: 'PayrollEngine.Backend', version: '${{ inputs.backend_version }}' }, | |
| { repo: 'PayrollEngine.PayrollConsole', version: '${{ inputs.console_version }}' }, | |
| { repo: 'PayrollEngine.WebApp', version: '${{ inputs.webapp_version }}' }, | |
| { repo: 'PayrollEngine.McpServer', version: '${{ inputs.mcpserver_version }}' } | |
| ].filter(a => a.version); | |
| if (apps.length > 0) console.log(`\n🐳 Checking app versions\n`); | |
| for (const { repo, version } of apps) { | |
| const tag = `v${version}`; | |
| try { | |
| await github.rest.git.getRef({ owner: org, repo, ref: `tags/${tag}` }); | |
| errors.push(`${repo}: Git tag '${tag}' already exists`); | |
| } catch (e) { if (e.status !== 404) throw e; } | |
| try { | |
| await github.rest.repos.getReleaseByTag({ owner: org, repo, tag }); | |
| errors.push(`${repo}: GitHub Release '${tag}' already exists`); | |
| } catch (e) { if (e.status !== 404) throw e; } | |
| try { | |
| const packageName = repo.toLowerCase(); | |
| const versions = await github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg({ | |
| package_type: 'container', package_name: packageName, org | |
| }); | |
| if (versions.data.some(v => v.metadata?.container?.tags?.includes(version))) { | |
| errors.push(`${repo}: Docker image tag '${version}' already exists on ghcr.io`); | |
| } | |
| } catch (e) { if (e.status !== 404) console.warn(` ⚠️ ${repo}: ${e.message}`); } | |
| if (!errors.some(e => e.startsWith(repo))) console.log(` ✅ ${repo} v${version}`); | |
| } | |
| console.log(`\n📋 Checking umbrella release\n`); | |
| try { | |
| await github.rest.repos.getReleaseByTag({ owner: org, repo: 'PayrollEngine', tag: libTag }); | |
| errors.push(`PayrollEngine: Umbrella release '${libTag}' already exists`); | |
| } catch (e) { | |
| if (e.status !== 404) throw e; | |
| console.log(` ✅ PayrollEngine umbrella release`); | |
| } | |
| if (errors.length > 0) { | |
| core.setFailed([ | |
| `❌ Version guard failed with ${errors.length} conflict(s):`, | |
| '', ...errors.map(e => ` - ${e}`), '', | |
| 'Please use a new version number or clean up the existing artifacts.' | |
| ].join('\n')); | |
| } else { | |
| console.log('\n✅ All version checks passed — safe to proceed\n'); | |
| } | |
| # ======================================================= | |
| # BREAKING CHANGE GUARD | |
| # ======================================================= | |
| breaking-change-guard: | |
| needs: [prepare, version-guard] | |
| if: | | |
| always() && needs.prepare.outputs.dry_run == 'false' && | |
| needs.prepare.result == 'success' && | |
| (needs.version-guard.result == 'success' || needs.version-guard.result == 'skipped') | |
| runs-on: windows-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| token: ${{ secrets.PAT_DISPATCH }} | |
| fetch-depth: 0 | |
| - name: Checkout API repos | |
| shell: bash | |
| run: | | |
| ORG="${{ env.ORG }}" | |
| for REPO in PayrollEngine.Backend PayrollEngine.Client.Scripting PayrollEngine.Client.Services; do | |
| git clone --depth=2 \ | |
| "https://x-access-token:${{ secrets.PAT_DISPATCH }}@github.com/${ORG}/${REPO}.git" \ | |
| "../${REPO}" | |
| done | |
| - uses: actions/setup-dotnet@v4 | |
| with: | |
| dotnet-version: ${{ env.DOTNET_VERSION }} | |
| - name: Run breaking change detection | |
| shell: pwsh | |
| id: detect | |
| continue-on-error: true | |
| run: | | |
| $reposRoot = Resolve-Path ".." | |
| ./devops/scripts/Update-BreakingChanges.ps1 ` | |
| -ReposRoot $reposRoot ` | |
| -BaselineRef "HEAD~1" ` | |
| -SkipReleaseNotes ` | |
| -ReportPath "breaking-changes.md" | |
| $ec = $LASTEXITCODE | |
| # Capture exit code as step output so the check step can distinguish | |
| # exit 1 (breaking changes) from exit 2 (infrastructure failure). | |
| "exit_code=$ec" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append | |
| exit $ec | |
| - name: Check for undocumented breaking changes | |
| if: steps.detect.outcome == 'failure' | |
| shell: pwsh | |
| run: | | |
| # Exit code 2 = infrastructure failure (build, git, or tool error). | |
| # We cannot trust the detection result — always block. | |
| if ('${{ steps.detect.outputs.exit_code }}' -eq '2') { | |
| Write-Host "::error::Breaking change detection script failed due to an infrastructure error (build, git, or tool failure). Check the breaking-change-report artifact." | |
| exit 1 | |
| } | |
| $notes = Get-Content "RELEASE_NOTES.md" -Raw -ErrorAction SilentlyContinue | |
| if (-not $notes -or $notes -notmatch 'breaking change') { | |
| Write-Host "::error::Breaking changes detected but not documented in RELEASE_NOTES.md" | |
| Write-Host "Run Update-BreakingChanges.ps1 locally to review and document the changes." | |
| exit 1 | |
| } | |
| Write-Host "Breaking changes are documented in RELEASE_NOTES.md" | |
| - name: Upload report | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: breaking-change-report | |
| path: breaking-changes.md | |
| if-no-files-found: ignore | |
| # ═══════════════════════════════════════════════════════ | |
| # WAVE 1: PayrollEngine.Core | |
| # ═══════════════════════════════════════════════════════ | |
| wave-1-core: | |
| needs: [prepare, version-guard, breaking-change-guard] | |
| if: | | |
| always() && needs.prepare.result == 'success' && | |
| (needs.version-guard.result == 'success' || needs.version-guard.result == 'skipped') && | |
| (needs.breaking-change-guard.result == 'success' || needs.breaking-change-guard.result == 'skipped') | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: peter-evans/repository-dispatch@v4 | |
| with: | |
| token: ${{ secrets.PAT_DISPATCH }} | |
| repository: ${{ env.ORG }}/PayrollEngine.Core | |
| event-type: release | |
| client-payload: >- | |
| { | |
| "version": "${{ needs.prepare.outputs.lib_version }}", | |
| "wave": "1", | |
| "is_prerelease": "${{ needs.prepare.outputs.lib_prerelease }}", | |
| "dry_run": "${{ needs.prepare.outputs.dry_run }}" | |
| } | |
| wait-wave-1: | |
| needs: [prepare, wave-1-core] | |
| if: always() && needs.wave-1-core.result == 'success' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| steps: | |
| - name: Wait for Core NuGet package | |
| uses: actions/github-script@v8 | |
| with: | |
| github-token: ${{ secrets.PAT_DISPATCH }} | |
| script: | | |
| const org = '${{ env.ORG }}'; | |
| const version = '${{ needs.prepare.outputs.lib_version }}'; | |
| const maxAttempts = 60; | |
| const delay = 15000; | |
| let found = false; | |
| for (let i = 0; i < maxAttempts && !found; i++) { | |
| try { | |
| const pkgs = await github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg({ | |
| package_type: 'nuget', package_name: 'payrollengine.core', org, | |
| state: 'active', per_page: 100 | |
| }); | |
| found = pkgs.data.some(p => p.name === version); | |
| if (found) console.log(`✅ PayrollEngine.Core ${version} available on GitHub Packages`); | |
| } catch (e) { | |
| if (e.status === 401 || e.status === 403) throw e; | |
| if (e.status !== 404) console.warn(`⚠️ Attempt ${i+1}: ${e.status ?? 'network'} ${e.message}`); | |
| } | |
| if (!found) { | |
| console.log(`⏳ Attempt ${i + 1}/${maxAttempts} – waiting for Core package...`); | |
| await new Promise(r => setTimeout(r, delay)); | |
| } | |
| } | |
| if (!found) throw new Error('❌ Timeout waiting for PayrollEngine.Core NuGet package'); | |
| // Extra propagation buffer | |
| await new Promise(r => setTimeout(r, 10000)); | |
| # ═══════════════════════════════════════════════════════ | |
| # WAVE 2: Serilog, Document, Client.Core (parallel) | |
| # ═══════════════════════════════════════════════════════ | |
| wave-2-serilog: | |
| needs: [prepare, wait-wave-1] | |
| if: always() && needs.wait-wave-1.result == 'success' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: peter-evans/repository-dispatch@v4 | |
| with: | |
| token: ${{ secrets.PAT_DISPATCH }} | |
| repository: ${{ env.ORG }}/PayrollEngine.Serilog | |
| event-type: release | |
| client-payload: >- | |
| { | |
| "version": "${{ needs.prepare.outputs.lib_version }}", | |
| "wave": "2", | |
| "is_prerelease": "${{ needs.prepare.outputs.lib_prerelease }}", | |
| "dry_run": "${{ needs.prepare.outputs.dry_run }}" | |
| } | |
| wave-2-document: | |
| needs: [prepare, wait-wave-1] | |
| if: always() && needs.wait-wave-1.result == 'success' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: peter-evans/repository-dispatch@v4 | |
| with: | |
| token: ${{ secrets.PAT_DISPATCH }} | |
| repository: ${{ env.ORG }}/PayrollEngine.Document | |
| event-type: release | |
| client-payload: >- | |
| { | |
| "version": "${{ needs.prepare.outputs.lib_version }}", | |
| "wave": "2", | |
| "is_prerelease": "${{ needs.prepare.outputs.lib_prerelease }}", | |
| "dry_run": "${{ needs.prepare.outputs.dry_run }}" | |
| } | |
| wave-2-client-core: | |
| needs: [prepare, wait-wave-1] | |
| if: always() && needs.wait-wave-1.result == 'success' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: peter-evans/repository-dispatch@v4 | |
| with: | |
| token: ${{ secrets.PAT_DISPATCH }} | |
| repository: ${{ env.ORG }}/PayrollEngine.Client.Core | |
| event-type: release | |
| client-payload: >- | |
| { | |
| "version": "${{ needs.prepare.outputs.lib_version }}", | |
| "wave": "2", | |
| "is_prerelease": "${{ needs.prepare.outputs.lib_prerelease }}", | |
| "dry_run": "${{ needs.prepare.outputs.dry_run }}" | |
| } | |
| wait-wave-2: | |
| needs: [prepare, wave-2-serilog, wave-2-document, wave-2-client-core] | |
| if: | | |
| always() && | |
| needs.wave-2-serilog.result == 'success' && | |
| needs.wave-2-document.result == 'success' && | |
| needs.wave-2-client-core.result == 'success' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| steps: | |
| - name: Wait for Wave 2 NuGet packages | |
| uses: actions/github-script@v8 | |
| with: | |
| github-token: ${{ secrets.PAT_DISPATCH }} | |
| script: | | |
| const org = '${{ env.ORG }}'; | |
| const version = '${{ needs.prepare.outputs.lib_version }}'; | |
| const repos = ['PayrollEngine.Serilog', 'PayrollEngine.Document', 'PayrollEngine.Client.Core']; | |
| const maxAttempts = 60; | |
| const delay = 15000; | |
| for (const repo of repos) { | |
| let found = false; | |
| for (let i = 0; i < maxAttempts && !found; i++) { | |
| try { | |
| const pkgs = await github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg({ | |
| package_type: 'nuget', package_name: repo.toLowerCase(), org, | |
| state: 'active', per_page: 100 | |
| }); | |
| found = pkgs.data.some(p => p.name === version); | |
| if (found) console.log(`✅ ${repo} ${version} available on GitHub Packages`); | |
| } catch (e) { | |
| if (e.status === 401 || e.status === 403) throw e; | |
| if (e.status !== 404) console.warn(`⚠️ Attempt ${i+1}: ${e.status ?? 'network'} ${e.message}`); | |
| } | |
| if (!found) { | |
| console.log(`⏳ Waiting for ${repo} package... (${i + 1}/${maxAttempts})`); | |
| await new Promise(r => setTimeout(r, delay)); | |
| } | |
| } | |
| if (!found) throw new Error(`❌ Timeout: ${repo}`); | |
| } | |
| await new Promise(r => setTimeout(r, 10000)); | |
| # ═══════════════════════════════════════════════════════ | |
| # WAVE 3: Client.Scripting, Client.Test (parallel) | |
| # ═══════════════════════════════════════════════════════ | |
| wave-3-scripting: | |
| needs: [prepare, wait-wave-2] | |
| if: always() && needs.wait-wave-2.result == 'success' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: peter-evans/repository-dispatch@v4 | |
| with: | |
| token: ${{ secrets.PAT_DISPATCH }} | |
| repository: ${{ env.ORG }}/PayrollEngine.Client.Scripting | |
| event-type: release | |
| client-payload: >- | |
| { | |
| "version": "${{ needs.prepare.outputs.lib_version }}", | |
| "wave": "3", | |
| "is_prerelease": "${{ needs.prepare.outputs.lib_prerelease }}", | |
| "dry_run": "${{ needs.prepare.outputs.dry_run }}" | |
| } | |
| wave-3-test: | |
| needs: [prepare, wait-wave-2] | |
| if: always() && needs.wait-wave-2.result == 'success' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: peter-evans/repository-dispatch@v4 | |
| with: | |
| token: ${{ secrets.PAT_DISPATCH }} | |
| repository: ${{ env.ORG }}/PayrollEngine.Client.Test | |
| event-type: release | |
| client-payload: >- | |
| { | |
| "version": "${{ needs.prepare.outputs.lib_version }}", | |
| "wave": "3", | |
| "is_prerelease": "${{ needs.prepare.outputs.lib_prerelease }}", | |
| "dry_run": "${{ needs.prepare.outputs.dry_run }}" | |
| } | |
| wait-wave-3: | |
| needs: [prepare, wave-3-scripting, wave-3-test] | |
| if: | | |
| always() && | |
| needs.wave-3-scripting.result == 'success' && | |
| needs.wave-3-test.result == 'success' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| steps: | |
| - name: Wait for Wave 3 NuGet packages | |
| uses: actions/github-script@v8 | |
| with: | |
| github-token: ${{ secrets.PAT_DISPATCH }} | |
| script: | | |
| const org = '${{ env.ORG }}'; | |
| const version = '${{ needs.prepare.outputs.lib_version }}'; | |
| const repos = ['PayrollEngine.Client.Scripting', 'PayrollEngine.Client.Test']; | |
| const maxAttempts = 60; | |
| const delay = 15000; | |
| for (const repo of repos) { | |
| let found = false; | |
| for (let i = 0; i < maxAttempts && !found; i++) { | |
| try { | |
| const pkgs = await github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg({ | |
| package_type: 'nuget', package_name: repo.toLowerCase(), org, | |
| state: 'active', per_page: 100 | |
| }); | |
| found = pkgs.data.some(p => p.name === version); | |
| if (found) console.log(`✅ ${repo} ${version} available on GitHub Packages`); | |
| } catch (e) { | |
| if (e.status === 401 || e.status === 403) throw e; | |
| if (e.status !== 404) console.warn(`⚠️ Attempt ${i+1}: ${e.status ?? 'network'} ${e.message}`); | |
| } | |
| if (!found) { | |
| console.log(`⏳ Waiting for ${repo} package... (${i + 1}/${maxAttempts})`); | |
| await new Promise(r => setTimeout(r, delay)); | |
| } | |
| } | |
| if (!found) throw new Error(`❌ Timeout: ${repo}`); | |
| } | |
| await new Promise(r => setTimeout(r, 10000)); | |
| # ═══════════════════════════════════════════════════════ | |
| # WAVE 4: Client.Services | |
| # ═══════════════════════════════════════════════════════ | |
| wave-4-services: | |
| needs: [prepare, wait-wave-3] | |
| if: always() && needs.wait-wave-3.result == 'success' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: peter-evans/repository-dispatch@v4 | |
| with: | |
| token: ${{ secrets.PAT_DISPATCH }} | |
| repository: ${{ env.ORG }}/PayrollEngine.Client.Services | |
| event-type: release | |
| client-payload: >- | |
| { | |
| "version": "${{ needs.prepare.outputs.lib_version }}", | |
| "wave": "4", | |
| "is_prerelease": "${{ needs.prepare.outputs.lib_prerelease }}", | |
| "dry_run": "${{ needs.prepare.outputs.dry_run }}" | |
| } | |
| wait-wave-4: | |
| needs: [prepare, wave-4-services] | |
| if: always() && needs.wave-4-services.result == 'success' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| steps: | |
| - name: Wait for Client.Services NuGet package | |
| uses: actions/github-script@v8 | |
| with: | |
| github-token: ${{ secrets.PAT_DISPATCH }} | |
| script: | | |
| const org = '${{ env.ORG }}'; | |
| const version = '${{ needs.prepare.outputs.lib_version }}'; | |
| const repo = 'PayrollEngine.Client.Services'; | |
| const maxAttempts = 60; | |
| const delay = 15000; | |
| let found = false; | |
| for (let i = 0; i < maxAttempts && !found; i++) { | |
| try { | |
| const pkgs = await github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg({ | |
| package_type: 'nuget', package_name: repo.toLowerCase(), org, | |
| state: 'active', per_page: 100 | |
| }); | |
| found = pkgs.data.some(p => p.name === version); | |
| if (found) console.log(`✅ ${repo} ${version} available on GitHub Packages`); | |
| } catch (e) { | |
| if (e.status === 401 || e.status === 403) throw e; | |
| if (e.status !== 404) console.warn(`⚠️ Attempt ${i+1}: ${e.status ?? 'network'} ${e.message}`); | |
| } | |
| if (!found) { | |
| console.log(`⏳ Attempt ${i + 1}/${maxAttempts}...`); | |
| await new Promise(r => setTimeout(r, delay)); | |
| } | |
| } | |
| if (!found) throw new Error(`❌ Timeout: ${repo} ${version}`); | |
| await new Promise(r => setTimeout(r, 10000)); | |
| # ═══════════════════════════════════════════════════════ | |
| # WAVE 5: Apps – Docker Images (parallel, conditional) | |
| # ═══════════════════════════════════════════════════════ | |
| wave-5-backend: | |
| needs: [prepare, wait-wave-4] | |
| if: always() && needs.wait-wave-4.result == 'success' && needs.prepare.outputs.build_backend == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Resolve app version | |
| id: ver | |
| run: | | |
| if [ "${{ needs.prepare.outputs.dry_run }}" = "true" ]; then | |
| echo "version=0.0.0-dryrun.${{ github.run_number }}" >> $GITHUB_OUTPUT | |
| else | |
| echo "version=${{ inputs.backend_version }}" >> $GITHUB_OUTPUT | |
| fi | |
| - uses: peter-evans/repository-dispatch@v4 | |
| with: | |
| token: ${{ secrets.PAT_DISPATCH }} | |
| repository: ${{ env.ORG }}/PayrollEngine.Backend | |
| event-type: release | |
| client-payload: >- | |
| { | |
| "version": "${{ steps.ver.outputs.version }}", | |
| "is_prerelease": "${{ needs.prepare.outputs.backend_prerelease }}", | |
| "dry_run": "${{ needs.prepare.outputs.dry_run }}" | |
| } | |
| wave-5-console: | |
| needs: [prepare, wait-wave-4] | |
| if: always() && needs.wait-wave-4.result == 'success' && needs.prepare.outputs.build_console == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Resolve app version | |
| id: ver | |
| run: | | |
| if [ "${{ needs.prepare.outputs.dry_run }}" = "true" ]; then | |
| echo "version=0.0.0-dryrun.${{ github.run_number }}" >> $GITHUB_OUTPUT | |
| else | |
| echo "version=${{ inputs.console_version }}" >> $GITHUB_OUTPUT | |
| fi | |
| - uses: peter-evans/repository-dispatch@v4 | |
| with: | |
| token: ${{ secrets.PAT_DISPATCH }} | |
| repository: ${{ env.ORG }}/PayrollEngine.PayrollConsole | |
| event-type: release | |
| client-payload: >- | |
| { | |
| "version": "${{ steps.ver.outputs.version }}", | |
| "is_prerelease": "${{ needs.prepare.outputs.console_prerelease }}", | |
| "dry_run": "${{ needs.prepare.outputs.dry_run }}" | |
| } | |
| wave-5-webapp: | |
| needs: [prepare, wait-wave-4] | |
| if: always() && needs.wait-wave-4.result == 'success' && needs.prepare.outputs.build_webapp == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Resolve app version | |
| id: ver | |
| run: | | |
| if [ "${{ needs.prepare.outputs.dry_run }}" = "true" ]; then | |
| echo "version=0.0.0-dryrun.${{ github.run_number }}" >> $GITHUB_OUTPUT | |
| else | |
| echo "version=${{ inputs.webapp_version }}" >> $GITHUB_OUTPUT | |
| fi | |
| - uses: peter-evans/repository-dispatch@v4 | |
| with: | |
| token: ${{ secrets.PAT_DISPATCH }} | |
| repository: ${{ env.ORG }}/PayrollEngine.WebApp | |
| event-type: release | |
| client-payload: >- | |
| { | |
| "version": "${{ steps.ver.outputs.version }}", | |
| "is_prerelease": "${{ needs.prepare.outputs.webapp_prerelease }}", | |
| "dry_run": "${{ needs.prepare.outputs.dry_run }}" | |
| } | |
| wave-5-mcpserver: | |
| needs: [prepare, wait-wave-4] | |
| if: always() && needs.wait-wave-4.result == 'success' && needs.prepare.outputs.build_mcpserver == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Resolve app version | |
| id: ver | |
| run: | | |
| if [ "${{ needs.prepare.outputs.dry_run }}" = "true" ]; then | |
| echo "version=0.0.0-dryrun.${{ github.run_number }}" >> $GITHUB_OUTPUT | |
| else | |
| echo "version=${{ inputs.mcpserver_version }}" >> $GITHUB_OUTPUT | |
| fi | |
| - uses: peter-evans/repository-dispatch@v4 | |
| with: | |
| token: ${{ secrets.PAT_DISPATCH }} | |
| repository: ${{ env.ORG }}/PayrollEngine.McpServer | |
| event-type: release | |
| client-payload: >- | |
| { | |
| "version": "${{ steps.ver.outputs.version }}", | |
| "is_prerelease": "${{ needs.prepare.outputs.mcpserver_prerelease }}", | |
| "dry_run": "${{ needs.prepare.outputs.dry_run }}" | |
| } | |
| wait-wave-5: | |
| needs: [prepare, wave-5-backend, wave-5-console, wave-5-webapp, wave-5-mcpserver] | |
| if: | | |
| always() && ( | |
| needs.wave-5-backend.result == 'success' || | |
| needs.wave-5-console.result == 'success' || | |
| needs.wave-5-webapp.result == 'success' || | |
| needs.wave-5-mcpserver.result == 'success' | |
| ) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 45 | |
| steps: | |
| - name: Wait for app releases | |
| uses: actions/github-script@v8 | |
| with: | |
| github-token: ${{ secrets.PAT_DISPATCH }} | |
| script: | | |
| if ('${{ needs.prepare.outputs.dry_run }}' === 'true') { | |
| // App releases are drafted (not public) in dry-run; | |
| // create-umbrella-release is skipped anyway. Return immediately. | |
| console.log('⚠️ Dry-run: skipping app release poll'); | |
| return; | |
| } | |
| const org = '${{ env.ORG }}'; | |
| const apps = []; | |
| if ('${{ needs.wave-5-backend.result }}' === 'success') | |
| apps.push({ repo: 'PayrollEngine.Backend', tag: `v${{ inputs.backend_version }}` }); | |
| if ('${{ needs.wave-5-console.result }}' === 'success') | |
| apps.push({ repo: 'PayrollEngine.PayrollConsole', tag: `v${{ inputs.console_version }}` }); | |
| if ('${{ needs.wave-5-webapp.result }}' === 'success') | |
| apps.push({ repo: 'PayrollEngine.WebApp', tag: `v${{ inputs.webapp_version }}` }); | |
| if ('${{ needs.wave-5-mcpserver.result }}' === 'success') | |
| apps.push({ repo: 'PayrollEngine.McpServer', tag: `v${{ inputs.mcpserver_version }}` }); | |
| const maxAttempts = 90; | |
| const delay = 15000; | |
| for (const { repo, tag } of apps) { | |
| let found = false; | |
| for (let i = 0; i < maxAttempts && !found; i++) { | |
| try { | |
| const releases = await github.rest.repos.listReleases({ owner: org, repo, per_page: 10 }); | |
| found = releases.data.some(r => r.tag_name === tag); | |
| if (found) console.log(`✅ ${repo} ${tag} release found`); | |
| } catch (e) { | |
| if (e.status === 401 || e.status === 403) throw e; | |
| if (e.status !== 404) console.warn(`⚠️ Attempt ${i+1}: ${e.status ?? 'network'} ${e.message}`); | |
| } | |
| if (!found) { | |
| console.log(`⏳ Waiting for ${repo} ${tag}... (${i + 1}/${maxAttempts})`); | |
| await new Promise(r => setTimeout(r, delay)); | |
| } | |
| } | |
| if (!found) throw new Error(`❌ Timeout: ${repo} ${tag}`); | |
| } | |
| # ═══════════════════════════════════════════════════════ | |
| # FINALIZE: Umbrella Release | |
| # ═══════════════════════════════════════════════════════ | |
| create-umbrella-release: | |
| needs: [prepare, wait-wave-4, wait-wave-5] | |
| if: always() && needs.wait-wave-4.result == 'success' && needs.prepare.outputs.dry_run == 'false' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Read RELEASE_NOTES.md | |
| id: release_notes | |
| run: | | |
| if [ -f RELEASE_NOTES.md ] && [ -s RELEASE_NOTES.md ]; then | |
| echo "found=true" >> $GITHUB_OUTPUT | |
| { | |
| echo "content<<RELEASE_NOTES_EOF" | |
| cat RELEASE_NOTES.md | |
| echo "" | |
| echo "RELEASE_NOTES_EOF" | |
| } >> $GITHUB_OUTPUT | |
| echo "✅ RELEASE_NOTES.md found ($(wc -l < RELEASE_NOTES.md) lines)" | |
| else | |
| echo "found=false" >> $GITHUB_OUTPUT | |
| echo "content=Release v${{ needs.prepare.outputs.lib_version }}" >> $GITHUB_OUTPUT | |
| echo "⚠️ RELEASE_NOTES.md not found or empty — using fallback" | |
| fi | |
| - name: Generate release body | |
| id: notes | |
| env: | |
| RELEASE_NOTES_CONTENT: ${{ steps.release_notes.outputs.content }} | |
| uses: actions/github-script@v8 | |
| with: | |
| script: | | |
| const libVersion = '${{ needs.prepare.outputs.lib_version }}'; | |
| const libPre = '${{ needs.prepare.outputs.lib_prerelease }}' === 'true'; | |
| let body = ''; | |
| // Passed via env to avoid backtick/`${...}` injection when content | |
| // is interpolated directly into a JS template literal. | |
| const userNotes = process.env.RELEASE_NOTES_CONTENT || ''; | |
| body += userNotes.trim() + '\n'; | |
| body += `\n---\n\n## 📦 NuGet Packages (v${libVersion})\n\n`; | |
| body += `| Package | GitHub Packages | NuGet.org |\n|---------|----------------|----------|\n`; | |
| const libs = [ | |
| 'PayrollEngine.Core', 'PayrollEngine.Serilog', 'PayrollEngine.Document', | |
| 'PayrollEngine.Client.Core', 'PayrollEngine.Client.Scripting', | |
| 'PayrollEngine.Client.Test', 'PayrollEngine.Client.Services' | |
| ]; | |
| for (const lib of libs) { | |
| const ghUrl = `https://github.com/Payroll-Engine/${lib}/packages`; | |
| const nugetUrl = `https://www.nuget.org/packages/${lib}/${libVersion}`; | |
| body += `| ${lib} | [${libVersion}](${ghUrl}) | `; | |
| body += libPre ? '_pending sync_' : `[${libVersion}](${nugetUrl})`; | |
| body += ` |\n`; | |
| } | |
| const apps = [ | |
| { name: 'Backend', version: '${{ inputs.backend_version }}', pre: '${{ needs.prepare.outputs.backend_prerelease }}' }, | |
| { name: 'PayrollConsole', version: '${{ inputs.console_version }}', pre: '${{ needs.prepare.outputs.console_prerelease }}' }, | |
| { name: 'WebApp', version: '${{ inputs.webapp_version }}', pre: '${{ needs.prepare.outputs.webapp_prerelease }}' }, | |
| { name: 'McpServer', version: '${{ inputs.mcpserver_version }}', pre: '${{ needs.prepare.outputs.mcpserver_prerelease }}' } | |
| ].filter(a => a.version); | |
| if (apps.length > 0) { | |
| body += `\n## 🐳 Docker Images (Linux)\n\n| App | Version | Pull Command |\n|-----|---------|-------------|\n`; | |
| for (const app of apps) { | |
| const image = `ghcr.io/payroll-engine/payrollengine.${app.name.toLowerCase()}`; | |
| const preTag = app.pre === 'true' ? ' _(pre-release)_' : ''; | |
| body += `| PayrollEngine.${app.name} | ${app.version}${preTag} | \`docker pull ${image}:${app.version}\` |\n`; | |
| } | |
| } | |
| return body; | |
| result-encoding: string | |
| - name: Create umbrella release | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| tag_name: v${{ needs.prepare.outputs.lib_version }} | |
| name: PayrollEngine v${{ needs.prepare.outputs.lib_version }} | |
| body: ${{ steps.notes.outputs.result }} | |
| prerelease: ${{ needs.prepare.outputs.any_prerelease }} | |
| - name: Download swagger.json from Backend release | |
| if: needs.prepare.outputs.build_backend == 'true' | |
| uses: actions/github-script@v8 | |
| with: | |
| github-token: ${{ secrets.PAT_DISPATCH }} | |
| script: | | |
| const fs = require('fs'); | |
| const org = '${{ env.ORG }}'; | |
| const tag = `v${{ inputs.backend_version }}`; | |
| const releases = await github.rest.repos.listReleases({ | |
| owner: org, repo: 'PayrollEngine.Backend', per_page: 10 | |
| }); | |
| const release = releases.data.find(r => r.tag_name === tag); | |
| if (!release) { console.log(`⚠️ Backend release ${tag} not found`); return; } | |
| const asset = release.assets.find(a => a.name === 'swagger.json'); | |
| if (!asset) { console.log('⚠️ swagger.json not found'); return; } | |
| const response = await github.request( | |
| 'GET /repos/{owner}/{repo}/releases/assets/{asset_id}', | |
| { owner: org, repo: 'PayrollEngine.Backend', asset_id: asset.id, | |
| headers: { accept: 'application/octet-stream' } } | |
| ); | |
| fs.writeFileSync('swagger.json', Buffer.from(response.data)); | |
| console.log(`✅ swagger.json downloaded`); | |
| - name: Check swagger.json presence | |
| id: swagger_check | |
| if: needs.prepare.outputs.build_backend == 'true' && needs.wait-wave-5.result == 'success' | |
| run: | | |
| if [ -f swagger.json ]; then | |
| echo "exists=true" >> $GITHUB_OUTPUT | |
| echo "✅ swagger.json present ($(wc -c < swagger.json) bytes)" | |
| else | |
| echo "exists=false" >> $GITHUB_OUTPUT | |
| echo "⚠️ swagger.json not found — skipping attach" | |
| fi | |
| - name: Attach swagger.json to umbrella release | |
| if: needs.prepare.outputs.build_backend == 'true' && needs.wait-wave-5.result == 'success' && steps.swagger_check.outputs.exists == 'true' | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| tag_name: v${{ needs.prepare.outputs.lib_version }} | |
| files: swagger.json | |
| # ═══════════════════════════════════════════════════════ | |
| # FINALIZE: Wiki Update (deprecated — kept for manual override) | |
| # ═══════════════════════════════════════════════════════ | |
| update-wiki: | |
| needs: [prepare, create-umbrella-release] | |
| if: always() && needs.create-umbrella-release.result == 'success' && inputs.update_wiki == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| path: main | |
| - uses: actions/checkout@v4 | |
| with: | |
| repository: Payroll-Engine/PayrollEngine.wiki | |
| token: ${{ secrets.PAT_DISPATCH }} | |
| path: wiki | |
| - name: Update Releases page | |
| uses: actions/github-script@v8 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const libVersion = '${{ needs.prepare.outputs.lib_version }}'; | |
| const releasesFile = 'wiki/Releases.md'; | |
| const notesFile = 'main/RELEASE_NOTES.md'; | |
| let userNotes = fs.existsSync(notesFile) ? fs.readFileSync(notesFile, 'utf8').trim() : ''; | |
| if (!userNotes) { userNotes = `Release v${libVersion}`; } | |
| const now = new Date(); | |
| const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; | |
| const dateStr = `${months[now.getMonth()]} ${now.getDate()}, ${now.getFullYear()}`; | |
| let entry = `\n## v${libVersion} - ${dateStr}\n` + userNotes + '\n'; | |
| let content = fs.readFileSync(releasesFile, 'utf8'); | |
| const idx = content.search(/\n## v\d/); | |
| content = idx !== -1 ? content.slice(0, idx) + entry + content.slice(idx) : content + entry; | |
| fs.writeFileSync(releasesFile, content); | |
| console.log(`✅ Wiki entry added: ## v${libVersion} - ${dateStr}`); | |
| - name: Push wiki changes | |
| working-directory: wiki | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| git add -A | |
| git diff --cached --quiet || \ | |
| (git commit -m "Release v${{ needs.prepare.outputs.lib_version }}" && git push) |