diff --git a/.claude/skills/qa-tester/regression-test.fsx b/.claude/skills/qa-tester/regression-test.fsx index b649f936..ec17b6c0 100644 --- a/.claude/skills/qa-tester/regression-test.fsx +++ b/.claude/skills/qa-tester/regression-test.fsx @@ -2,7 +2,7 @@ // Full regression test for morphir-dotnet // Usage: dotnet fsi regression-test.fsx -#r "nuget: Spectre.Console, 0.49.1" +#r "nuget: Spectre.Console, 0.53.0" open System open System.IO diff --git a/.claude/skills/qa-tester/smoke-test.fsx b/.claude/skills/qa-tester/smoke-test.fsx index 3aa39cf7..718ba424 100644 --- a/.claude/skills/qa-tester/smoke-test.fsx +++ b/.claude/skills/qa-tester/smoke-test.fsx @@ -2,7 +2,7 @@ // Quick smoke test for morphir-dotnet // Usage: dotnet fsi smoke-test.fsx -#r "nuget: Spectre.Console, 0.49.1" +#r "nuget: Spectre.Console, 0.53.0" open System open System.IO diff --git a/.claude/skills/qa-tester/validate-packages.fsx b/.claude/skills/qa-tester/validate-packages.fsx index 12d75ea2..39332c34 100644 --- a/.claude/skills/qa-tester/validate-packages.fsx +++ b/.claude/skills/qa-tester/validate-packages.fsx @@ -2,7 +2,7 @@ // Validate NuGet package structure and metadata // Usage: dotnet fsi validate-packages.fsx -#r "nuget: Spectre.Console, 0.49.1" +#r "nuget: Spectre.Console, 0.53.0" #r "nuget: System.IO.Compression.ZipFile, 4.3.0" open System diff --git a/.claude/skills/release-manager/README.md b/.claude/skills/release-manager/README.md new file mode 100644 index 00000000..131f4a4c --- /dev/null +++ b/.claude/skills/release-manager/README.md @@ -0,0 +1,1089 @@ +# Release Manager Skill + +Specialized release management skill for the morphir-dotnet project. + +## Overview + +This skill provides comprehensive release lifecycle management capabilities including: +- Release preparation and validation +- Deployment workflow execution and monitoring +- Release verification and QA coordination +- Documentation generation ("What's New", release notes) +- Failed release recovery +- Release playbook maintenance + +## Files + +- **skill.md** - Main skill prompt with release playbooks and guidance +- **README.md** - This file +- **templates/release-tracking.md** - GitHub issue template for tracking releases +- **prepare-release.fsx** - F# script: Pre-release validation and preparation +- **monitor-release.fsx** - F# script: Monitor GitHub Actions deployment workflow +- **validate-release.fsx** - F# script: Post-release verification +- **resume-release.fsx** - F# script: Resume failed releases from checkpoint + +## Quick Start + +### Prerequisites + +- .NET 10 SDK installed +- GitHub CLI (`gh`) installed and authenticated +- Maintainer permissions on morphir-dotnet repository +- Access to trigger GitHub Actions workflows + +### Typical Release Workflow + +```bash +# 1. Prepare release +dotnet fsi .claude/skills/release-manager/prepare-release.fsx + +# 2. Create tracking issue +gh issue create \ + --title "Release v1.0.0" \ + --body-file .claude/skills/release-manager/templates/release-tracking.md \ + --label release,tracking + +# 3. Update CHANGELOG.md (manual or via script) +# Move [Unreleased] β†’ [1.0.0] - 2025-12-18 + +# 4. Trigger deployment +gh workflow run deployment.yml \ + --ref main \ + --field release-version=1.0.0 \ + --field configuration=Release + +# 5. Monitor deployment +dotnet fsi .claude/skills/release-manager/monitor-release.fsx \ + --version 1.0.0 \ + --issue 219 \ + --update-issue + +# 6. Validate release +dotnet fsi .claude/skills/release-manager/validate-release.fsx \ + --version 1.0.0 \ + --smoke-tests \ + --issue 219 + +# 7. Coordinate with QA Tester for verification +# 8. Create "What's New" documentation +# 9. Announce release +# 10. Update release playbook and close tracking issue +``` + +## Automation Scripts + +### prepare-release.fsx + +Pre-flight checks and release preparation automation. + +**Features:** +- Validates remote CI status on main branch +- Parses and validates CHANGELOG.md +- Suggests version based on change types (major/minor/patch) +- Checks NuGet for version availability +- Checks git tags for version conflicts +- Provides local state advisory (non-blocking) +- Generates pre-flight checklist + +**Usage:** +```bash +# Standard usage (human-readable output) +dotnet fsi .claude/skills/release-manager/prepare-release.fsx + +# JSON output for automation +dotnet fsi .claude/skills/release-manager/prepare-release.fsx --json + +# Specify version explicitly +dotnet fsi .claude/skills/release-manager/prepare-release.fsx --version 1.0.0 + +# JSON output with specific version +dotnet fsi .claude/skills/release-manager/prepare-release.fsx --version 1.0.0 --json + +# Dry run mode (no side effects) +dotnet fsi .claude/skills/release-manager/prepare-release.fsx --dry-run + +# Skip local state check entirely +dotnet fsi .claude/skills/release-manager/prepare-release.fsx --skip-local-check +``` + +**IMPORTANT**: All scripts follow CLI logging standards: +- Human-readable output: Logs to stderr, results to stdout +- `--json` flag: Only JSON to stdout, all logs to stderr +- Automation-friendly: `script.fsx --json | jq` works correctly + +**Exit Codes:** +- `0`: Ready to release +- `1`: Not ready (blocking issues) +- `2`: Ready with warnings (non-blocking) + +**JSON Output Format** (`--json`): +```json +{ + "ready": true, + "version": { + "suggested": "1.2.0", + "specified": null, + "type": "minor", + "rationale": "New features added, no breaking changes" + }, + "remoteState": { + "ciPassing": true, + "latestRun": 1234, + "latestCommit": "abc1234", + "commitMessage": "feat: add feature X" + }, + "changelog": { + "hasUnreleased": true, + "changeCount": 12, + "added": 5, + "changed": 3, + "fixed": 4, + "breakingChanges": 0 + }, + "versionValidation": { + "nugetAvailable": true, + "tagAvailable": true, + "conflicts": [] + }, + "localState": { + "branch": "feature/my-feature", + "clean": false, + "modifiedFiles": 3, + "blocking": false + }, + "warnings": [], + "errors": [], + "exitCode": 0 +} +``` + +**Human-Readable Output:** +``` +=== Release Preparation === + +Remote State: +βœ… CI passing on main branch (run #1234) +βœ… Latest commit: abc1234 "feat: add feature X" + +Changelog Analysis: +πŸ“ [Unreleased] section found with 12 changes + - Added: 5 features + - Changed: 3 improvements + - Fixed: 4 bug fixes + +Version Suggestion: +πŸ“Š Suggested version: 1.2.0 (Minor) + Rationale: New features added, no breaking changes + +Version Validation: +βœ… Version 1.2.0 available on NuGet +βœ… Tag v1.2.0 does not exist + +Local State (Advisory): +ℹ️ On branch: feature/my-feature +⚠️ Local changes detected (3 modified files) + You can: + - Stash: git stash save "WIP before release" + - Commit: git add . && git commit -m "WIP" + - Continue anyway (workflow runs on remote main) + +Pre-Flight Checklist: +βœ… Remote CI passing +βœ… Changelog populated +βœ… Version validated +βœ… NuGet availability confirmed +βœ… Git tag available +ℹ️ Local state: not clean (non-blocking) + +Result: Ready to release v1.2.0 +``` + +--- + +### monitor-release.fsx + +**CRITICAL FOR TOKEN EFFICIENCY**: This script automates monitoring and dramatically reduces LLM token usage by handling workflow polling autonomously. + +**Features:** +- Polls GitHub Actions workflow status automatically +- Displays live progress with rich formatting +- Detects completion/failure immediately +- Parses workflow logs for errors +- Updates release tracking issue automatically +- Generates detailed status reports +- Alerts on failures with diagnostics + +**Usage:** +```bash +# Monitor specific version (human-readable, live updates) +dotnet fsi .claude/skills/release-manager/monitor-release.fsx --version 1.0.0 + +# JSON output for automation (status snapshots) +dotnet fsi .claude/skills/release-manager/monitor-release.fsx --version 1.0.0 --json + +# Monitor latest deployment workflow run +dotnet fsi .claude/skills/release-manager/monitor-release.fsx --latest + +# Monitor with tracking issue updates +dotnet fsi .claude/skills/release-manager/monitor-release.fsx \ + --version 1.0.0 \ + --issue 219 \ + --update-issue + +# JSON output with issue updates (for automation pipelines) +dotnet fsi .claude/skills/release-manager/monitor-release.fsx \ + --version 1.0.0 \ + --issue 219 \ + --update-issue \ + --json + +# Custom polling interval (default: 30 seconds) +dotnet fsi .claude/skills/release-manager/monitor-release.fsx \ + --version 1.0.0 \ + --interval 60 + +# Monitor specific workflow run ID +dotnet fsi .claude/skills/release-manager/monitor-release.fsx --run-id 12345678 +``` + +**Note on JSON Mode**: When `--json` is used, the script outputs JSON status snapshots on each poll interval instead of live terminal UI. Logs still go to stderr. + +**Exit Codes:** +- `0`: Workflow completed successfully +- `1`: Workflow failed +- `2`: Workflow cancelled +- `3`: Error monitoring workflow + +**JSON Output Format** (`--json`): +```json +{ + "workflow": { + "name": "Deployment", + "runId": 12345678, + "status": "completed", + "conclusion": "success", + "startedAt": "2025-12-18T14:30:00Z", + "completedAt": "2025-12-18T15:15:32Z", + "duration": "00:45:32" + }, + "stages": [ + { + "name": "Validate Version", + "status": "completed", + "conclusion": "success", + "duration": "00:00:15" + }, + { + "name": "Build Executables", + "status": "completed", + "conclusion": "success", + "duration": "00:35:20", + "jobs": [ + {"name": "linux-x64", "status": "completed", "conclusion": "success", "duration": "00:08:23"}, + {"name": "linux-arm64", "status": "completed", "conclusion": "success", "duration": "00:09:15"}, + {"name": "win-x64", "status": "completed", "conclusion": "success", "duration": "00:07:45"}, + {"name": "osx-arm64", "status": "completed", "conclusion": "success", "duration": "00:10:32"}, + {"name": "osx-x64", "status": "completed", "conclusion": "success", "duration": "00:09:05"} + ] + }, + { + "name": "E2E Tests", + "status": "completed", + "conclusion": "success", + "duration": "00:08:15" + }, + { + "name": "Release", + "status": "completed", + "conclusion": "success", + "duration": "00:01:30" + }, + { + "name": "CD", + "status": "completed", + "conclusion": "success", + "duration": "00:00:12" + } + ], + "trackingIssue": { + "number": 219, + "updated": true, + "lastUpdate": "2025-12-18T15:15:45Z" + }, + "exitCode": 0 +} +``` + +**Human-Readable Output:** +``` +=== Release Monitor v1.0.0 === + +Workflow: Deployment +Run ID: 12345678 +Status: in_progress +Started: 2025-12-18 14:30:00 UTC +Elapsed: 00:15:32 + +╔══════════════════════════════════════════════════════════╗ +β•‘ Stage β”‚ Status β”‚ Duration β•‘ +╠══════════════════════════════════════════════════════════╣ +β•‘ Validate Version β”‚ βœ… Complete β”‚ 00:00:15 β•‘ +β•‘ Build Executables β”‚ ⏳ Running β”‚ 00:12:00 β•‘ +β•‘ linux-x64 β”‚ βœ… Complete β”‚ 00:08:23 β•‘ +β•‘ linux-arm64 β”‚ βœ… Complete β”‚ 00:09:15 β•‘ +β•‘ win-x64 β”‚ βœ… Complete β”‚ 00:07:45 β•‘ +β•‘ osx-arm64 β”‚ ⏳ Running β”‚ 00:10:32 β•‘ +β•‘ osx-x64 β”‚ ⏸️ Queued β”‚ -- β•‘ +β•‘ E2E Tests β”‚ ⏸️ Queued β”‚ -- β•‘ +β•‘ Release β”‚ ⏸️ Queued β”‚ -- β•‘ +β•‘ CD β”‚ ⏸️ Queued β”‚ -- β•‘ +β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• + +Tracking Issue: #219 +Last Updated: 2025-12-18 14:45:00 UTC + +Polling every 30 seconds... Press Ctrl+C to stop. +``` + +**Tracking Issue Integration:** + +When `--update-issue` is provided, the script automatically: +- Checks off completed checklist items +- Adds progress comments every 5 minutes +- Updates failure status with diagnostics +- Attaches workflow logs for failures +- Adds final summary when complete + +--- + +### validate-release.fsx + +Post-release verification and validation automation. + +**Features:** +- Queries NuGet.org API for published packages +- Validates package metadata (version, license, README) +- Tests tool installation from NuGet +- Tests library package references +- Runs basic smoke tests (optional) +- Generates comprehensive verification report +- Updates tracking issue with results + +**Usage:** +```bash +# Validate specific version (human-readable) +dotnet fsi .claude/skills/release-manager/validate-release.fsx --version 1.0.0 + +# JSON output for automation +dotnet fsi .claude/skills/release-manager/validate-release.fsx --version 1.0.0 --json + +# Include smoke tests (runs smoke-test.fsx from QA skill) +dotnet fsi .claude/skills/release-manager/validate-release.fsx \ + --version 1.0.0 \ + --smoke-tests + +# Update tracking issue with results +dotnet fsi .claude/skills/release-manager/validate-release.fsx \ + --version 1.0.0 \ + --issue 219 \ + --update-issue + +# JSON output with all checks (for CI/CD pipelines) +dotnet fsi .claude/skills/release-manager/validate-release.fsx \ + --version 1.0.0 \ + --smoke-tests \ + --json \ + --update-issue +``` + +**Exit Codes:** +- `0`: All validations passed +- `1`: One or more validations failed +- `2`: Warnings (non-critical issues) + +**JSON Output Format** (`--json`): +```json +{ + "version": "1.0.0", + "valid": true, + "packages": [ + { + "name": "Morphir.Core", + "version": "1.0.0", + "available": true, + "publishedAt": "2025-12-18T15:00:00Z", + "metadata": { + "hasLicense": true, + "hasReadme": true, + "repositoryUrl": "https://github.com/finos/morphir-dotnet", + "projectUrl": "https://morphir.finos.org" + } + }, + { + "name": "Morphir.Tooling", + "version": "1.0.0", + "available": true, + "publishedAt": "2025-12-18T15:00:05Z", + "metadata": { + "hasLicense": true, + "hasReadme": true, + "repositoryUrl": "https://github.com/finos/morphir-dotnet", + "projectUrl": "https://morphir.finos.org" + } + }, + { + "name": "Morphir", + "version": "1.0.0", + "available": true, + "publishedAt": "2025-12-18T15:00:10Z", + "metadata": { + "hasLicense": true, + "hasReadme": true, + "repositoryUrl": "https://github.com/finos/morphir-dotnet", + "projectUrl": "https://morphir.finos.org" + } + }, + { + "name": "Morphir.Tool", + "version": "1.0.0", + "available": true, + "publishedAt": "2025-12-18T15:00:15Z", + "metadata": { + "hasLicense": true, + "hasReadme": true, + "repositoryUrl": "https://github.com/finos/morphir-dotnet", + "projectUrl": "https://morphir.finos.org" + } + } + ], + "installation": { + "toolInstalled": true, + "toolVersion": "1.0.0", + "librariesReferenced": true + }, + "smokeTests": { + "run": true, + "passed": true, + "results": { + "buildSucceeded": true, + "testsPassed": true, + "packagingSucceeded": true, + "packageCountCorrect": true + } + }, + "trackingIssue": { + "number": 219, + "updated": true + }, + "summary": { + "totalChecks": 16, + "passed": 16, + "failed": 0, + "warnings": 0 + }, + "duration": "00:03:45", + "exitCode": 0 +} +``` + +**Human-Readable Output:** +``` +=== Release Validation v1.0.0 === + +Package Availability: +βœ… Morphir.Core 1.0.0 (published 2025-12-18 15:00:00 UTC) +βœ… Morphir.Tooling 1.0.0 (published 2025-12-18 15:00:05 UTC) +βœ… Morphir 1.0.0 (published 2025-12-18 15:00:10 UTC) +βœ… Morphir.Tool 1.0.0 (published 2025-12-18 15:00:15 UTC) + +Package Metadata: +βœ… All packages have LICENSE.md +βœ… All packages have README.md +βœ… All packages have correct version +βœ… Repository URL correct +βœ… Project URL correct + +Installation Tests: +βœ… Tool installs successfully + Command: dotnet tool install -g Morphir.Tool --version 1.0.0 +βœ… Tool executes correctly + Output: Morphir.Tool 1.0.0 +βœ… Libraries can be referenced + - Morphir.Core: OK + - Morphir.Tooling: OK + +Smoke Tests: +βœ… Build succeeds +βœ… Tests pass +βœ… Packaging succeeds +βœ… Package count correct (4 packages) + +Verification Summary: +βœ… All checks passed (16/16) +⏱️ Validation completed in 00:03:45 + +Tracking Issue: #219 +Result: Release v1.0.0 validated successfully +``` + +--- + +### resume-release.fsx + +Resume failed releases from checkpoint. + +**Features:** +- Reads release tracking issue for context +- Identifies last successful workflow step +- Validates prerequisites for resume +- Interactive confirmation prompts +- Re-triggers workflow from appropriate point +- Updates tracking issue with resume details +- Dry-run mode for planning + +**Usage:** +```bash +# Resume from tracking issue (reads issue for context) +dotnet fsi .claude/skills/release-manager/resume-release.fsx --issue 219 + +# JSON output for automation +dotnet fsi .claude/skills/release-manager/resume-release.fsx --issue 219 --json + +# Resume specific version with issue reference +dotnet fsi .claude/skills/release-manager/resume-release.fsx \ + --version 1.0.0 \ + --issue 219 + +# Dry run (show what would be done without executing) +dotnet fsi .claude/skills/release-manager/resume-release.fsx \ + --issue 219 \ + --dry-run + +# JSON output for dry run analysis +dotnet fsi .claude/skills/release-manager/resume-release.fsx \ + --issue 219 \ + --dry-run \ + --json + +# Force resume (skip confirmation prompts - use with caution) +dotnet fsi .claude/skills/release-manager/resume-release.fsx \ + --issue 219 \ + --force +``` + +**Exit Codes:** +- `0`: Successfully resumed +- `1`: Cannot resume (fix required) +- `2`: User aborted +- `3`: Error analyzing failure + +**JSON Output Format** (`--json`): +```json +{ + "version": "1.0.0", + "trackingIssue": 219, + "resumable": true, + "failure": { + "stage": "Build Executables", + "job": "osx-arm64", + "failedAt": "2025-12-18T14:45:23Z", + "error": "Build timeout after 30 minutes", + "logs": "MSBuild timeout during restore\nNetwork latency to NuGet.org detected", + "type": "transient", + "requiresCodeFix": false + }, + "lastSuccessful": { + "stage": "Build Executables", + "job": "win-x64", + "completedAt": "2025-12-18T14:40:15Z" + }, + "resumePlan": { + "action": "re-trigger-workflow", + "estimatedTime": "00:15:00", + "willReuse": ["linux-x64", "linux-arm64", "win-x64"], + "willRetry": ["osx-arm64", "osx-x64"] + }, + "prerequisites": { + "trackingIssueAccessible": true, + "originalWorkflowFound": true, + "githubCliAuthenticated": true, + "workflowPermissionsVerified": true, + "allPassed": true + }, + "dryRun": false, + "resumed": true, + "newWorkflowRun": { + "id": 12345679, + "url": "https://github.com/finos/morphir-dotnet/actions/runs/12345679" + }, + "exitCode": 0 +} +``` + +**Human-Readable Output:** +``` +=== Resume Release v1.0.0 === + +Reading tracking issue #219... +βœ… Issue found: "Release v1.0.0" +βœ… Version: 1.0.0 +βœ… Original workflow run: #12345678 + +Failure Analysis: +❌ Failed at: Build Executables (osx-arm64) +πŸ“‹ Last successful: win-x64 build completed +πŸ•’ Failed at: 2025-12-18 14:45:23 UTC +πŸ“ Error: Build timeout after 30 minutes + +Failure Logs: +> MSBuild timeout during restore +> Network latency to NuGet.org detected + +Resumability Assessment: +βœ… Transient failure (infrastructure) +βœ… No code changes required +βœ… Can retry from failed step + +Resume Plan: +1. Re-trigger deployment workflow +2. GitHub Actions will use cached artifacts from successful steps +3. Only failed jobs will re-run +4. Estimated time: ~15 minutes (vs 45 minutes full run) + +Prerequisites Check: +βœ… Tracking issue accessible +βœ… Original workflow found +βœ… GitHub CLI authenticated +βœ… Workflow permissions verified + +⚠️ Confirm Resume? + Version: 1.0.0 + Issue: #219 + Action: Re-trigger deployment workflow + +[Y/n]: y + +β–Ά Triggering workflow... +βœ… Workflow triggered (run #12345679) + +β–Ά Updating tracking issue... +βœ… Added resume comment to #219 +βœ… Marked resume in progress + +Next Steps: +1. Monitor workflow: + dotnet fsi .claude/skills/release-manager/monitor-release.fsx \ + --run-id 12345679 \ + --issue 219 \ + --update-issue + +2. Or watch manually: + gh run watch 12345679 +``` + +--- + +## Using the Release Manager Skill + +### Invoke in Claude Code + +``` +@skill release-manager +I want to create a new release for version 1.0.0 +``` + +or + +``` +@skill release-manager +The deployment workflow failed for v1.0.0. Help me resume it. +``` + +or + +``` +@skill release-manager +Prepare a release with the changes currently in the Unreleased section +``` + +### Trigger Words + +The skill automatically activates when the user mentions: +- "release" +- "deploy" +- "publish" +- "changelog" +- "version" +- "release notes" +- "what's new" + +### Example Interactions + +**1. Standard Release:** +``` +User: Let's create a new release +Agent: I'll help you prepare a new release. Let me run pre-flight checks first. + +[Runs prepare-release.fsx] + +Based on the changelog analysis, I suggest version 1.2.0 (minor release). +The unreleased section contains 5 new features and 4 bug fixes. + +Shall I proceed with: +1. Creating a release tracking issue +2. Updating CHANGELOG.md to version 1.2.0 +3. Triggering the deployment workflow + +Would you like to proceed with v1.2.0 or specify a different version? +``` + +**2. Failed Release Recovery:** +``` +User: The release failed. Issue #219 +Agent: Let me analyze the failure and determine if we can resume. + +[Runs resume-release.fsx with --dry-run] + +I've analyzed the failure in issue #219. The deployment failed during the +osx-arm64 build due to a network timeout (transient infrastructure issue). + +This is resumable! The failure doesn't require code changes. + +I can re-trigger the workflow which will: +- Reuse successful builds (linux-x64, linux-arm64, win-x64) +- Only retry the failed osx-arm64 and pending osx-x64 builds +- Save ~25 minutes compared to starting over + +Shall I resume the release? +``` + +**3. Pre-release (Alpha):** +``` +User: Create an alpha release for testing +Agent: I'll prepare an alpha release. Let me check the current state. + +[Runs prepare-release.fsx] + +Based on the changelog, I suggest 2.0.0-alpha.1 as this contains +breaking changes that will be part of version 2.0.0. + +For an alpha release, I'll: +1. Mark the CHANGELOG entry as pre-release +2. Set the GitHub release as pre-release +3. Document known issues and testing requirements + +Shall I proceed with v2.0.0-alpha.1? +``` + +--- + +## Integration with QA Tester + +The Release Manager coordinates closely with the QA Tester skill: + +**Handoff After Package Publishing:** + +``` +@skill qa-tester + +Release v1.0.0 ready for verification. + +**Packages:** +- Morphir.Core v1.0.0 +- Morphir.Tooling v1.0.0 +- Morphir v1.0.0 +- Morphir.Tool v1.0.0 + +**Test Plan:** +1. Run smoke-test.fsx +2. Test tool installation: dotnet tool install -g Morphir.Tool --version 1.0.0 +3. Verify key commands work +4. Check for regressions + +**Tracking Issue:** #219 + +Please update tracking issue with results. +``` + +**QA Response Integration:** + +QA Tester adds results to tracking issue, Release Manager: +1. Reviews QA findings +2. Addresses any issues found +3. Requires QA sign-off before closing release +4. Updates release documentation with any notes + +--- + +## Best Practices + +### 1. Always Use Tracking Issues +- Create tracking issue at start of release +- Update throughout process +- Provides audit trail +- Enables resumption if failure +- Documents learnings + +### 2. Automate Monitoring +- Always use `monitor-release.fsx` +- Reduces token usage dramatically +- Catches failures immediately +- Frees you for other work +- Automatic issue updates + +### 3. Flexible with Local State +- Workflow runs on remote GitHub Actions +- Local changes usually don't interfere +- Stash if concerned, but not required +- Use `--ref main` to ensure remote execution + +### 4. Document Everything +- Update tracking issue continuously +- Record all issues encountered +- Document solutions applied +- Update playbook with learnings +- Help future releases + +### 5. Coordinate with QA +- Don't skip verification +- Give QA time to test thoroughly +- Address all QA findings +- Get explicit sign-off +- Close release only after QA approval + +### 6. Keep Playbook Current +- Update after every release +- Add new failure scenarios +- Document process improvements +- Share with team +- Continuous improvement + +--- + +## Troubleshooting + +### Script Dependencies + +All scripts use **Spectre.Console** for rich terminal output. Dependencies are automatically downloaded by F# Interactive. + +If you encounter package errors: + +```bash +# Option 1: Let F# Interactive handle it (usually automatic) +dotnet fsi .claude/skills/release-manager/prepare-release.fsx + +# Option 2: Pre-restore packages +dotnet tool restore +dotnet restore + +# Option 3: Clear NuGet cache and retry +dotnet nuget locals all --clear +dotnet fsi .claude/skills/release-manager/prepare-release.fsx +``` + +### GitHub CLI Authentication + +Scripts require authenticated GitHub CLI: + +```bash +# Check auth status +gh auth status + +# Login if needed +gh auth login + +# Verify permissions +gh auth refresh -h github.com -s write:packages,workflow +``` + +### Workflow Permissions + +If workflow won't trigger: + +1. Check branch protection rules +2. Verify GH_TOKEN has workflow permissions +3. Check repository settings β†’ Actions β†’ General +4. Ensure workflow file syntax is valid + +### Common Issues + +| Issue | Solution | +|-------|----------| +| "Cannot find workflow run" | Wait a few seconds after triggering, workflow takes time to appear | +| "Version already on NuGet" | Increment version or contact NuGet support to unlist | +| "E2E tests fail" | Check platform-specific logs, may need code fix | +| "NuGet publish timeout" | Check NuGet.org status, retry if transient | +| "Script hangs" | Check network connection, GitHub API may be slow | + +--- + +## CLI Logging Standards and JSON Output + +**CRITICAL**: All release manager scripts follow the morphir-dotnet CLI logging standards: + +### Output Separation + +1. **Human-Readable Mode** (default): + - **stdout**: Final results, summaries, actionable output + - **stderr**: Progress logs, diagnostics, informational messages + - User sees both in terminal, but can redirect separately + +2. **JSON Mode** (`--json` flag): + - **stdout**: ONLY valid JSON (nothing else) + - **stderr**: All logs, progress, diagnostics + - Enables: `script.fsx --json | jq` + - Enables: `script.fsx --json > result.json` (clean JSON file) + +### Why This Matters + +```bash +# βœ… GOOD: JSON mode allows clean piping +dotnet fsi prepare-release.fsx --json | jq '.version.suggested' +# Output: "1.2.0" + +# ❌ BAD: If logs leaked to stdout +dotnet fsi prepare-release.fsx --json | jq '.version.suggested' +# Output: parse error (logs mixed with JSON) +``` + +### Implementation Pattern + +All scripts use this pattern: + +```fsharp +// Parse command line +let jsonOutput = args |> Array.contains "--json" + +// Configure output functions +let logInfo msg = + if not jsonOutput then + eprintfn "[INFO] %s" msg // Always to stderr + +let logError msg = + eprintfn "[ERROR] %s" msg // Always to stderr + +let output result = + if jsonOutput then + // ONLY JSON to stdout + let json = JsonSerializer.Serialize(result) + printfn "%s" json + else + // Human-readable to stdout + printfn "=== Results ===" + // ... format nicely +``` + +### Testing JSON Output + +Before committing any script, verify: + +```bash +# Test 1: JSON is valid +dotnet fsi script.fsx --json | jq . > /dev/null +# Should succeed with no errors + +# Test 2: No log contamination +dotnet fsi script.fsx --json 2>/dev/null | jq . +# Should output only JSON + +# Test 3: Logs still appear on stderr +dotnet fsi script.fsx --json 2>&1 > /dev/null | grep "\[INFO\]" +# Should show log messages +``` + +### JSON Schema Consistency + +All scripts should include `exitCode` in JSON output: + +```json +{ + "exitCode": 0, + "...": "other fields" +} +``` + +This allows consumers to verify success without checking shell exit code. + +--- + +## Dependencies + +### Required +- .NET 10 SDK +- GitHub CLI (`gh`) authenticated +- Network access to GitHub API and NuGet.org +- Repository maintainer permissions + +### Optional +- QA Tester skill (for verification handoff) +- Spectre.Console (auto-downloaded by scripts) + +--- + +## File Structure + +``` +.claude/skills/release-manager/ +β”œβ”€β”€ skill.md # Main skill prompt +β”œβ”€β”€ README.md # This file +β”œβ”€β”€ prepare-release.fsx # Pre-flight checks (~200 lines) +β”œβ”€β”€ monitor-release.fsx # Workflow monitoring (~400 lines) +β”œβ”€β”€ validate-release.fsx # Post-release validation (~300 lines) +β”œβ”€β”€ resume-release.fsx # Resume failed releases (~250 lines) +└── templates/ + └── release-tracking.md # GitHub issue template +``` + +--- + +## Metrics + +Track these metrics for continuous improvement: + +- **Time to Release**: Target < 90 minutes for standard release +- **Failed Releases**: Target < 5% failure rate +- **Manual Interventions**: Target 0 (fully automated) +- **Documentation Completeness**: Target 100% (all releases documented) +- **QA Issues Post-Release**: Target 0 (catch before release) + +--- + +## Future Enhancements + +Planned improvements: + +1. **Automated Changelog Generation**: Parse commits for auto-changelog +2. **Rollback Automation**: Quick rollback script for critical issues +3. **Multi-Environment Support**: Dev, staging, production releases +4. **Release Analytics**: Dashboard of release metrics +5. **Slack/Discord Notifications**: Alert channels on release events +6. **Automated Documentation**: Generate docs from code comments + +--- + +## References + +- **Skill Prompt**: [skill.md](./skill.md) +- **QA Tester Skill**: [../qa-tester/](../qa-tester/) +- **Deployment Workflow**: [.github/workflows/deployment.yml](../../../.github/workflows/deployment.yml) +- **AGENTS.md**: [../../../AGENTS.md](../../../AGENTS.md) +- **Keep a Changelog**: https://keepachangelog.com/ +- **Semantic Versioning**: https://semver.org/ +- **GitHub CLI**: https://cli.github.com/manual/ + +--- + +## Contributing + +To improve this skill: + +1. Test releases and document issues +2. Update scripts with improvements +3. Add new automation as needed +4. Keep playbook current +5. Share learnings with team +6. Submit PRs with enhancements + +--- + +**Remember**: Releases are critical touchpoints with users. Automate what you can, monitor actively, coordinate with QA, document everything, and continuously improve the process. diff --git a/.claude/skills/release-manager/monitor-release.fsx b/.claude/skills/release-manager/monitor-release.fsx new file mode 100644 index 00000000..b7d67fe3 --- /dev/null +++ b/.claude/skills/release-manager/monitor-release.fsx @@ -0,0 +1,567 @@ +#!/usr/bin/env dotnet fsi +// Monitor a GitHub Actions workflow run for morphir-dotnet release +// Usage: dotnet fsi monitor-release.fsx --version 1.2.0 [--run-id ] [--issue ] [--update-issue] [--json] [--use-gh-watch] [--timeout ] + +#r "nuget: Spectre.Console, 0.53.0" +#r "nuget: System.Text.Json, 9.0.0" +#r "nuget: Argu, 6.2.4" + +open System +open System.IO +open System.Diagnostics +open System.Text.Json +open System.Text.Json.Serialization +open System.Threading +open Argu +open Spectre.Console + +// ============================================================================ +// CLI Arguments +// ============================================================================ + +type MonitorArguments = + | [] Version of string + | [] Run_Id of int + | [] Issue of int + | Update_Issue + | Use_Gh_Watch + | Json + | [] Timeout of int + + interface IArgParserTemplate with + member s.Usage = + match s with + | Version _ -> "Release version to monitor (e.g., 1.2.0)" + | Run_Id _ -> "Specific workflow run ID to monitor" + | Issue _ -> "GitHub issue number to update with progress" + | Update_Issue -> "Update the specified issue with workflow status" + | Use_Gh_Watch -> "Use 'gh run watch' for real-time monitoring" + | Json -> "Output results as JSON" + | Timeout _ -> "Maximum time to wait in minutes" + +// ============================================================================ +// Types +// ============================================================================ + +type WorkflowJob = { + Name: string + Status: string + Conclusion: string option +} + +type WorkflowRun = { + Id: int + Status: string + Conclusion: string option + StartedAt: DateTime + UpdatedAt: DateTime + Jobs: WorkflowJob list +} + +type MonitorResult = { + Success: bool + Version: string + Run: WorkflowRun option + IssueUpdated: bool + Duration: TimeSpan option + TimedOut: bool + Cancelled: bool + Warnings: string list + Errors: string list + ExitCode: int +} + +// ============================================================================ +// Utilities +// ============================================================================ + +let projectRoot = + let scriptDir = __SOURCE_DIRECTORY__ + Path.GetFullPath(Path.Combine(scriptDir, "..", "..", "..")) + +let logInfo msg = + eprintfn "[INFO] %s" msg + +let logWarn msg = + eprintfn "[WARN] %s" msg + +let logError msg = + eprintfn "[ERROR] %s" msg + +let runCommandAsync (command: string) (args: string) (cancellationToken: CancellationToken) : Async> = + async { + try + let psi = ProcessStartInfo( + FileName = command, + Arguments = args, + WorkingDirectory = projectRoot, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + ) + + use proc = new Process() + proc.StartInfo <- psi + proc.Start() |> ignore + + // Register cancellation + use _ = cancellationToken.Register(fun () -> + try + if not proc.HasExited then + proc.Kill() + with _ -> () + ) + + let! output = proc.StandardOutput.ReadToEndAsync() |> Async.AwaitTask + let! error = proc.StandardError.ReadToEndAsync() |> Async.AwaitTask + proc.WaitForExit() + + if cancellationToken.IsCancellationRequested then + return Error "Command cancelled" + elif proc.ExitCode = 0 then + return Ok output + else + return Error (if String.IsNullOrWhiteSpace(error) then output else error) + with + | :? OperationCanceledException -> + return Error "Command cancelled" + | ex -> + return Error ex.Message + } + +// ============================================================================ +// GitHub CLI Interactions +// ============================================================================ + +let findLatestDeploymentRunAsync (version: string) (ct: CancellationToken) : Async> = + async { + logInfo "Finding latest deployment workflow run..." + + let! result = runCommandAsync "gh" "run list --workflow=deployment.yml --limit 10 --json databaseId,status,conclusion,headBranch" ct + + match result with + | Error err -> return Error (sprintf "Failed to list workflow runs: %s" err) + | Ok output -> + try + use doc = JsonDocument.Parse(output) + let runs = doc.RootElement.EnumerateArray() |> Seq.toList + + match runs |> List.tryHead with + | Some run -> + let runId = run.GetProperty("databaseId").GetInt32() + return Ok runId + | None -> + return Error "No deployment workflow runs found" + with ex -> + return Error (sprintf "Failed to parse workflow runs: %s" ex.Message) + } + +let getWorkflowRunAsync (runId: int) (ct: CancellationToken) : Async> = + async { + let! result = runCommandAsync "gh" (sprintf "run view %d --json databaseId,status,conclusion,startedAt,updatedAt" runId) ct + + match result with + | Error err -> return Error (sprintf "Failed to get workflow run: %s" err) + | Ok output -> + try + use doc = JsonDocument.Parse(output) + let root = doc.RootElement + + let status = root.GetProperty("status").GetString() + let conclusionProp = root.GetProperty("conclusion") + let conclusion = if conclusionProp.ValueKind = JsonValueKind.Null then None else Some (conclusionProp.GetString()) + let startedAt = DateTime.Parse(root.GetProperty("startedAt").GetString()) + let updatedAt = DateTime.Parse(root.GetProperty("updatedAt").GetString()) + + return Ok { + Id = runId + Status = status + Conclusion = conclusion + StartedAt = startedAt + UpdatedAt = updatedAt + Jobs = [] // Will be populated separately if needed + } + with ex -> + return Error (sprintf "Failed to parse workflow run: %s" ex.Message) + } + +let getWorkflowJobsAsync (runId: int) (ct: CancellationToken) : Async> = + async { + let! result = runCommandAsync "gh" (sprintf "run view %d --json jobs" runId) ct + + match result with + | Error err -> return Error (sprintf "Failed to get workflow jobs: %s" err) + | Ok output -> + try + use doc = JsonDocument.Parse(output) + let jobsArray = doc.RootElement.GetProperty("jobs").EnumerateArray() + + let jobs = + jobsArray + |> Seq.map (fun job -> + let name = job.GetProperty("name").GetString() + let status = job.GetProperty("status").GetString() + let conclusionProp = job.GetProperty("conclusion") + let conclusion = if conclusionProp.ValueKind = JsonValueKind.Null then None else Some (conclusionProp.GetString()) + + { Name = name; Status = status; Conclusion = conclusion } + ) + |> Seq.toList + + return Ok jobs + with ex -> + return Error (sprintf "Failed to parse workflow jobs: %s" ex.Message) + } + +let watchWorkflowRunAsync (runId: int) (ct: CancellationToken) : Async> = + async { + logInfo (sprintf "Watching workflow run %d using gh CLI..." runId) + + let psi = ProcessStartInfo( + FileName = "gh", + Arguments = sprintf "run watch %d" runId, + WorkingDirectory = projectRoot, + RedirectStandardOutput = false, + RedirectStandardError = false, + UseShellExecute = false + ) + + try + use proc = new Process() + proc.StartInfo <- psi + proc.Start() |> ignore + + // Register cancellation + use _ = ct.Register(fun () -> + try + if not proc.HasExited then + logWarn "Cancelling gh run watch..." + proc.Kill() + with _ -> () + ) + + proc.WaitForExit() + + if ct.IsCancellationRequested then + return Error "Watch cancelled" + elif proc.ExitCode = 0 then + return Ok () + else + return Error (sprintf "gh run watch failed with exit code %d" proc.ExitCode) + with + | :? OperationCanceledException -> + return Error "Watch cancelled" + | ex -> + return Error (sprintf "Failed to watch workflow: %s" ex.Message) + } + +let updateIssueAsync (issueNumber: int) (version: string) (run: WorkflowRun) (ct: CancellationToken) : Async> = + async { + logInfo (sprintf "Updating issue #%d with workflow status..." issueNumber) + + let statusIcon = + match run.Status, run.Conclusion with + | "completed", Some "success" -> "βœ…" + | "completed", Some "failure" -> "❌" + | "in_progress", _ -> "πŸ”„" + | "queued", _ -> "⏳" + | _ -> "❓" + + let duration = run.UpdatedAt - run.StartedAt + let durationStr = sprintf "%02d:%02d:%02d" (int duration.TotalHours) duration.Minutes duration.Seconds + + let comment = sprintf """## Workflow Update + +**Status**: %s %s +**Run ID**: %d +**Duration**: %s +**Updated**: %s + +[View Workflow Run](https://github.com/finos/morphir-dotnet/actions/runs/%d) + +--- +*Updated by monitor-release.fsx* +""" statusIcon run.Status run.Id durationStr (run.UpdatedAt.ToString("yyyy-MM-dd HH:mm:ss")) run.Id + + let escapedComment = comment.Replace("\"", "\\\"").Replace("\n", "\\n") + let! result = runCommandAsync "gh" (sprintf "issue comment %d --body \"%s\"" issueNumber escapedComment) ct + + match result with + | Ok _ -> + logInfo "Issue updated successfully" + return Ok () + | Error err -> + return Error (sprintf "Failed to update issue: %s" err) + } + +// ============================================================================ +// Display Helpers +// ============================================================================ + +let displayRunStatus (run: WorkflowRun) = + let statusColor = + match run.Status, run.Conclusion with + | "completed", Some "success" -> "green" + | "completed", Some "failure" -> "red" + | "in_progress", _ -> "yellow" + | "queued", _ -> "blue" + | _ -> "grey" + + let statusText = + match run.Conclusion with + | Some conclusion -> sprintf "%s (%s)" run.Status conclusion + | None -> run.Status + + let duration = run.UpdatedAt - run.StartedAt + let durationStr = sprintf "%02d:%02d:%02d" (int duration.TotalHours) duration.Minutes duration.Seconds + + AnsiConsole.MarkupLine(sprintf "[bold]Workflow Run #%d[/]" run.Id) + AnsiConsole.MarkupLine(sprintf "[%s]Status: %s[/]" statusColor statusText) + AnsiConsole.MarkupLine(sprintf "Started: %s" (run.StartedAt.ToString("yyyy-MM-dd HH:mm:ss"))) + AnsiConsole.MarkupLine(sprintf "Updated: %s" (run.UpdatedAt.ToString("yyyy-MM-dd HH:mm:ss"))) + AnsiConsole.MarkupLine(sprintf "Duration: %s" durationStr) + AnsiConsole.WriteLine() + +let displayJobs (jobs: WorkflowJob list) = + let table = Table() + table.AddColumn("Job Name") |> ignore + table.AddColumn("Status") |> ignore + table.AddColumn("Conclusion") |> ignore + + for job in jobs do + let statusMarkup = + match job.Status with + | "completed" -> "[green]completed[/]" + | "in_progress" -> "[yellow]in_progress[/]" + | "queued" -> "[blue]queued[/]" + | _ -> job.Status + + let conclusionMarkup = + match job.Conclusion with + | Some "success" -> "[green]success[/]" + | Some "failure" -> "[red]failure[/]" + | Some c -> c + | None -> "-" + + table.AddRow(job.Name, statusMarkup, conclusionMarkup) |> ignore + + AnsiConsole.Write(table) + AnsiConsole.WriteLine() + +// ============================================================================ +// Main Logic +// ============================================================================ + +let monitorAsync (results: ParseResults) (ct: CancellationToken) : Async = + async { + let version = results.GetResult Version + let runId = results.TryGetResult Run_Id + let issueNumber = results.TryGetResult Issue + let updateIssue = results.Contains Update_Issue + let useGhWatch = results.Contains Use_Gh_Watch + let jsonOutput = results.Contains Json + + let mutable warnings = [] + let mutable errors = [] + let mutable timedOut = false + let mutable cancelled = false + + if not jsonOutput then + AnsiConsole.Write( + FigletText("Release Monitor") + .Centered() + .Color(Color.Blue) + ) + AnsiConsole.MarkupLine(sprintf "[bold]Version:[/] %s" version) + match results.TryGetResult Timeout with + | Some mins -> AnsiConsole.MarkupLine(sprintf "[dim]Timeout: %d minutes[/]" mins) + | None -> () + AnsiConsole.WriteLine() + + try + // Find or use provided run ID + let! runIdResult = + match runId with + | Some id -> + logInfo (sprintf "Using provided run ID: %d" id) + async { return Ok id } + | None -> + logInfo "No run ID provided, finding latest deployment run..." + findLatestDeploymentRunAsync version ct + + match runIdResult with + | Error err -> + errors <- err :: errors + return { + Success = false + Version = version + Run = None + IssueUpdated = false + Duration = None + TimedOut = timedOut + Cancelled = cancelled + Warnings = warnings + Errors = errors + ExitCode = 1 + } + | Ok runId -> + // Use gh watch if requested + if useGhWatch then + let! watchResult = watchWorkflowRunAsync runId ct + match watchResult with + | Error err -> + if err.Contains("cancelled") then + cancelled <- true + errors <- err :: errors + | Ok () -> + logInfo "gh run watch completed" + + // Get final status + let! runResult = getWorkflowRunAsync runId ct + match runResult with + | Error err -> + errors <- err :: errors + return { + Success = false + Version = version + Run = None + IssueUpdated = false + Duration = None + TimedOut = timedOut + Cancelled = cancelled + Warnings = warnings + Errors = errors + ExitCode = 1 + } + | Ok run -> + // Get jobs for detailed status + let! jobsResult = getWorkflowJobsAsync runId ct + let jobs = + match jobsResult with + | Ok jobs -> jobs + | Error err -> + warnings <- (sprintf "Could not fetch jobs: %s" err) :: warnings + [] + + let runWithJobs = { run with Jobs = jobs } + + if not jsonOutput then + displayRunStatus runWithJobs + if not (List.isEmpty jobs) then + AnsiConsole.MarkupLine("[bold]Jobs:[/]") + displayJobs jobs + + // Update issue if requested + let! issueUpdated = + async { + match issueNumber, updateIssue with + | Some issueNum, true -> + let! updateResult = updateIssueAsync issueNum version runWithJobs ct + match updateResult with + | Ok () -> return true + | Error err -> + warnings <- (sprintf "Failed to update issue: %s" err) :: warnings + return false + | _ -> return false + } + + let success = run.Conclusion = Some "success" + let duration = Some (run.UpdatedAt - run.StartedAt) + + if not jsonOutput then + if success then + AnsiConsole.MarkupLine("[green]βœ… Workflow completed successfully[/]") + else + AnsiConsole.MarkupLine(sprintf "[red]❌ Workflow %s[/]" (run.Conclusion |> Option.defaultValue "incomplete")) + + return { + Success = success + Version = version + Run = Some runWithJobs + IssueUpdated = issueUpdated + Duration = duration + TimedOut = timedOut + Cancelled = cancelled + Warnings = warnings + Errors = errors + ExitCode = if success then 0 else 1 + } + with + | :? OperationCanceledException -> + if ct.IsCancellationRequested then + cancelled <- true + logWarn "Operation cancelled" + return { + Success = false + Version = version + Run = None + IssueUpdated = false + Duration = None + TimedOut = timedOut + Cancelled = cancelled + Warnings = warnings + Errors = ["Operation cancelled"] @ errors + ExitCode = 2 + } + | :? TimeoutException -> + timedOut <- true + logError "Operation timed out" + return { + Success = false + Version = version + Run = None + IssueUpdated = false + Duration = None + TimedOut = timedOut + Cancelled = cancelled + Warnings = warnings + Errors = ["Operation timed out"] @ errors + ExitCode = 3 + } + } + +let outputJson (result: MonitorResult) = + let options = JsonSerializerOptions( + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + ) + let json = JsonSerializer.Serialize(result, options) + printfn "%s" json + +let main (args: string array) = + let parser = ArgumentParser.Create(programName = "monitor-release.fsx") + + try + let results = parser.Parse(args) + + use cts = new CancellationTokenSource() + + // Set timeout if specified + match results.TryGetResult Timeout with + | Some minutes -> + cts.CancelAfter(TimeSpan.FromMinutes(float minutes)) + | None -> () + + // Handle Ctrl+C + Console.CancelKeyPress.Add(fun args -> + logWarn "Cancellation requested..." + cts.Cancel() + args.Cancel <- true + ) + + let result = Async.RunSynchronously(monitorAsync results cts.Token) + + if results.Contains Json then + outputJson result + + result.ExitCode + with + | :? ArguParseException as ex -> + logError ex.Message + eprintfn "%s" (parser.PrintUsage()) + 1 + | ex -> + logError (sprintf "Unexpected error: %s" ex.Message) + 1 + +exit (main fsi.CommandLineArgs.[1..]) diff --git a/.claude/skills/release-manager/prepare-release.fsx b/.claude/skills/release-manager/prepare-release.fsx new file mode 100755 index 00000000..a995e99d --- /dev/null +++ b/.claude/skills/release-manager/prepare-release.fsx @@ -0,0 +1,589 @@ +#!/usr/bin/env dotnet fsi +// Release preparation and pre-flight validation +// Usage: dotnet fsi prepare-release.fsx [--version VERSION] [--json] [--dry-run] [--skip-local-check] + +#r "nuget: Spectre.Console, 0.53.0" +#r "nuget: System.Text.Json, 9.0.0" + +open System +open System.IO +open System.Diagnostics +open System.Text.Json +open System.Text.Json.Serialization +open Spectre.Console + +// ============================================================================ +// Types +// ============================================================================ + +type VersionType = Major | Minor | Patch | Prerelease + +type VersionInfo = { + Suggested: string + Specified: string option + Type: string + Rationale: string +} + +type RemoteState = { + CiPassing: bool + LatestRun: int64 option + LatestCommit: string + CommitMessage: string +} + +type ChangelogInfo = { + HasUnreleased: bool + ChangeCount: int + Added: int + Changed: int + FixedCount: int + BreakingChanges: int +} + +type VersionValidation = { + NugetAvailable: bool + TagAvailable: bool + Conflicts: string list +} + +type LocalState = { + Branch: string + Clean: bool + ModifiedFiles: int + Blocking: bool +} + +type PrepareResult = { + Ready: bool + Version: VersionInfo + RemoteState: RemoteState + Changelog: ChangelogInfo + VersionValidation: VersionValidation + LocalState: LocalState option + Warnings: string list + Errors: string list + ExitCode: int +} + +// ============================================================================ +// Configuration +// ============================================================================ + +let projectRoot = + let scriptDir = __SOURCE_DIRECTORY__ + Path.GetFullPath(Path.Combine(scriptDir, "..", "..", "..")) + +let changelogPath = Path.Combine(projectRoot, "CHANGELOG.md") + +// ============================================================================ +// Command Line Parsing +// ============================================================================ + +let args = fsi.CommandLineArgs |> Array.skip 1 + +let jsonOutput = args |> Array.contains "--json" +let dryRun = args |> Array.contains "--dry-run" +let skipLocalCheck = args |> Array.contains "--skip-local-check" + +let specifiedVersion = + args + |> Array.tryFindIndex ((=) "--version") + |> Option.bind (fun i -> + if i + 1 < args.Length then Some args.[i + 1] + else None + ) + +// ============================================================================ +// Logging (respects CLI standards) +// ============================================================================ + +let logInfo msg = + if not jsonOutput then + eprintfn "[INFO] %s" msg + +let logWarning msg = + eprintfn "[WARN] %s" msg + +let logError msg = + eprintfn "[ERROR] %s" msg + +let logVerbose msg = + if not jsonOutput then + eprintfn "[VERBOSE] %s" msg + +// ============================================================================ +// Shell Execution +// ============================================================================ + +let runCommand (command: string) (args: string) : int * string * string = + let psi = ProcessStartInfo( + FileName = command, + Arguments = args, + WorkingDirectory = projectRoot, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + ) + + use proc = Process.Start(psi) + let output = proc.StandardOutput.ReadToEnd() + let error = proc.StandardError.ReadToEnd() + proc.WaitForExit() + + (proc.ExitCode, output.Trim(), error.Trim()) + +// ============================================================================ +// Remote State Validation +// ============================================================================ + +let checkRemoteCI () : Result = + logInfo "Checking remote CI status on main branch..." + + // Check latest GitHub Actions run on main + let (code, output, error) = runCommand "gh" "run list --branch main --limit 1 --json conclusion,databaseId,headSha" + + if code <> 0 then + Error $"Failed to query GitHub Actions: {error}" + else + try + logInfo $"Parsing GitHub response (length: {output.Length})" + let doc = JsonDocument.Parse(output) + let runs = doc.RootElement.EnumerateArray() |> Seq.toList + + if runs.IsEmpty then + Error "No workflow runs found on main branch" + else + let root = runs.[0] + + let conclusionProp = root.GetProperty("conclusion") + let conclusion = if conclusionProp.ValueKind = JsonValueKind.Null then "in_progress" else conclusionProp.GetString() + let runId = root.GetProperty("databaseId").GetInt64() + let sha = root.GetProperty("headSha").GetString() + + // Get commit message from git log + let shortSha = if sha.Length > 7 then sha.Substring(0, 7) else sha + let commitMsg = + try + let (gitCode, gitOutput, _) = runCommand "git" (sprintf "log -1 --pretty=format:%%s %s" shortSha) + if gitCode = 0 then gitOutput.Trim() else "" + with _ -> "" + + let ciPassing = conclusion = "success" + + if not ciPassing then + logWarning $"Latest CI run #{runId} has conclusion: {conclusion}" + + Ok { + CiPassing = ciPassing + LatestRun = Some runId + LatestCommit = shortSha + CommitMessage = commitMsg + } + with ex -> + logError $"Exception details: {ex}" + Error $"Failed to parse CI response: {ex.Message}" + +// ============================================================================ +// Changelog Parsing +// ============================================================================ + +let parseChangelog () : Result = + logInfo "Parsing CHANGELOG.md..." + + if not (File.Exists changelogPath) then + Error "CHANGELOG.md not found" + else + let lines = File.ReadAllLines(changelogPath) + + let rec findUnreleased lineNum = + if lineNum >= lines.Length then None + elif lines.[lineNum].StartsWith("## [Unreleased]") then Some lineNum + else findUnreleased (lineNum + 1) + + let rec findNextVersion lineNum = + if lineNum >= lines.Length then lines.Length + elif lines.[lineNum].StartsWith("## [") && not (lines.[lineNum].StartsWith("## [Unreleased]")) then lineNum + else findNextVersion (lineNum + 1) + + match findUnreleased 0 with + | None -> + Error "No [Unreleased] section found in CHANGELOG.md" + | Some startLine -> + let endLine = findNextVersion (startLine + 1) + let unreleasedLines = lines.[startLine..endLine-1] + + let countCategory (prefix: string) = + unreleasedLines + |> Array.filter (fun line -> line.StartsWith($"- {prefix}")) + |> Array.length + + let added = countCategory "Added" + countCategory "**Added**" + let changed = countCategory "Changed" + countCategory "**Changed**" + let fixedCount = countCategory "Fixed" + countCategory "**Fixed**" + let breaking = + unreleasedLines + |> Array.filter (fun line -> line.Contains("BREAKING") || line.Contains("**BREAKING**")) + |> Array.length + + let totalChanges = added + changed + fixedCount + + if totalChanges = 0 then + logWarning "No changes found in [Unreleased] section" + + Ok { + HasUnreleased = true + ChangeCount = totalChanges + Added = added + Changed = changed + FixedCount = fixedCount + BreakingChanges = breaking + } + +// ============================================================================ +// Version Suggestion +// ============================================================================ + +let suggestVersion (changelog: ChangelogInfo) : VersionInfo = + logInfo "Analyzing changes to suggest version..." + + let versionType, rationale = + if changelog.BreakingChanges > 0 then + Major, "Breaking changes detected" + elif changelog.Added > 0 then + Minor, "New features added, no breaking changes" + elif changelog.Changed > 0 then + Minor, "Improvements made" + elif changelog.FixedCount > 0 then + Patch, "Bug fixes only" + else + Patch, "No significant changes" + + // Get current version from latest tag + let (code, output, _) = runCommand "git" "describe --tags --abbrev=0" + let currentVersion = + if code = 0 && output <> "" then output + else "0.1.0" + + let parts = currentVersion.TrimStart('v').Split([|'-'|]).[0].Split('.') + let major = if parts.Length > 0 then int parts.[0] else 0 + let minor = if parts.Length > 1 then int parts.[1] else 0 + let patch = if parts.Length > 2 then int parts.[2] else 0 + + let suggested = + match versionType with + | Major -> $"{major + 1}.0.0" + | Minor -> $"{major}.{minor + 1}.0" + | Patch -> $"{major}.{minor}.{patch + 1}" + | Prerelease -> $"{major}.{minor}.{patch + 1}-alpha.1" + + { + Suggested = suggested + Specified = specifiedVersion + Type = string versionType + Rationale = rationale + } + +// ============================================================================ +// Version Validation +// ============================================================================ + +let validateVersion (version: string) : Result = + logInfo $"Validating version {version}..." + + // Check if version exists on NuGet + let (nugetCode, _, _) = runCommand "dotnet" $"nuget search Morphir.Core --exact-match --version {version}" + let nugetAvailable = nugetCode <> 0 // Non-zero means not found, which is good + + // Check if git tag exists + let (tagCode, _, _) = runCommand "git" $"rev-parse v{version}" + let tagAvailable = tagCode <> 0 // Non-zero means tag doesn't exist, which is good + + let conflicts = [ + if not nugetAvailable then yield "Version already exists on NuGet" + if not tagAvailable then yield $"Git tag v{version} already exists" + ] + + if conflicts.IsEmpty then + Ok { + NugetAvailable = nugetAvailable + TagAvailable = tagAvailable + Conflicts = [] + } + else + let conflictMsg = String.concat ", " conflicts + logWarning (sprintf "Version conflicts detected: %s" conflictMsg) + Ok { + NugetAvailable = nugetAvailable + TagAvailable = tagAvailable + Conflicts = conflicts + } + +// ============================================================================ +// Local State Check +// ============================================================================ + +let checkLocalState () : LocalState = + logInfo "Checking local state..." + + // Get current branch + let (_, branch, _) = runCommand "git" "branch --show-current" + + // Check if working tree is clean + let (_, status, _) = runCommand "git" "status --porcelain" + let clean = String.IsNullOrWhiteSpace(status) + let modifiedFiles = if clean then 0 else status.Split('\n').Length + + if not clean then + logWarning $"Working tree has {modifiedFiles} modified files" + logWarning "Consider stashing: git stash save \"WIP before release\"" + + { + Branch = branch + Clean = clean + ModifiedFiles = modifiedFiles + Blocking = false // Never blocking for releases + } + +// ============================================================================ +// Main Logic +// ============================================================================ + +let prepare () : PrepareResult = + let mutable warnings = [] + let mutable errors = [] + + // 1. Check remote CI + let remoteState = + match checkRemoteCI() with + | Ok state -> + if not state.CiPassing then + warnings <- "CI not passing on main branch" :: warnings + state + | Error err -> + errors <- err :: errors + { + CiPassing = false + LatestRun = None + LatestCommit = "" + CommitMessage = "" + } + + // 2. Parse changelog + let changelog = + match parseChangelog() with + | Ok info -> + if info.ChangeCount = 0 then + warnings <- "No changes in [Unreleased] section" :: warnings + info + | Error err -> + errors <- err :: errors + { + HasUnreleased = false + ChangeCount = 0 + Added = 0 + Changed = 0 + FixedCount = 0 + BreakingChanges = 0 + } + + // 3. Suggest version + let versionInfo = suggestVersion changelog + let versionToValidate = specifiedVersion |> Option.defaultValue versionInfo.Suggested + + // 4. Validate version + let versionValidation = + match validateVersion versionToValidate with + | Ok validation -> + if not validation.Conflicts.IsEmpty then + errors <- validation.Conflicts @ errors + validation + | Error err -> + errors <- err :: errors + { + NugetAvailable = false + TagAvailable = false + Conflicts = [err] + } + + // 5. Check local state (optional) + let localState = + if skipLocalCheck then + logInfo "Skipping local state check (--skip-local-check)" + None + else + Some (checkLocalState()) + + // Determine readiness + let ready = errors.IsEmpty + let exitCode = + if ready && warnings.IsEmpty then 0 + elif ready then 2 // Ready with warnings + else 1 // Not ready + + { + Ready = ready + Version = versionInfo + RemoteState = remoteState + Changelog = changelog + VersionValidation = versionValidation + LocalState = localState + Warnings = List.rev warnings + Errors = List.rev errors + ExitCode = exitCode + } + +// ============================================================================ +// Output Formatting +// ============================================================================ + +let outputJson (result: PrepareResult) = + let options = JsonSerializerOptions( + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + ) + + let json = JsonSerializer.Serialize(result, options) + printfn "%s" json + +let outputHuman (result: PrepareResult) = + AnsiConsole.Write( + FigletText("Prepare Release") + .Centered() + .Color(if result.Ready then Color.Green else Color.Red) + ) + + AnsiConsole.MarkupLine($"[dim]Project root: {projectRoot}[/]") + AnsiConsole.WriteLine() + + // Remote State + AnsiConsole.MarkupLine("[bold]Remote State:[/]") + if result.RemoteState.CiPassing then + let runNum = result.RemoteState.LatestRun |> Option.map string |> Option.defaultValue "N/A" + let ciMsg = sprintf "[green]βœ… CI passing on main branch (run #%s)[/]" runNum + AnsiConsole.MarkupLine(ciMsg) + else + AnsiConsole.MarkupLine("[red]❌ CI not passing on main branch[/]") + let commitMsg = sprintf "[dim] Latest commit: %s \"%s\"[/]" result.RemoteState.LatestCommit result.RemoteState.CommitMessage + AnsiConsole.MarkupLine(commitMsg) + AnsiConsole.WriteLine() + + // Changelog Analysis + AnsiConsole.MarkupLine("[bold]Changelog Analysis:[/]") + if result.Changelog.HasUnreleased then + let changelogMsg = sprintf "[green]πŸ“ [[Unreleased]] section found with %d changes[/]" result.Changelog.ChangeCount + AnsiConsole.MarkupLine(changelogMsg) + if result.Changelog.Added > 0 then + let addedMsg = sprintf "[dim] - Added: %d features[/]" result.Changelog.Added + AnsiConsole.MarkupLine(addedMsg) + if result.Changelog.Changed > 0 then + let changedMsg = sprintf "[dim] - Changed: %d improvements[/]" result.Changelog.Changed + AnsiConsole.MarkupLine(changedMsg) + if result.Changelog.FixedCount > 0 then + let fixedMsg = sprintf "[dim] - Fixed: %d bug fixes[/]" result.Changelog.FixedCount + AnsiConsole.MarkupLine(fixedMsg) + if result.Changelog.BreakingChanges > 0 then + let breakingMsg = sprintf "[red] - Breaking changes: %d[/]" result.Changelog.BreakingChanges + AnsiConsole.MarkupLine(breakingMsg) + else + AnsiConsole.MarkupLine("[red]❌ No [[Unreleased]] section found[/]") + AnsiConsole.WriteLine() + + // Version Suggestion + AnsiConsole.MarkupLine("[bold]Version Suggestion:[/]") + let version = result.Version.Specified |> Option.defaultValue result.Version.Suggested + let suggestedMsg = sprintf "[yellow]πŸ“Š Suggested version: %s (%s)[/]" result.Version.Suggested result.Version.Type + AnsiConsole.MarkupLine(suggestedMsg) + let rationaleMsg = sprintf "[dim] Rationale: %s[/]" result.Version.Rationale + AnsiConsole.MarkupLine(rationaleMsg) + if result.Version.Specified.IsSome then + let specifiedMsg = sprintf "[cyan] User specified: %s[/]" result.Version.Specified.Value + AnsiConsole.MarkupLine(specifiedMsg) + AnsiConsole.WriteLine() + + // Version Validation + AnsiConsole.MarkupLine("[bold]Version Validation:[/]") + if result.VersionValidation.NugetAvailable then + let nugetMsg = sprintf "[green]βœ… Version %s available on NuGet[/]" version + AnsiConsole.MarkupLine(nugetMsg) + else + let nugetMsg = sprintf "[red]❌ Version %s already exists on NuGet[/]" version + AnsiConsole.MarkupLine(nugetMsg) + if result.VersionValidation.TagAvailable then + let tagMsg = sprintf "[green]βœ… Tag v%s does not exist[/]" version + AnsiConsole.MarkupLine(tagMsg) + else + let tagMsg = sprintf "[red]❌ Tag v%s already exists[/]" version + AnsiConsole.MarkupLine(tagMsg) + AnsiConsole.WriteLine() + + // Local State (if checked) + match result.LocalState with + | Some state -> + AnsiConsole.MarkupLine("[bold]Local State (Advisory):[/]") + let branchMsg = sprintf "[dim]ℹ️ On branch: %s[/]" state.Branch + AnsiConsole.MarkupLine(branchMsg) + if state.Clean then + AnsiConsole.MarkupLine("[green]βœ… Working tree clean[/]") + else + let changesMsg = sprintf "[yellow]⚠️ Local changes detected (%d modified files)[/]" state.ModifiedFiles + AnsiConsole.MarkupLine(changesMsg) + AnsiConsole.MarkupLine("[dim] You can:[/]") + AnsiConsole.MarkupLine("[dim] - Stash: git stash save \"WIP before release\"[/]") + AnsiConsole.MarkupLine("[dim] - Commit: git add . && git commit -m \"WIP\"[/]") + AnsiConsole.MarkupLine("[dim] - Continue anyway (workflow runs on remote main)[/]") + AnsiConsole.WriteLine() + | None -> + AnsiConsole.MarkupLine("[dim]Local state check skipped[/]") + AnsiConsole.WriteLine() + + // Warnings + if not result.Warnings.IsEmpty then + AnsiConsole.MarkupLine("[bold yellow]Warnings:[/]") + for warning in result.Warnings do + AnsiConsole.MarkupLine($"[yellow]⚠️ {warning}[/]") + AnsiConsole.WriteLine() + + // Errors + if not result.Errors.IsEmpty then + AnsiConsole.MarkupLine("[bold red]Errors:[/]") + for error in result.Errors do + AnsiConsole.MarkupLine($"[red]❌ {error}[/]") + AnsiConsole.WriteLine() + + // Result + AnsiConsole.WriteLine() + if result.Ready then + let message = + if result.Warnings.IsEmpty then + sprintf "[green]βœ… Ready to release v%s[/]" version + else + sprintf "[yellow]⚠️ Ready to release v%s (with warnings)[/]" version + AnsiConsole.MarkupLine(message) + else + AnsiConsole.MarkupLine("[red]❌ Not ready for release - please fix the errors above[/]") + +// ============================================================================ +// Entry Point +// ============================================================================ + +let main () = + try + logInfo "Starting release preparation..." + if dryRun then + logInfo "Dry run mode enabled (no side effects)" + + let result = prepare() + + if jsonOutput then + outputJson result + else + outputHuman result + + result.ExitCode + with ex -> + logError $"Unhandled exception: {ex.Message}" + logError $"Stack trace: {ex.StackTrace}" + 1 + +exit (main()) diff --git a/.claude/skills/release-manager/resume-release.fsx b/.claude/skills/release-manager/resume-release.fsx new file mode 100644 index 00000000..c1eaf463 --- /dev/null +++ b/.claude/skills/release-manager/resume-release.fsx @@ -0,0 +1,648 @@ +#!/usr/bin/env dotnet fsi +// Resume a failed morphir-dotnet release +// Usage: dotnet fsi resume-release.fsx --version 1.2.0 --issue [--json] [--timeout ] + +#r "nuget: Spectre.Console, 0.53.0" +#r "nuget: System.Text.Json, 9.0.0" +#r "nuget: Argu, 6.2.4" + +open System +open System.IO +open System.Diagnostics +open System.Text.Json +open System.Text.Json.Serialization +open System.Text.RegularExpressions +open System.Threading +open Argu +open Spectre.Console + +// ============================================================================ +// CLI Arguments +// ============================================================================ + +type ResumeArguments = + | [] Version of string + | [] Issue of int + | Json + | [] Timeout of int + | Force + + interface IArgParserTemplate with + member s.Usage = + match s with + | Version _ -> "Release version to resume (e.g., 1.2.0)" + | Issue _ -> "GitHub issue number tracking the release" + | Json -> "Output results as JSON" + | Timeout _ -> "Maximum time to wait in minutes" + | Force -> "Force resume even if release appears successful" + +// ============================================================================ +// Types +// ============================================================================ + +type ReleasePhase = + | Preparation + | Execution + | Verification + | Documentation + | PostRelease + | Unknown + +type ChecklistItem = { + Phase: ReleasePhase + Description: string + Completed: bool +} + +type IssueAnalysis = { + Version: string + IssueNumber: int + CurrentPhase: ReleasePhase + CompletedItems: ChecklistItem list + PendingItems: ChecklistItem list + FailurePoint: string option + CanResume: bool + ResumeStrategy: string option +} + +type WorkflowRun = { + Id: int + Status: string + Conclusion: string option +} + +type ResumeResult = { + Success: bool + Version: string + Issue: int + Analysis: IssueAnalysis + WorkflowTriggered: bool + WorkflowRunId: int option + IssueUpdated: bool + TimedOut: bool + Cancelled: bool + Warnings: string list + Errors: string list + ExitCode: int +} + +// ============================================================================ +// Utilities +// ============================================================================ + +let projectRoot = + let scriptDir = __SOURCE_DIRECTORY__ + Path.GetFullPath(Path.Combine(scriptDir, "..", "..", "..")) + +let logInfo msg = + eprintfn "[INFO] %s" msg + +let logWarn msg = + eprintfn "[WARN] %s" msg + +let logError msg = + eprintfn "[ERROR] %s" msg + +let runCommandAsync (command: string) (args: string) (cancellationToken: CancellationToken) : Async> = + async { + try + let psi = ProcessStartInfo( + FileName = command, + Arguments = args, + WorkingDirectory = projectRoot, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + ) + + use proc = new Process() + proc.StartInfo <- psi + proc.Start() |> ignore + + // Register cancellation + use _ = cancellationToken.Register(fun () -> + try + if not proc.HasExited then + proc.Kill() + with _ -> () + ) + + let! output = proc.StandardOutput.ReadToEndAsync() |> Async.AwaitTask + let! error = proc.StandardError.ReadToEndAsync() |> Async.AwaitTask + proc.WaitForExit() + + if cancellationToken.IsCancellationRequested then + return Error "Command cancelled" + elif proc.ExitCode = 0 then + return Ok output + else + return Error (if String.IsNullOrWhiteSpace(error) then output else error) + with + | :? OperationCanceledException -> + return Error "Command cancelled" + | ex -> + return Error ex.Message + } + +// ============================================================================ +// Issue Analysis +// ============================================================================ + +let parsePhase (text: string) : ReleasePhase = + if text.Contains("Preparation") then Preparation + elif text.Contains("Execution") then Execution + elif text.Contains("Verification") then Verification + elif text.Contains("Documentation") then Documentation + elif text.Contains("Post-Release") then PostRelease + else Unknown + +let getIssueBodyAsync (issueNumber: int) (ct: CancellationToken) : Async> = + async { + logInfo (sprintf "Fetching issue #%d..." issueNumber) + + let! result = runCommandAsync "gh" (sprintf "issue view %d --json body" issueNumber) ct + + match result with + | Error err -> return Error (sprintf "Failed to fetch issue: %s" err) + | Ok output -> + try + use doc = JsonDocument.Parse(output) + let body = doc.RootElement.GetProperty("body").GetString() + return Ok body + with ex -> + return Error (sprintf "Failed to parse issue body: %s" ex.Message) + } + +let analyzeIssue (issueNumber: int) (version: string) (issueBody: string) : IssueAnalysis = + logInfo "Analyzing issue for release state..." + + // Parse checklist items + let checklistPattern = @"- \[([ x])\] (.+?)(?:\n|$)" + let matches = Regex.Matches(issueBody, checklistPattern) + + let items = + matches + |> Seq.cast + |> Seq.map (fun m -> + let completed = m.Groups.[1].Value = "x" + let description = m.Groups.[2].Value.Trim() + + // Try to determine phase from context + let phase = + if description.Contains("Pre-flight") || description.Contains("CHANGELOG") then Preparation + elif description.Contains("Deployment workflow") || description.Contains("Build executable") then Execution + elif description.Contains("Package validation") || description.Contains("QA Tester") then Verification + elif description.Contains("GitHub release") || description.Contains("What's New") then Documentation + elif description.Contains("Release playbook") || description.Contains("Retrospective") then PostRelease + else Unknown + + { + Phase = phase + Description = description + Completed = completed + } + ) + |> Seq.toList + + let completedItems = items |> List.filter (fun i -> i.Completed) + let pendingItems = items |> List.filter (fun i -> not i.Completed) + + // Determine current phase + let currentPhase = + if pendingItems |> List.isEmpty then + PostRelease // All done + else + pendingItems + |> List.tryHead + |> Option.map (fun i -> i.Phase) + |> Option.defaultValue Unknown + + // Look for failure point in issue body + let failurePoint = + let failurePattern = @"\*\*Failure Point\*\*: (.+?)(?:\n|$)" + let m = Regex.Match(issueBody, failurePattern) + if m.Success then Some (m.Groups.[1].Value.Trim()) + else None + + // Determine resume strategy + let resumeStrategy = + match currentPhase with + | Preparation -> + Some "Complete pre-flight checks and update CHANGELOG, then trigger deployment workflow" + | Execution -> + Some "Re-trigger deployment workflow or monitor existing run" + | Verification -> + Some "Run validation tests and update issue with results" + | Documentation -> + Some "Create GitHub release and update documentation" + | PostRelease -> + Some "Complete retrospective and close issue" + | Unknown -> + None + + let canResume = + not (List.isEmpty pendingItems) && resumeStrategy.IsSome + + { + Version = version + IssueNumber = issueNumber + CurrentPhase = currentPhase + CompletedItems = completedItems + PendingItems = pendingItems + FailurePoint = failurePoint + CanResume = canResume + ResumeStrategy = resumeStrategy + } + +// ============================================================================ +// Resume Actions +// ============================================================================ + +let checkExistingWorkflowAsync (version: string) (ct: CancellationToken) : Async> = + async { + logInfo "Checking for existing workflow runs..." + + let! result = runCommandAsync "gh" "run list --workflow=deployment.yml --limit 5 --json databaseId,status,conclusion" ct + + match result with + | Error err -> return Error (sprintf "Failed to list workflows: %s" err) + | Ok output -> + try + use doc = JsonDocument.Parse(output) + let runs = doc.RootElement.EnumerateArray() |> Seq.toList + + let latestRun = + runs + |> List.tryHead + |> Option.map (fun run -> + let id = run.GetProperty("databaseId").GetInt32() + let status = run.GetProperty("status").GetString() + let conclusionProp = run.GetProperty("conclusion") + let conclusion = if conclusionProp.ValueKind = JsonValueKind.Null then None else Some (conclusionProp.GetString()) + + { Id = id; Status = status; Conclusion = conclusion } + ) + + return Ok latestRun + with ex -> + return Error (sprintf "Failed to parse workflow runs: %s" ex.Message) + } + +let triggerWorkflowAsync (version: string) (ct: CancellationToken) : Async> = + async { + logInfo (sprintf "Triggering deployment workflow for v%s..." version) + + let! result = runCommandAsync "gh" (sprintf "workflow run deployment.yml --ref main --field release-version=%s --field configuration=Release" version) ct + + match result with + | Error err -> return Error (sprintf "Failed to trigger workflow: %s" err) + | Ok _ -> + // Wait a moment for workflow to register + do! Async.Sleep(2000) + + // Get the run ID + let! listResult = runCommandAsync "gh" "run list --workflow=deployment.yml --limit 1 --json databaseId" ct + match listResult with + | Ok output -> + try + use doc = JsonDocument.Parse(output) + let runs = doc.RootElement.EnumerateArray() |> Seq.toList + match runs |> List.tryHead with + | Some run -> + let runId = run.GetProperty("databaseId").GetInt32() + logInfo (sprintf "Workflow triggered with run ID: %d" runId) + return Ok runId + | None -> + return Error "Could not find triggered workflow run" + with ex -> + return Error (sprintf "Failed to get run ID: %s" ex.Message) + | Error err -> + return Error (sprintf "Failed to get run ID: %s" err) + } + +let updateIssueAsync (issueNumber: int) (version: string) (analysis: IssueAnalysis) (workflowRunId: int option) (ct: CancellationToken) : Async> = + async { + logInfo (sprintf "Updating issue #%d with resume information..." issueNumber) + + let phaseStr = + match analysis.CurrentPhase with + | Preparation -> "Preparation" + | Execution -> "Execution" + | Verification -> "Verification" + | Documentation -> "Documentation" + | PostRelease -> "Post-Release" + | Unknown -> "Unknown" + + let workflowInfo = + match workflowRunId with + | Some runId -> + sprintf """ +**New Workflow Run**: [#%d](https://github.com/finos/morphir-dotnet/actions/runs/%d) +**Status**: πŸ”„ Running +""" runId runId + | None -> "" + + let comment = sprintf """## Release Resumed + +**Version**: %s +**Current Phase**: %s +**Strategy**: %s +%s +--- +*Resumed by resume-release.fsx* +""" version phaseStr (analysis.ResumeStrategy |> Option.defaultValue "Manual intervention required") workflowInfo + + let escapedComment = comment.Replace("\"", "\\\"").Replace("\n", "\\n") + let! result = runCommandAsync "gh" (sprintf "issue comment %d --body \"%s\"" issueNumber escapedComment) ct + + match result with + | Ok _ -> + logInfo "Issue updated successfully" + return Ok () + | Error err -> + return Error (sprintf "Failed to update issue: %s" err) + } + +// ============================================================================ +// Display Helpers +// ============================================================================ + +let displayAnalysis (analysis: IssueAnalysis) = + let phaseColor = + match analysis.CurrentPhase with + | Preparation -> "blue" + | Execution -> "yellow" + | Verification -> "cyan" + | Documentation -> "magenta" + | PostRelease -> "green" + | Unknown -> "grey" + + let phaseStr = + match analysis.CurrentPhase with + | Preparation -> "Preparation" + | Execution -> "Execution" + | Verification -> "Verification" + | Documentation -> "Documentation" + | PostRelease -> "Post-Release" + | Unknown -> "Unknown" + + AnsiConsole.MarkupLine(sprintf "[bold]Current Phase:[/] [%s]%s[/]" phaseColor phaseStr) + AnsiConsole.MarkupLine(sprintf "[bold]Progress:[/] %d / %d items completed" analysis.CompletedItems.Length (analysis.CompletedItems.Length + analysis.PendingItems.Length)) + + match analysis.FailurePoint with + | Some point -> AnsiConsole.MarkupLine(sprintf "[red]Failure Point:[/] %s" point) + | None -> () + + AnsiConsole.WriteLine() + + if analysis.CanResume then + AnsiConsole.MarkupLine("[green]βœ“ Release can be resumed[/]") + match analysis.ResumeStrategy with + | Some strategy -> + AnsiConsole.MarkupLine(sprintf "[bold]Resume Strategy:[/] %s" strategy) + | None -> () + else + AnsiConsole.MarkupLine("[red]βœ— Release cannot be automatically resumed[/]") + + AnsiConsole.WriteLine() + +// ============================================================================ +// Main Logic +// ============================================================================ + +let resumeAsync (results: ParseResults) (ct: CancellationToken) : Async = + async { + let version = results.GetResult Version + let issueNumber = results.GetResult Issue + let jsonOutput = results.Contains Json + let force = results.Contains Force + + let mutable warnings = [] + let mutable errors = [] + let mutable timedOut = false + let mutable cancelled = false + + if not jsonOutput then + AnsiConsole.Write( + FigletText("Resume Release") + .Centered() + .Color(Color.Blue) + ) + AnsiConsole.MarkupLine(sprintf "[bold]Version:[/] %s" version) + AnsiConsole.MarkupLine(sprintf "[bold]Issue:[/] #%d" issueNumber) + match results.TryGetResult Timeout with + | Some mins -> AnsiConsole.MarkupLine(sprintf "[dim]Timeout: %d minutes[/]" mins) + | None -> () + AnsiConsole.WriteLine() + + try + // Fetch and analyze issue + let! issueBodyResult = getIssueBodyAsync issueNumber ct + + match issueBodyResult with + | Error err -> + errors <- err :: errors + return { + Success = false + Version = version + Issue = issueNumber + Analysis = { + Version = version + IssueNumber = issueNumber + CurrentPhase = Unknown + CompletedItems = [] + PendingItems = [] + FailurePoint = None + CanResume = false + ResumeStrategy = None + } + WorkflowTriggered = false + WorkflowRunId = None + IssueUpdated = false + TimedOut = timedOut + Cancelled = cancelled + Warnings = warnings + Errors = errors + ExitCode = 1 + } + | Ok issueBody -> + let analysis = analyzeIssue issueNumber version issueBody + + if not jsonOutput then + displayAnalysis analysis + + if not analysis.CanResume && not force then + errors <- "Release cannot be automatically resumed. Use --force to override." :: errors + return { + Success = false + Version = version + Issue = issueNumber + Analysis = analysis + WorkflowTriggered = false + WorkflowRunId = None + IssueUpdated = false + TimedOut = timedOut + Cancelled = cancelled + Warnings = warnings + Errors = errors + ExitCode = 1 + } + else + // Check for existing workflow + let! existingWorkflow = checkExistingWorkflowAsync version ct + + let! (workflowTriggered, workflowRunId) = + async { + match existingWorkflow with + | Ok (Some workflow) when workflow.Status = "in_progress" || workflow.Status = "queued" -> + logInfo (sprintf "Found existing workflow run #%d in progress" workflow.Id) + warnings <- (sprintf "Workflow #%d already in progress" workflow.Id) :: warnings + return (false, Some workflow.Id) + | _ -> + // Trigger new workflow if we're in execution phase + if analysis.CurrentPhase = Execution || force then + let! triggerResult = triggerWorkflowAsync version ct + match triggerResult with + | Ok runId -> + return (true, Some runId) + | Error err -> + errors <- err :: errors + return (false, None) + else + logInfo "Not in execution phase, skipping workflow trigger" + return (false, None) + } + + // Update issue + let! issueUpdateResult = updateIssueAsync issueNumber version analysis workflowRunId ct + let issueUpdated = + match issueUpdateResult with + | Ok () -> true + | Error err -> + warnings <- err :: warnings + false + + let success = List.isEmpty errors + + if not jsonOutput then + if success then + AnsiConsole.MarkupLine("[green]βœ… Release resume initiated[/]") + else + AnsiConsole.MarkupLine("[red]❌ Failed to resume release[/]") + + return { + Success = success + Version = version + Issue = issueNumber + Analysis = analysis + WorkflowTriggered = workflowTriggered + WorkflowRunId = workflowRunId + IssueUpdated = issueUpdated + TimedOut = timedOut + Cancelled = cancelled + Warnings = warnings + Errors = errors + ExitCode = if success then 0 else 1 + } + + with + | :? OperationCanceledException -> + if ct.IsCancellationRequested then + cancelled <- true + logWarn "Operation cancelled" + return { + Success = false + Version = version + Issue = issueNumber + Analysis = { + Version = version + IssueNumber = issueNumber + CurrentPhase = Unknown + CompletedItems = [] + PendingItems = [] + FailurePoint = None + CanResume = false + ResumeStrategy = None + } + WorkflowTriggered = false + WorkflowRunId = None + IssueUpdated = false + TimedOut = timedOut + Cancelled = cancelled + Warnings = warnings + Errors = ["Operation cancelled"] @ errors + ExitCode = 2 + } + | :? TimeoutException -> + timedOut <- true + logError "Operation timed out" + return { + Success = false + Version = version + Issue = issueNumber + Analysis = { + Version = version + IssueNumber = issueNumber + CurrentPhase = Unknown + CompletedItems = [] + PendingItems = [] + FailurePoint = None + CanResume = false + ResumeStrategy = None + } + WorkflowTriggered = false + WorkflowRunId = None + IssueUpdated = false + TimedOut = timedOut + Cancelled = cancelled + Warnings = warnings + Errors = ["Operation timed out"] @ errors + ExitCode = 3 + } + } + +let outputJson (result: ResumeResult) = + let options = JsonSerializerOptions( + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + ) + let json = JsonSerializer.Serialize(result, options) + printfn "%s" json + +let main (args: string array) = + let parser = ArgumentParser.Create(programName = "resume-release.fsx") + + try + let results = parser.Parse(args) + + use cts = new CancellationTokenSource() + + // Set timeout if specified + match results.TryGetResult Timeout with + | Some minutes -> + cts.CancelAfter(TimeSpan.FromMinutes(float minutes)) + | None -> () + + // Handle Ctrl+C + Console.CancelKeyPress.Add(fun args -> + logWarn "Cancellation requested..." + cts.Cancel() + args.Cancel <- true + ) + + let result = Async.RunSynchronously(resumeAsync results cts.Token) + + if results.Contains Json then + outputJson result + + result.ExitCode + with + | :? ArguParseException as ex -> + logError ex.Message + eprintfn "%s" (parser.PrintUsage()) + 1 + | ex -> + logError (sprintf "Unexpected error: %s" ex.Message) + 1 + +exit (main fsi.CommandLineArgs.[1..]) diff --git a/.claude/skills/release-manager/skill.md b/.claude/skills/release-manager/skill.md new file mode 100644 index 00000000..3b10fddd --- /dev/null +++ b/.claude/skills/release-manager/skill.md @@ -0,0 +1,1000 @@ +--- +name: release-manager +description: Specialized release management for morphir-dotnet. Use when user asks to prepare releases, execute releases, monitor deployments, validate releases, resume failed releases, update changelog, create release notes, or manage release workflow. Triggers include "release", "deploy", "publish", "changelog", "version", "release notes", "what's new". +--- + +# Release Manager Skill + +You are a specialized release management agent for the morphir-dotnet project. Your role is to orchestrate the complete release lifecycle from preparation through verification, ensuring quality, consistency, and comprehensive documentation. + +## Primary Responsibilities + +1. **Release Preparation** - Validate state, update changelog, select version, prepare documentation +2. **Release Execution** - Trigger workflows, create releases, publish packages +3. **Release Monitoring** - Track GitHub Actions, monitor progress, detect failures +4. **Release Verification** - Coordinate with QA Tester, validate packages, test installation +5. **Release Documentation** - Update release notes, "What's New", maintain playbook +6. **Release Recovery** - Resume failed releases, document issues, prevent recurrence + +## Core Competencies + +### Version Management + +**When selecting a version:** +1. Parse CHANGELOG.md [Unreleased] section +2. Analyze change types (Added, Changed, Fixed, Breaking, etc.) +3. Suggest version bump: + - **Major (X.0.0)**: Breaking changes, major new features + - **Minor (x.Y.0)**: New features (backwards compatible) + - **Patch (x.y.Z)**: Bug fixes only + - **Pre-release (x.y.z-alpha.N)**: Alpha, beta, rc versions +4. Validate semantic versioning format +5. Check version doesn't already exist +6. Respect user override if they specify version + +**Version detection from context:** +- "release 1.0.0" β†’ Use exactly 1.0.0 +- "create a release" β†’ Analyze changes and suggest +- "alpha release" β†’ Suggest next alpha version +- "patch release" β†’ Increment patch version + +### Changelog Management + +**CRITICAL**: CHANGELOG.md follows [Keep a Changelog](https://keepachangelog.com/) format. + +**When updating changelog:** +1. Validate [Unreleased] section has content +2. Review changes for proper categorization: + - **Added**: New features + - **Changed**: Changes to existing functionality + - **Deprecated**: Soon-to-be-removed features + - **Removed**: Removed features + - **Fixed**: Bug fixes + - **Security**: Security fixes +3. Move [Unreleased] content to new version section with date +4. Update comparison links at bottom of file +5. Create new empty [Unreleased] section at top +6. Validate all links work correctly + +**Changelog validation checklist:** +- [ ] [Unreleased] section exists and has content +- [ ] Changes properly categorized +- [ ] Each change has clear description +- [ ] Breaking changes clearly marked with **BREAKING:** +- [ ] Issue/PR numbers referenced where applicable +- [ ] Version follows semantic versioning +- [ ] Date is in YYYY-MM-DD format +- [ ] Comparison links updated +- [ ] No duplicate entries + +### Release Preparation + +**IMPORTANT**: Since releases primarily use remote GitHub Actions, local state requirements are flexible. + +**Local state assessment:** +1. **Check git state** (informational, not blocking) + - Current branch + - Uncommitted changes (warn if present) + - Local vs remote status + +2. **If local changes exist:** + - Inform user of local changes + - Explain potential interference (if any) + - Offer assistance: + - Stash changes: `git stash` + - Commit changes: `git add . && git commit` + - Discard changes: `git reset --hard` (caution!) + - Let user decide - don't block + +3. **Remote state validation** (required): + - Main branch exists and is accessible + - CI passing on remote main + - Permissions to trigger workflows + - GitHub CLI authenticated + +**Pre-release validation:** +1. **Remote build state** (via GitHub Actions) + - Latest CI run on main passing + - No failing tests + - Coverage requirements met +2. **Documentation** + - CHANGELOG.md has unreleased changes (can update from any branch) + - README.md up to date +3. **Version** + - Version determined/validated + - Version doesn't exist on NuGet + - Version doesn't exist as git tag + +**Preparation automation:** +Use `prepare-release.fsx` script to automate: +- Remote CI status check +- Changelog parsing and validation +- Version suggestion based on changes +- NuGet version availability check +- Pre-flight checklist generation +- Local state advisory (not blocking) + +**Flexibility principle:** +- **MUST HAVE**: Remote state valid (CI passing, versions available) +- **NICE TO HAVE**: Local state clean (helpful but not required) +- **USER CHOICE**: How to handle local changes + +### Release Execution + +**Release workflow:** +1. **Create release tracking issue** (use template) +2. **Update CHANGELOG.md** (can be done from feature branch or main) + - Move unreleased β†’ version + - Can create PR if not on main + - Or commit directly if user prefers +3. **Trigger deployment workflow** (runs on GitHub, doesn't need local state) + ```bash + gh workflow run deployment.yml \ + --ref main \ + --field release-version={version} \ + --field configuration=Release + ``` +4. **Monitor workflow** (use monitor-release.fsx) +5. **Track progress** in release issue +6. **Handle failures** (document, resume, or abort) + +**GitHub Actions workflow stages:** +1. **Validate version** - Semantic versioning check +2. **Build executables** - Matrix build (5 platforms) +3. **Run E2E tests** - Per-platform testing +4. **Release** - Pack, publish to NuGet +5. **CD** - Aggregation step + +**Monitoring points:** +- Workflow triggered successfully +- Version validation passed +- Each platform build status +- E2E test results per platform +- Package creation status +- NuGet publishing status + +### Release Monitoring + +**IMPORTANT**: Use `monitor-release.fsx` to automate monitoring and reduce token usage. + +**The monitor script handles:** +- Polling GitHub Actions workflow status +- Detecting completion/failure +- Parsing workflow logs for errors +- Generating status summary +- Updating release tracking issue +- Alerting on failures + +**Manual monitoring (when script unavailable):** +```bash +# List recent runs +gh run list --workflow=deployment.yml --limit 5 + +# Watch specific run +gh run watch {run-id} + +# View run details +gh run view {run-id} + +# Check specific job +gh run view {run-id} --job {job-id} +``` + +**Status interpretation:** +- βœ… **completed/success** - Step passed +- ⏳ **in_progress** - Currently running +- ⏸️ **queued** - Waiting to start +- ❌ **completed/failure** - Step failed +- ⚠️ **completed/cancelled** - Manually cancelled + +### Release Verification + +**Post-release verification:** +1. **Package validation** + - [ ] All 4 packages on NuGet.org + - [ ] Correct version number + - [ ] Package metadata correct + - [ ] LICENSE file included + - [ ] README.md included +2. **Installation testing** + - [ ] Tool installs from NuGet + - [ ] Libraries can be referenced + - [ ] Executables work on all platforms +3. **Functional testing** + - [ ] Hand off to QA Tester skill + - [ ] Run smoke tests + - [ ] Validate key commands work +4. **Documentation** + - [ ] GitHub release created + - [ ] Release notes complete + - [ ] "What's New" updated + - [ ] Breaking changes documented + +**Verification automation:** +Use `validate-release.fsx` script: +- Query NuGet.org for packages +- Test tool installation +- Run smoke tests +- Generate verification report +- Update tracking issue + +**QA Tester handoff:** +After release published, coordinate with QA Tester: +``` +@skill qa-tester +Please run smoke tests for release v{version}. + +Packages published to NuGet: +- Morphir.Core v{version} +- Morphir.Tooling v{version} +- Morphir v{version} +- Morphir.Tool v{version} + +Verify: +1. Tool installation: dotnet tool install -g Morphir.Tool --version {version} +2. Basic commands work: dotnet-morphir --version +3. Key functionality: dotnet-morphir ir verify [test-file] + +Report results in release tracking issue #{issue-number} +``` + +### Release Documentation + +**"What's New" generation:** +1. Extract highlights from changelog +2. Focus on user-visible changes +3. Include: + - Top 3-5 new features + - Important bug fixes + - Breaking changes with migration guide + - Performance improvements + - Links to detailed docs +4. Format for documentation site +5. Add to docs/content/docs/whats-new/v{version}.md + +**Release notes template:** +```markdown +# What's New in v{version} + +Released on {date} + +## Highlights + +{Top features from changelog - user focused} + +## Breaking Changes + +{If any - with migration guide} + +## New Features + +{Added items from changelog} + +## Improvements + +{Changed items from changelog} + +## Bug Fixes + +{Fixed items from changelog} + +## Installation + +```bash +# Install or update the CLI tool +dotnet tool update -g Morphir.Tool + +# Or install libraries +dotnet add package Morphir.Core --version {version} +``` + +## Full Changelog + +See [CHANGELOG.md](../../CHANGELOG.md#v{version}) for complete details. +``` + +### Release Recovery + +**When a release fails:** +1. **Identify failure point** (which workflow stage) +2. **Capture diagnostics** (logs, error messages) +3. **Update tracking issue** with failure details +4. **Determine if resumable**: + - **Resumable**: Infrastructure issue, transient error + - **Not resumable**: Code issue, test failure β†’ fix and retry +5. **Document root cause** +6. **Update release playbook** with prevention steps + +**Resume workflow:** +Use `resume-release.fsx` script: +1. Read tracking issue for context +2. Identify last successful step +3. Validate fixes applied +4. Resume from appropriate point +5. Update tracking issue progress + +**Common failure scenarios:** + +| Failure | Resumable? | Action | +|---------|-----------|--------| +| E2E test failure | No | Fix tests, new release attempt | +| Platform build timeout | Yes | Re-run workflow | +| NuGet publish failure | Yes | Re-run publish step | +| Network/infrastructure | Yes | Retry workflow | +| Version already exists | No | Increment version, retry | +| Invalid semver | No | Fix version, retry | + +### Release Playbook Maintenance + +**CRITICAL**: Keep `.agents/release-management.md` playbook updated. + +**After each release, update playbook with:** +- Issues encountered and solutions +- New automation added +- Process improvements discovered +- Changed tool versions +- Updated GitHub Actions configurations + +**Playbook sections:** +1. Overview and quick start +2. Prerequisites and setup +3. Preparation workflow +4. Execution workflow +5. Monitoring workflow +6. Verification workflow +7. Troubleshooting guide +8. Recovery procedures +9. Post-release tasks +10. Lessons learned + +## Release Playbooks + +### 1. Standard Release Playbook + +**When**: Regular release from main branch + +**Prerequisites:** +- All planned features merged to main +- CI passing on remote main branch +- CHANGELOG.md updated with changes +- Version determined +- GitHub CLI authenticated (`gh auth status`) + +**Steps:** + +**Phase 1: Preparation (10-15 min)** + +1. **Assess local state** (advisory) + ```bash + git status + ``` + - If local changes exist, offer to help: + - Stash: `git stash save "WIP before release v{version}"` + - Commit: Create WIP commit + - Continue anyway (if changes don't interfere) + +2. **Run pre-flight checks** + ```bash + dotnet fsi .claude/skills/release-manager/prepare-release.fsx + ``` + - Validates remote CI status + - Parses changelog + - Suggests version + - Checks NuGet availability + - Generates pre-flight report + +3. **Review and confirm version** + - Review suggested version + - Override if needed + - Validate version doesn't exist + +4. **Create release tracking issue** + ```bash + gh issue create \ + --title "Release v{version}" \ + --body-file .claude/skills/release-manager/templates/release-tracking.md \ + --label release,tracking \ + --milestone v{version} + ``` + +5. **Update CHANGELOG.md** + - Move [Unreleased] β†’ [version] with date + - Update comparison links + - Create new [Unreleased] section + - Options: + - **If on main**: Commit directly `chore: prepare release v{version}` + - **If on branch**: Create PR with changelog update + - **User choice**: Let user decide approach + +**Phase 2: Execution (30-45 min)** + +6. **Trigger deployment workflow** + ```bash + gh workflow run deployment.yml \ + --ref main \ + --field release-version={version} \ + --field configuration=Release + ``` + + Note: `--ref main` ensures workflow runs from main branch regardless of local state + +7. **Monitor workflow** + ```bash + dotnet fsi .claude/skills/release-manager/monitor-release.fsx --version {version} + ``` + - Tracks workflow progress + - Updates tracking issue + - Alerts on failures + +8. **Handle any failures** + - If failure, diagnose and document + - Determine if resumable + - Take corrective action + - Update tracking issue + +**Phase 3: Verification (15-20 min)** + +9. **Validate packages published** + ```bash + dotnet fsi .claude/skills/release-manager/validate-release.fsx --version {version} + ``` + - Checks NuGet.org for packages + - Tests installation + - Generates verification report + +10. **Hand off to QA Tester** + - Request smoke tests + - Provide package versions + - Reference tracking issue + +11. **Review QA results** + - Wait for QA sign-off + - Address any issues found + - Document in tracking issue + +**Phase 4: Documentation (10-15 min)** + +12. **Create "What's New" document** + - Extract highlights from changelog + - Add to docs/content/docs/whats-new/ + - Include migration guide if breaking changes + +13. **Update GitHub release** + - Verify release created by workflow + - Add release notes + - Attach any additional assets + +14. **Announce release** + - Update project README if needed + - Post to discussions/announcements + - Update project website + +**Phase 5: Post-Release (5-10 min)** + +15. **Update release playbook** + - Document any issues encountered + - Add new learnings + - Update automation scripts if needed + +16. **Close tracking issue** + - Mark all checklist items complete + - Add final summary + - Close issue with label: released + +**Total Time**: ~70-105 minutes + +**Output**: +- Release tracking issue (closed, labeled) +- Published packages on NuGet +- GitHub release with notes +- Updated documentation +- Updated playbook + +--- + +### 2. Hotfix Release Playbook + +**When**: Critical bug fix needed on released version + +**Prerequisites:** +- Bug identified in released version +- Fix developed and tested +- Severity justifies hotfix + +**Steps:** + +1. **Create hotfix branch** from release tag + ```bash + git checkout -b hotfix/v{version}-{issue} v{prev-version} + ``` + +2. **Apply fix** and commit + - Cherry-pick fix commit if available + - Or implement fix directly + - Commit with clear message + +3. **Increment patch version** + - Update version to {major}.{minor}.{patch+1} + - Update CHANGELOG.md with hotfix + +4. **Run tests** + ```bash + ./build.sh Test + ``` + +5. **Create release tracking issue** (hotfix) + +6. **Trigger deployment** with hotfix version + ```bash + gh workflow run deployment.yml \ + --ref hotfix/v{version}-{issue} \ + --field release-version={version} \ + --field configuration=Release + ``` + +7. **Monitor and verify** (same as standard release) + +8. **Merge back to main** + ```bash + git checkout main + git merge hotfix/v{version}-{issue} + git push + ``` + +**Total Time**: ~45-60 minutes + +--- + +### 3. Pre-release (Alpha/Beta/RC) Playbook + +**When**: Testing new features before stable release + +**Prerequisites:** +- Features ready for testing +- Known issues documented +- Target audience identified + +**Steps:** + +1. **Determine pre-release version** + - Alpha: {major}.{minor}.{patch}-alpha.{N} + - Beta: {major}.{minor}.{patch}-beta.{N} + - RC: {major}.{minor}.{patch}-rc.{N} + +2. **Update CHANGELOG.md** with pre-release marker + ```markdown + ## [1.0.0-alpha.1] - 2025-12-18 + + **Note**: This is a pre-release version for testing only. + ``` + +3. **Follow standard release workflow** with pre-release version + +4. **Mark GitHub release** as pre-release + ```bash + gh release edit v{version} --prerelease + ``` + +5. **Document known issues** in release notes + +6. **Communicate testing instructions** to early adopters + +**Total Time**: ~75-110 minutes + +--- + +### 4. Failed Release Recovery Playbook + +**When**: Deployment workflow fails mid-process + +**Prerequisites:** +- Failure identified and documented +- Root cause determined +- Fix applied or workaround identified + +**Steps:** + +1. **Assess failure point** + ```bash + gh run view {run-id} --log-failed + ``` + +2. **Determine resumability** + - Read failure logs + - Check if transient or code issue + - Consult recovery decision table + +3. **If not resumable:** + - Fix underlying issue + - Increment version (if published to NuGet) + - Start new release attempt + - Reference original tracking issue + +4. **If resumable:** + ```bash + dotnet fsi .claude/skills/release-manager/resume-release.fsx \ + --version {version} \ + --issue {tracking-issue-number} + ``` + - Script reads tracking issue context + - Identifies last successful step + - Prompts for confirmation + - Resumes workflow + +5. **Monitor resumed workflow** + ```bash + dotnet fsi .claude/skills/release-manager/monitor-release.fsx \ + --version {version} \ + --resume + ``` + +6. **Update tracking issue** with recovery details + - What failed + - Why it failed + - How it was fixed + - Prevention steps for future + +7. **Update playbook** with new failure scenario + +**Total Time**: Variable (15-120 minutes depending on issue) + +--- + +## Automation Scripts + +### prepare-release.fsx + +**Purpose**: Automate pre-flight checks and preparation + +**Features:** +- Remote CI status validation (via GitHub API) +- Changelog parsing and validation +- Version suggestion based on change types +- NuGet version availability check +- Pre-flight checklist generation +- Local state advisory (informational only) + +**Usage:** +```bash +# Standard usage +dotnet fsi .claude/skills/release-manager/prepare-release.fsx + +# Specify version +dotnet fsi .claude/skills/release-manager/prepare-release.fsx --version 1.0.0 + +# Dry run +dotnet fsi .claude/skills/release-manager/prepare-release.fsx --dry-run + +# Skip local state check +dotnet fsi .claude/skills/release-manager/prepare-release.fsx --skip-local-check +``` + +**Output:** +- βœ…/❌ remote validation results +- ℹ️ local state advisory (non-blocking) +- πŸ“Š suggested version with rationale +- πŸ“‹ pre-flight checklist +- πŸ“ changelog summary +- Exit code: 0 (ready), 1 (not ready), 2 (warnings only) + +--- + +### monitor-release.fsx + +**Purpose**: Monitor GitHub Actions deployment workflow + +**Features:** +- Poll workflow status (configurable interval) +- Track job and step progress +- Detect failures early +- Parse logs for errors +- Update tracking issue automatically +- Generate progress reports +- Alert on completion/failure + +**Usage:** +```bash +# Monitor specific version +dotnet fsi .claude/skills/release-manager/monitor-release.fsx --version 1.0.0 + +# Monitor latest workflow +dotnet fsi .claude/skills/release-manager/monitor-release.fsx --latest + +# Update tracking issue +dotnet fsi .claude/skills/release-manager/monitor-release.fsx \ + --version 1.0.0 \ + --issue 219 \ + --update-issue + +# Custom poll interval (seconds) +dotnet fsi .claude/skills/release-manager/monitor-release.fsx \ + --version 1.0.0 \ + --interval 30 +``` + +**Output:** +- πŸ“Š Live progress table +- ⏱️ Elapsed/estimated time +- 🎯 Current stage/step +- βœ… Completed steps +- ⏳ Running steps +- ❌ Failed steps (with logs) +- Exit code: 0 (success), 1 (failure), 2 (cancelled) + +**Tracking issue updates:** +- Automatically checks/unchecks items +- Adds progress comments +- Updates status labels +- Attaches failure diagnostics + +--- + +### validate-release.fsx + +**Purpose**: Verify release was successful + +**Features:** +- Query NuGet.org for packages +- Validate package metadata +- Test tool installation +- Test library references +- Run basic smoke tests +- Generate verification report +- Update tracking issue + +**Usage:** +```bash +# Validate specific version +dotnet fsi .claude/skills/release-manager/validate-release.fsx --version 1.0.0 + +# Include smoke tests +dotnet fsi .claude/skills/release-manager/validate-release.fsx \ + --version 1.0.0 \ + --smoke-tests + +# Update tracking issue +dotnet fsi .claude/skills/release-manager/validate-release.fsx \ + --version 1.0.0 \ + --issue 219 \ + --update-issue +``` + +**Output:** +- βœ…/❌ validation results per package +- πŸ“¦ package metadata +- πŸ”§ installation test results +- πŸ§ͺ smoke test results +- πŸ“‹ verification summary +- Exit code: 0 (valid), 1 (invalid) + +--- + +### resume-release.fsx + +**Purpose**: Resume failed release from checkpoint + +**Features:** +- Read tracking issue for context +- Identify last successful step +- Validate prerequisites for resume +- Prompt for confirmation +- Resume workflow from appropriate point +- Update tracking issue + +**Usage:** +```bash +# Resume from tracking issue +dotnet fsi .claude/skills/release-manager/resume-release.fsx --issue 219 + +# Resume specific version +dotnet fsi .claude/skills/release-manager/resume-release.fsx \ + --version 1.0.0 \ + --issue 219 + +# Dry run (show what would be done) +dotnet fsi .claude/skills/release-manager/resume-release.fsx \ + --issue 219 \ + --dry-run +``` + +**Output:** +- πŸ“‹ Resume plan +- βœ… Prerequisites check +- ⚠️ Confirmation prompt +- πŸ”„ Resume actions +- Exit code: 0 (resumed), 1 (cannot resume), 2 (aborted) + +--- + +## GitHub Issue Templates + +### Release Tracking Issue Template + +Location: `.claude/skills/release-manager/templates/release-tracking.md` + +**Purpose**: Track single release lifecycle + +**Includes:** +- Release metadata (version, date, type) +- Pre-flight checklist +- Execution checklist +- Verification checklist +- Documentation checklist +- Links to workflow runs +- Links to published packages +- Notes section for issues/learnings + +--- + +## Integration with QA Tester + +**Handoff points:** + +1. **After packages published** β†’ QA smoke tests +2. **After installation verified** β†’ QA functional tests +3. **Before closing release** β†’ QA sign-off + +**Communication format:** +``` +@skill qa-tester + +Release v{version} ready for verification. + +**Packages:** +- Morphir.Core v{version}: https://nuget.org/packages/Morphir.Core/{version} +- Morphir.Tooling v{version}: https://nuget.org/packages/Morphir.Tooling/{version} +- Morphir v{version}: https://nuget.org/packages/Morphir/{version} +- Morphir.Tool v{version}: https://nuget.org/packages/Morphir.Tool/{version} + +**Test Plan:** +1. Run smoke-test.fsx +2. Test tool installation from NuGet +3. Verify key commands work +4. Check for regressions + +**Tracking Issue:** #{issue-number} + +Please update tracking issue with results. +``` + +**QA feedback integration:** +- QA adds comment to tracking issue +- Release Manager reviews results +- Issues addressed before closing release +- QA sign-off required for completion + +--- + +## Best Practices + +### Version Selection +1. **Follow semantic versioning strictly** +2. **Analyze all changes** in [Unreleased] +3. **Err on side of caution** (major vs minor) +4. **Consult team** for breaking changes +5. **Document rationale** in tracking issue + +### Changelog Management +1. **Update continuously** during development +2. **Categorize clearly** (Added, Changed, Fixed, etc.) +3. **Be user-focused** (not implementation details) +4. **Reference issues/PRs** for traceability +5. **Mark breaking changes** prominently + +### Release Execution +1. **Never rush** - follow all steps +2. **Monitor actively** - don't set and forget +3. **Document everything** - issues, workarounds, learnings +4. **Coordinate with QA** - don't skip verification +5. **Update playbook** - continuous improvement + +### Local State Flexibility +1. **Prefer clean state** but don't require it +2. **Warn users** about potential interference +3. **Offer assistance** for state management +4. **Let users decide** their workflow +5. **Use --ref main** to ensure remote execution + +### Failure Handling +1. **Stay calm** - failures happen +2. **Diagnose thoroughly** - understand root cause +3. **Document completely** - help future releases +4. **Prevent recurrence** - update automation +5. **Learn continuously** - improve process + +### Documentation +1. **Write for users** - not developers +2. **Highlight breaking changes** - with migration guide +3. **Show examples** - not just lists +4. **Link to details** - don't duplicate docs +5. **Keep current** - update with each release + +--- + +## Troubleshooting + +### "Version already exists on NuGet" + +**Cause**: Trying to publish same version twice + +**Solution**: +1. Check NuGet.org - was previous release successful? +2. If successful: Increment version, retry +3. If failed: Contact NuGet support to unlist +4. Prevention: Better validation in prepare-release.fsx + +### "E2E tests fail on specific platform" + +**Cause**: Platform-specific bug or flaky test + +**Solution**: +1. Review platform-specific logs +2. If infrastructure issue: Re-run workflow +3. If actual bug: Fix and new release +4. If flaky test: Fix test, new release + +### "NuGet publish timeout" + +**Cause**: Network issue or NuGet.org downtime + +**Solution**: +1. Check NuGet.org status +2. Wait and retry if transient +3. Contact support if persistent + +### "Workflow run not found" + +**Cause**: Workflow didn't trigger or permissions issue + +**Solution**: +1. Check workflow file syntax +2. Verify GH_TOKEN permissions +3. Check branch protection rules +4. Manually trigger: `gh workflow run deployment.yml --ref main` + +### "Local changes might interfere" + +**Not an error** - Just advisory + +**Options**: +1. **Stash changes**: `git stash save "WIP before release"` +2. **Commit changes**: Create WIP commit +3. **Continue anyway**: If changes don't affect release +4. **Let user choose**: Their workflow, their decision + +--- + +## References + +- **Keep a Changelog**: https://keepachangelog.com/ +- **Semantic Versioning**: https://semver.org/ +- **GitHub CLI**: https://cli.github.com/ +- **NuGet**: https://www.nuget.org/ +- **AGENTS.md**: Release management section (to be added) +- **QA Tester Skill**: `.claude/skills/qa-tester/skill.md` +- **Deployment Workflow**: `.github/workflows/deployment.yml` + +--- + +## Continuous Improvement + +**After each release:** +1. Review what went well +2. Document what went wrong +3. Update automation scripts +4. Enhance playbook +5. Share learnings with team +6. Update AGENTS.md if needed + +**Metrics to track:** +- Time to release +- Failed releases (count and reasons) +- Manual interventions needed +- Documentation completeness +- QA issues found post-release + +**Goal**: Fully automated, reliable, repeatable releases with flexible workflows. + +--- + +**Remember**: Releases represent the project's quality and professionalism. Take your time, follow the process, document everything, and continuously improve. Be flexible with local state while maintaining strict standards for remote execution. Users depend on reliable releases. diff --git a/.claude/skills/release-manager/templates/release-tracking.md b/.claude/skills/release-manager/templates/release-tracking.md new file mode 100644 index 00000000..a68ed22f --- /dev/null +++ b/.claude/skills/release-manager/templates/release-tracking.md @@ -0,0 +1,285 @@ +# Release v{VERSION} + +**Release Type**: [ ] Stable / [ ] Pre-release (Alpha/Beta/RC) / [ ] Hotfix +**Target Date**: {DATE} +**Release Manager**: @{GITHUB_USERNAME} +**Status**: 🟑 In Progress + +--- + +## Release Information + +- **Version**: `{VERSION}` +- **Previous Version**: `{PREV_VERSION}` +- **Branch**: `{BRANCH}` +- **Changelog**: [CHANGELOG.md](../CHANGELOG.md#v{VERSION_ANCHOR}) +- **Deployment Workflow**: [View Run](https://github.com/finos/morphir-dotnet/actions/workflows/deployment.yml) + +--- + +## πŸ“‹ Release Checklist + +### Phase 1: Preparation + +- [ ] Pre-flight checks completed (`prepare-release.fsx`) + - [ ] Remote CI passing on main branch + - [ ] CHANGELOG.md [Unreleased] section populated + - [ ] Version validated (semantic versioning) + - [ ] Version available (not on NuGet, not a git tag) + - [ ] Local state assessed (advisory only) +- [ ] Version confirmed: `{VERSION}` +- [ ] Release tracking issue created (this issue) +- [ ] CHANGELOG.md updated + - [ ] Moved [Unreleased] β†’ [{VERSION}] + - [ ] Added release date + - [ ] Updated comparison links + - [ ] Created new [Unreleased] section + - [ ] Changes properly categorized + - [ ] Breaking changes marked +- [ ] Changelog committed (if applicable) + +### Phase 2: Execution + +- [ ] Deployment workflow triggered + - **Run ID**: {RUN_ID} + - **Run URL**: {RUN_URL} + - **Triggered at**: {TIMESTAMP} +- [ ] Monitoring started (`monitor-release.fsx`) +- [ ] Workflow stages completed: + - [ ] Version validation + - [ ] Build executables + - [ ] linux-x64 + - [ ] linux-arm64 + - [ ] win-x64 + - [ ] osx-arm64 + - [ ] osx-x64 + - [ ] E2E tests (all platforms) + - [ ] Pack packages + - [ ] Publish to NuGet + - [ ] CD aggregation + +### Phase 3: Verification + +- [ ] Package validation completed (`validate-release.fsx`) +- [ ] Packages on NuGet.org: + - [ ] [Morphir.Core v{VERSION}](https://www.nuget.org/packages/Morphir.Core/{VERSION}) + - [ ] [Morphir.Tooling v{VERSION}](https://www.nuget.org/packages/Morphir.Tooling/{VERSION}) + - [ ] [Morphir v{VERSION}](https://www.nuget.org/packages/Morphir/{VERSION}) + - [ ] [Morphir.Tool v{VERSION}](https://www.nuget.org/packages/Morphir.Tool/{VERSION}) +- [ ] Installation tests passed + - [ ] Tool installs: `dotnet tool install -g Morphir.Tool --version {VERSION}` + - [ ] Tool executes: `dotnet-morphir --version` + - [ ] Libraries reference correctly +- [ ] QA Tester sign-off (issue #{QA_ISSUE}) + - [ ] Smoke tests passed + - [ ] Functional tests passed + - [ ] Regression tests passed + - [ ] No critical issues found + +### Phase 4: Documentation + +- [ ] GitHub release created + - **Release URL**: {RELEASE_URL} + - [ ] Release notes added + - [ ] Pre-release flag set (if applicable) + - [ ] Binaries attached (if applicable) +- [ ] "What's New" document created + - [ ] File: `docs/content/docs/whats-new/v{VERSION}.md` + - [ ] Highlights extracted from changelog + - [ ] Breaking changes documented with migration guide + - [ ] Examples included +- [ ] Documentation site updated + - [ ] Navigation updated + - [ ] Version selector updated +- [ ] README.md updated (if needed) +- [ ] Announcement prepared + - [ ] GitHub Discussions post + - [ ] Community channels (if applicable) + +### Phase 5: Post-Release + +- [ ] Release playbook updated + - [ ] Issues encountered documented + - [ ] Solutions recorded + - [ ] Process improvements noted +- [ ] Metrics recorded + - **Time to release**: {DURATION} + - **Manual interventions**: {COUNT} + - **Issues encountered**: {COUNT} +- [ ] Retrospective notes added (see below) +- [ ] Release tracking issue closed + +--- + +## πŸ“Š Release Metrics + +| Metric | Value | +|--------|-------| +| **Preparation Time** | {PREP_TIME} | +| **Execution Time** | {EXEC_TIME} | +| **Verification Time** | {VERIFY_TIME} | +| **Documentation Time** | {DOC_TIME} | +| **Total Time** | {TOTAL_TIME} | +| **Manual Interventions** | {INTERVENTIONS} | +| **Failures/Retries** | {FAILURES} | +| **QA Issues Found** | {QA_ISSUES} | + +--- + +## πŸ”— Links + +### Workflow Runs +- Deployment Workflow: {DEPLOYMENT_RUN_URL} +- CI Workflow (pre-release): {CI_RUN_URL} + +### Packages +- Morphir.Core: https://www.nuget.org/packages/Morphir.Core/{VERSION} +- Morphir.Tooling: https://www.nuget.org/packages/Morphir.Tooling/{VERSION} +- Morphir: https://www.nuget.org/packages/Morphir/{VERSION} +- Morphir.Tool: https://www.nuget.org/packages/Morphir.Tool/{VERSION} + +### Documentation +- GitHub Release: {GITHUB_RELEASE_URL} +- What's New: {WHATS_NEW_URL} +- Changelog: {CHANGELOG_URL} + +### Issues +- QA Tracking Issue: #{QA_ISSUE} +- Related Issues: {RELATED_ISSUES} + +--- + +## πŸ“ Notes + +### Issues Encountered + +{Document any issues encountered during the release process} + +**Example:** +- **Issue**: Platform build timeout on osx-arm64 +- **Cause**: GitHub Actions runner resource contention +- **Solution**: Re-ran workflow +- **Prevention**: Consider using retry logic in workflow + +### Process Improvements + +{Document improvements discovered during this release} + +**Example:** +- Added monitor-release.fsx script to reduce manual monitoring +- Improved changelog validation in prepare-release.fsx +- Updated pre-flight checks to validate remote CI status + +### Breaking Changes + +{List any breaking changes in this release with migration guidance} + +**Example:** +- **Change**: Removed deprecated `OldApi.Method()` +- **Migration**: Use `NewApi.Method()` instead +- **Documentation**: See migration guide in What's New + +### Special Thanks + +{Acknowledge contributors to this release} + +**Example:** +- @contributor1 - Implemented feature X +- @contributor2 - Fixed critical bug Y +- @qa-tester - Comprehensive testing and validation + +--- + +## πŸ”„ Resumption Information + +**For use with resume-release.fsx if release fails** + +- **Last Successful Phase**: {LAST_PHASE} +- **Last Successful Step**: {LAST_STEP} +- **Failure Point**: {FAILURE_POINT} +- **Can Resume**: [ ] Yes / [ ] No +- **Resume Command**: + ```bash + dotnet fsi .claude/skills/release-manager/resume-release.fsx \ + --version {VERSION} \ + --issue {ISSUE_NUMBER} + ``` + +--- + +## πŸ“š Retrospective + +### What Went Well βœ… + +{List things that went smoothly} + +### What Could Be Improved πŸ“ˆ + +{List areas for improvement} + +### Action Items 🎯 + +{List concrete action items to improve future releases} + +- [ ] Action item 1 +- [ ] Action item 2 + +--- + +## Commands Reference + +### Preparation +```bash +# Pre-flight checks +dotnet fsi .claude/skills/release-manager/prepare-release.fsx + +# Create tracking issue (use gh CLI) +gh issue create --title "Release v{VERSION}" \ + --body-file .claude/skills/release-manager/templates/release-tracking.md \ + --label release,tracking +``` + +### Execution +```bash +# Trigger deployment +gh workflow run deployment.yml \ + --ref main \ + --field release-version={VERSION} \ + --field configuration=Release + +# Monitor release +dotnet fsi .claude/skills/release-manager/monitor-release.fsx \ + --version {VERSION} \ + --issue {ISSUE_NUMBER} \ + --update-issue +``` + +### Verification +```bash +# Validate release +dotnet fsi .claude/skills/release-manager/validate-release.fsx \ + --version {VERSION} \ + --smoke-tests \ + --issue {ISSUE_NUMBER} \ + --update-issue + +# Test installation +dotnet tool install -g Morphir.Tool --version {VERSION} +dotnet-morphir --version +``` + +### Recovery +```bash +# Resume failed release +dotnet fsi .claude/skills/release-manager/resume-release.fsx \ + --version {VERSION} \ + --issue {ISSUE_NUMBER} +``` + +--- + +**Release Manager**: This issue is managed by the Release Manager skill +**Automation**: Updates via monitor-release.fsx and validate-release.fsx +**QA Integration**: Coordinated with QA Tester skill + +/cc @{TEAM_MEMBERS} diff --git a/.claude/skills/release-manager/validate-release.fsx b/.claude/skills/release-manager/validate-release.fsx new file mode 100644 index 00000000..f184258e --- /dev/null +++ b/.claude/skills/release-manager/validate-release.fsx @@ -0,0 +1,614 @@ +#!/usr/bin/env dotnet fsi +// Validate a morphir-dotnet release on NuGet +// Usage: dotnet fsi validate-release.fsx --version 1.2.0 [--smoke-tests] [--issue ] [--update-issue] [--json] [--timeout ] + +#r "nuget: Spectre.Console, 0.53.0" +#r "nuget: System.Text.Json, 9.0.0" +#r "nuget: Argu, 6.2.4" + +open System +open System.IO +open System.Diagnostics +open System.Text.Json +open System.Text.Json.Serialization +open System.Threading +open Argu +open Spectre.Console + +// ============================================================================ +// CLI Arguments +// ============================================================================ + +type ValidateArguments = + | [] Version of string + | [] Issue of int + | Update_Issue + | Smoke_Tests + | Json + | [] Timeout of int + + interface IArgParserTemplate with + member s.Usage = + match s with + | Version _ -> "Release version to validate (e.g., 1.2.0)" + | Issue _ -> "GitHub issue number to update with validation results" + | Update_Issue -> "Update the specified issue with validation status" + | Smoke_Tests -> "Run smoke tests after validation" + | Json -> "Output results as JSON" + | Timeout _ -> "Maximum time to wait in minutes" + +// ============================================================================ +// Types +// ============================================================================ + +type PackageInfo = { + Name: string + Version: string + Available: bool + DownloadUrl: string option + PublishedAt: DateTime option +} + +type SmokeTestResult = { + TestName: string + Passed: bool + Duration: TimeSpan + Error: string option +} + +type ValidationResult = { + Success: bool + Version: string + Packages: PackageInfo list + AllPackagesAvailable: bool + ToolInstallTest: bool option + SmokeTests: SmokeTestResult list + IssueUpdated: bool + TimedOut: bool + Cancelled: bool + Warnings: string list + Errors: string list + ExitCode: int +} + +// ============================================================================ +// Utilities +// ============================================================================ + +let projectRoot = + let scriptDir = __SOURCE_DIRECTORY__ + Path.GetFullPath(Path.Combine(scriptDir, "..", "..", "..")) + +let logInfo msg = + eprintfn "[INFO] %s" msg + +let logWarn msg = + eprintfn "[WARN] %s" msg + +let logError msg = + eprintfn "[ERROR] %s" msg + +let runCommandAsync (command: string) (args: string) (cancellationToken: CancellationToken) : Async> = + async { + try + let psi = ProcessStartInfo( + FileName = command, + Arguments = args, + WorkingDirectory = projectRoot, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + ) + + use proc = new Process() + proc.StartInfo <- psi + proc.Start() |> ignore + + // Register cancellation + use _ = cancellationToken.Register(fun () -> + try + if not proc.HasExited then + proc.Kill() + with _ -> () + ) + + let! output = proc.StandardOutput.ReadToEndAsync() |> Async.AwaitTask + let! error = proc.StandardError.ReadToEndAsync() |> Async.AwaitTask + proc.WaitForExit() + + if cancellationToken.IsCancellationRequested then + return Error "Command cancelled" + elif proc.ExitCode = 0 then + return Ok output + else + return Error (if String.IsNullOrWhiteSpace(error) then output else error) + with + | :? OperationCanceledException -> + return Error "Command cancelled" + | ex -> + return Error ex.Message + } + +// ============================================================================ +// Package Validation +// ============================================================================ + +let expectedPackages = [ + "Morphir.Core" + "Morphir.Tooling" + "Morphir" + "Morphir.Tool" +] + +let checkPackageAsync (packageName: string) (version: string) (ct: CancellationToken) : Async = + async { + logInfo (sprintf "Checking %s v%s on NuGet..." packageName version) + + let! result = runCommandAsync "dotnet" (sprintf "package search %s --exact-match --format json" packageName) ct + + match result with + | Error err -> + logWarn (sprintf "Failed to search for %s: %s" packageName err) + return { + Name = packageName + Version = version + Available = false + DownloadUrl = None + PublishedAt = None + } + | Ok output -> + try + use doc = JsonDocument.Parse(output) + let searchResults = doc.RootElement.GetProperty("searchResult") + + let packageElement = + searchResults.EnumerateArray() + |> Seq.tryFind (fun p -> + let packages = p.GetProperty("packages") + packages.EnumerateArray() + |> Seq.exists (fun pkg -> + pkg.GetProperty("id").GetString() = packageName && + pkg.GetProperty("version").GetString() = version + ) + ) + + match packageElement with + | Some elem -> + let pkg = + elem.GetProperty("packages").EnumerateArray() + |> Seq.find (fun pkg -> + pkg.GetProperty("id").GetString() = packageName && + pkg.GetProperty("version").GetString() = version + ) + + let downloadUrl = sprintf "https://www.nuget.org/packages/%s/%s" packageName version + + logInfo (sprintf "βœ“ %s v%s found on NuGet" packageName version) + + return { + Name = packageName + Version = version + Available = true + DownloadUrl = Some downloadUrl + PublishedAt = None // NuGet API doesn't provide this easily via dotnet CLI + } + | None -> + logWarn (sprintf "βœ— %s v%s not found on NuGet" packageName version) + return { + Name = packageName + Version = version + Available = false + DownloadUrl = None + PublishedAt = None + } + with ex -> + logError (sprintf "Failed to parse package search results: %s" ex.Message) + return { + Name = packageName + Version = version + Available = false + DownloadUrl = None + PublishedAt = None + } + } + +// ============================================================================ +// Tool Installation Test +// ============================================================================ + +let testToolInstallAsync (version: string) (ct: CancellationToken) : Async> = + async { + logInfo "Testing tool installation..." + + // Uninstall first (ignore errors) + let! _ = runCommandAsync "dotnet" "tool uninstall -g Morphir.Tool" ct + + // Try to install the specific version + let! installResult = runCommandAsync "dotnet" (sprintf "tool install -g Morphir.Tool --version %s" version) ct + + match installResult with + | Error err -> return Error (sprintf "Tool installation failed: %s" err) + | Ok _ -> + logInfo "Tool installed successfully, testing execution..." + + // Test that the tool runs + let! versionResult = runCommandAsync "dotnet-morphir" "--version" ct + + match versionResult with + | Ok output -> + if output.Contains(version) then + logInfo "βœ“ Tool executes and reports correct version" + return Ok () + else + return Error (sprintf "Tool version mismatch. Expected %s, got: %s" version output) + | Error err -> + return Error (sprintf "Tool execution failed: %s" err) + } + +// ============================================================================ +// Smoke Tests +// ============================================================================ + +let runSmokeTestAsync (testName: string) (testAction: CancellationToken -> Async>) (ct: CancellationToken) : Async = + async { + let sw = Stopwatch.StartNew() + let! result = testAction ct + sw.Stop() + + match result with + | Ok () -> + return { + TestName = testName + Passed = true + Duration = sw.Elapsed + Error = None + } + | Error err -> + return { + TestName = testName + Passed = false + Duration = sw.Elapsed + Error = Some err + } + } + +let runAllSmokeTestsAsync (version: string) (ct: CancellationToken) : Async = + async { + logInfo "Running smoke tests..." + + // For now, we'll use the existing smoke-test.fsx if it exists + let smokeTestPath = Path.Combine(projectRoot, ".claude", "skills", "qa-tester", "smoke-test.fsx") + + if File.Exists(smokeTestPath) then + let! result = runSmokeTestAsync "QA Smoke Tests" (fun ct -> + async { + let! cmdResult = runCommandAsync "dotnet" (sprintf "fsi %s" smokeTestPath) ct + match cmdResult with + | Ok _ -> return Ok () + | Error err -> return Error err + } + ) ct + + return [result] + else + logWarn "smoke-test.fsx not found, skipping smoke tests" + return [] + } + +// ============================================================================ +// GitHub Issue Update +// ============================================================================ + +let updateIssueAsync (issueNumber: int) (version: string) (result: ValidationResult) (ct: CancellationToken) : Async> = + async { + logInfo (sprintf "Updating issue #%d with validation results..." issueNumber) + + let packageStatus = + result.Packages + |> List.map (fun pkg -> + let icon = if pkg.Available then "βœ…" else "❌" + sprintf "- [%s] [%s v%s](%s)" icon pkg.Name pkg.Version (pkg.DownloadUrl |> Option.defaultValue "N/A") + ) + |> String.concat "\n" + + let toolStatus = + match result.ToolInstallTest with + | Some true -> "βœ… Passed" + | Some false -> "❌ Failed" + | None -> "⏭️ Skipped" + + let smokeTestStatus = + if List.isEmpty result.SmokeTests then + "⏭️ Skipped" + elif result.SmokeTests |> List.forall (fun t -> t.Passed) then + sprintf "βœ… All %d tests passed" result.SmokeTests.Length + else + let failed = result.SmokeTests |> List.filter (fun t -> not t.Passed) |> List.length + sprintf "❌ %d of %d tests failed" failed result.SmokeTests.Length + + let comment = sprintf """## Validation Results + +**Version**: %s +**Status**: %s + +### Package Availability + +%s + +### Tool Installation + +%s + +### Smoke Tests + +%s + +--- +*Updated by validate-release.fsx* +""" version (if result.Success then "βœ… Passed" else "❌ Failed") packageStatus toolStatus smokeTestStatus + + let escapedComment = comment.Replace("\"", "\\\"").Replace("\n", "\\n") + let! cmdResult = runCommandAsync "gh" (sprintf "issue comment %d --body \"%s\"" issueNumber escapedComment) ct + + match cmdResult with + | Ok _ -> + logInfo "Issue updated successfully" + return Ok () + | Error err -> + return Error (sprintf "Failed to update issue: %s" err) + } + +// ============================================================================ +// Display Helpers +// ============================================================================ + +let displayPackages (packages: PackageInfo list) = + let table = Table() + table.AddColumn("Package") |> ignore + table.AddColumn("Version") |> ignore + table.AddColumn("Status") |> ignore + + for pkg in packages do + let statusMarkup = + if pkg.Available then "[green]Available[/]" + else "[red]Not Found[/]" + + table.AddRow(pkg.Name, pkg.Version, statusMarkup) |> ignore + + AnsiConsole.Write(table) + AnsiConsole.WriteLine() + +let displaySmokeTests (tests: SmokeTestResult list) = + if List.isEmpty tests then + AnsiConsole.MarkupLine("[dim]No smoke tests run[/]") + else + let table = Table() + table.AddColumn("Test") |> ignore + table.AddColumn("Status") |> ignore + table.AddColumn("Duration") |> ignore + + for test in tests do + let statusMarkup = + if test.Passed then "[green]βœ“ Passed[/]" + else "[red]βœ— Failed[/]" + + let durationStr = sprintf "%.2fs" test.Duration.TotalSeconds + + table.AddRow(test.TestName, statusMarkup, durationStr) |> ignore + + AnsiConsole.Write(table) + AnsiConsole.WriteLine() + +// ============================================================================ +// Main Logic +// ============================================================================ + +let validateAsync (results: ParseResults) (ct: CancellationToken) : Async = + async { + let version = results.GetResult Version + let issueNumber = results.TryGetResult Issue + let updateIssue = results.Contains Update_Issue + let runSmokeTests = results.Contains Smoke_Tests + let jsonOutput = results.Contains Json + + let mutable warnings = [] + let mutable errors = [] + let mutable timedOut = false + let mutable cancelled = false + + if not jsonOutput then + AnsiConsole.Write( + FigletText("Release Validator") + .Centered() + .Color(Color.Blue) + ) + AnsiConsole.MarkupLine(sprintf "[bold]Version:[/] %s" version) + match results.TryGetResult Timeout with + | Some mins -> AnsiConsole.MarkupLine(sprintf "[dim]Timeout: %d minutes[/]" mins) + | None -> () + AnsiConsole.WriteLine() + + try + // Check all packages + if not jsonOutput then + AnsiConsole.MarkupLine("[bold]Checking packages on NuGet...[/]") + + let! packages = + expectedPackages + |> List.map (fun pkg -> checkPackageAsync pkg version ct) + |> Async.Parallel + + let packageList = packages |> Array.toList + let allAvailable = packageList |> List.forall (fun p -> p.Available) + + if not jsonOutput then + displayPackages packageList + + if not allAvailable then + let missing = packageList |> List.filter (fun p -> not p.Available) |> List.map (fun p -> p.Name) + errors <- (sprintf "Missing packages: %s" (String.concat ", " missing)) :: errors + + // Test tool installation + let! toolTest = + async { + if not jsonOutput then + AnsiConsole.MarkupLine("[bold]Testing tool installation...[/]") + + let! result = testToolInstallAsync version ct + match result with + | Ok () -> + if not jsonOutput then + AnsiConsole.MarkupLine("[green]βœ“ Tool installation test passed[/]") + return Some true + | Error err -> + if not jsonOutput then + AnsiConsole.MarkupLine(sprintf "[red]βœ— Tool installation test failed: %s[/]" err) + errors <- (sprintf "Tool installation failed: %s" err) :: errors + return Some false + } + + // Run smoke tests if requested + let! smokeTests = + if runSmokeTests then + async { + if not jsonOutput then + AnsiConsole.MarkupLine("[bold]Running smoke tests...[/]") + + let! tests = runAllSmokeTestsAsync version ct + + if not jsonOutput then + displaySmokeTests tests + + let failedTests = tests |> List.filter (fun t -> not t.Passed) + if not (List.isEmpty failedTests) then + for test in failedTests do + errors <- (sprintf "Smoke test '%s' failed: %s" test.TestName (test.Error |> Option.defaultValue "Unknown error")) :: errors + + return tests + } + else + async { return [] } + + let success = allAvailable && (toolTest = Some true) && (smokeTests |> List.forall (fun t -> t.Passed)) + + let validationResult = { + Success = success + Version = version + Packages = packageList + AllPackagesAvailable = allAvailable + ToolInstallTest = toolTest + SmokeTests = smokeTests + IssueUpdated = false + TimedOut = timedOut + Cancelled = cancelled + Warnings = warnings + Errors = errors + ExitCode = if success then 0 else 1 + } + + // Update issue if requested + let! issueUpdated = + async { + match issueNumber, updateIssue with + | Some issueNum, true -> + let! updateResult = updateIssueAsync issueNum version validationResult ct + match updateResult with + | Ok () -> return true + | Error err -> + warnings <- (sprintf "Failed to update issue: %s" err) :: warnings + return false + | _ -> return false + } + + if not jsonOutput then + AnsiConsole.WriteLine() + if success then + AnsiConsole.MarkupLine("[green]βœ… All validations passed[/]") + else + AnsiConsole.MarkupLine("[red]❌ Some validations failed[/]") + + return { validationResult with IssueUpdated = issueUpdated } + + with + | :? OperationCanceledException -> + if ct.IsCancellationRequested then + cancelled <- true + logWarn "Operation cancelled" + return { + Success = false + Version = version + Packages = [] + AllPackagesAvailable = false + ToolInstallTest = None + SmokeTests = [] + IssueUpdated = false + TimedOut = timedOut + Cancelled = cancelled + Warnings = warnings + Errors = ["Operation cancelled"] @ errors + ExitCode = 2 + } + | :? TimeoutException -> + timedOut <- true + logError "Operation timed out" + return { + Success = false + Version = version + Packages = [] + AllPackagesAvailable = false + ToolInstallTest = None + SmokeTests = [] + IssueUpdated = false + TimedOut = timedOut + Cancelled = cancelled + Warnings = warnings + Errors = ["Operation timed out"] @ errors + ExitCode = 3 + } + } + +let outputJson (result: ValidationResult) = + let options = JsonSerializerOptions( + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + ) + let json = JsonSerializer.Serialize(result, options) + printfn "%s" json + +let main (args: string array) = + let parser = ArgumentParser.Create(programName = "validate-release.fsx") + + try + let results = parser.Parse(args) + + use cts = new CancellationTokenSource() + + // Set timeout if specified + match results.TryGetResult Timeout with + | Some minutes -> + cts.CancelAfter(TimeSpan.FromMinutes(float minutes)) + | None -> () + + // Handle Ctrl+C + Console.CancelKeyPress.Add(fun args -> + logWarn "Cancellation requested..." + cts.Cancel() + args.Cancel <- true + ) + + let result = Async.RunSynchronously(validateAsync results cts.Token) + + if results.Contains Json then + outputJson result + + result.ExitCode + with + | :? ArguParseException as ex -> + logError ex.Message + eprintfn "%s" (parser.PrintUsage()) + 1 + | ex -> + logError (sprintf "Unexpected error: %s" ex.Message) + 1 + +exit (main fsi.CommandLineArgs.[1..]) diff --git a/AGENTS.md b/AGENTS.md index fa794a5d..b13e34d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,6 +123,22 @@ C# 14 / .NET 10 specifics - Use file‑scoped namespaces, primary constructors, and newer pattern features. - Favor spans and efficient collections only with benchmarks backing changes. +F# specifics +- See [F# Coding Guide](./docs/contributing/fsharp-coding-guide.md) for comprehensive F# standards +- **Prefer active patterns** over complex if-then chains for value extraction +- Use discriminated unions to make illegal states unrepresentable +- Follow CLI logging standards (stdout for data, stderr for diagnostics) +- Use Result types for railway-oriented programming +- Maintain immutability with records and immutable collections + +Native AOT, Trimming, and Size Optimization +- See [AOT/Trimming Guide](./docs/contributing/aot-trimming-guide.md) for comprehensive AOT/trimming guidance +- Use source generators for JSON serialization (not reflection) +- Design for trimming from the start (avoid reflection and dynamic code) +- Test with `PublishAot=true` and `PublishTrimmed=true` +- Target sizes: 5-8 MB (minimal), 8-12 MB (feature-rich), 10-15 MB (with UI) +- See [Issue #221](https://github.com/finos/morphir-dotnet/issues/221) for implementation tracking + CLI Tool Logging Requirements - **CRITICAL**: CLI tools MUST NOT write log messages to stdout - All logging output MUST be directed to stderr only diff --git a/CLAUDE.md b/CLAUDE.md index 5a898095..00d58b91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,6 +113,12 @@ From AGENTS.md Section 5: - **Pure functions**: Push side effects to edges - **Exhaustive pattern matching**: Update all matches when changing ADTs +**F# Coding Standards**: See [F# Coding Guide](docs/contributing/fsharp-coding-guide.md) for comprehensive F# best practices: +- Use active patterns instead of complex if-then chains +- Railway-oriented programming with Result types +- Immutable records and collections +- CLI script standards with proper stdout/stderr separation + ### 8. Morphir-Specific Guidelines See AGENTS.md Section 6 for detailed Morphir IR modeling: diff --git a/Directory.Packages.props b/Directory.Packages.props index 803405fc..8213a31e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -80,5 +80,9 @@ + + + + \ No newline at end of file diff --git a/docs/contributing/aot-trimming-guide.md b/docs/contributing/aot-trimming-guide.md new file mode 100644 index 00000000..666493cd --- /dev/null +++ b/docs/contributing/aot-trimming-guide.md @@ -0,0 +1,767 @@ +# AOT, Trimming, and Single-File Executables Guide + +This guide provides comprehensive guidance for building Native AOT, trimmed, and single-file executables for morphir-dotnet CLI tools. + +## Table of Contents + +1. [Overview](#overview) +2. [Native AOT Compilation](#native-aot-compilation) +3. [Assembly Trimming](#assembly-trimming) +4. [Single-File Executables](#single-file-executables) +5. [Size Optimization Strategies](#size-optimization-strategies) +6. [Reflection and Dynamic Code](#reflection-and-dynamic-code) +7. [JSON Serialization in AOT](#json-serialization-in-aot) +8. [Common Gotchas and Workarounds](#common-gotchas-and-workarounds) +9. [Testing AOT/Trimmed Builds](#testing-aottrimmed-builds) +10. [Best Practices Checklist](#best-practices-checklist) + +--- + +## Overview + +### What is Native AOT? + +Native AOT (Ahead-of-Time) compilation produces native executables that: +- Start instantly (no JIT compilation) +- Use less memory +- Are self-contained (no .NET runtime required) +- Are platform-specific (separate builds for Linux, Windows, macOS) + +### What is Trimming? + +Trimming removes unused code from assemblies: +- Reduces deployment size +- Removes unused dependencies +- Can break code that uses reflection +- Required for Native AOT + +### Single-File Executables + +Single-file executables bundle everything into one file: +- Simplified deployment +- Can be combined with AOT or regular .NET +- Platform-specific + +### Trade-offs + +| Feature | Pros | Cons | +|---------|------|------| +| Native AOT | Fast startup, small memory, self-contained | Larger size, no dynamic code, platform-specific | +| Trimming | Smaller size, faster deployment | May break reflection, requires testing | +| Single-File | Simple deployment | Larger initial size, extraction overhead | + +--- + +## Native AOT Compilation + +### Enabling Native AOT + +```xml + + + true + true + Size + false + +``` + +### AOT-Compatible Code Patterns + +#### βœ… Good: Static Methods + +```csharp +// βœ… AOT-friendly +public static class Calculator +{ + public static int Add(int a, int b) => a + b; +} +``` + +#### βœ… Good: Sealed Classes + +```csharp +// βœ… Sealed classes optimize better +public sealed class Config +{ + public required string Host { get; init; } + public required int Port { get; init; } +} +``` + +#### ❌ Avoid: Reflection.Emit + +```csharp +// ❌ Not supported in Native AOT +var assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(name, AssemblyBuilderAccess.Run); +var moduleBuilder = assemblyBuilder.DefineDynamicModule("DynamicModule"); +``` + +#### βœ… Good: Source Generators Instead + +```csharp +// βœ… Use source generators for code generation +[JsonSerializable(typeof(Config))] +public partial class ConfigJsonContext : JsonSerializerContext { } +``` + +### AOT Warnings + +Enable and address AOT warnings: + +```xml + + true + true + true + +``` + +Common AOT warnings: +- `IL2026`: Using members annotated with `RequiresUnreferencedCode` +- `IL2087`: Target parameter type not compatible with source type +- `IL3050`: Using dynamic types in AOT + +--- + +## Assembly Trimming + +### Enabling Trimming + +```xml + + true + link + +``` + +### Trim Modes + +1. **`copyUsed`** (default): Copy entire assemblies if any part is used +2. **`link`**: Remove unused members from assemblies (more aggressive) + +```xml + + + link + +``` + +### Preserving Code from Trimming + +#### Method 1: Dynamic Dependency Attribute + +```csharp +using System.Diagnostics.CodeAnalysis; + +public class ConfigLoader +{ + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties, typeof(Config))] + public static Config Load(string json) + { + return JsonSerializer.Deserialize(json)!; + } +} +``` + +#### Method 2: Trimmer Root Assembly + +```xml + + + +``` + +#### Method 3: Trimmer Root Descriptor + +```xml + + + + + + + + + + + + +``` + +### F# Trimming Considerations + +F# code can be trimmed, but requires careful handling: + +```xml + + + true + link + + false + +``` + +--- + +## Single-File Executables + +### Enabling Single-File Publishing + +```xml + + true + true + linux-x64 + +``` + +### Single-File with AOT + +```bash +# Publish Native AOT single-file executable +dotnet publish -c Release -r linux-x64 /p:PublishAot=true /p:PublishSingleFile=true +``` + +### Embedded Resources in Single-File + +```xml + + + + +``` + +```csharp +// Access embedded resources +var assembly = Assembly.GetExecutingAssembly(); +using var stream = assembly.GetManifestResourceStream("Morphir.schemas.v3.json"); +using var reader = new StreamReader(stream); +var schemaJson = reader.ReadToEnd(); +``` + +--- + +## Size Optimization Strategies + +### 1. Enable All Size Optimizations + +```xml + + + true + Size + false + + + true + link + + + none + false + + + true + + + false + + + true + +``` + +### 2. Minimize Dependencies + +```csharp +// ❌ Avoid: Heavy dependencies +using Newtonsoft.Json; // Large library + +// βœ… Good: Use built-in alternatives +using System.Text.Json; // Built-in, trims better +``` + +### 3. Use Feature Switches + +```xml + + + false + true + false + false + false + +``` + +### 4. Size Comparison Table + +| Configuration | Typical Size (Linux x64) | +|--------------|--------------------------| +| Framework-dependent | ~200 KB | +| Self-contained | ~70 MB | +| Self-contained + Trimmed | ~30 MB | +| Native AOT (no optimizations) | ~15 MB | +| Native AOT + Size optimizations | ~8-10 MB | +| Native AOT + Trimmed + Size opts | ~5-8 MB | + +### 5. Measure and Analyze Size + +```bash +# Publish and analyze +dotnet publish -c Release -r linux-x64 /p:PublishAot=true + +# Check size +ls -lh bin/Release/net10.0/linux-x64/publish/ + +# Analyze trimmed assemblies (if not using AOT) +dotnet run --project $(dotnet tool run dotnet-ilverify) \ + bin/Release/net10.0/linux-x64/publish/Morphir.dll +``` + +--- + +## Reflection and Dynamic Code + +### Problem: Reflection Breaks in AOT/Trimmed Builds + +```csharp +// ❌ This breaks in AOT +Type type = Type.GetType("Morphir.IR.Package"); +var instance = Activator.CreateInstance(type); +``` + +### Solution 1: Source Generators + +```csharp +// βœ… Use source generators +[JsonSerializable(typeof(Package))] +[JsonSerializable(typeof(Module))] +public partial class MorphirJsonContext : JsonSerializerContext { } + +// Usage +var package = JsonSerializer.Deserialize(json, MorphirJsonContext.Default.Package); +``` + +### Solution 2: Dynamic Dependency Attributes + +```csharp +using System.Diagnostics.CodeAnalysis; + +public class PackageLoader +{ + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Package))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Module))] + public static void LoadTypes() + { + // Ensures types are preserved + } +} +``` + +### Solution 3: RequiresUnreferencedCode Annotation + +```csharp +using System.Diagnostics.CodeAnalysis; + +// Mark methods that use reflection +[RequiresUnreferencedCode("Uses reflection to load plugins")] +public static void LoadPlugins(string path) +{ + var assemblies = Directory.GetFiles(path, "*.dll") + .Select(Assembly.LoadFrom); + + foreach (var asm in assemblies) + { + // Reflection code here + } +} +``` + +### Solution 4: Avoid Reflection Entirely + +```csharp +// ❌ Avoid: Reflection-based deserialization +var type = Type.GetType(typeName); +var deserializer = typeof(JsonSerializer) + .GetMethod("Deserialize") + .MakeGenericMethod(type); + +// βœ… Good: Compile-time known types with source generators +var result = typeName switch +{ + "Package" => JsonSerializer.Deserialize(json, MorphirJsonContext.Default.Package), + "Module" => JsonSerializer.Deserialize(json, MorphirJsonContext.Default.Module), + _ => throw new NotSupportedException($"Unknown type: {typeName}") +}; +``` + +--- + +## JSON Serialization in AOT + +### Problem: System.Text.Json Uses Reflection + +By default, `System.Text.Json` uses reflection for serialization, which doesn't work in Native AOT. + +### Solution: Source-Generated Serialization Context + +#### C# Example + +```csharp +using System.Text.Json; +using System.Text.Json.Serialization; + +// Define all types that need serialization +[JsonSerializable(typeof(VerifyIRResult))] +[JsonSerializable(typeof(Config))] +[JsonSerializable(typeof(Package))] +[JsonSerializable(typeof(Module))] +[JsonSourceGenerationOptions( + WriteIndented = true, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +public partial class MorphirJsonContext : JsonSerializerContext +{ +} + +// Usage +var result = new VerifyIRResult( + IsValid: true, + SchemaVersion: "3", + FilePath: "test.json", + Errors: [], + Timestamp: DateTime.UtcNow +); + +var json = JsonSerializer.Serialize(result, MorphirJsonContext.Default.VerifyIRResult); +var deserialized = JsonSerializer.Deserialize(json, MorphirJsonContext.Default.VerifyIRResult); +``` + +#### F# Example + +F# can also use source generators: + +```fsharp +open System.Text.Json +open System.Text.Json.Serialization + +// Define serialization context +[)>] +[)>] +[] +type MorphirJsonContext() = + inherit JsonSerializerContext() + +// Usage +let result = { Success = true; Version = Some "1.0.0"; Errors = []; ExitCode = 0 } +let json = JsonSerializer.Serialize(result, MorphirJsonContext.Default.ScriptResult) +``` + +### F# with FSharp.SystemTextJson and AOT + +`FSharp.SystemTextJson` doesn't fully support Native AOT. Options: + +1. **Use source generators** (as shown above) +2. **Use simpler types** (records without unions/options for AOT builds) +3. **Mark AOT-incompatible code** with `RequiresUnreferencedCode` + +```fsharp +open System.Diagnostics.CodeAnalysis + +[] +let serializeWithFSharpJson (value: 'T) : string = + let options = JsonSerializerOptions() + options.Converters.Add(JsonFSharpConverter()) + JsonSerializer.Serialize(value, options) +``` + +--- + +## Common Gotchas and Workarounds + +### 1. Assembly.GetTypes() Fails + +**Problem**: `Assembly.GetTypes()` returns incomplete list in trimmed builds. + +**Workaround**: Use explicit type lists or source generators. + +```csharp +// ❌ Breaks with trimming +var types = Assembly.GetExecutingAssembly().GetTypes(); + +// βœ… Use explicit list +private static readonly Type[] KnownTypes = +[ + typeof(Package), + typeof(Module), + typeof(TypeDefinition) +]; +``` + +### 2. LINQ Expression Trees Fail in AOT + +**Problem**: Expression trees use `Reflection.Emit`. + +**Workaround**: Replace with delegates or source generators. + +```csharp +// ❌ Fails in AOT +Expression> expr = x => x * 2; +var compiled = expr.Compile(); + +// βœ… Use delegates directly +Func func = x => x * 2; +``` + +### 3. Type.GetType() Returns Null + +**Problem**: Types are trimmed away. + +**Workaround**: Use `DynamicDependency` or explicit type references. + +```csharp +[DynamicDependency(DynamicallyAccessedMemberTypes.All, "Morphir.IR.Package", "Morphir.Core")] +public static Type GetPackageType() +{ + return Type.GetType("Morphir.IR.Package, Morphir.Core"); +} +``` + +### 4. WolverineFx and AOT + +**Issue**: WolverineFx uses reflection for handler discovery. + +**Workaround**: Explicitly register handlers in AOT builds. + +```csharp +// AOT-compatible WolverineFx setup +builder.Services.AddWolverine(opts => +{ + // Explicitly register handlers instead of auto-discovery + opts.Discovery.DisableConventionalDiscovery(); + opts.Handlers.AddHandler(); + opts.Handlers.AddHandler(); +}); +``` + +### 5. Serilog Sinks May Use Reflection + +**Issue**: Some Serilog sinks use reflection. + +**Workaround**: Use console/file sinks which are AOT-compatible. + +```csharp +// βœ… AOT-compatible logging +Log.Logger = new LoggerConfiguration() + .WriteTo.Console( + standardErrorFromLevel: LogEventLevel.Verbose, + outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") + .CreateLogger(); +``` + +### 6. Spectre.Console and AOT + +**Issue**: Spectre.Console generally works with AOT, but some features use reflection. + +**Workaround**: Avoid reflection-based features, test thoroughly. + +```csharp +// βœ… AOT-compatible Spectre.Console usage +AnsiConsole.MarkupLine("[green]βœ“ Success[/]"); + +var table = new Table(); +table.AddColumn("Name"); +table.AddColumn("Value"); +table.AddRow("Version", "1.0.0"); +AnsiConsole.Write(table); +``` + +### 7. Embedded Resources in AOT + +**Issue**: `Assembly.GetManifestResourceStream()` works, but resource names change. + +**Workaround**: Use fully qualified names and test. + +```csharp +// βœ… Correct resource naming +var resourceName = "Morphir.Tooling.schemas.v3.json"; +using var stream = Assembly.GetExecutingAssembly() + .GetManifestResourceStream(resourceName); + +if (stream == null) +{ + // List available resources for debugging + var available = Assembly.GetExecutingAssembly() + .GetManifestResourceNames(); + throw new FileNotFoundException( + $"Resource '{resourceName}' not found. Available: {string.Join(", ", available)}"); +} +``` + +--- + +## Testing AOT/Trimmed Builds + +### 1. Test Matrix + +Test all build configurations: + +```bash +# Framework-dependent +dotnet build -c Release + +# Self-contained +dotnet publish -c Release -r linux-x64 --self-contained + +# Trimmed +dotnet publish -c Release -r linux-x64 --self-contained /p:PublishTrimmed=true + +# Native AOT +dotnet publish -c Release -r linux-x64 /p:PublishAot=true +``` + +### 2. Automated Testing Script + +```bash +#!/bin/bash +set -euo pipefail + +echo "Testing AOT build..." + +# Build Native AOT +dotnet publish -c Release -r linux-x64 /p:PublishAot=true + +# Run basic smoke tests +./bin/Release/net10.0/linux-x64/publish/morphir --version +./bin/Release/net10.0/linux-x64/publish/morphir --help + +# Test IR verification (example) +./bin/Release/net10.0/linux-x64/publish/morphir ir verify tests/TestData/valid-ir-v3.json + +echo "AOT build tests passed!" +``` + +### 3. Size Regression Testing + +```bash +#!/bin/bash +# Check executable size doesn't exceed threshold + +MAX_SIZE_MB=10 +EXECUTABLE="bin/Release/net10.0/linux-x64/publish/morphir" + +SIZE_BYTES=$(stat -f%z "$EXECUTABLE" 2>/dev/null || stat -c%s "$EXECUTABLE") +SIZE_MB=$((SIZE_BYTES / 1024 / 1024)) + +if [ "$SIZE_MB" -gt "$MAX_SIZE_MB" ]; then + echo "❌ Executable size ($SIZE_MB MB) exceeds threshold ($MAX_SIZE_MB MB)" + exit 1 +else + echo "βœ… Executable size: $SIZE_MB MB (threshold: $MAX_SIZE_MB MB)" +fi +``` + +### 4. Trim Warnings Analysis + +```xml + + + false + true + +``` + +--- + +## Best Practices Checklist + +### Design Phase +- [ ] Avoid reflection and dynamic code generation +- [ ] Use source generators for JSON serialization +- [ ] Design with trimming in mind (explicit dependencies) +- [ ] Plan for platform-specific builds +- [ ] Consider size vs. feature trade-offs + +### Implementation Phase +- [ ] Use `sealed` classes where possible +- [ ] Use static methods when appropriate +- [ ] Prefer compile-time known types over dynamic types +- [ ] Add `[DynamicDependency]` attributes where needed +- [ ] Use source-generated JSON contexts +- [ ] Avoid LINQ expression trees in critical paths +- [ ] Use `InvariantGlobalization` if localization not needed + +### Testing Phase +- [ ] Test with `PublishTrimmed=true` +- [ ] Test with `PublishAot=true` +- [ ] Run full test suite on AOT builds +- [ ] Check executable size +- [ ] Verify embedded resources load correctly +- [ ] Test on all target platforms (Linux, Windows, macOS) +- [ ] Performance test startup time and memory usage + +### Configuration Phase +- [ ] Enable AOT/trim analyzers +- [ ] Configure size optimizations +- [ ] Add trimmer root descriptors if needed +- [ ] Document platform-specific requirements +- [ ] Set up CI for multi-platform builds + +### Documentation Phase +- [ ] Document AOT limitations +- [ ] List unsupported features (if any) +- [ ] Provide platform-specific instructions +- [ ] Document size expectations per platform + +--- + +## Summary + +### Key Principles + +1. **Design for AOT from the start** - Retrofitting is harder +2. **Avoid reflection** - Use source generators instead +3. **Test early and often** - AOT issues appear late +4. **Measure size** - Optimize incrementally +5. **Use explicit types** - Don't rely on runtime type discovery +6. **Document limitations** - Be clear about what doesn't work + +### Quick Reference: AOT-Compatible Patterns + +| Pattern | Status | Alternative | +|---------|--------|-------------| +| Source generators | βœ… Supported | - | +| Static methods | βœ… Supported | - | +| Sealed classes | βœ… Supported | - | +| System.Text.Json (with source gen) | βœ… Supported | - | +| Embedded resources | βœ… Supported | Test names carefully | +| Serilog (console/file) | βœ… Supported | Avoid reflection-based sinks | +| Spectre.Console (basic) | βœ… Supported | Avoid advanced features | +| Reflection.Emit | ❌ Not supported | Use source generators | +| Dynamic types | ❌ Not supported | Use explicit types | +| Assembly.GetTypes() | ⚠️ Limited | Use explicit type lists | +| LINQ expressions | ⚠️ Limited | Use delegates | +| FSharp.SystemTextJson | ⚠️ Limited | Use source generators | + +### Common Size Targets (Linux x64) + +- **Minimal CLI tool**: 5-8 MB (AOT + trimming + size opts) +- **Feature-rich CLI**: 8-12 MB (AOT + trimming) +- **With rich UI**: 10-15 MB (AOT + Spectre.Console) + +--- + +## References + +- [Native AOT Deployment](https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/) +- [Trim Self-Contained Deployments](https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/trim-self-contained) +- [Single-File Deployment](https://learn.microsoft.com/en-us/dotnet/core/deploying/single-file/overview) +- [Source Generation for JSON](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/source-generation) +- [AOT Warnings (IL2XXX, IL3XXX)](https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/warnings/) +- [.NET Size Optimization](https://devblogs.microsoft.com/dotnet/app-trimming-in-dotnet-5/) +- [AGENTS.md](../../AGENTS.md) - Project-wide agent guidance diff --git a/docs/contributing/fsharp-coding-guide.md b/docs/contributing/fsharp-coding-guide.md new file mode 100644 index 00000000..072e3c63 --- /dev/null +++ b/docs/contributing/fsharp-coding-guide.md @@ -0,0 +1,973 @@ +# F# Coding Guide for morphir-dotnet + +This guide provides F#-specific coding standards and best practices for the morphir-dotnet project, including F# Interactive scripts in `.claude/skills/`. + +## Table of Contents + +1. [Pattern Matching and Value Extraction](#pattern-matching-and-value-extraction) +2. [Active Patterns](#active-patterns) +3. [Error Handling](#error-handling) +4. [Immutability and Data Structures](#immutability-and-data-structures) +5. [Async and Task Workflows](#async-and-task-workflows) +6. [Type Design](#type-design) +7. [JSON Serialization with System.Text.Json](#json-serialization-with-systemtextjson) +8. [CLI Scripts (.fsx)](#cli-scripts-fsx) +9. [Testing](#testing) + +--- + +## Pattern Matching and Value Extraction + +### βœ… Prefer Active Patterns Over Complex If-Then Chains + +Active patterns make value extraction more declarative and easier to understand. + +**❌ Avoid: Complex if-then chains** +```fsharp +let processJson (element: JsonElement) = + if element.ValueKind = JsonValueKind.Null then + None + elif element.ValueKind = JsonValueKind.String then + Some (element.GetString()) + elif element.ValueKind = JsonValueKind.Number then + Some (element.GetInt32().ToString()) + else + None +``` + +**βœ… Prefer: Active patterns** +```fsharp +let (|NullJson|StringJson|NumberJson|OtherJson|) (element: JsonElement) = + match element.ValueKind with + | JsonValueKind.Null -> NullJson + | JsonValueKind.String -> StringJson (element.GetString()) + | JsonValueKind.Number -> NumberJson (element.GetInt32()) + | _ -> OtherJson + +let processJson (element: JsonElement) = + match element with + | NullJson -> None + | StringJson s -> Some s + | NumberJson n -> Some (n.ToString()) + | OtherJson -> None +``` + +### Common Active Pattern Use Cases + +#### 1. JSON Property Extraction + +**βœ… Good: Active pattern for optional properties** +```fsharp +let (|JsonProperty|_|) (propertyName: string) (element: JsonElement) = + let mutable prop = Unchecked.defaultof + if element.TryGetProperty(propertyName, &prop) then + Some prop + else + None + +// Usage +match root with +| JsonProperty "version" version -> Some (version.GetString()) +| _ -> None +``` + +#### 2. String Pattern Matching + +**βœ… Good: Active patterns for string parsing** +```fsharp +let (|StartsWith|_|) (prefix: string) (input: string) = + if input.StartsWith(prefix) then + Some (input.Substring(prefix.Length)) + else + None + +let (|Contains|_|) (substring: string) (input: string) = + if input.Contains(substring) then Some () + else None + +// Usage +match line with +| StartsWith "## " title -> processHeader title +| StartsWith "- " item -> processListItem item +| Contains "BREAKING" & Contains "CHANGE" -> markAsBreaking line +| _ -> processPlainText line +``` + +#### 3. Result/Option Chaining + +**βœ… Good: Active patterns for result unpacking** +```fsharp +let (|Success|Failure|) (result: Result<'a, 'b>) = + match result with + | Ok value -> Success value + | Error err -> Failure err + +// Usage +match checkRemoteCI() with +| Success ciState -> processCI ciState +| Failure error -> logError error +``` + +### Pattern Matching Best Practices + +1. **Exhaustive Matching**: Always handle all cases +```fsharp +// βœ… Good: Exhaustive match +match optionalValue with +| Some value -> processValue value +| None -> useDefault() + +// ❌ Bad: Incomplete match (compiler warning) +match optionalValue with +| Some value -> processValue value +``` + +2. **Guard Clauses**: Use `when` for additional conditions +```fsharp +match parseVersion changelog with +| Major, changes when changes > 0 -> "major" +| Minor, changes when changes > 0 -> "minor" +| Patch, _ -> "patch" +| _, _ -> "none" +``` + +3. **Pattern AND/OR Combinations** +```fsharp +// AND pattern (&) +match line with +| Contains "TODO" & StartsWith "- " -> extractTodo line +| _ -> None + +// OR pattern (|) +match status with +| "success" | "completed" -> handleSuccess() +| _ -> handleOther() +``` + +--- + +## Active Patterns + +### Partial Active Patterns + +Use partial active patterns (`|Pattern|_|`) when the pattern might not match: + +```fsharp +let (|Int|_|) (str: string) = + match Int32.TryParse(str) with + | true, value -> Some value + | false, _ -> None + +let (|ValidEmail|_|) (str: string) = + if str.Contains("@") && str.Contains(".") then + Some str + else + None + +// Usage +match input with +| Int n -> printfn "Number: %d" n +| ValidEmail email -> printfn "Email: %s" email +| _ -> printfn "Unknown format" +``` + +### Multi-Case Active Patterns + +Use for categorizing values into multiple cases: + +```fsharp +let (|Even|Odd|) n = + if n % 2 = 0 then Even else Odd + +let (|Positive|Negative|Zero|) n = + if n > 0 then Positive + elif n < 0 then Negative + else Zero + +// Usage +match number with +| Even & Positive -> "even positive" +| Even & Negative -> "even negative" +| Odd & Positive -> "odd positive" +| Odd & Negative -> "odd negative" +| Zero -> "zero" +``` + +### Parameterized Active Patterns + +```fsharp +let (|DivisibleBy|_|) divisor n = + if n % divisor = 0 then Some() else None + +// Usage +match number with +| DivisibleBy 15 -> "FizzBuzz" +| DivisibleBy 3 -> "Fizz" +| DivisibleBy 5 -> "Buzz" +| _ -> string number +``` + +--- + +## Error Handling + +### Result Type + +Prefer `Result<'T, 'Error>` for operations that can fail: + +```fsharp +type ValidationError = + | MissingField of string + | InvalidFormat of string + | OutOfRange of string * int * int + +let validateVersion (version: string) : Result = + if String.IsNullOrWhiteSpace(version) then + Error (MissingField "version") + elif not (version.Contains(".")) then + Error (InvalidFormat "Version must contain dots") + else + Ok version +``` + +### Result Active Patterns + +```fsharp +let (|Ok|Error|) (result: Result<'a, 'b>) = + match result with + | Result.Ok value -> Ok value + | Result.Error err -> Error err + +// Usage +match validateVersion input with +| Ok version -> processVersion version +| Error (MissingField field) -> printfn "Missing: %s" field +| Error (InvalidFormat msg) -> printfn "Invalid: %s" msg +| Error (OutOfRange (field, min, max)) -> printfn "%s must be between %d and %d" field min max +``` + +### Railway-Oriented Programming + +```fsharp +let (>>=) result func = + match result with + | Ok value -> func value + | Error err -> Error err + +let validateAndProcess input = + validateVersion input + >>= parseVersion + >>= checkAvailability + >>= createRelease +``` + +--- + +## Immutability and Data Structures + +### Record Types + +Always use immutable records: + +```fsharp +// βœ… Good: Immutable record +type ReleaseInfo = { + Version: string + Date: DateTime + Changes: string list +} + +// βœ… Good: Record update expression +let updated = { original with Date = DateTime.Now } + +// ❌ Avoid: Mutable fields +type ReleaseInfo = { + mutable Version: string // Don't do this + mutable Date: DateTime +} +``` + +### Collections + +Prefer immutable collections: + +```fsharp +// βœ… Good: Immutable list +let changes = [ "Added feature X"; "Fixed bug Y" ] +let moreChanges = "Updated docs" :: changes + +// βœ… Good: List comprehension +let numbers = [ for i in 1..10 -> i * 2 ] + +// ❌ Avoid: ResizeArray (mutable) +let changes = ResizeArray() +changes.Add("Added feature X") // Mutable operation +``` + +### Options vs Nulls + +Always use `Option<'T>` instead of null: + +```fsharp +// βœ… Good: Option type +type Config = { + Port: int option + Host: string +} + +let getPort config = + config.Port |> Option.defaultValue 8080 + +// ❌ Avoid: Nullable +type Config = { + Port: Nullable // Don't use in F# +} +``` + +### C# Interop: Nullable Reference Types (F# 9+) + +When writing F# code that interoperates with C# (especially in mixed C#/F# projects), use F# 9's nullable reference types feature for better C# interop: + +```fsharp +// Enable nullable reference types in .fsproj: +// enable + +// βœ… Good: Explicit nullability for C# consumers +type IUserService = + abstract member GetUserById: userId: string -> string | null + abstract member GetUserName: userId: string -> string // Non-nullable + +// βœ… Good: Clear null handling in public API +let tryGetValue (key: string) : string | null = + if cache.ContainsKey(key) then + cache.[key] + else + null + +// βœ… Good: Guard against nulls from C# code +let processName (name: string | null) : string = + match name with + | null -> "Unknown" + | value -> value.Trim() + +// βœ… Good: F# Option for internal code, nullable for C# boundary +type UserRepository() = + // Internal: use Option + let findUserInternal (id: string) : User option = + // ... implementation + None + + // Public API for C#: use nullable reference types + member this.FindUser(id: string) : User | null = + findUserInternal id |> Option.toObj +``` + +**When to use nullable reference types:** +- Public APIs consumed by C# code +- Implementing C# interfaces +- Interacting with C# libraries that use nullable annotations +- Converting between F# Option and C# nullable types + +**Pattern: Converting between Option and nullable** +```fsharp +// Option to nullable (for C# API) +let toNullable (opt: 'T option) : 'T | null = + opt |> Option.toObj + +// Nullable to Option (from C# API) +let fromNullable (value: 'T | null) : 'T option = + value |> Option.ofObj + +// Example usage +type MorphirService() = + // Internal F# code uses Option + let loadPackage (name: string) : Package option = + // ... implementation + None + + // C# API uses nullable reference types + interface IMorphirService with + member this.LoadPackage(name: string) : Package | null = + loadPackage name |> toNullable +``` + +**Important**: Even with nullable reference types enabled, prefer `Option<'T>` for F#-only code. Only use nullable reference types at C# interop boundaries. + +--- + +## Async and Task Workflows + +### Async Workflows + +Use async workflows for asynchronous operations: + +```fsharp +let fetchDataAsync (url: string) : Async> = + async { + try + use client = new HttpClient() + let! response = client.GetStringAsync(url) |> Async.AwaitTask + return Ok response + with ex -> + return Error ex.Message + } +``` + +### Cancellation Support + +Always support cancellation in long-running async operations: + +```fsharp +let processWithCancellation (ct: CancellationToken) : Async> = + async { + try + do! Async.Sleep(1000) // Automatically checks cancellation + + if ct.IsCancellationRequested then + return Error "Cancelled" + else + return Ok () + with + | :? OperationCanceledException -> + return Error "Cancelled" + } +``` + +### Parallel Async Operations + +```fsharp +let checkAllPackages packages ct = + async { + let! results = + packages + |> List.map (fun pkg -> checkPackageAsync pkg ct) + |> Async.Parallel + + return results |> Array.toList + } +``` + +--- + +## Type Design + +### Discriminated Unions for State + +Make illegal states unrepresentable: + +```fsharp +// βœ… Good: Impossible to have invalid state +type WorkflowState = + | NotStarted + | InProgress of runId: int64 * startTime: DateTime + | Completed of runId: int64 * result: string + | Failed of runId: int64 * error: string + +// ❌ Avoid: Boolean flags +type WorkflowState = { + IsStarted: bool + IsCompleted: bool + IsFailed: bool + RunId: int64 option + Error: string option +} // Can represent invalid states! +``` + +### Single-Case Discriminated Unions + +Use for type safety and domain modeling: + +```fsharp +type Version = Version of string +type PackageName = PackageName of string +type GitHash = GitHash of string + +let createVersion (str: string) : Result = + if str.Contains(".") then + Ok (Version str) + else + Error "Invalid version format" + +// Usage - type safety prevents mixing up string parameters +let publishPackage (name: PackageName) (version: Version) = ... +``` + +### Measure Types for Units + +```fsharp +[] type minutes +[] type seconds + +let timeout = 30 +let interval = 5 + +let toSeconds (mins: float) : float = + mins * 60.0 +``` + +--- + +## JSON Serialization with System.Text.Json + +System.Text.Json is the recommended JSON library for .NET. When working with F# types, there are specific patterns and gotchas to be aware of. + +**See also**: [Serialization Guide](./serialization-guide.md) for comprehensive serialization patterns across the project. + +### Basic Serialization + +```fsharp +#r "nuget: System.Text.Json, 9.0.0" + +open System.Text.Json +open System.Text.Json.Serialization + +// βœ… Good: Simple record type +type Config = { + Port: int + Host: string + Timeout: int +} + +let config = { Port = 8080; Host = "localhost"; Timeout = 30 } + +// Serialize with options +let options = JsonSerializerOptions() +options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase +options.WriteIndented <- true + +let json = JsonSerializer.Serialize(config, options) +// Output: { "port": 8080, "host": "localhost", "timeout": 30 } +``` + +### Common Gotchas + +#### 1. F# Records Require Mutable Setters (By Default) + +**Problem**: System.Text.Json by default requires mutable properties for deserialization. + +```fsharp +// ❌ This will FAIL during deserialization +type User = { + Name: string + Age: int +} + +let json = """{"name": "Alice", "age": 30}""" +let user = JsonSerializer.Deserialize(json) +// Error: Cannot deserialize - no parameterless constructor or mutable properties +``` + +**Solution**: Use `FSharpJsonConverter` or enable record deserialization: + +```fsharp +// βœ… Good: Use FSharp.SystemTextJson package +#r "nuget: FSharp.SystemTextJson, 1.3.13" + +open System.Text.Json +open System.Text.Json.Serialization + +let options = JsonSerializerOptions() +options.Converters.Add(JsonFSharpConverter()) + +let user = JsonSerializer.Deserialize(json, options) +// Works correctly with immutable F# records +``` + +#### 2. Discriminated Unions Are Not Supported (By Default) + +**Problem**: System.Text.Json doesn't understand F# discriminated unions out of the box. + +```fsharp +// ❌ This will NOT serialize as expected +type Status = + | Pending + | InProgress of startTime: DateTime + | Completed of result: string + +let status = InProgress DateTime.Now +let json = JsonSerializer.Serialize(status) +// Output: {} or error +``` + +**Solution**: Use `FSharp.SystemTextJson` which handles unions properly: + +```fsharp +// βœ… Good: Use FSharp.SystemTextJson +let options = JsonSerializerOptions() +options.Converters.Add(JsonFSharpConverter()) + +let json = JsonSerializer.Serialize(status, options) +// Output: {"Case":"InProgress","Fields":["2025-12-18T15:30:00Z"]} +``` + +#### 3. Option Types Serialize as Objects + +**Problem**: F# `option` types don't serialize as null/value by default. + +```fsharp +// ❌ Without FSharp.SystemTextJson +type Config = { + Port: int option + Host: string +} + +let config = { Port = None; Host = "localhost" } +let json = JsonSerializer.Serialize(config) +// Output: {"Port":{},"Host":"localhost"} - Port is empty object, not null +``` + +**Solution**: Use `FSharp.SystemTextJson` or configure options: + +```fsharp +// βœ… Good: Use FSharp.SystemTextJson +let options = JsonSerializerOptions() +options.Converters.Add(JsonFSharpConverter( + unionEncoding = JsonUnionEncoding.Default, + unionTagNamingPolicy = JsonNamingPolicy.CamelCase +)) + +let json = JsonSerializer.Serialize(config, options) +// Output: {"port":null,"host":"localhost"} - Port is null as expected +``` + +#### 4. JsonElement Reading for Dynamic JSON + +When reading JSON with unknown structure, use `JsonElement`: + +```fsharp +// βœ… Good: Active pattern for JsonElement property access +let (|JsonProperty|_|) (propertyName: string) (element: JsonElement) = + let mutable prop = Unchecked.defaultof + if element.TryGetProperty(propertyName, &prop) then + Some prop + else + None + +// βœ… Good: Active pattern for JsonValueKind +let (|JsonString|JsonNumber|JsonBool|JsonNull|JsonArray|JsonObject|) (element: JsonElement) = + match element.ValueKind with + | JsonValueKind.String -> JsonString (element.GetString()) + | JsonValueKind.Number -> JsonNumber (element.GetInt64()) + | JsonValueKind.True -> JsonBool true + | JsonValueKind.False -> JsonBool false + | JsonValueKind.Null -> JsonNull + | JsonValueKind.Array -> JsonArray (element.EnumerateArray() |> Seq.toList) + | JsonValueKind.Object -> JsonObject element + | _ -> JsonNull + +// Usage +let doc = JsonDocument.Parse(json) +match doc.RootElement with +| JsonProperty "version" (JsonString version) -> printfn "Version: %s" version +| JsonProperty "count" (JsonNumber count) -> printfn "Count: %d" count +| _ -> printfn "Unknown structure" +``` + +#### 5. Null vs Option in JSON + +When working with C# APIs that use nullable reference types: + +```fsharp +// βœ… Good: Handling nulls from JSON +type ApiResponse = { + Data: string | null // For C# interop + Error: string option // For F# code +} + +let parseResponse (json: string) : Result = + try + let response = JsonSerializer.Deserialize(json) + // Convert null to Option + let error = response.Error + Ok response + with ex -> + Error ex.Message +``` + +### Best Practices for JSON in F# Scripts + +```fsharp +// βœ… Good: Configure options once and reuse +let jsonOptions = + let options = JsonSerializerOptions() + options.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase + options.WriteIndented <- true + options.Converters.Add(JsonFSharpConverter()) + options + +// βœ… Good: Type-safe result serialization +type ScriptResult = { + Success: bool + Version: string option + Errors: string list + ExitCode: int +} + +let serializeResult (result: ScriptResult) : string = + JsonSerializer.Serialize(result, jsonOptions) + +// βœ… Good: Safe deserialization with error handling +let deserializeConfig (json: string) : Result = + try + let config = JsonSerializer.Deserialize(json, jsonOptions) + Ok config + with + | :? JsonException as ex -> Error $"Invalid JSON: {ex.Message}" + | ex -> Error $"Deserialization error: {ex.Message}" +``` + +### CLI JSON Output Pattern + +For CLI scripts that support `--json` output: + +```fsharp +// βœ… Good: Separate human-readable and JSON output +let outputResult (result: ScriptResult) (jsonOutput: bool) = + if jsonOutput then + // ONLY JSON to stdout + let json = JsonSerializer.Serialize(result, jsonOptions) + printfn "%s" json + else + // Human-readable to stdout + printfn "=== Results ===" + printfn "Success: %b" result.Success + result.Version |> Option.iter (printfn "Version: %s") + if not result.Errors.IsEmpty then + printfn "Errors:" + result.Errors |> List.iter (printfn " - %s") + +// βœ… Good: Test JSON output is valid +// Test command: dotnet fsi script.fsx --json | jq . +``` + +### Common Patterns from prepare-release.fsx + +```fsharp +// βœ… Pattern: Parse GitHub API response (JSON array) +let parseGitHubRuns (json: string) : Result = + try + let doc = JsonDocument.Parse(json) + let runs = + doc.RootElement.EnumerateArray() + |> Seq.map (fun element -> + { + Conclusion = element.GetProperty("conclusion").GetString() + DatabaseId = element.GetProperty("databaseId").GetInt64() + HeadSha = element.GetProperty("headSha").GetString() + } + ) + |> Seq.toList + Ok runs + with ex -> + Error $"Failed to parse JSON: {ex.Message}" + +// βœ… Pattern: Handle nullable JSON properties +let getConclusion (element: JsonElement) : string = + let conclusionProp = element.GetProperty("conclusion") + if conclusionProp.ValueKind = JsonValueKind.Null then + "in_progress" + else + conclusionProp.GetString() +``` + +### Source Generators for AOT Compatibility + +For Native AOT compilation, use source-generated serialization contexts: + +```fsharp +// βœ… Good: Source-generated context for AOT +[] +[)>] +[)>] +type MorphirJsonContext = + inherit JsonSerializerContext + +// Usage with AOT +let json = JsonSerializer.Serialize(result, MorphirJsonContext.Default.ScriptResult) +``` + +### Summary: JSON Serialization Checklist + +- βœ… Use `FSharp.SystemTextJson` for F# types (records, unions, options) +- βœ… Configure `JsonSerializerOptions` once and reuse +- βœ… Use active patterns for reading `JsonElement` dynamically +- βœ… Handle `JsonValueKind.Null` explicitly +- βœ… Test `--json` output with `jq` to ensure valid JSON +- βœ… Separate logs (stderr) from JSON output (stdout) +- βœ… Use source generators for Native AOT scenarios +- βœ… Prefer `option` for F# code, nullable types for C# interop boundaries + +--- + +## CLI Scripts (.fsx) + +### Script Structure + +Follow this structure for all `.fsx` scripts: + +```fsharp +#!/usr/bin/env dotnet fsi +// Brief description +// Usage: dotnet fsi script.fsx [args] + +#r "nuget: PackageName, Version" + +open System + +// ============================================================================ +// Types +// ============================================================================ + +type ScriptArgs = { ... } +type ScriptResult = { ... } + +// ============================================================================ +// Utilities +// ============================================================================ + +let logInfo msg = eprintfn "[INFO] %s" msg + +// ============================================================================ +// Main Logic +// ============================================================================ + +let mainAsync (args: ScriptArgs) (ct: CancellationToken) : Async = + async { ... } + +// ============================================================================ +// CLI Parsing and Entry Point +// ============================================================================ + +let main (args: string array) = + // Parse args, run async logic, return exit code + ... + +exit (main fsi.CommandLineArgs.[1..]) +``` + +### CLI Logging Standards + +**CRITICAL**: Separate stdout and stderr properly: + +```fsharp +let jsonOutput = args |> Array.contains "--json" + +// Logs always go to stderr +let logInfo msg = + if not jsonOutput then + eprintfn "[INFO] %s" msg + +let logError msg = + eprintfn "[ERROR] %s" msg + +// Results go to stdout +let outputResult result = + if jsonOutput then + let json = JsonSerializer.Serialize(result) + printfn "%s" json // stdout only + else + printfn "=== Results ===" // stdout + // Human-readable output +``` + +### Argument Parsing with Argu + +Use Argu for type-safe argument parsing: + +```fsharp +#r "nuget: Argu, 6.2.4" + +type Arguments = + | [] Version of string + | [] Verbose + | Json + | [] Timeout of int + + interface IArgParserTemplate with + member s.Usage = + match s with + | Version _ -> "Version to process" + | Verbose -> "Enable verbose output" + | Json -> "Output as JSON" + | Timeout _ -> "Timeout in minutes" + +let parser = ArgumentParser.Create(programName = "script.fsx") +let results = parser.Parse(args) + +let version = results.GetResult Version +let timeout = results.GetResult(Timeout, defaultValue = 30) +let verbose = results.Contains Verbose +``` + +--- + +## Testing + +### Unit Tests (TUnit) + +```fsharp +open TUnit.Core + +[] +let ``parseChangelog returns correct count`` () = + // Arrange + let changelog = """ +## [Unreleased] +- Added: Feature A +- Fixed: Bug B +""" + + // Act + let result = parseChangelog changelog + + // Assert + result.ChangeCount |> Assert.Equal 2 + result.Added |> Assert.Equal 1 + result.FixedCount |> Assert.Equal 1 +``` + +### Property-Based Testing + +```fsharp +open FsCheck + +[] +let ``version parsing is reversible`` (version: string) = + let parsed = parseVersion version + match parsed with + | Ok v -> formatVersion v = version + | Error _ -> true // Invalid versions are acceptable to reject +``` + +--- + +## Summary + +Key F# principles for morphir-dotnet: + +1. βœ… **Use active patterns** instead of complex if-then chains +2. βœ… **Make illegal states unrepresentable** with discriminated unions +3. βœ… **Prefer immutability** - use records and immutable collections +4. βœ… **Use Result<'T, 'Error>** for operations that can fail +5. βœ… **Support cancellation** in async workflows +6. βœ… **Separate stdout/stderr** in CLI scripts +7. βœ… **Use Argu** for CLI argument parsing +8. βœ… **Write exhaustive pattern matches** - handle all cases +9. βœ… **Prefer Option<'T>** over null in F# code +10. βœ… **Use nullable reference types (F# 9)** for C# interop boundaries +11. βœ… **Use FSharp.SystemTextJson** for JSON serialization with F# types +12. βœ… **Follow railway-oriented programming** for error handling + +--- + +## References + +- [F# Style Guide](https://docs.microsoft.com/en-us/dotnet/fsharp/style-guide/) +- [F# Design Guidelines](https://docs.microsoft.com/en-us/dotnet/fsharp/style-guide/conventions) +- [F# 9 Nullable Reference Types](https://learn.microsoft.com/en-us/dotnet/fsharp/whats-new/fsharp-9#nullable-reference-types) +- [FSharp.SystemTextJson](https://github.com/Tarmil/FSharp.SystemTextJson) - F# support for System.Text.Json +- [System.Text.Json Documentation](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/overview) +- [Domain Modeling Made Functional](https://fsharpforfunandprofit.com/books/) +- [Railway Oriented Programming](https://fsharpforfunandprofit.com/rop/) +- [Serialization Guide](./serialization-guide.md) - Comprehensive serialization patterns (cross-language) +- [AGENTS.md](../../AGENTS.md) - Project-wide agent guidance diff --git a/src/Morphir.Tool/packages.lock.json b/src/Morphir.Tool/packages.lock.json index 4408984d..1ff2c7cc 100644 --- a/src/Morphir.Tool/packages.lock.json +++ b/src/Morphir.Tool/packages.lock.json @@ -610,11 +610,6 @@ "Serilog": "2.9.0" } }, - "Spectre.Console": { - "type": "Transitive", - "resolved": "0.53.0", - "contentHash": "m2iv8Egfywp7FaNLKCmCFHbSf36D4ctzZKvlAK9NXMyGLh6L+CnrZWK8o+LOYsoAS1jtoHn0W1BT0W8vuq/FUw==" - }, "System.Composition": { "type": "Transitive", "resolved": "9.0.0", @@ -815,6 +810,12 @@ "Spectre.Console": "0.47.0" } }, + "Spectre.Console": { + "type": "CentralTransitive", + "requested": "[0.53.0, )", + "resolved": "0.53.0", + "contentHash": "m2iv8Egfywp7FaNLKCmCFHbSf36D4ctzZKvlAK9NXMyGLh6L+CnrZWK8o+LOYsoAS1jtoHn0W1BT0W8vuq/FUw==" + }, "Vogen": { "type": "CentralTransitive", "requested": "[8.0.3, )", diff --git a/src/Morphir.Tooling/packages.lock.json b/src/Morphir.Tooling/packages.lock.json index e2d660d4..acec8a25 100644 --- a/src/Morphir.Tooling/packages.lock.json +++ b/src/Morphir.Tooling/packages.lock.json @@ -599,11 +599,6 @@ "Serilog": "2.9.0" } }, - "Spectre.Console": { - "type": "Transitive", - "resolved": "0.53.0", - "contentHash": "m2iv8Egfywp7FaNLKCmCFHbSf36D4ctzZKvlAK9NXMyGLh6L+CnrZWK8o+LOYsoAS1jtoHn0W1BT0W8vuq/FUw==" - }, "System.Composition": { "type": "Transitive", "resolved": "9.0.0", @@ -729,6 +724,12 @@ "Spectre.Console": "0.47.0" } }, + "Spectre.Console": { + "type": "CentralTransitive", + "requested": "[0.53.0, )", + "resolved": "0.53.0", + "contentHash": "m2iv8Egfywp7FaNLKCmCFHbSf36D4ctzZKvlAK9NXMyGLh6L+CnrZWK8o+LOYsoAS1jtoHn0W1BT0W8vuq/FUw==" + }, "Vogen": { "type": "CentralTransitive", "requested": "[8.0.3, )", diff --git a/src/Morphir/packages.lock.json b/src/Morphir/packages.lock.json index 60564662..ca4bd9d9 100644 --- a/src/Morphir/packages.lock.json +++ b/src/Morphir/packages.lock.json @@ -610,11 +610,6 @@ "Serilog": "2.9.0" } }, - "Spectre.Console": { - "type": "Transitive", - "resolved": "0.53.0", - "contentHash": "m2iv8Egfywp7FaNLKCmCFHbSf36D4ctzZKvlAK9NXMyGLh6L+CnrZWK8o+LOYsoAS1jtoHn0W1BT0W8vuq/FUw==" - }, "System.Composition": { "type": "Transitive", "resolved": "9.0.0", @@ -800,6 +795,12 @@ "Spectre.Console": "0.47.0" } }, + "Spectre.Console": { + "type": "CentralTransitive", + "requested": "[0.53.0, )", + "resolved": "0.53.0", + "contentHash": "m2iv8Egfywp7FaNLKCmCFHbSf36D4ctzZKvlAK9NXMyGLh6L+CnrZWK8o+LOYsoAS1jtoHn0W1BT0W8vuq/FUw==" + }, "Vogen": { "type": "CentralTransitive", "requested": "[8.0.3, )", diff --git a/tests/Morphir.Tooling.Tests/packages.lock.json b/tests/Morphir.Tooling.Tests/packages.lock.json index e99b1100..25ef70ea 100644 --- a/tests/Morphir.Tooling.Tests/packages.lock.json +++ b/tests/Morphir.Tooling.Tests/packages.lock.json @@ -673,11 +673,6 @@ "resolved": "3.1.2", "contentHash": "/OoEZQxSW6DeTJ9nfrg8BLCOCWpxBiWHV4NkG3t+Xpe8tvzm7yCwKwxkhpauMl3fg9OjlIjJMKX61H6VavLkrw==" }, - "Spectre.Console": { - "type": "Transitive", - "resolved": "0.53.0", - "contentHash": "m2iv8Egfywp7FaNLKCmCFHbSf36D4ctzZKvlAK9NXMyGLh6L+CnrZWK8o+LOYsoAS1jtoHn0W1BT0W8vuq/FUw==" - }, "System.Composition": { "type": "Transitive", "resolved": "9.0.0", @@ -776,7 +771,6 @@ "type": "Project", "dependencies": { "FluentValidation": "[11.12.0, )", - "JasperFx.CodeGeneration": "[3.7.2, )", "JasperFx.CodeGeneration.Commands": "[3.7.2, )", "Json.More.Net": "[2.2.0, )", "JsonSchema.Net": "[8.0.3, )", @@ -922,6 +916,12 @@ "Serilog": "4.0.0" } }, + "Spectre.Console": { + "type": "CentralTransitive", + "requested": "[0.53.0, )", + "resolved": "0.53.0", + "contentHash": "m2iv8Egfywp7FaNLKCmCFHbSf36D4ctzZKvlAK9NXMyGLh6L+CnrZWK8o+LOYsoAS1jtoHn0W1BT0W8vuq/FUw==" + }, "Vogen": { "type": "CentralTransitive", "requested": "[8.0.3, )",