Skip to content

Orchestrate Release #28

Orchestrate Release

Orchestrate Release #28

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)