From 2e97e0a693a9a863506be1338023b8d0e6d0dd2f Mon Sep 17 00:00:00 2001 From: Jason Sylvestre Date: Tue, 6 Oct 2026 13:11:36 -0700 Subject: [PATCH] Apply generated runtime app settings during package deployments The reusable App Service deployment workflow (`deploy-azure-appservice.yml`) now automatically validates and applies generated runtime application settings from GitHub Environments before deploying the application package. This change streamlines the CI/CD process by ensuring app updates consistently use the configured runtime settings without requiring a separate run of the `Configure Azure` workflow. The `Configure Azure` workflow is now primarily focused on infrastructure changes, though it can still apply runtime settings independently. Documentation has been updated to clarify the distinct responsibilities of the `Configure Azure` and `CI/CD` workflows regarding application runtime configuration. --- .github/workflows/deploy-azure-appservice.yml | 131 ++++++++++++++++++ README.customization.md | 15 +- docs/ARCHITECTURE.md | 7 +- infrastructure/azure/README.md | 6 +- .../azure/deployment-settings.schema.json | 2 +- scripts/sync-deployment-settings.mts | 30 +++- 6 files changed, 175 insertions(+), 16 deletions(-) diff --git a/.github/workflows/deploy-azure-appservice.yml b/.github/workflows/deploy-azure-appservice.yml index 26d84f6..a29d51d 100644 --- a/.github/workflows/deploy-azure-appservice.yml +++ b/.github/workflows/deploy-azure-appservice.yml @@ -64,6 +64,14 @@ jobs: - name: Validate deployment configuration shell: bash + env: + DEPLOY_ENV: ${{ inputs.environment }} + # + ROSETTACLIENT__BASEURL: ${{ vars.ROSETTACLIENT__BASEURL }} + ROSETTACLIENT__CLIENTID: ${{ vars.ROSETTACLIENT__CLIENTID }} + ROSETTACLIENT__CLIENTSECRET: ${{ secrets.ROSETTACLIENT__CLIENTSECRET }} + ROSETTACLIENT__TOKENURL: ${{ vars.ROSETTACLIENT__TOKENURL }} + # run: | set -euo pipefail @@ -89,6 +97,28 @@ jobs: exit 1 fi + # + if [[ -z "$ROSETTACLIENT__BASEURL" ]]; then + echo "ROSETTACLIENT__BASEURL must be configured as a GitHub Environment variable." + exit 1 + fi + + if [[ -z "$ROSETTACLIENT__CLIENTID" ]]; then + echo "ROSETTACLIENT__CLIENTID must be configured as a GitHub Environment variable." + exit 1 + fi + + if [[ -z "$ROSETTACLIENT__CLIENTSECRET" ]]; then + echo "ROSETTACLIENT__CLIENTSECRET must be configured as a GitHub Environment secret." + exit 1 + fi + + if [[ -z "$ROSETTACLIENT__TOKENURL" ]]; then + echo "ROSETTACLIENT__TOKENURL must be configured as a GitHub Environment variable." + exit 1 + fi + # + - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: @@ -171,6 +201,107 @@ jobs: echo "web_app_name=$web_app_name" >> "$GITHUB_OUTPUT" + - name: Apply runtime app settings + shell: bash + env: + WEB_APP_NAME: ${{ steps.resolve_app.outputs.web_app_name }} + DB_CONNECTION: ${{ secrets.DB_CONNECTION }} + # + AUTH_CALLBACK_PATH: ${{ vars.AUTH_CALLBACK_PATH || '/signin-oidc' }} + AUTH_CLIENT_ID: ${{ vars.AUTH_CLIENT_ID }} + AUTH_DOMAIN: ${{ vars.AUTH_DOMAIN }} + AUTH_INSTANCE: ${{ vars.AUTH_INSTANCE || 'https://login.microsoftonline.com/' }} + AUTH_TENANT_ID: ${{ vars.AUTH_TENANT_ID }} + NOTIFICATION_BASE_URL: ${{ vars.NOTIFICATION_BASE_URL }} + NOTIFICATION_DEFAULT_APP_NAME: ${{ vars.NOTIFICATION_DEFAULT_APP_NAME }} + NOTIFICATION_DEFAULT_BUTTON_TEXT: ${{ vars.NOTIFICATION_DEFAULT_BUTTON_TEXT }} + OTEL_EXPORTER_OTLP_ENDPOINT: ${{ vars.OTEL_EXPORTER_OTLP_ENDPOINT }} + OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }} + OTEL_EXPORTER_OTLP_PROTOCOL: ${{ vars.OTEL_EXPORTER_OTLP_PROTOCOL || 'http/protobuf' }} + OTEL_RESOURCE_ATTRIBUTES: ${{ vars.OTEL_RESOURCE_ATTRIBUTES }} + OTEL_SERVICE_NAME: ${{ vars.OTEL_SERVICE_NAME }} + ROSETTACLIENT__BASEURL: ${{ vars.ROSETTACLIENT__BASEURL }} + ROSETTACLIENT__CLIENTID: ${{ vars.ROSETTACLIENT__CLIENTID }} + ROSETTACLIENT__CLIENTSECRET: ${{ secrets.ROSETTACLIENT__CLIENTSECRET }} + ROSETTACLIENT__SCOPE: ${{ vars.ROSETTACLIENT__SCOPE }} + ROSETTACLIENT__TOKENURL: ${{ vars.ROSETTACLIENT__TOKENURL }} + SMTP_BCC_EMAIL: ${{ vars.SMTP_BCC_EMAIL }} + SMTP_FROM_EMAIL: ${{ vars.SMTP_FROM_EMAIL }} + SMTP_FROM_NAME: ${{ vars.SMTP_FROM_NAME }} + SMTP_HOST: ${{ vars.SMTP_HOST }} + SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }} + SMTP_PORT: ${{ vars.SMTP_PORT }} + SMTP_REPLY_TO_EMAIL: ${{ vars.SMTP_REPLY_TO_EMAIL }} + SMTP_TIMEOUT: ${{ vars.SMTP_TIMEOUT }} + SMTP_USE_SSL: ${{ vars.SMTP_USE_SSL }} + SMTP_USERNAME: ${{ vars.SMTP_USERNAME }} + # + run: | + set -euo pipefail + + settings=( + "ASPNETCORE_ENVIRONMENT=$DEPLOY_ENV" + "WEBSITE_RUN_FROM_PACKAGE=1" + ) + + add_setting() { + local name="$1" + local value="$2" + + if [[ -n "$value" ]]; then + settings+=("$name=$value") + fi + } + + add_setting "DB_CONNECTION" "$DB_CONNECTION" + # + disabled_settings=( + ) + + if (( ${#disabled_settings[@]} > 0 )); then + az webapp config appsettings delete \ + --resource-group "$RESOURCE_GROUP" \ + --name "$WEB_APP_NAME" \ + --setting-names "${disabled_settings[@]}" \ + --output none + fi + + add_setting "Auth__CallbackPath" "$AUTH_CALLBACK_PATH" + add_setting "Auth__ClientId" "$AUTH_CLIENT_ID" + add_setting "Auth__Domain" "$AUTH_DOMAIN" + add_setting "Auth__Instance" "$AUTH_INSTANCE" + add_setting "Auth__TenantId" "$AUTH_TENANT_ID" + add_setting "Notification__BaseUrl" "$NOTIFICATION_BASE_URL" + add_setting "Notification__DefaultAppName" "$NOTIFICATION_DEFAULT_APP_NAME" + add_setting "Notification__DefaultButtonText" "$NOTIFICATION_DEFAULT_BUTTON_TEXT" + settings+=("OTEL_EXPORTER_OTLP_ENDPOINT=$OTEL_EXPORTER_OTLP_ENDPOINT") + add_setting "OTEL_EXPORTER_OTLP_HEADERS" "$OTEL_EXPORTER_OTLP_HEADERS" + add_setting "OTEL_EXPORTER_OTLP_PROTOCOL" "$OTEL_EXPORTER_OTLP_PROTOCOL" + add_setting "OTEL_RESOURCE_ATTRIBUTES" "$OTEL_RESOURCE_ATTRIBUTES" + add_setting "OTEL_SERVICE_NAME" "$OTEL_SERVICE_NAME" + add_setting "RosettaClient__BaseUrl" "$ROSETTACLIENT__BASEURL" + add_setting "RosettaClient__ClientId" "$ROSETTACLIENT__CLIENTID" + add_setting "RosettaClient__ClientSecret" "$ROSETTACLIENT__CLIENTSECRET" + add_setting "RosettaClient__Scope" "$ROSETTACLIENT__SCOPE" + add_setting "RosettaClient__TokenUrl" "$ROSETTACLIENT__TOKENURL" + add_setting "Smtp__BccEmail" "$SMTP_BCC_EMAIL" + add_setting "Smtp__FromEmail" "$SMTP_FROM_EMAIL" + add_setting "Smtp__FromName" "$SMTP_FROM_NAME" + add_setting "Smtp__Host" "$SMTP_HOST" + add_setting "Smtp__Password" "$SMTP_PASSWORD" + add_setting "Smtp__Port" "$SMTP_PORT" + add_setting "Smtp__ReplyToEmail" "$SMTP_REPLY_TO_EMAIL" + add_setting "Smtp__Timeout" "$SMTP_TIMEOUT" + add_setting "Smtp__UseSsl" "$SMTP_USE_SSL" + add_setting "Smtp__Username" "$SMTP_USERNAME" + # + + az webapp config appsettings set \ + --resource-group "$RESOURCE_GROUP" \ + --name "$WEB_APP_NAME" \ + --settings "${settings[@]}" \ + --output none + - name: Check SCM readiness shell: bash env: diff --git a/README.customization.md b/README.customization.md index 57b815f..c6bd425 100644 --- a/README.customization.md +++ b/README.customization.md @@ -128,7 +128,7 @@ Most projects should edit only `deployment-settings.json`, then run: npm run deployment-settings:sync ``` -Pull request validation runs `npm run deployment-settings:check` so generated Configure Azure workflow and local deploy script regions cannot drift. +Pull request validation runs `npm run deployment-settings:check` so generated regions in both Azure workflows and the local deploy script cannot drift. Override a built-in runtime mapping by GitHub Environment variable name: @@ -160,7 +160,7 @@ Infrastructure deployment inputs, such as SQL admin values, database SKUs, exist Use `defaultValue` on a built-in override or added setting when generated deployment scripts should apply a stable fallback if the GitHub Environment variable is unset. -`requiredWhen` values apply to the generated tooling as follows: `always` is required by Configure Azure and all local deployments; `deploy_infra` is required by Configure Azure and local infrastructure deployments; `existing_infra` applies only to local deployments with `DEPLOY_INFRA=false`; and `never` remains optional. +`requiredWhen` values apply to the generated tooling as follows: `always` is required by both Azure workflows and all local deployments; `deploy_infra` is required by Configure Azure and local infrastructure deployments; `existing_infra` is required by the reusable App Service deployment workflow and local deployments with `DEPLOY_INFRA=false`; and `never` remains optional. Add a runtime App Service setting: @@ -181,7 +181,7 @@ Add a runtime App Service setting: } ``` -Secrets use `"classification": "secret"` and are read from the selected GitHub Environment by the manual Configure Azure workflow. Routine package deployments do not receive application runtime secrets. +Secrets use `"classification": "secret"` and are read from the selected GitHub Environment by both the manual Configure Azure workflow and the reusable App Service deployment workflow. Each package deployment validates required runtime settings and applies the generated settings before deploying the app. `DB_CONNECTION`, App Insights settings, `ASPNETCORE_ENVIRONMENT`, and `WEBSITE_RUN_FROM_PACKAGE` remain hand-authored or platform-derived settings rather than overlay entries. `NOTIFICATION_BASE_URL` is a direct runtime setting; set it explicitly when notification links should use a stable hostname or custom domain. @@ -286,10 +286,13 @@ For an existing installation that used the previous deployment app registration, The `CI/CD` workflow: - Validates pull requests. -- Deploys pushes to `main` to the `test` environment without changing infrastructure or App Service settings. -- Supports manual package deployments to `test` or `prod`. +- Deploys pushes to `main` to the `test` environment when `AZURE_TEST_READY` is `true`. +- Supports manual deployments to `test` or `prod`. +- Validates and applies generated runtime App Service settings from the selected GitHub Environment before each package deployment. -Before the first package deployment, run the manual `Configure Azure` workflow for the target environment. Run it again whenever Bicep, deployment settings, GitHub Environment variables, or GitHub Environment secrets change. Wait for configuration to finish before starting a package deployment. Both workflows use the same environment-specific FIFO concurrency queue, so configuration and package deployment for one environment cannot overlap, pending operations do not displace one another, and running operations are not canceled. The `test` and `prod` queues are independent, and GitHub retains up to 100 pending operations in each queue. +Before the first package deployment, run the manual `Configure Azure` workflow for the target environment. Run it again whenever Bicep or infrastructure inputs change. It also applies runtime settings and can be used to update those settings without deploying a package. Wait for configuration to finish before starting a package deployment. Both workflows use the same environment-specific FIFO concurrency queue, so configuration and package deployment for one environment cannot overlap, pending operations do not displace one another, and running operations are not canceled. The `test` and `prod` queues are independent, and GitHub retains up to 100 pending operations in each queue. + +Changing GitHub Environment variables or secrets alone does not trigger a deployment. The next automatic or manual `CI/CD` deployment applies the current generated runtime settings; run `CI/CD` manually to apply them immediately with the app package. Infrastructure and platform-derived settings still require `Configure Azure`. For production, complete the manual [SQL connectivity prerequisite](infrastructure/azure/README.md#production-sql-connectivity) before deploying the first package. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f0ce84e..1b212be 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -135,7 +135,7 @@ Responsibilities: - `infrastructure/azure/deployment-settings.json` is the app-facing overlay for setting overrides, additions, and disabled built-ins - `infrastructure/azure/deployment-settings-defaults.json` is the template-owned catalog for built-in direct runtime setting metadata -- `scripts/sync-deployment-settings.mts` resolves the overlay with defaults and rewrites generated regions in the Configure Azure workflow and local deploy script +- `scripts/sync-deployment-settings.mts` resolves the overlay with defaults and rewrites generated regions in both Azure workflows and the local deploy script ### `infrastructure/azure/github-oidc.bicep` @@ -153,16 +153,18 @@ Responsibilities: - Checks deployment-setting generated regions for drift - Deploys pushes to `main` to the `test` GitHub Environment - Supports manual deployments to `test` or `prod` +- Uses the reusable deployment workflow to validate and apply generated runtime settings before each package deployment; changing GitHub Environment variables or secrets alone does not trigger a run ### `.github/workflows/deploy-azure-appservice.yml` Responsibilities: - Provides the reusable App Service deployment job +- Validates required runtime settings from the selected GitHub Environment - Builds, tests, publishes, and packages the app - Logs in to Azure with GitHub OIDC - Resolves the existing App Service by environment tags or an explicit name -- Checks SCM readiness, deploys the app package without changing configuration, and verifies application health +- Applies generated runtime App Service settings, checks SCM readiness, deploys the app package, and verifies application health ### `.github/workflows/configure-azure.yml` @@ -170,6 +172,7 @@ Responsibilities: - Provides the manual `Configure Azure` workflow for `test` and `prod` - Applies Bicep-managed infrastructure and generated runtime App Service settings +- Handles initial infrastructure provisioning and infrastructure changes, and can apply runtime settings without deploying a package - Waits for the App Service SCM endpoint to recover after configuration - Uses the same environment-specific FIFO concurrency queue as package deployment, preserving up to 100 pending operations without canceling running work - Keeps the `test` and `prod` operation queues independent diff --git a/infrastructure/azure/README.md b/infrastructure/azure/README.md index 5a0a3e7..90ceff9 100644 --- a/infrastructure/azure/README.md +++ b/infrastructure/azure/README.md @@ -6,10 +6,12 @@ Booking uses GitHub's immutable OIDC subject prefix `repo:ucdavis@573450/booking Both GitHub environments use `APP_NAME=booking`. Test targets `rg-booking-test` and production targets `rg-booking-prod`. Shared plans remain `DefaultPlan2` and `Nibbler`. Public URLs are https://booking-test.ucdavis.edu and https://booking.ucdavis.edu. -1. Run **Configure Azure** for infrastructure or runtime setting changes. -2. Run **CI/CD** for a package deployment. Pushes to `main` deploy to test; manual runs can select test or prod. +1. Run **Configure Azure** before the first deployment and whenever infrastructure changes. It also applies runtime settings and can update those settings without deploying a package. +2. Run **CI/CD** to validate and apply generated runtime settings from the selected GitHub Environment, then deploy the app package. Pushes to `main` deploy to test when `AZURE_TEST_READY` is `true`; manual runs can select test or prod. 3. Check the deployment's application health result and verify sign-in at the app's hostname. +Changing GitHub Environment variables or secrets alone does not trigger a deployment. The next automatic or manual **CI/CD** deployment applies the current generated runtime settings. Infrastructure and platform-derived settings still require **Configure Azure**. + Local `deploy_test.sh` and `deploy_prod.sh` are secondary operator tools. They apply runtime settings as part of deployment and do not perform the GitHub workflow's explicit application health check. Verify `/health` and sign-in after using them. ## Production SQL connectivity diff --git a/infrastructure/azure/deployment-settings.schema.json b/infrastructure/azure/deployment-settings.schema.json index af082e9..b4050a6 100644 --- a/infrastructure/azure/deployment-settings.schema.json +++ b/infrastructure/azure/deployment-settings.schema.json @@ -30,7 +30,7 @@ }, "requiredWhen": { "type": "string", - "description": "Controls when generated deployment tooling requires the setting. 'always' applies to Configure Azure and every local deployment; 'deploy_infra' applies to Configure Azure and local infrastructure deployments; 'existing_infra' applies only to local existing-infrastructure deployments; 'never' is optional.", + "description": "Controls when generated deployment tooling requires the setting. 'always' applies to both GitHub workflows and every local deployment; 'deploy_infra' applies to Configure Azure and local infrastructure deployments; 'existing_infra' applies to the reusable App Service deployment workflow and local existing-infrastructure deployments; 'never' is optional.", "enum": ["always", "deploy_infra", "existing_infra", "never"] }, "emitWhen": { diff --git a/scripts/sync-deployment-settings.mts b/scripts/sync-deployment-settings.mts index f73313d..86ad239 100644 --- a/scripts/sync-deployment-settings.mts +++ b/scripts/sync-deployment-settings.mts @@ -39,6 +39,7 @@ const defaultsPath = path.join(repoRoot, 'infrastructure/azure/deployment-settin const generatedTargets = [ '.github/workflows/configure-azure.yml', + '.github/workflows/deploy-azure-appservice.yml', 'infrastructure/azure/deploy.sh', ]; @@ -325,9 +326,13 @@ function renderWorkflowEnv(settings: DeploymentSetting[]): string[] { }); } -function renderConfigureWorkflowRequiredChecks(settings: DeploymentSetting[]): string[] { +function renderWorkflowRequiredChecks( + settings: DeploymentSetting[], + requiredWhen: 'deploy_infra' | 'existing_infra', +): string[] { const lines: string[] = []; - const deployInfraSettings = requiredSettings(settings, 'deploy_infra'); + const conditionalSettings = requiredSettings(settings, requiredWhen); + const operation = requiredWhen === 'deploy_infra' ? 'configuring Azure' : 'deploying to existing Azure infrastructure'; const alwaysSettings = requiredSettings(settings, 'always'); for (const setting of alwaysSettings) { @@ -338,9 +343,9 @@ function renderConfigureWorkflowRequiredChecks(settings: DeploymentSetting[]): s lines.push(''); } - for (const setting of deployInfraSettings) { + for (const setting of conditionalSettings) { lines.push(`if [[ -z "$${setting.githubName}" ]]; then`); - lines.push(` echo "${setting.githubName} must be configured as a GitHub Environment ${setting.classification} when configuring Azure."`); + lines.push(` echo "${setting.githubName} must be configured as a GitHub Environment ${setting.classification} when ${operation}."`); lines.push(' exit 1'); lines.push('fi'); lines.push(''); @@ -468,8 +473,23 @@ function renderFile( switch (relativePath) { case '.github/workflows/configure-azure.yml': + case '.github/workflows/deploy-azure-appservice.yml': content = replaceBlock(content, 'workflow-env', renderWorkflowEnv(settings)); - content = replaceBlock(content, 'workflow-required-checks', renderConfigureWorkflowRequiredChecks(settings)); + content = replaceBlock( + content, + 'workflow-required-checks', + renderWorkflowRequiredChecks( + settings, + relativePath === '.github/workflows/configure-azure.yml' ? 'deploy_infra' : 'existing_infra', + ), + ); + if (relativePath === '.github/workflows/deploy-azure-appservice.yml') { + content = replaceBlock( + content, + 'deploy-workflow-validation-env', + renderWorkflowEnv(settings.filter((setting) => setting.requiredWhen === 'always' || setting.requiredWhen === 'existing_infra')), + ); + } content = replaceBlock(content, 'workflow-runtime-settings', [ ...renderWorkflowDisabledSettings(disabledSettings), ...renderWorkflowRuntimeSettings(settings),