Skip to content

Canary Release

Canary Release #18

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 }}"