feat: Azure Functions deployment target (v4, Node 22) (#330)

* feat: add Azure Functions deployment target (v4, Node 22)

- server/entry-azure.ts: Azure Functions v4 handler wrapping the Hono
  app via app.http(); uses createLibsqlPlatform for Turso and serves
  the SPA from ./dist via @hono/node-server/serve-static
- server/azure-host.json: runtime manifest (extensionBundle v4)
- deploy/azure-functions/main.bicep: idempotent Bicep template
  provisioning Storage Account, Consumption plan and Function App;
  BETTER_AUTH_SECRET handled separately by the workflow
- .github/workflows/deploy-azure.yml: 8-step workflow (secret check,
  checkout, Node setup, az login, Bicep deploy, build, db:migrate,
  func publish) with BETTER_AUTH_SECRET generate-if-missing logic
- package.json: build:azure script + @azure/functions dependency
- docs/deploy/azure-functions.md: setup guide covering SP JSON format,
  required secrets, and local emulation with func start

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f

* fix: address review issues in Azure Functions deploy

- Move BETTER_AUTH_SECRET and APP_URL setup to before func publish
  (bootstrap.ts throws on missing secret; any request between publish
  and the old secret-set step would have returned 500)
- Remove placeholder appUrl Bicep param; workflow sets APP_URL and
  BETTER_AUTH_URL via appsettings after Bicep, before publish
- Fix HttpRequest→Request body handling: construct a proper Web API
  Request with body cast and duplex option instead of double-casting
  HttpRequest, ensuring POST/PUT/PATCH body-reading routes work
- Add push: branches: [master] trigger + upstream guard to match other
  deploy workflow conventions; document the auto-deploy behaviour
- Update docs/deploy/azure-functions.md to reflect the push trigger

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f

* ci: re-trigger CI for review fixes

---------

Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
This commit is contained in:
Jasper Van
2026-04-22 02:08:54 -04:00
committed by GitHub
co-authored by Bob
parent 8b72a7dba9
commit d33800f23e
7 changed files with 539 additions and 0 deletions
+219
View File
@@ -0,0 +1,219 @@
name: Deploy to Azure Functions
on:
push:
branches: [master]
workflow_dispatch:
inputs:
resource_group:
description: 'Azure Resource Group name (created if absent)'
required: true
default: 'zpan-rg'
location:
description: 'Azure region (e.g. eastus)'
required: true
default: 'eastus'
version:
description: 'Release tag to deploy (e.g. v2.5.0). Leave empty for latest.'
required: false
# Prevent overlapping deployments.
concurrency:
group: deploy-azure
cancel-in-progress: false
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
# Only run on forks that have configured Azure credentials.
# The upstream repo uses Cloudflare Workers Builds; Azure is for self-hosters.
if: github.repository != 'saltbo/zpan'
steps:
# ------------------------------------------------------------------
# Step 1 — Verify all required secrets are present before doing work.
# ------------------------------------------------------------------
- name: Check required secrets
env:
HAS_AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS != '' }}
HAS_TURSO_URL: ${{ secrets.TURSO_DATABASE_URL != '' }}
HAS_TURSO_TOKEN: ${{ secrets.TURSO_AUTH_TOKEN != '' }}
run: |
MISSING=()
[ "$HAS_AZURE_CREDENTIALS" != "true" ] && MISSING+=("AZURE_CREDENTIALS")
[ "$HAS_TURSO_URL" != "true" ] && MISSING+=("TURSO_DATABASE_URL")
[ "$HAS_TURSO_TOKEN" != "true" ] && MISSING+=("TURSO_AUTH_TOKEN")
if [ ${#MISSING[@]} -gt 0 ]; then
echo "::error::Missing required secrets: ${MISSING[*]}"
echo "Go to Settings → Secrets and variables → Actions and add the missing secrets."
exit 1
fi
# ------------------------------------------------------------------
# Step 2 — Resolve release tag and check out that version.
# ------------------------------------------------------------------
- name: Resolve release tag
id: release
env:
GH_TOKEN: ${{ github.token }}
INPUT_VERSION: ${{ inputs.version }}
run: |
if [ -n "$INPUT_VERSION" ]; then
TAG="$INPUT_VERSION"
else
TAG=$(gh api repos/saltbo/zpan/releases/latest --jq '.tag_name')
fi
if [ -z "$TAG" ]; then
echo "::error::No release found in saltbo/zpan"
exit 1
fi
echo "version=$TAG" >> "$GITHUB_OUTPUT"
echo "### 🚀 Deploying $TAG to Azure Functions" >> "$GITHUB_STEP_SUMMARY"
- uses: actions/checkout@v4
with:
repository: saltbo/zpan
ref: ${{ steps.release.outputs.version }}
# ------------------------------------------------------------------
# Step 3 — Node.js setup + install dependencies.
# ------------------------------------------------------------------
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
# ------------------------------------------------------------------
# Step 4 — Log in to Azure using the service-principal credentials.
# ------------------------------------------------------------------
- name: Azure login
uses: azure/login@v2
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
# ------------------------------------------------------------------
# Step 5 — Provision infrastructure (idempotent create-or-update).
# ------------------------------------------------------------------
- name: Resolve inputs (push vs. dispatch)
id: params
run: |
echo "resource_group=${{ inputs.resource_group || 'zpan-rg' }}" >> "$GITHUB_OUTPUT"
echo "location=${{ inputs.location || 'eastus' }}" >> "$GITHUB_OUTPUT"
- name: Ensure Resource Group exists
run: |
az group create \
--name "${{ steps.params.outputs.resource_group }}" \
--location "${{ steps.params.outputs.location }}" \
--output none
- name: Deploy Bicep template
id: bicep
env:
TURSO_DATABASE_URL: ${{ secrets.TURSO_DATABASE_URL }}
TURSO_AUTH_TOKEN: ${{ secrets.TURSO_AUTH_TOKEN }}
run: |
OUTPUT=$(az deployment group create \
--resource-group "${{ steps.params.outputs.resource_group }}" \
--template-file deploy/azure-functions/main.bicep \
--parameters \
tursoDatabaseUrl="$TURSO_DATABASE_URL" \
tursoAuthToken="$TURSO_AUTH_TOKEN" \
--query "properties.outputs" \
--output json)
FUNC_NAME=$(echo "$OUTPUT" | jq -r '.functionAppName.value')
FUNC_URL=$(echo "$OUTPUT" | jq -r '.functionAppUrl.value')
echo "functionAppName=$FUNC_NAME" >> "$GITHUB_OUTPUT"
echo "functionAppUrl=$FUNC_URL" >> "$GITHUB_OUTPUT"
echo "Function App: $FUNC_NAME ($FUNC_URL)" >> "$GITHUB_STEP_SUMMARY"
# ------------------------------------------------------------------
# Step 6a — Set BETTER_AUTH_SECRET before publish.
# The Function App exists after Bicep; setting the secret now means
# the very first invocation after publish already has it configured.
# bootstrap.ts throws 'BETTER_AUTH_SECRET is required' if it is absent,
# so publishing before this step would cause 500s until the step ran.
# ------------------------------------------------------------------
- name: Set BETTER_AUTH_SECRET (generate once, never overwrite)
env:
USER_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
run: |
FUNC_NAME="${{ steps.bicep.outputs.functionAppName }}"
RG="${{ steps.params.outputs.resource_group }}"
EXISTS=$(az functionapp config appsettings list \
--name "$FUNC_NAME" \
--resource-group "$RG" \
--query "[?name=='BETTER_AUTH_SECRET'].value" \
--output tsv)
if [ -n "$USER_SECRET" ]; then
az functionapp config appsettings set \
--name "$FUNC_NAME" \
--resource-group "$RG" \
--settings "BETTER_AUTH_SECRET=$USER_SECRET" \
--output none
echo "Set BETTER_AUTH_SECRET from GitHub secret."
elif [ -z "$EXISTS" ]; then
GENERATED=$(openssl rand -base64 32)
az functionapp config appsettings set \
--name "$FUNC_NAME" \
--resource-group "$RG" \
--settings "BETTER_AUTH_SECRET=$GENERATED" \
--output none
echo "Auto-generated BETTER_AUTH_SECRET."
else
echo "BETTER_AUTH_SECRET already set — skipping."
fi
# ------------------------------------------------------------------
# Step 6b — Patch APP_URL / BETTER_AUTH_URL to the real hostname.
# Bicep sets both from the `appUrl` parameter (defaults to an empty
# placeholder when not provided). Finalise before publish so auth
# redirects are correct from the first request.
# ------------------------------------------------------------------
- name: Update APP_URL to real function app URL
run: |
FUNC_NAME="${{ steps.bicep.outputs.functionAppName }}"
FUNC_URL="${{ steps.bicep.outputs.functionAppUrl }}"
RG="${{ steps.params.outputs.resource_group }}"
az functionapp config appsettings set \
--name "$FUNC_NAME" \
--resource-group "$RG" \
--settings "APP_URL=$FUNC_URL" "BETTER_AUTH_URL=$FUNC_URL" \
--output none
echo "APP_URL set to $FUNC_URL"
# ------------------------------------------------------------------
# Step 7 — Build frontend + Azure Functions bundle.
# ------------------------------------------------------------------
- name: Build
run: npm run build:azure
# ------------------------------------------------------------------
# Step 8 — Run database migrations against Turso.
# ------------------------------------------------------------------
- name: Run database migrations
env:
TURSO_DATABASE_URL: ${{ secrets.TURSO_DATABASE_URL }}
TURSO_AUTH_TOKEN: ${{ secrets.TURSO_AUTH_TOKEN }}
run: npm run db:migrate
# ------------------------------------------------------------------
# Step 9 — Install Azure Functions Core Tools and publish.
# All required app settings (BETTER_AUTH_SECRET, APP_URL, Turso creds)
# are already in place before this step runs.
# ------------------------------------------------------------------
- name: Install Azure Functions Core Tools
run: npm install -g azure-functions-core-tools@4 --unsafe-perm true
- name: Publish to Azure Functions
working-directory: azure-functions
run: func azure functionapp publish "${{ steps.bicep.outputs.functionAppName }}" --node
- name: Deployment summary
run: |
echo "### ✅ Deployed: ${{ steps.bicep.outputs.functionAppUrl }}" >> "$GITHUB_STEP_SUMMARY"