Rewrite playground AppHost import parser for multi-line chains #58
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: "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}" |