Skip to content

Extract AppHost parser to shared module with 25 unit tests #59

Extract AppHost parser to shared module with 25 unit tests

Extract AppHost parser to shared module with 25 unit tests #59

Workflow file for this run

name: "Release & Deploy"
on:
push:
branches: [main]
tags:
- 'v*'
workflow_dispatch:
inputs:
deploy_only:
description: 'Skip tests and deploy the current main branch'
type: boolean
default: false
environment:
description: 'Target environment'
type: choice
options:
- production
default: production
permissions:
contents: write
id-token: write # Required for OIDC federated credentials with Azure
env:
AZURE_RESOURCE_GROUP: ${{ vars.AZURE_RESOURCE_GROUP || 'aspire-academy' }}
AZURE_LOCATION: ${{ vars.AZURE_LOCATION || 'eastus' }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID || vars.AZURE_SUBSCRIPTION_ID }}
jobs:
# ─── Stage 1: Validate ────────────────────────────────────────────
validate:
name: Validate Release
if: ${{ !inputs.deploy_only && startsWith(github.ref, 'refs/tags/v') }}
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Extract version from tag
id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Check changelog entry exists
run: |
VERSION="${{ steps.version.outputs.version }}"
if ! grep -q "version: '$VERSION'" AspireAcademy.Web/src/data/changelog.ts; then
echo "::error::No changelog entry for version $VERSION in AspireAcademy.Web/src/data/changelog.ts"
exit 1
fi
echo "✓ Changelog entry found for v$VERSION"
# ─── Stage 2: Test (parallel) ─────────────────────────────────────
backend-unit:
name: Backend Unit Tests
if: ${{ !inputs.deploy_only }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '11.0.x'
dotnet-quality: 'preview'
- name: Restore & Build
run: dotnet build AspireAcademy.Api.Tests/AspireAcademy.Api.Tests.csproj
- name: Test
run: dotnet test AspireAcademy.Api.Tests/ --no-build --filter 'FullyQualifiedName!~E2E&Category!=Integration' --verbosity normal
backend-integration:
name: Backend Integration Tests
if: ${{ !inputs.deploy_only }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '11.0.x'
dotnet-quality: 'preview'
- name: Restore & Build
run: dotnet build AspireAcademy.Api.Tests/AspireAcademy.Api.Tests.csproj
- name: Test
run: dotnet test AspireAcademy.Api.Tests/ --no-build --filter 'Category=Integration&FullyQualifiedName!~E2E' --verbosity normal
timeout-minutes: 10
frontend:
name: Frontend Checks
if: ${{ !inputs.deploy_only }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: AspireAcademy.Web/package-lock.json
- name: Install dependencies
working-directory: AspireAcademy.Web
run: npm ci
- name: Type check
working-directory: AspireAcademy.Web
run: npm run type-check
- name: Lint
working-directory: AspireAcademy.Web
run: npm run lint
- name: Build
working-directory: AspireAcademy.Web
run: npm run build
# ─── Stage 3: Create GitHub Release ───────────────────────────────
release:
name: Create GitHub Release
needs: [validate, backend-unit, backend-integration, frontend]
if: ${{ !inputs.deploy_only && startsWith(github.ref, 'refs/tags/v') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Extract release notes from changelog
id: notes
run: |
export VERSION="${{ needs.validate.outputs.version }}"
python3 - <<'PYTHON'
import os, re, sys
version = os.environ.get("VERSION", "")
if not version:
print("VERSION environment variable is empty", file=sys.stderr)
sys.exit(1)
with open("AspireAcademy.Web/src/data/changelog.ts") as f:
content = f.read()
marker = f"version: '{version}'"
marker_index = content.find(marker)
if marker_index == -1:
print(f"Could not find changelog entry for {version}", file=sys.stderr)
sys.exit(1)
# Walk backward to the opening brace and forward with brace balancing
# to isolate exactly one entry object from the changelog array.
start = content.rfind("{", 0, marker_index)
if start == -1:
print(f"Could not locate start of changelog entry for {version}", file=sys.stderr)
sys.exit(1)
depth = 0
end = None
for i in range(start, len(content)):
ch = content[i]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
end = i
break
if end is None:
print(f"Could not locate end of changelog entry for {version}", file=sys.stderr)
sys.exit(1)
block = content[start:end + 1]
title_match = re.search(r"title:\s*'([^']*)'", block)
title = title_match.group(1) if title_match else f"v{version}"
entries = re.findall(r"\{\s*type:\s*'(\w+)',\s*text:\s*'([^']*)'\s*\}", block)
lines = [f"# {title}\n"]
type_labels = {"feature": "Features", "improvement": "Improvements", "fix": "Fixes", "content": "Content"}
for t in ["feature", "improvement", "fix", "content"]:
items = [text for typ, text in entries if typ == t]
if items:
lines.append(f"\n## {type_labels[t]}\n")
for item in items:
lines.append(f"- {item}")
notes = "\n".join(lines)
with open("release_notes.md", "w") as f:
f.write(notes)
PYTHON
- name: Create Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "${{ github.ref_name }}" \
--title "Release ${{ github.ref_name }}" \
--notes-file release_notes.md
# ─── Stage 4: Deploy to Azure ─────────────────────────────────────
deploy:
name: Deploy to Azure
needs: [release, backend-unit, backend-integration, frontend]
if: |
always() &&
(
needs.release.result == 'success' ||
(github.ref == 'refs/heads/main' && needs.backend-unit.result == 'success' && needs.backend-integration.result == 'success' && needs.frontend.result == 'success') ||
inputs.deploy_only
)
runs-on: ubuntu-latest
environment: production
concurrency:
group: deploy-production
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '11.0.x'
dotnet-quality: 'preview'
- name: Install Aspire CLI
run: curl -sSL https://aspire.dev/install.sh | bash
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: AspireAcademy.Web/package-lock.json
- name: Install frontend dependencies
working-directory: AspireAcademy.Web
run: npm ci
- name: Log in to Azure
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
allow-no-subscriptions: true
- name: Resolve subscription ID
run: |
SUBSCRIPTION_ID="${{ env.AZURE_SUBSCRIPTION_ID }}"
if [[ -z "$SUBSCRIPTION_ID" ]]; then
SUBSCRIPTION_ID=$(az account show --query id -o tsv)
fi
if [[ -z "$SUBSCRIPTION_ID" ]]; then
echo "::error::Could not resolve Azure subscription ID from secrets/vars or Azure context"
exit 1
fi
echo "AZURE_SUBSCRIPTION_ID=$SUBSCRIPTION_ID" >> "$GITHUB_ENV"
- name: Deploy with Aspire
env:
Azure__SubscriptionId: ${{ env.AZURE_SUBSCRIPTION_ID }}
Azure__ResourceGroup: ${{ env.AZURE_RESOURCE_GROUP }}
Azure__Location: ${{ env.AZURE_LOCATION }}
ConnectionStrings__openai: ${{ secrets.OPENAI_CONNECTION_STRING }}
Parameters__jwt-key: ${{ secrets.JWT_KEY }}
run: aspire deploy --non-interactive --clear-cache
timeout-minutes: 20
- name: Get app URL
id: url
run: |
APP_URL=$(az containerapp show \
--name api \
--resource-group "${{ env.AZURE_RESOURCE_GROUP }}" \
--query "properties.configuration.ingress.fqdn" -o tsv 2>/dev/null || echo "")
if [[ -n "$APP_URL" ]]; then
echo "url=https://$APP_URL" >> "$GITHUB_OUTPUT"
echo "### Deployed to https://$APP_URL" >> "$GITHUB_STEP_SUMMARY"
fi
- name: Smoke test
if: steps.url.outputs.url != ''
run: |
echo "Running smoke test against ${{ steps.url.outputs.url }}..."
for i in {1..5}; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${{ steps.url.outputs.url }}/health" --max-time 15 || echo "000")
if [[ "$STATUS" == "200" ]]; then
echo "✓ Health check passed (attempt $i)"
exit 0
fi
echo " Attempt $i: got $STATUS, retrying in 15s..."
sleep 15
done
echo "::warning::Smoke test did not get 200 from /health after 5 attempts"
- name: Update GitHub Release with deploy info
if: startsWith(github.ref, 'refs/tags/v') && steps.url.outputs.url != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
CURRENT_BODY=$(gh release view "${{ github.ref_name }}" --json body -q .body)
DEPLOY_NOTE="**Deployed** to ${{ steps.url.outputs.url }} at $(date -u +%Y-%m-%dT%H:%M:%SZ)"
gh release edit "${{ github.ref_name }}" \
--notes "${CURRENT_BODY}
${DEPLOY_NOTE}"