Skip to content

Commit d33800f

Browse files
authored
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>
1 parent 8b72a7d commit d33800f

7 files changed

Lines changed: 539 additions & 0 deletions

File tree

.github/workflows/deploy-azure.yml

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
name: Deploy to Azure Functions
2+
3+
on:
4+
push:
5+
branches: [master]
6+
workflow_dispatch:
7+
inputs:
8+
resource_group:
9+
description: 'Azure Resource Group name (created if absent)'
10+
required: true
11+
default: 'zpan-rg'
12+
location:
13+
description: 'Azure region (e.g. eastus)'
14+
required: true
15+
default: 'eastus'
16+
version:
17+
description: 'Release tag to deploy (e.g. v2.5.0). Leave empty for latest.'
18+
required: false
19+
20+
# Prevent overlapping deployments.
21+
concurrency:
22+
group: deploy-azure
23+
cancel-in-progress: false
24+
25+
jobs:
26+
deploy:
27+
name: Deploy
28+
runs-on: ubuntu-latest
29+
# Only run on forks that have configured Azure credentials.
30+
# The upstream repo uses Cloudflare Workers Builds; Azure is for self-hosters.
31+
if: github.repository != 'saltbo/zpan'
32+
steps:
33+
# ------------------------------------------------------------------
34+
# Step 1 — Verify all required secrets are present before doing work.
35+
# ------------------------------------------------------------------
36+
- name: Check required secrets
37+
env:
38+
HAS_AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS != '' }}
39+
HAS_TURSO_URL: ${{ secrets.TURSO_DATABASE_URL != '' }}
40+
HAS_TURSO_TOKEN: ${{ secrets.TURSO_AUTH_TOKEN != '' }}
41+
run: |
42+
MISSING=()
43+
[ "$HAS_AZURE_CREDENTIALS" != "true" ] && MISSING+=("AZURE_CREDENTIALS")
44+
[ "$HAS_TURSO_URL" != "true" ] && MISSING+=("TURSO_DATABASE_URL")
45+
[ "$HAS_TURSO_TOKEN" != "true" ] && MISSING+=("TURSO_AUTH_TOKEN")
46+
if [ ${#MISSING[@]} -gt 0 ]; then
47+
echo "::error::Missing required secrets: ${MISSING[*]}"
48+
echo "Go to Settings → Secrets and variables → Actions and add the missing secrets."
49+
exit 1
50+
fi
51+
52+
# ------------------------------------------------------------------
53+
# Step 2 — Resolve release tag and check out that version.
54+
# ------------------------------------------------------------------
55+
- name: Resolve release tag
56+
id: release
57+
env:
58+
GH_TOKEN: ${{ github.token }}
59+
INPUT_VERSION: ${{ inputs.version }}
60+
run: |
61+
if [ -n "$INPUT_VERSION" ]; then
62+
TAG="$INPUT_VERSION"
63+
else
64+
TAG=$(gh api repos/saltbo/zpan/releases/latest --jq '.tag_name')
65+
fi
66+
if [ -z "$TAG" ]; then
67+
echo "::error::No release found in saltbo/zpan"
68+
exit 1
69+
fi
70+
echo "version=$TAG" >> "$GITHUB_OUTPUT"
71+
echo "### 🚀 Deploying $TAG to Azure Functions" >> "$GITHUB_STEP_SUMMARY"
72+
73+
- uses: actions/checkout@v4
74+
with:
75+
repository: saltbo/zpan
76+
ref: ${{ steps.release.outputs.version }}
77+
78+
# ------------------------------------------------------------------
79+
# Step 3 — Node.js setup + install dependencies.
80+
# ------------------------------------------------------------------
81+
- uses: actions/setup-node@v4
82+
with:
83+
node-version: 22
84+
cache: npm
85+
86+
- run: npm ci
87+
88+
# ------------------------------------------------------------------
89+
# Step 4 — Log in to Azure using the service-principal credentials.
90+
# ------------------------------------------------------------------
91+
- name: Azure login
92+
uses: azure/login@v2
93+
with:
94+
creds: ${{ secrets.AZURE_CREDENTIALS }}
95+
96+
# ------------------------------------------------------------------
97+
# Step 5 — Provision infrastructure (idempotent create-or-update).
98+
# ------------------------------------------------------------------
99+
- name: Resolve inputs (push vs. dispatch)
100+
id: params
101+
run: |
102+
echo "resource_group=${{ inputs.resource_group || 'zpan-rg' }}" >> "$GITHUB_OUTPUT"
103+
echo "location=${{ inputs.location || 'eastus' }}" >> "$GITHUB_OUTPUT"
104+
105+
- name: Ensure Resource Group exists
106+
run: |
107+
az group create \
108+
--name "${{ steps.params.outputs.resource_group }}" \
109+
--location "${{ steps.params.outputs.location }}" \
110+
--output none
111+
112+
- name: Deploy Bicep template
113+
id: bicep
114+
env:
115+
TURSO_DATABASE_URL: ${{ secrets.TURSO_DATABASE_URL }}
116+
TURSO_AUTH_TOKEN: ${{ secrets.TURSO_AUTH_TOKEN }}
117+
run: |
118+
OUTPUT=$(az deployment group create \
119+
--resource-group "${{ steps.params.outputs.resource_group }}" \
120+
--template-file deploy/azure-functions/main.bicep \
121+
--parameters \
122+
tursoDatabaseUrl="$TURSO_DATABASE_URL" \
123+
tursoAuthToken="$TURSO_AUTH_TOKEN" \
124+
--query "properties.outputs" \
125+
--output json)
126+
127+
FUNC_NAME=$(echo "$OUTPUT" | jq -r '.functionAppName.value')
128+
FUNC_URL=$(echo "$OUTPUT" | jq -r '.functionAppUrl.value')
129+
echo "functionAppName=$FUNC_NAME" >> "$GITHUB_OUTPUT"
130+
echo "functionAppUrl=$FUNC_URL" >> "$GITHUB_OUTPUT"
131+
echo "Function App: $FUNC_NAME ($FUNC_URL)" >> "$GITHUB_STEP_SUMMARY"
132+
133+
# ------------------------------------------------------------------
134+
# Step 6a — Set BETTER_AUTH_SECRET before publish.
135+
# The Function App exists after Bicep; setting the secret now means
136+
# the very first invocation after publish already has it configured.
137+
# bootstrap.ts throws 'BETTER_AUTH_SECRET is required' if it is absent,
138+
# so publishing before this step would cause 500s until the step ran.
139+
# ------------------------------------------------------------------
140+
- name: Set BETTER_AUTH_SECRET (generate once, never overwrite)
141+
env:
142+
USER_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
143+
run: |
144+
FUNC_NAME="${{ steps.bicep.outputs.functionAppName }}"
145+
RG="${{ steps.params.outputs.resource_group }}"
146+
147+
EXISTS=$(az functionapp config appsettings list \
148+
--name "$FUNC_NAME" \
149+
--resource-group "$RG" \
150+
--query "[?name=='BETTER_AUTH_SECRET'].value" \
151+
--output tsv)
152+
153+
if [ -n "$USER_SECRET" ]; then
154+
az functionapp config appsettings set \
155+
--name "$FUNC_NAME" \
156+
--resource-group "$RG" \
157+
--settings "BETTER_AUTH_SECRET=$USER_SECRET" \
158+
--output none
159+
echo "Set BETTER_AUTH_SECRET from GitHub secret."
160+
elif [ -z "$EXISTS" ]; then
161+
GENERATED=$(openssl rand -base64 32)
162+
az functionapp config appsettings set \
163+
--name "$FUNC_NAME" \
164+
--resource-group "$RG" \
165+
--settings "BETTER_AUTH_SECRET=$GENERATED" \
166+
--output none
167+
echo "Auto-generated BETTER_AUTH_SECRET."
168+
else
169+
echo "BETTER_AUTH_SECRET already set — skipping."
170+
fi
171+
172+
# ------------------------------------------------------------------
173+
# Step 6b — Patch APP_URL / BETTER_AUTH_URL to the real hostname.
174+
# Bicep sets both from the `appUrl` parameter (defaults to an empty
175+
# placeholder when not provided). Finalise before publish so auth
176+
# redirects are correct from the first request.
177+
# ------------------------------------------------------------------
178+
- name: Update APP_URL to real function app URL
179+
run: |
180+
FUNC_NAME="${{ steps.bicep.outputs.functionAppName }}"
181+
FUNC_URL="${{ steps.bicep.outputs.functionAppUrl }}"
182+
RG="${{ steps.params.outputs.resource_group }}"
183+
az functionapp config appsettings set \
184+
--name "$FUNC_NAME" \
185+
--resource-group "$RG" \
186+
--settings "APP_URL=$FUNC_URL" "BETTER_AUTH_URL=$FUNC_URL" \
187+
--output none
188+
echo "APP_URL set to $FUNC_URL"
189+
190+
# ------------------------------------------------------------------
191+
# Step 7 — Build frontend + Azure Functions bundle.
192+
# ------------------------------------------------------------------
193+
- name: Build
194+
run: npm run build:azure
195+
196+
# ------------------------------------------------------------------
197+
# Step 8 — Run database migrations against Turso.
198+
# ------------------------------------------------------------------
199+
- name: Run database migrations
200+
env:
201+
TURSO_DATABASE_URL: ${{ secrets.TURSO_DATABASE_URL }}
202+
TURSO_AUTH_TOKEN: ${{ secrets.TURSO_AUTH_TOKEN }}
203+
run: npm run db:migrate
204+
205+
# ------------------------------------------------------------------
206+
# Step 9 — Install Azure Functions Core Tools and publish.
207+
# All required app settings (BETTER_AUTH_SECRET, APP_URL, Turso creds)
208+
# are already in place before this step runs.
209+
# ------------------------------------------------------------------
210+
- name: Install Azure Functions Core Tools
211+
run: npm install -g azure-functions-core-tools@4 --unsafe-perm true
212+
213+
- name: Publish to Azure Functions
214+
working-directory: azure-functions
215+
run: func azure functionapp publish "${{ steps.bicep.outputs.functionAppName }}" --node
216+
217+
- name: Deployment summary
218+
run: |
219+
echo "### ✅ Deployed: ${{ steps.bicep.outputs.functionAppUrl }}" >> "$GITHUB_STEP_SUMMARY"

deploy/azure-functions/main.bicep

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
@description('Application name — used as a prefix for all resources.')
2+
param appName string = 'zpan'
3+
4+
@description('Azure region. Defaults to the resource group location.')
5+
param location string = resourceGroup().location
6+
7+
@description('Turso (libSQL) database URL, e.g. libsql://your-db.turso.io')
8+
param tursoDatabaseUrl string
9+
10+
@secure()
11+
@description('Turso auth token.')
12+
param tursoAuthToken string
13+
14+
// Unique suffix derived from the resource group so re-runs produce the same names (idempotent).
15+
var suffix = uniqueString(resourceGroup().id)
16+
var storageAccountName = take('${toLower(replace(appName, '-', ''))}${suffix}', 24)
17+
var hostingPlanName = '${appName}-plan-${suffix}'
18+
var functionAppName = '${appName}-func-${suffix}'
19+
20+
// ---------------------------------------------------------------------------
21+
// Storage Account — required by the Azure Functions runtime.
22+
// ---------------------------------------------------------------------------
23+
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {
24+
name: storageAccountName
25+
location: location
26+
sku: { name: 'Standard_LRS' }
27+
kind: 'StorageV2'
28+
properties: {
29+
supportsHttpsTrafficOnly: true
30+
minimumTlsVersion: 'TLS1_2'
31+
}
32+
}
33+
34+
// ---------------------------------------------------------------------------
35+
// Consumption plan (Y1 / Dynamic SKU).
36+
// ---------------------------------------------------------------------------
37+
resource hostingPlan 'Microsoft.Web/serverfarms@2023-01-01' = {
38+
name: hostingPlanName
39+
location: location
40+
sku: {
41+
name: 'Y1'
42+
tier: 'Dynamic'
43+
}
44+
properties: {}
45+
}
46+
47+
// ---------------------------------------------------------------------------
48+
// Function App — Node 22, programming model v4.
49+
// BETTER_AUTH_SECRET is intentionally absent here; it is managed by the
50+
// deploy workflow so that it is generated once and never overwritten on
51+
// subsequent runs.
52+
// ---------------------------------------------------------------------------
53+
resource functionApp 'Microsoft.Web/sites@2023-01-01' = {
54+
name: functionAppName
55+
location: location
56+
kind: 'functionapp'
57+
properties: {
58+
serverFarmId: hostingPlan.id
59+
httpsOnly: true
60+
siteConfig: {
61+
nodeVersion: '~22'
62+
appSettings: [
63+
{
64+
name: 'AzureWebJobsStorage'
65+
value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}'
66+
}
67+
{ name: 'FUNCTIONS_EXTENSION_VERSION', value: '~4' }
68+
{ name: 'FUNCTIONS_WORKER_RUNTIME', value: 'node' }
69+
{ name: 'WEBSITE_RUN_FROM_PACKAGE', value: '1' }
70+
{ name: 'TURSO_DATABASE_URL', value: tursoDatabaseUrl }
71+
{ name: 'TURSO_AUTH_TOKEN', value: tursoAuthToken }
72+
]
73+
}
74+
}
75+
}
76+
77+
// ---------------------------------------------------------------------------
78+
// Outputs consumed by the deploy workflow.
79+
// ---------------------------------------------------------------------------
80+
@description('Name of the deployed Function App.')
81+
output functionAppName string = functionApp.name
82+
83+
@description('Default HTTPS URL of the Function App.')
84+
output functionAppUrl string = 'https://${functionApp.properties.defaultHostName}'

0 commit comments

Comments
 (0)