Canary Release #18
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Canary Release | |
| on: | |
| pull_request: | |
| branches: [ "main", "master" ] | |
| types: [opened, synchronize, reopened] | |
| schedule: | |
| # Run daily at 23:59 UTC to capture all commits for the day | |
| - cron: '59 23 * * *' | |
| workflow_dispatch: | |
| inputs: | |
| skip_ai_notes: | |
| description: 'Skip AI-generated release notes' | |
| type: boolean | |
| default: false | |
| jobs: | |
| check-commits: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| has_new_commits: ${{ steps.check.outputs.has_new_commits }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Check for new commits since last canary | |
| id: check | |
| run: | | |
| # PR and manual triggers always proceed | |
| if [ "${{ github.event_name }}" != "schedule" ]; then | |
| echo "Trigger is '${{ github.event_name }}' — skipping commit check." | |
| echo "has_new_commits=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| git fetch --tags origin | |
| # Find the most recent canary tag | |
| LAST_CANARY=$(git tag --list 'v*-canary-*' --sort=-creatordate | head -1) | |
| if [ -z "$LAST_CANARY" ]; then | |
| echo "No previous canary tag found — proceeding." | |
| echo "has_new_commits=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| echo "Last canary tag: $LAST_CANARY" | |
| COMMIT_COUNT=$(git rev-list "${LAST_CANARY}..HEAD" --count) | |
| echo "New commits since $LAST_CANARY: $COMMIT_COUNT" | |
| if [ "$COMMIT_COUNT" -eq 0 ]; then | |
| echo "No new commits since last canary — skipping workflow." | |
| echo "has_new_commits=false" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "has_new_commits=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| canary: | |
| needs: check-commits | |
| if: needs.check-commits.outputs.has_new_commits == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 60 | |
| permissions: | |
| contents: write | |
| models: read | |
| pull-requests: write | |
| packages: write | |
| issues: write | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Pull latest changes | |
| run: | | |
| git fetch origin | |
| if [ "${{ github.event_name }}" == "pull_request" ]; then | |
| git checkout ${{ github.sha }} | |
| echo "Checked out PR commit: $(git log -1 --oneline)" | |
| else | |
| git reset --hard origin/main | |
| echo "HEAD is now: $(git log -1 --oneline)" | |
| fi | |
| - name: Extract and validate version | |
| id: version | |
| run: | | |
| if [ ! -f build/Package.props ]; then | |
| echo "ERROR: build/Package.props not found" | |
| exit 1 | |
| fi | |
| VERSION=$(grep -oP '(?<=<PackageVersion>)[0-9]+\.[0-9]+\.[0-9]+[^<]*' build/Package.props | head -1) | |
| if [ -z "$VERSION" ]; then | |
| echo "ERROR: Could not extract a valid PackageVersion from build/Package.props" | |
| cat build/Package.props | |
| exit 1 | |
| fi | |
| if [[ "$VERSION" =~ [[:space:]] ]]; then | |
| echo "ERROR: Extracted version '$VERSION' contains whitespace" | |
| exit 1 | |
| fi | |
| # Generate canary version: append -canary-YYYYMMDD-HHMMSS | |
| CANARY_SUFFIX="-canary-$(date +%Y%m%d-%H%M%S)" | |
| CANARY_VERSION="${VERSION}${CANARY_SUFFIX}" | |
| echo "version=$VERSION" >> $GITHUB_OUTPUT | |
| echo "canary_version=$CANARY_VERSION" >> $GITHUB_OUTPUT | |
| echo "Detected base version: $VERSION" | |
| echo "Canary version: $CANARY_VERSION" | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@v4 | |
| with: | |
| dotnet-version: '9.0.x' | |
| - name: Restore (with retry) | |
| uses: nick-fields/retry@v3 | |
| with: | |
| timeout_minutes: 10 | |
| max_attempts: 3 | |
| retry_wait_seconds: 15 | |
| command: | | |
| dotnet restore src/PleasantUI/PleasantUI.csproj | |
| dotnet restore src/PleasantUI.DataGrid/PleasantUI.DataGrid.csproj | |
| dotnet restore src/PleasantUI.MaterialIcons/PleasantUI.MaterialIcons.csproj | |
| dotnet restore src/PleasantUI.ToolKit/PleasantUI.ToolKit.csproj | |
| - name: Modify Package.props with canary version | |
| run: | | |
| CANARY_VERSION="${{ steps.version.outputs.canary_version }}" | |
| echo "Modifying build/Package.props to use canary version: $CANARY_VERSION" | |
| # Backup original Package.props | |
| cp build/Package.props build/Package.props.backup | |
| # Replace PackageVersion with canary version | |
| sed -i "s/<PackageVersion>.*<\/PackageVersion>/<PackageVersion>$CANARY_VERSION<\/PackageVersion>/" build/Package.props | |
| echo "Modified Package.props content:" | |
| cat build/Package.props | |
| - name: Build | |
| run: | | |
| dotnet build src/PleasantUI/PleasantUI.csproj --configuration Release --no-restore | |
| dotnet build src/PleasantUI.DataGrid/PleasantUI.DataGrid.csproj --configuration Release --no-restore | |
| dotnet build src/PleasantUI.MaterialIcons/PleasantUI.MaterialIcons.csproj --configuration Release --no-restore | |
| dotnet build src/PleasantUI.ToolKit/PleasantUI.ToolKit.csproj --configuration Release --no-restore | |
| - name: Build single-file example app | |
| uses: nick-fields/retry@v3 | |
| with: | |
| timeout_minutes: 10 | |
| max_attempts: 3 | |
| retry_wait_seconds: 15 | |
| command: | | |
| # Only build for linux-x64 since cross-OS native compilation is not supported | |
| # Use framework-dependent single-file to avoid AOT compilation version mismatch | |
| # Disable AOT compilation to prevent ILCompiler version mismatch | |
| dotnet publish samples/PleasantUI.Example.Desktop/PleasantUI.Example.Desktop.csproj \ | |
| --configuration Release \ | |
| --runtime linux-x64 \ | |
| -p:PublishSingleFile=true \ | |
| -p:IncludeNativeLibrariesForSelfExtract=true \ | |
| -p:PublishAot=false \ | |
| -p:TreatWarningsAsErrors=false \ | |
| -o /tmp/example-app-linux-x64 || exit 1 | |
| # Verify output exists | |
| if [ ! -f /tmp/example-app-linux-x64/PleasantUI.Example.Desktop ]; then | |
| echo "ERROR: Executable not found after build" | |
| ls -la /tmp/example-app-linux-x64/ | |
| exit 1 | |
| fi | |
| # Create zip archive | |
| cd /tmp/example-app-linux-x64 | |
| zip -r /tmp/PleasantUI-Example-linux-x64.zip . || exit 1 | |
| # Verify zip was created | |
| if [ ! -f /tmp/PleasantUI-Example-linux-x64.zip ]; then | |
| echo "ERROR: Zip file was not created" | |
| exit 1 | |
| fi | |
| echo "Built single-file example app for linux-x64 successfully" | |
| - name: Verify packages exist | |
| run: | | |
| echo "=== Searching for all .nupkg files under src/ ===" | |
| find src/ -name "*.nupkg" 2>/dev/null | sort | |
| FAILED=0 | |
| for project in PleasantUI PleasantUI.DataGrid PleasantUI.MaterialIcons PleasantUI.ToolKit; do | |
| FOUND=$(find "src/$project" -name "*.nupkg" 2>/dev/null | head -1) | |
| if [ -z "$FOUND" ]; then | |
| echo "ERROR: No .nupkg found anywhere under src/$project" | |
| find "src/$project/bin" -type f 2>/dev/null || echo "(bin dir not found)" | |
| FAILED=1 | |
| else | |
| echo "OK: Found package for $project at $FOUND" | |
| fi | |
| done | |
| if [ "$FAILED" -eq 1 ]; then exit 1; fi | |
| - name: Rename packages with canary version | |
| run: | | |
| CANARY_VERSION="${{ steps.version.outputs.canary_version }}" | |
| for project in PleasantUI PleasantUI.DataGrid PleasantUI.MaterialIcons PleasantUI.ToolKit; do | |
| for pkg in src/$project/bin/Release/*.nupkg; do | |
| if [ -f "$pkg" ]; then | |
| BASE_NAME=$(basename "$pkg" .nupkg) | |
| BASE_VERSION=$(echo "$BASE_NAME" | sed 's/.*\.//') | |
| NEW_NAME="${BASE_NAME%.$BASE_VERSION}.${CANARY_VERSION}.nupkg" | |
| mv "$pkg" "$(dirname "$pkg")/$NEW_NAME" | |
| echo "Renamed: $(basename "$pkg") -> $NEW_NAME" | |
| fi | |
| done | |
| done | |
| - name: Restore Package.props | |
| if: always() | |
| run: | | |
| if [ -f build/Package.props.backup ]; then | |
| mv build/Package.props.backup build/Package.props | |
| echo "Restored original Package.props" | |
| fi | |
| - name: Collect commits and build context chunks | |
| id: collect | |
| run: | | |
| # ── Determine commit range based on trigger type ──────────────────── | |
| if [ "${{ github.event_name }}" == "schedule" ]; then | |
| # Daily run: get commits from last 24 hours | |
| echo "Running as scheduled daily build" | |
| # Find the commit from 24 hours ago | |
| YESTERDAY=$(date -d "yesterday" +%Y-%m-%d) | |
| RANGE="${YESTERDAY}..HEAD" | |
| echo "Range: commits since $YESTERDAY" | |
| elif [ "${{ github.event_name }}" == "pull_request" ]; then | |
| # PR run: diff against base branch | |
| echo "Running as PR build" | |
| RANGE="${{ github.event.pull_request.base.sha }}..${{ github.sha }}" | |
| echo "Range: PR diff" | |
| else | |
| # Manual run: use previous tag or first commit | |
| echo "Running as manual build" | |
| PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") | |
| echo "Previous tag: '${PREV_TAG}'" | |
| if [ -n "$PREV_TAG" ]; then | |
| RANGE="${PREV_TAG}..HEAD" | |
| else | |
| FIRST=$(git rev-list --max-parents=0 HEAD) | |
| RANGE="${FIRST}..HEAD" | |
| fi | |
| echo "Range: $RANGE" | |
| fi | |
| echo "range=$RANGE" >> $GITHUB_OUTPUT | |
| echo "Diff range: $RANGE" | |
| # ── Commit log ──────────────────────────────────────────────────────── | |
| COMMITS=$(git log "$RANGE" --pretty=format:"[%h] %s (%an, %ad)" --date=short) | |
| [ -z "$COMMITS" ] && COMMITS="(no commits in range — possibly initial release)" | |
| # ── Changed files (all paths) ───────────────────────────────────────── | |
| FILE_LIST=$(git diff --name-only "$RANGE" | head -200) | |
| STAT_SUMMARY=$(git diff --shortstat "$RANGE" | tail -1) | |
| # ── Shared system prompt — all printf, no heredoc ────────────────────── | |
| printf '%s\n' \ | |
| 'You are a senior technical writer analysing a PleasantUI canary release diff batch.' \ | |
| 'PleasantUI is a cross-platform UI theme and control library for Avalonia (.NET), inspired by Microsoft Fluent Design / WinUI.' \ | |
| 'The diff covers the full repository: library source (src/), example app (samples/), and CI workflows (.github/).' \ | |
| '' \ | |
| '## Your task' \ | |
| 'Extract EVERY meaningful change from the diffs below. Output structured bullet points — NOT final release notes.' \ | |
| 'This output will be merged with other batches, so completeness matters more than brevity.' \ | |
| 'Do NOT drop or summarise away any finding. One bullet per distinct change.' \ | |
| '' \ | |
| '## Rules' \ | |
| '- Each diff is a COMPLETE per-file diff — read it fully before drawing conclusions.' \ | |
| '- New control/feature: name, what it does, key public API (properties, events, methods).' \ | |
| '- New property/event: name, type, one-line purpose.' \ | |
| '- Bug fix: describe the symptom that was fixed, not just the commit wording.' \ | |
| '- Breaking change: old API → new API, one line each.' \ | |
| '- Example app changes (samples/): note new demo pages, updated usage patterns, or new showcased features.' \ | |
| '- CI changes (.github/): note workflow fixes, new steps, or changed behaviour.' \ | |
| '- If a file has no meaningful public-facing change (e.g. only whitespace/comments), skip it.' \ | |
| '- Do NOT invent anything not evidenced by the diff.' \ | |
| '' \ | |
| '## Output format (strict)' \ | |
| 'Group findings under these exact headings (omit empty ones):' \ | |
| '**New Controls / Features**' \ | |
| '**Improvements**' \ | |
| '**Bug Fixes**' \ | |
| '**Breaking Changes**' \ | |
| '**Example App**' \ | |
| '**CI / Tooling**' \ | |
| '' \ | |
| 'Each bullet: `- [FileName] <one concise sentence>`' \ | |
| '' \ | |
| '---' \ | |
| > /tmp/system-prompt.txt | |
| # ── Prompt 1: commits + files + API surface (always fits, no diff) ───── | |
| { | |
| cat /tmp/system-prompt.txt | |
| printf '\n' | |
| printf 'Version: %s\n' "${{ steps.version.outputs.canary_version }}" | |
| printf 'Base version: %s\n' "${{ steps.version.outputs.version }}" | |
| printf 'Release type: CANARY RELEASE (daily build / PR test — may contain incomplete features or breaking changes)\n\n' | |
| printf '## Commit Log\n```\n%s\n```\n\n' "$COMMITS" | |
| printf '## All Changed Files\n```\n%s\n\nSummary: %s\n```\n\n' \ | |
| "$FILE_LIST" "$STAT_SUMMARY" | |
| } > /tmp/prompt-1.txt | |
| # ── Split diff per-file into batches of ~700 lines each ─────────────── | |
| CHANGED_SRC=$(git diff --name-only "$RANGE" \ | |
| | grep -E '\.(cs|axaml|yml)$') | |
| mkdir -p /tmp/prompts | |
| cp /tmp/prompt-1.txt /tmp/prompts/ | |
| BATCH_NUM=2 | |
| BATCH_LINES=0 | |
| BATCH_CONTENT="" | |
| MAX_BATCH_LINES=300 | |
| MAX_PROMPT_BYTES=15000 | |
| MAX_BATCHES=7 | |
| for FILE in $CHANGED_SRC; do | |
| FILE_DIFF=$(git diff "$RANGE" --unified=3 -- "$FILE" 2>/dev/null) | |
| [ -z "$FILE_DIFF" ] && continue | |
| FILE_LINES=$(printf '%s\n' "$FILE_DIFF" | wc -l) | |
| # If adding this file would overflow the batch, flush current batch first | |
| if [ $BATCH_LINES -gt 0 ] && [ $(( BATCH_LINES + FILE_LINES )) -gt $MAX_BATCH_LINES ]; then | |
| if [ $BATCH_NUM -le $(( MAX_BATCHES + 1 )) ]; then | |
| PART_NUM=$(( BATCH_NUM - 1 )) | |
| { | |
| cat /tmp/system-prompt.txt | |
| printf '\nThis is diff batch %d. Analyse each file diff completely and list your findings.\n\n' "$PART_NUM" | |
| printf '%s\n' "$BATCH_CONTENT" | |
| } > "/tmp/prompts/prompt-${BATCH_NUM}.txt" | |
| echo "Batch ${BATCH_NUM}: ${BATCH_LINES} lines -> /tmp/prompts/prompt-${BATCH_NUM}.txt" | |
| PROMPT_SIZE=$(wc -c < "/tmp/prompts/prompt-${BATCH_NUM}.txt") | |
| if [ "$PROMPT_SIZE" -gt "$MAX_PROMPT_BYTES" ]; then | |
| echo "WARNING: Prompt ${BATCH_NUM} too large (${PROMPT_SIZE} bytes), truncating to ${MAX_PROMPT_BYTES} bytes..." | |
| head -c "$MAX_PROMPT_BYTES" "/tmp/prompts/prompt-${BATCH_NUM}.txt" > "/tmp/prompts/prompt-${BATCH_NUM}.txt.tmp" | |
| mv "/tmp/prompts/prompt-${BATCH_NUM}.txt.tmp" "/tmp/prompts/prompt-${BATCH_NUM}.txt" | |
| printf '\n\n[Content truncated due to size limits]\n' >> "/tmp/prompts/prompt-${BATCH_NUM}.txt" | |
| fi | |
| fi | |
| BATCH_LINES=0 | |
| BATCH_CONTENT="" | |
| BATCH_NUM=$((BATCH_NUM + 1)) | |
| fi | |
| # Accumulate into current batch (skip if we already hit max batches) | |
| if [ $BATCH_NUM -le $(( MAX_BATCHES + 1 )) ]; then | |
| BATCH_CONTENT=$(printf '%s\n\n### File: %s\n```diff\n%s\n```\n' \ | |
| "$BATCH_CONTENT" "$FILE" "$FILE_DIFF") | |
| BATCH_LINES=$(( BATCH_LINES + FILE_LINES )) | |
| fi | |
| done | |
| # Flush final batch | |
| if [ $BATCH_LINES -gt 0 ] && [ $BATCH_NUM -le $(( MAX_BATCHES + 1 )) ]; then | |
| PART_NUM=$(( BATCH_NUM - 1 )) | |
| { | |
| cat /tmp/system-prompt.txt | |
| printf '\nThis is diff batch %d. Analyse each file diff completely and list your findings.\n\n' "$PART_NUM" | |
| printf '%s\n' "$BATCH_CONTENT" | |
| } > "/tmp/prompts/prompt-${BATCH_NUM}.txt" | |
| echo "Batch ${BATCH_NUM}: ${BATCH_LINES} lines -> /tmp/prompts/prompt-${BATCH_NUM}.txt" | |
| PROMPT_SIZE=$(wc -c < "/tmp/prompts/prompt-${BATCH_NUM}.txt") | |
| if [ "$PROMPT_SIZE" -gt "$MAX_PROMPT_BYTES" ]; then | |
| echo "WARNING: Prompt ${BATCH_NUM} too large (${PROMPT_SIZE} bytes), truncating..." | |
| head -c "$MAX_PROMPT_BYTES" "/tmp/prompts/prompt-${BATCH_NUM}.txt" > "/tmp/prompts/prompt-${BATCH_NUM}.txt.tmp" | |
| mv "/tmp/prompts/prompt-${BATCH_NUM}.txt.tmp" "/tmp/prompts/prompt-${BATCH_NUM}.txt" | |
| printf '\n\n[Content truncated due to size limits]\n' >> "/tmp/prompts/prompt-${BATCH_NUM}.txt" | |
| fi | |
| fi | |
| # ── Ensure prompts 2-8 exist (empty marker if no content) ───────────── | |
| for N in 2 3 4 5 6 7 8; do | |
| if [ ! -f "/tmp/prompts/prompt-${N}.txt" ]; then | |
| printf 'No diff content for this batch.\n' > "/tmp/prompts/prompt-${N}.txt" | |
| fi | |
| done | |
| echo "=== All prompt sizes ===" | |
| for N in 1 2 3 4 5 6 7 8; do | |
| printf 'prompt-%d.txt: %d bytes\n' "$N" "$(wc -c < /tmp/prompts/prompt-${N}.txt)" | |
| done | |
| - name: Upload prompt artifacts | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: canary-ai-prompts | |
| path: /tmp/prompts/ | |
| retention-days: 1 | |
| # ── AI passes ──────────────────────────────────────────────────────────── | |
| - name: AI pass 1 — commits + API surface | |
| if: github.event.inputs.skip_ai_notes != 'true' | |
| id: ai_pass1 | |
| continue-on-error: true | |
| uses: actions/ai-inference@v1 | |
| with: | |
| model: openai/gpt-4o-mini | |
| max-tokens: 8000 | |
| prompt-file: /tmp/prompts/prompt-1.txt | |
| - name: Save pass 1 response | |
| if: github.event.inputs.skip_ai_notes != 'true' && steps.ai_pass1.conclusion == 'success' | |
| run: | | |
| cp "${{ steps.ai_pass1.outputs.response-file }}" /tmp/pass-1.txt || true | |
| sleep 65 | |
| - name: AI pass 2 — diff batch 1 | |
| if: github.event.inputs.skip_ai_notes != 'true' | |
| id: ai_pass2 | |
| continue-on-error: true | |
| uses: actions/ai-inference@v1 | |
| with: | |
| model: openai/gpt-4o-mini | |
| max-tokens: 8000 | |
| prompt-file: /tmp/prompts/prompt-2.txt | |
| - name: Save pass 2 response | |
| if: github.event.inputs.skip_ai_notes != 'true' && steps.ai_pass2.conclusion == 'success' | |
| run: | | |
| cp "${{ steps.ai_pass2.outputs.response-file }}" /tmp/pass-2.txt || true | |
| sleep 65 | |
| - name: AI pass 3 — diff batch 2 | |
| if: github.event.inputs.skip_ai_notes != 'true' | |
| id: ai_pass3 | |
| continue-on-error: true | |
| uses: actions/ai-inference@v1 | |
| with: | |
| model: openai/gpt-4o-mini | |
| max-tokens: 8000 | |
| prompt-file: /tmp/prompts/prompt-3.txt | |
| - name: Save pass 3 response | |
| if: github.event.inputs.skip_ai_notes != 'true' && steps.ai_pass3.conclusion == 'success' | |
| run: | | |
| cp "${{ steps.ai_pass3.outputs.response-file }}" /tmp/pass-3.txt || true | |
| sleep 65 | |
| - name: AI pass 4 — diff batch 3 | |
| if: github.event.inputs.skip_ai_notes != 'true' | |
| id: ai_pass4 | |
| continue-on-error: true | |
| uses: actions/ai-inference@v1 | |
| with: | |
| model: openai/gpt-4o-mini | |
| max-tokens: 8000 | |
| prompt-file: /tmp/prompts/prompt-4.txt | |
| - name: Save pass 4 response | |
| if: github.event.inputs.skip_ai_notes != 'true' && steps.ai_pass4.conclusion == 'success' | |
| run: | | |
| cp "${{ steps.ai_pass4.outputs.response-file }}" /tmp/pass-4.txt || true | |
| sleep 65 | |
| - name: AI pass 5 — diff batch 4 | |
| if: github.event.inputs.skip_ai_notes != 'true' | |
| id: ai_pass5 | |
| continue-on-error: true | |
| uses: actions/ai-inference@v1 | |
| with: | |
| model: openai/gpt-4o-mini | |
| max-tokens: 8000 | |
| prompt-file: /tmp/prompts/prompt-5.txt | |
| - name: Save pass 5 response | |
| if: github.event.inputs.skip_ai_notes != 'true' && steps.ai_pass5.conclusion == 'success' | |
| run: | | |
| cp "${{ steps.ai_pass5.outputs.response-file }}" /tmp/pass-5.txt || true | |
| sleep 65 | |
| - name: AI pass 6 — diff batch 5 | |
| if: github.event.inputs.skip_ai_notes != 'true' | |
| id: ai_pass6 | |
| continue-on-error: true | |
| uses: actions/ai-inference@v1 | |
| with: | |
| model: openai/gpt-4o-mini | |
| max-tokens: 8000 | |
| prompt-file: /tmp/prompts/prompt-6.txt | |
| - name: Save pass 6 response | |
| if: github.event.inputs.skip_ai_notes != 'true' && steps.ai_pass6.conclusion == 'success' | |
| run: | | |
| cp "${{ steps.ai_pass6.outputs.response-file }}" /tmp/pass-6.txt || true | |
| sleep 65 | |
| - name: AI pass 7 — diff batch 6 | |
| if: github.event.inputs.skip_ai_notes != 'true' | |
| id: ai_pass7 | |
| continue-on-error: true | |
| uses: actions/ai-inference@v1 | |
| with: | |
| model: openai/gpt-4o-mini | |
| max-tokens: 8000 | |
| prompt-file: /tmp/prompts/prompt-7.txt | |
| - name: Save pass 7 response | |
| if: github.event.inputs.skip_ai_notes != 'true' && steps.ai_pass7.conclusion == 'success' | |
| run: | | |
| cp "${{ steps.ai_pass7.outputs.response-file }}" /tmp/pass-7.txt || true | |
| sleep 65 | |
| - name: AI pass 8 — diff batch 7 | |
| if: github.event.inputs.skip_ai_notes != 'true' | |
| id: ai_pass8 | |
| continue-on-error: true | |
| uses: actions/ai-inference@v1 | |
| with: | |
| model: openai/gpt-4o-mini | |
| max-tokens: 8000 | |
| prompt-file: /tmp/prompts/prompt-8.txt | |
| - name: Save pass 8 response | |
| if: github.event.inputs.skip_ai_notes != 'true' && steps.ai_pass8.conclusion == 'success' | |
| run: | | |
| cp "${{ steps.ai_pass8.outputs.response-file }}" /tmp/pass-8.txt || true | |
| sleep 65 | |
| - name: Build merge-A prompt (passes 1-4) | |
| if: github.event.inputs.skip_ai_notes != 'true' | |
| run: | | |
| printf '%s\n' \ | |
| 'You are a senior technical writer for PleasantUI — a cross-platform UI theme and control library for Avalonia (.NET).' \ | |
| 'The diff covers the full repository: library source (src/), example app (samples/), and CI workflows (.github/).' \ | |
| 'Below are raw per-batch findings from analysis passes 1-4 of this canary release.' \ | |
| '' \ | |
| 'Your task: produce a LOSSLESS intermediate summary.' \ | |
| '- Keep EVERY distinct finding. Do not drop or merge away any item.' \ | |
| '- Deduplicate only exact duplicates (same file, same change mentioned twice).' \ | |
| '- Be concise per bullet (one sentence max) but preserve all facts: names, types, behaviours.' \ | |
| '- Group under: **New Controls / Features** | **Improvements** | **Bug Fixes** | **Breaking Changes** | **Example App** | **CI / Tooling**' \ | |
| '- Each bullet: `- [FileName] <concise fact>`' \ | |
| '- This output feeds a final merge step — completeness is critical.' \ | |
| '' \ | |
| '---' \ | |
| > /tmp/prompt-merge-a.txt | |
| { | |
| printf '## Pass 1 — Commits, changed files\n' | |
| cat /tmp/pass-1.txt 2>/dev/null || printf '(no output)\n' | |
| printf '\n## Pass 2 — Diff batch 1\n' | |
| cat /tmp/pass-2.txt 2>/dev/null || printf '(no output)\n' | |
| printf '\n## Pass 3 — Diff batch 2\n' | |
| cat /tmp/pass-3.txt 2>/dev/null || printf '(no output)\n' | |
| printf '\n## Pass 4 — Diff batch 3\n' | |
| cat /tmp/pass-4.txt 2>/dev/null || printf '(no output)\n' | |
| } >> /tmp/prompt-merge-a.txt | |
| printf 'merge-A prompt: %d bytes\n' "$(wc -c < /tmp/prompt-merge-a.txt)" | |
| - name: AI merge-A (passes 1-4) | |
| if: github.event.inputs.skip_ai_notes != 'true' | |
| id: ai_merge_a | |
| continue-on-error: true | |
| uses: actions/ai-inference@v1 | |
| with: | |
| model: openai/gpt-4o-mini | |
| max-tokens: 8000 | |
| prompt-file: /tmp/prompt-merge-a.txt | |
| - name: Save merge-A response | |
| if: github.event.inputs.skip_ai_notes != 'true' && steps.ai_merge_a.conclusion == 'success' | |
| run: | | |
| cp "${{ steps.ai_merge_a.outputs.response-file }}" /tmp/merge-a.txt || true | |
| sleep 65 | |
| - name: Compress merge-A output | |
| if: github.event.inputs.skip_ai_notes != 'true' | |
| run: | | |
| printf '%s\n' \ | |
| 'You are compressing a change-log summary for PleasantUI canary release.' \ | |
| 'Rewrite the input as a TIGHT structured list. Rules:' \ | |
| '- One bullet per distinct change. Max 12 words per bullet.' \ | |
| '- Format: `- [Category] FileName: <fact>` where Category is one of: New | Improved | Fixed | Breaking' \ | |
| '- Preserve ALL items — do not drop any finding.' \ | |
| '- No prose, no headers, no blank lines between bullets.' \ | |
| '- Output ONLY the bullet list, nothing else.' \ | |
| '---' \ | |
| > /tmp/prompt-compress-a.txt | |
| cat /tmp/merge-a.txt >> /tmp/prompt-compress-a.txt | |
| printf 'compress-A prompt: %d bytes\n' "$(wc -c < /tmp/prompt-compress-a.txt)" | |
| - name: AI compress-A | |
| if: github.event.inputs.skip_ai_notes != 'true' | |
| id: ai_compress_a | |
| continue-on-error: true | |
| uses: actions/ai-inference@v1 | |
| with: | |
| model: openai/gpt-4o-mini | |
| max-tokens: 8000 | |
| prompt-file: /tmp/prompt-compress-a.txt | |
| - name: Save compress-A response | |
| if: github.event.inputs.skip_ai_notes != 'true' && steps.ai_compress_a.conclusion == 'success' | |
| run: | | |
| cp "${{ steps.ai_compress_a.outputs.response-file }}" /tmp/compress-a.txt || true | |
| sleep 65 | |
| - name: Build merge-B prompt (passes 5-8) | |
| if: github.event.inputs.skip_ai_notes != 'true' | |
| run: | | |
| printf '%s\n' \ | |
| 'You are a senior technical writer for PleasantUI — a cross-platform UI theme and control library for Avalonia (.NET).' \ | |
| 'The diff covers the full repository: library source (src/), example app (samples/), and CI workflows (.github/).' \ | |
| 'Below are raw per-batch findings from analysis passes 5-8 of this canary release.' \ | |
| '' \ | |
| 'Your task: produce a LOSSLESS intermediate summary.' \ | |
| '- Keep EVERY distinct finding. Do not drop or merge away any item.' \ | |
| '- Deduplicate only exact duplicates (same file, same change mentioned twice).' \ | |
| '- Be concise per bullet (one sentence max) but preserve all facts: names, types, behaviours.' \ | |
| '- Group under: **New Controls / Features** | **Improvements** | **Bug Fixes** | **Breaking Changes** | **Example App** | **CI / Tooling**' \ | |
| '- Each bullet: `- [FileName] <concise fact>`' \ | |
| '- This output feeds a final merge step — completeness is critical.' \ | |
| '' \ | |
| '---' \ | |
| > /tmp/prompt-merge-b.txt | |
| { | |
| printf '## Pass 5 — Diff batch 4\n' | |
| cat /tmp/pass-5.txt 2>/dev/null || printf '(no output)\n' | |
| printf '\n## Pass 6 — Diff batch 5\n' | |
| cat /tmp/pass-6.txt 2>/dev/null || printf '(no output)\n' | |
| printf '\n## Pass 7 — Diff batch 6\n' | |
| cat /tmp/pass-7.txt 2>/dev/null || printf '(no output)\n' | |
| printf '\n## Pass 8 — Diff batch 7\n' | |
| cat /tmp/pass-8.txt 2>/dev/null || printf '(no output)\n' | |
| } >> /tmp/prompt-merge-b.txt | |
| printf 'merge-B prompt: %d bytes\n' "$(wc -c < /tmp/prompt-merge-b.txt)" | |
| - name: AI merge-B (passes 5-8) | |
| if: github.event.inputs.skip_ai_notes != 'true' | |
| id: ai_merge_b | |
| continue-on-error: true | |
| uses: actions/ai-inference@v1 | |
| with: | |
| model: openai/gpt-4o-mini | |
| max-tokens: 8000 | |
| prompt-file: /tmp/prompt-merge-b.txt | |
| - name: Save merge-B response | |
| if: github.event.inputs.skip_ai_notes != 'true' && steps.ai_merge_b.conclusion == 'success' | |
| run: | | |
| cp "${{ steps.ai_merge_b.outputs.response-file }}" /tmp/merge-b.txt || true | |
| sleep 65 | |
| - name: Compress merge-B output | |
| if: github.event.inputs.skip_ai_notes != 'true' | |
| run: | | |
| printf '%s\n' \ | |
| 'You are compressing a change-log summary for PleasantUI canary release.' \ | |
| 'Rewrite the input as a TIGHT structured list. Rules:' \ | |
| '- One bullet per distinct change. Max 12 words per bullet.' \ | |
| '- Format: `- [Category] FileName: <fact>` where Category is one of: New | Improved | Fixed | Breaking' \ | |
| '- Preserve ALL items — do not drop any finding.' \ | |
| '- No prose, no headers, no blank lines between bullets.' \ | |
| '- Output ONLY the bullet list, nothing else.' \ | |
| '---' \ | |
| > /tmp/prompt-compress-b.txt | |
| cat /tmp/merge-b.txt >> /tmp/prompt-compress-b.txt | |
| printf 'compress-B prompt: %d bytes\n' "$(wc -c < /tmp/prompt-compress-b.txt)" | |
| - name: AI compress-B | |
| if: github.event.inputs.skip_ai_notes != 'true' | |
| id: ai_compress_b | |
| continue-on-error: true | |
| uses: actions/ai-inference@v1 | |
| with: | |
| model: openai/gpt-4o-mini | |
| max-tokens: 8000 | |
| prompt-file: /tmp/prompt-compress-b.txt | |
| - name: Save compress-B response | |
| if: github.event.inputs.skip_ai_notes != 'true' && steps.ai_compress_b.conclusion == 'success' | |
| run: | | |
| cp "${{ steps.ai_compress_b.outputs.response-file }}" /tmp/compress-b.txt || true | |
| sleep 65 | |
| - name: Build final merge prompt (compress-A + compress-B) | |
| if: github.event.inputs.skip_ai_notes != 'true' | |
| run: | | |
| VERSION="${{ steps.version.outputs.canary_version }}" | |
| BASE_VERSION="${{ steps.version.outputs.version }}" | |
| printf '%s\n' \ | |
| 'You are a senior technical writer for PleasantUI — a cross-platform UI theme and control library for Avalonia (.NET).' \ | |
| 'Below are two compressed change lists covering the COMPLETE diff of this canary release.' \ | |
| 'Each line is one distinct change. Together they represent every change in this release.' \ | |
| '' \ | |
| '## Critical rules' \ | |
| '- You MUST include every single bullet from BOTH lists in your output.' \ | |
| '- Read ALL bullets before writing. Do not stop after the first section.' \ | |
| '- Deduplicate only true duplicates (same file + same change in both lists).' \ | |
| '- When the same item appears in both, use the more detailed description.' \ | |
| '- Use markdown: ### section headers, bullet lists, inline code.' \ | |
| '- Do NOT invent anything not present in the lists.' \ | |
| '- After writing, mentally verify: does every input bullet appear somewhere in the output?' \ | |
| > /tmp/prompt-final.txt | |
| printf '%s\n' \ | |
| '' \ | |
| '## Release type: CANARY RELEASE' \ | |
| "This is a canary release ($VERSION based on $BASE_VERSION)." \ | |
| 'Adjust the tone accordingly:' \ | |
| '- Open with a clear statement that this is a canary build for testing purposes.' \ | |
| '- Note that canary builds may contain incomplete features, breaking changes, or bugs.' \ | |
| '- Use language like "adds", "improves", "fixes", "introduces" but with appropriate caveats.' \ | |
| '- Mark experimental or in-progress items with *(experimental)* or *(work in progress)*.' \ | |
| '- This build is NOT suitable for production deployment.' \ | |
| '- Emphasize that users should test with caution and report issues.' \ | |
| >> /tmp/prompt-final.txt | |
| printf '%s\n' \ | |
| '' \ | |
| '## Required output sections (include only those with content):' \ | |
| '### What is New' \ | |
| '### Improvements' \ | |
| '### Bug Fixes' \ | |
| '### Breaking Changes' \ | |
| '### Packages' \ | |
| '### Example App' \ | |
| '' \ | |
| 'Always end with a Packages section listing:' \ | |
| '- PleasantUI' \ | |
| '- PleasantUI.DataGrid' \ | |
| '- PleasantUI.MaterialIcons' \ | |
| '- PleasantUI.ToolKit' \ | |
| '' \ | |
| 'Add an Example App section noting that a single-file example application is included in the canary release artifacts for testing the canary build.' \ | |
| '' \ | |
| '---' \ | |
| >> /tmp/prompt-final.txt | |
| { | |
| printf '## Compressed list A (passes 1-4: commits, diff batches 1-3)\n' | |
| cat /tmp/compress-a.txt 2>/dev/null || printf '(no output)\n' | |
| printf '\n## Compressed list B (passes 5-8: diff batches 4-7)\n' | |
| cat /tmp/compress-b.txt 2>/dev/null || printf '(no output)\n' | |
| } >> /tmp/prompt-final.txt | |
| printf 'Final merge prompt: %d bytes\n' "$(wc -c < /tmp/prompt-final.txt)" | |
| - name: AI final merge — write release notes | |
| if: github.event.inputs.skip_ai_notes != 'true' | |
| id: ai_merge | |
| continue-on-error: true | |
| uses: actions/ai-inference@v1 | |
| with: | |
| model: openai/gpt-4.1 | |
| max-tokens: 8000 | |
| prompt-file: /tmp/prompt-final.txt | |
| - name: Save final merge response | |
| if: github.event.inputs.skip_ai_notes != 'true' && steps.ai_merge.conclusion == 'success' | |
| run: cp "${{ steps.ai_merge.outputs.response-file }}" /tmp/final-merge.txt || true | |
| - name: Write canary release notes to file | |
| id: notes_file | |
| run: | | |
| AI_FAILED="" | |
| if [ '${{ github.event.inputs.skip_ai_notes }}' == 'true' ]; then | |
| # Generate simple release notes from commits | |
| { | |
| printf '## Canary Release %s\n\n' '${{ steps.version.outputs.canary_version }}' | |
| printf 'This is a canary build based on version %s. It may contain incomplete features or breaking changes.\n\n' '${{ steps.version.outputs.version }}' | |
| printf '### Commits\n\n' | |
| git log "${{ steps.collect.outputs.range }}" --pretty=format:"- %h %s (%an)" || printf '- No commits found\n' | |
| printf '\n### Changed Files\n\n' | |
| git diff --name-only "${{ steps.collect.outputs.range }}" | head -50 | sed 's/^/- /' || printf '- No files changed\n' | |
| printf '\n### Packages\n' | |
| printf '- PleasantUI\n' | |
| printf '- PleasantUI.DataGrid\n' | |
| printf '- PleasantUI.MaterialIcons\n' | |
| printf '- PleasantUI.ToolKit\n' | |
| printf '\n### Example App\n\n' | |
| printf 'A single-file example application is included in the canary release artifacts for this build.\n' | |
| printf 'Download it from the workflow run page under the "canary-release-artifacts" artifact.\n' | |
| } > /tmp/canary_notes.md | |
| else | |
| if [ -f /tmp/final-merge.txt ]; then | |
| cp /tmp/final-merge.txt /tmp/canary_notes.md | |
| # Append example app section | |
| { | |
| printf '\n### Example App\n\n' | |
| printf 'A single-file example application is included in the canary release artifacts for this build.\n' | |
| printf 'Download it from the workflow run page under the "canary-release-artifacts" artifact.\n' | |
| } >> /tmp/canary_notes.md | |
| else | |
| AI_FAILED="true" | |
| { | |
| printf '## Canary Release %s\n\n' '${{ steps.version.outputs.canary_version }}' | |
| printf 'This is a canary build based on version %s. It may contain incomplete features or breaking changes.\n\n' '${{ steps.version.outputs.version }}' | |
| printf '### Commits\n\n' | |
| git log "${{ steps.collect.outputs.range }}" --pretty=format:"- %h %s (%an)" || printf '- No commits found\n' | |
| printf '\n### Changed Files\n\n' | |
| git diff --name-only "${{ steps.collect.outputs.range }}" | head -50 | sed 's/^/- /' || printf '- No files changed\n' | |
| printf '\n### Packages\n' | |
| printf '- PleasantUI\n' | |
| printf '- PleasantUI.DataGrid\n' | |
| printf '- PleasantUI.MaterialIcons\n' | |
| printf '- PleasantUI.ToolKit\n' | |
| printf '\n### Example App\n\n' | |
| printf 'A single-file example application is included in the canary release artifacts for this build.\n' | |
| printf 'Download it from the workflow run page under the "canary-release-artifacts" artifact.\n' | |
| } > /tmp/canary_notes.md | |
| fi | |
| fi | |
| echo "notes_file=/tmp/canary_notes.md" >> $GITHUB_OUTPUT | |
| echo "ai_failed=$AI_FAILED" >> $GITHUB_OUTPUT | |
| - name: Upload canary release notes | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: canary-release-notes | |
| path: /tmp/canary_notes.md | |
| retention-days: 7 | |
| - name: Upload canary release artifacts (packages + example app) | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: canary-release-artifacts | |
| path: | | |
| src/*/bin/Release/*.nupkg | |
| /tmp/PleasantUI-Example-linux-x64.zip | |
| retention-days: 7 | |
| - name: Comment on PR with canary notes | |
| if: github.event_name == 'pull_request' | |
| continue-on-error: true | |
| id: pr_comment | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const notes = fs.readFileSync('/tmp/canary_notes.md', 'utf8'); | |
| const runId = context.runId; | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const body = `## Canary Release Build\n\n${notes}\n\n### Release Artifacts\n\nDownload all canary release artifacts (NuGet packages + example app) from the workflow run page:\n\n[Download Artifacts](https://github.com/${owner}/${repo}/actions/runs/${runId})\n\nThe "canary-release-artifacts" artifact includes:\n- PleasantUI NuGet packages\n- PleasantUI.DataGrid NuGet packages\n- PleasantUI.MaterialIcons NuGet packages\n- PleasantUI.ToolKit NuGet packages\n- PleasantUI Example app (Linux x64)`; | |
| await github.rest.issues.createComment({ | |
| issue_number: context.issue.number, | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| body: body | |
| }); | |
| - name: Write canary notes to job summary (always) | |
| if: always() | |
| run: | | |
| NOTES_FILE="/tmp/canary_notes.md" | |
| RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" | |
| { | |
| echo "## Canary Release Build — ${{ steps.version.outputs.canary_version }}" | |
| echo "" | |
| if [ -f "$NOTES_FILE" ]; then | |
| cat "$NOTES_FILE" | |
| else | |
| echo "_(release notes not generated)_" | |
| fi | |
| echo "" | |
| echo "---" | |
| echo "### Release Artifacts" | |
| echo "" | |
| echo "[Download Artifacts]($RUN_URL)" | |
| echo "" | |
| echo "The \`canary-release-artifacts\` artifact includes:" | |
| echo "- PleasantUI NuGet packages" | |
| echo "- PleasantUI.DataGrid NuGet packages" | |
| echo "- PleasantUI.MaterialIcons NuGet packages" | |
| echo "- PleasantUI.ToolKit NuGet packages" | |
| echo "- PleasantUI Example app (Linux x64)" | |
| if [ "${{ steps.pr_comment.outcome }}" == "failure" ]; then | |
| echo "" | |
| echo "> **Note:** PR comment could not be posted (fork PR or insufficient permissions). Notes are available here in the job summary." | |
| fi | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Publish canary packages to GitHub Packages | |
| continue-on-error: true | |
| run: | | |
| PUSH_FAILED=0 | |
| for pkg in src/*/bin/Release/*.nupkg; do | |
| if [ -f "$pkg" ]; then | |
| dotnet nuget push "$pkg" \ | |
| --source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" \ | |
| --api-key "${{ secrets.GITHUB_TOKEN }}" \ | |
| --skip-duplicate || { | |
| EXIT=$? | |
| if [ $EXIT -eq 1 ]; then | |
| echo "WARNING: Push failed for $(basename "$pkg") (exit $EXIT) — skipping (likely 403/auth). Artifacts are still available for download." | |
| PUSH_FAILED=1 | |
| fi | |
| } | |
| fi | |
| done | |
| if [ $PUSH_FAILED -eq 1 ]; then | |
| echo "::warning::One or more packages could not be pushed to GitHub Packages (403 Forbidden). This is expected for fork PRs. Download packages from the workflow artifacts instead." | |
| fi | |
| - name: Publish canary packages summary | |
| run: | | |
| echo "=== Published Canary Packages ===" | |
| for pkg in src/*/bin/Release/*.nupkg; do | |
| if [ -f "$pkg" ]; then | |
| echo "- $(basename "$pkg")" | |
| fi | |
| done | |
| - name: Create GitHub Release | |
| id: create_release | |
| uses: softprops/action-gh-release@v2 | |
| continue-on-error: true | |
| with: | |
| tag_name: v${{ steps.version.outputs.canary_version }} | |
| name: Canary v${{ steps.version.outputs.canary_version }} | |
| prerelease: true | |
| body_path: /tmp/canary_notes.md | |
| fail_on_unmatched_files: true | |
| files: | | |
| src/*/bin/Release/*.nupkg | |
| /tmp/PleasantUI-Example-linux-x64.zip | |
| - name: Confirm release created | |
| run: | | |
| echo "Canary release created: ${{ steps.create_release.outputs.url }}" |