|
| 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" |
0 commit comments