> ## Documentation Index
> Fetch the complete documentation index at: https://docs.valedo.uniwave.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Deployment

> Bicep and Entra deployment with the shell and PowerShell scripts, by hand or from GitHub Actions, and application deployment.

Infrastructure is deployed with the scripts in `infra/`. You can run them by hand, and `.github/workflows/deploy.yml` runs the same bash scripts on every push to `develop` (dev) and every pull request merged into `main` (prod); see [Deployment from GitHub Actions](#deployment-from-github-actions).

Each environment has its own script, so the environment is chosen by the script you run, never by a default:

| Environment | bash or zsh (macOS, Linux) | PowerShell 7 (macOS, Linux, Windows) |
| - | - | - |
| `dev` | `infra/deployment-dev.sh` | `infra/deployment-dev.ps1` |
| `prod` | `infra/deployment-prod.sh` | `infra/deployment-prod.ps1` |

The environment scripts are thin wrappers around the shared implementation in `infra/lib/deployment.sh` and `infra/lib/deployment.ps1`; don't run the files in `infra/lib/` directly. The bash and PowerShell scripts take the same parameters and run the same steps. Run them from the repository root.

## Steps

1. Take the environment from the script name (`deployment-dev` or `deployment-prod`) and pass it to Bicep as `environmentName`.
2. Check that you are signed in to Azure CLI and that the target subscription is selected.
3. Create or confirm the environment's resource group (see below).
4. Run `what-if` and review the change.
5. Deploy `infra/main.bicep` in Incremental mode.

```mermaid theme={null}
flowchart LR
    A[Operator] --> B[infra/deployment-ENV.sh or infra/deployment-ENV.ps1]
    B --> P[environmentName = ENV from the script name]
    P --> C[Check Azure login and subscription]
    C --> D[Ensure resource group exists]
    D --> E{Soft-deleted Key Vault with same name?}
    E -- Yes --> F[Purge only after explicit confirmation]
    E -- No --> G
    F --> G[az deployment group what-if]
    G --> H[az deployment group create --mode Incremental with infra/main.bicep]
    H --> I[App Service, Worker Functions, Cosmos DB, Monitoring, Key Vault, role assignments]
```

## Environments

The script name selects the environment and passes it to Bicep as `environmentName`; the parameters files don't contain it. If an older file still has `environmentName`, the script warns and ignores it. Prod names have no suffix; dev names end in `-dev`. Parameters files live in `infra/parameters/`.

| Environment | Parameters file | Resource group | Command |
| - | - | - | - |
| `prod` | `infra/parameters/main.parameters.json` | `uw-rg-valedo-weu` | `infra/deployment-prod.sh` |
| `dev` | `infra/parameters/main.dev.parameters.json` | `uw-rg-valedo-weu-dev` | `infra/deployment-dev.sh` |

Redeploying updates existing resources in place. Incremental mode never deletes resources that are missing from the template. App settings are merged (`infra/modules/appsettings.bicep`): settings added outside Bicep are kept, and Bicep-owned keys are overwritten. Names depend only on `prefix`, `regionCode` and `environmentName`, so keep these values fixed for each environment: if you change one, the next deployment creates new resources next to the old ones.

## Resources

`infra/main.bicep` and its modules define these resources. Every name follows `{prefix}-<type>-valedo-{regionCode}`, with `-{env}` appended outside prod.

| Resource | Name | Defined in |
| - | - | - |
| Log Analytics workspace | `{prefix}-log-valedo-{regionCode}` | `infra/modules/monitoring.bicep` |
| Application Insights | `{prefix}-appi-valedo-{regionCode}` | `infra/modules/monitoring.bicep` |
| Cosmos DB account (database `signatures`) | `{prefix}-cosmos-valedo-{regionCode}` | `infra/modules/cosmos.bicep` |
| App Service plan | `{prefix}-plan-valedo-{regionCode}` | `infra/modules/appservice.bicep` |
| Valedo.API web app | `{prefix}-app-valedo-{regionCode}` | `infra/modules/appservice.bicep` |
| Publisher API web app | `{prefix}-app-publisher-valedo-{regionCode}` | `infra/modules/appservice.bicep` |
| Key Vault (purgeable) | `{prefix}-kv-valedo-{regionCode}` | `infra/modules/keyvault.bicep` |
| Worker function app (isolated .NET, on the App Service plan, Always On) | `{prefix}-func-valedo-{regionCode}` | `infra/modules/functions.bicep` |
| Worker user-assigned identity | `{prefix}-id-worker-valedo-{regionCode}` | `infra/modules/functions.bicep` |
| Worker storage account (no shared keys; letters and digits only) | `{prefix}stvaledo{regionCode}` (dev: `{prefix}stvaledo{regionCode}dev`) | `infra/modules/functions.bicep` |

Both API identities and the Worker identity get Cosmos DB data-contributor access (`infra/modules/cosmos-access.bicep`) and Monitoring Metrics Publisher on Application Insights (`infra/modules/monitoring-access.bicep`). The Worker identity also gets Storage Blob Data Owner, Storage Queue Data Contributor and Storage Table Data Contributor on its storage account (the Functions host connects with it, `AzureWebJobsStorage__credential = managedidentity`). The Entra deployment then trusts that identity as a federated credential of the `Valedo Marketplace` app and writes the Worker's `Marketplace__TenantId` and `Marketplace__ClientId`, so the Worker calls the Fulfillment API without a client secret, in dev and prod alike. If the Worker doesn't exist yet when the Entra script runs, the script says so and skips both.

## Developer data access

The Cosmos DB accounts accept only Entra sign-in (key authentication is disabled). Developers debug locally against dev with their own identity, so dev grants them the same Cosmos DB Built-in Data Contributor role as the apps. Prod never does.

| | Dev | Prod |
| - | - | - |
| Cosmos DB data access | App Service identities and the object IDs in `developerPrincipalIds` | App Service identities only |
| Parameters file | `developerPrincipalIds` in `main.dev.parameters.json` | Not in `main.parameters.example.json`; if set, `main.bicep` replaces it with `[]` |
| GitHub environment variable | `DEVELOPER_PRINCIPAL_IDS` (comma-separated) | Don't set it; it would be ignored |

Grant access in dev:

1. Add the developer's Entra object ID to `developerPrincipalIds` in `infra/parameters/main.dev.parameters.json`.

2. Add it to the GitHub `dev` environment, keeping the IDs already there:

   ```bash theme={null}
   gh variable get DEVELOPER_PRINCIPAL_IDS --env dev
   gh variable set DEVELOPER_PRINCIPAL_IDS --env dev --body "<object-id>,<object-id>"
   ```

3. Redeploy dev (`infra/deployment-dev.sh`), or let the next workflow run do it. For immediate access, deploy only the access module ([Quickstart](https://docs.valedo.uniwave.ai/developers/quickstart.md#2-configure-the-hosts-against-azure-dev)).

Revoke access in dev: remove the ID from both places, then delete the role assignment. Deployments run in incremental mode, so removing an ID from the parameter doesn't remove its assignment.

```bash theme={null}
az cosmosdb sql role assignment list -g uw-rg-valedo-weu-dev -a uw-cosmos-valedo-weu-dev \
  --query "[?principalId=='<object-id>'].id" -o tsv
az cosmosdb sql role assignment delete -g uw-rg-valedo-weu-dev -a uw-cosmos-valedo-weu-dev \
  --role-assignment-id <assignment-id>
```

> [!WARNING]
>
> Never deploy `cosmos-access.bicep` with a developer's object ID against the prod account `uw-cosmos-valedo-weu`. `main.bicep` blocks developer access in prod, but the module on its own doesn't, and prod holds customer data (README §15.1).

To check that prod has no developer assignments, list them and confirm that every `principalId` belongs to one of the two prod App Service identities:

```bash theme={null}
az cosmosdb sql role assignment list -g uw-rg-valedo-weu -a uw-cosmos-valedo-weu \
  --query "[].principalId" -o tsv
az webapp identity show -g uw-rg-valedo-weu -n uw-app-valedo-weu --query principalId -o tsv
az webapp identity show -g uw-rg-valedo-weu -n uw-app-publisher-valedo-weu --query principalId -o tsv
```

## Key Vault and soft delete

The Key Vault is created with soft delete on and purge protection off. Soft delete keeps a deleted vault and its name reserved for the retention period. Purge protection is irreversible once enabled, so it must stay off if the vault may need to be recreated under the same name.

Before deploying, the script checks for a soft-deleted vault with the same name. If one exists, it asks for confirmation before purging it. To purge without the prompt, pass `--purge-deleted-keyvault` to `deployment-{env}.sh` or `-PurgeDeletedKeyVault` to `deployment-{env}.ps1`. Purge removes the vault and its secrets permanently and needs purge permission on the subscription. If the deleted vault has purge protection enabled, the script stops and reports it, because that vault cannot be purged before its retention period ends.

> [!WARNING]
>
> Purging is irreversible. Confirm that the deleted vault's secrets are no longer needed before you approve the purge.

## Parameters

In `infra/parameters/`, copy `main.parameters.example.json` to `main.parameters.json` (prod), and `main.dev.parameters.example.json` to `main.dev.parameters.json` (dev). Fill in the values. The copies are gitignored. Pass another file with `-p` (sh) or `-ParametersFile` (ps1) Pass `-g` or `-ResourceGroup` to override the default resource group.

| Parameter | Example | Notes |
| - | - | - |
| `prefix` | `uw` | 2 to 8 characters |
| `location` | `westeurope` | EU regions only |
| `regionCode` | `weu` | Used in resource names |
| `appServiceSkuName` | `B1` | App Service plan SKU (cost-optimised interim; see README §4.2) |
| `developerPrincipalIds` | `["<object-id>"]` | Optional, dev only. Entra object IDs of developers who get Cosmos DB Built-in Data Contributor for local debugging ([Developer data access](#developer-data-access)). Ignored in prod |

## Custom domains

Hostnames are optional Bicep parameters, set through GitHub environment variables so that no hostname lives in a parameters file:

| Variable | Bound to | Notes |
| - | - | - |
| `API_CUSTOM_HOSTNAME`, `API_CUSTOM_HOSTNAME_SSL` | Valedo.API App Service | Two phases: bind with a managed certificate first, then set `_SSL=true` once the certificate exists. Needs the `asuid.<host>` TXT record and a DNS-only CNAME to the app host. |
| `ADMIN_PORTAL_CUSTOM_HOSTNAME` | Admin Portal Static Web App (`uw-swa-admin-valedo-weu[-dev]`) | Standard plan. Needs a DNS-only CNAME to the app's default host (output `adminPortalDefaultHostName`). CNAME validation only. |
| `CONSOLE_PORTAL_CUSTOM_HOSTNAME` | Publisher Console Static Web App (`uw-swa-console-valedo-weu[-dev]`) | As above, default host output `consolePortalDefaultHostName`. |
| `SITE_CUSTOM_HOSTNAME` | Public site Static Web App (`uw-swa-site-valedo-weu[-dev]`) | As above, default host output `siteDefaultHostName`. Prod `www.valedo.uniwave.ai`, dev `www.dev.valedo.uniwave.ai`. |

Order: create the DNS records, then set the variable and deploy. A missing record fails the deployment, so leave a variable empty until its record exists. Portal content is published separately; the portal Static Web Apps are created empty. The public site is published by the workflow ([Environment variables](#environment-variables)).

## Entra app registrations

The three app registrations (`Valedo`, `Valedo Marketplace`, `Valedo Publisher Console`) are defined in `infra/entra.bicep` with the Microsoft Graph Bicep extension. Deploy them after the infrastructure, with a separate script, because they need the Entra role Cloud Application Administrator rather than Azure RBAC alone. The deployment workflow runs this script right after the infrastructure script.

| Environment | bash or zsh (macOS, Linux) | PowerShell 7 (macOS, Linux, Windows) |
| - | - | - |
| `dev` | `infra/entra-deployment-dev.sh` | `infra/entra-deployment-dev.ps1` |
| `prod` | `infra/entra-deployment-prod.sh` | `infra/entra-deployment-prod.ps1` |

Like the infrastructure scripts, they wrap a shared implementation in `infra/lib/` and set `environmentName` from the script name.

The signed-in account needs the Entra role Cloud Application Administrator, and **Key Vault Secrets Officer** on the environment's Key Vault (`infra/main.bicep` grants it to whoever deploys the infrastructure, so run the infrastructure script with the same account first). Registrations are created in the tenant of the signed-in session, so select a subscription of the publisher tenant before running the script. In `infra/parameters/`, copy `entra.parameters.example.json` (prod) to `entra.parameters.json`, and `entra.dev.parameters.example.json` (dev) to `entra.dev.parameters.json`. Fill in the values. The deployment record goes to the same resource group as the infrastructure: `uw-rg-valedo-weu` for prod and `uw-rg-valedo-weu-dev` for dev.

| Parameter | Example | Notes |
| - | - | - |
| `addinDomain` | `dev.valedo.uniwave.ai` | Verified domain only; empty uses `api://<appId>` and skips the NAA redirect |
| `adminPortalOrigin` | `https://admin.dev.valedo.uniwave.ai` | SPA redirect of the Admin Portal |
| `publisherConsoleOrigin` | `https://console.dev.valedo.uniwave.ai` | SPA redirect of the Publisher Console |
| `adminConsentRedirectUri` | `https://admin.dev.valedo.uniwave.ai/onboarding/consent` | Receives the customer admin-consent response |
| `valedoExtraSpaRedirectUris` | `["http://localhost:5173", "brk-multihub://localhost:3000"]` | Extra SPA redirects of the `Valedo` app for local development; dev only, `[]` in prod |
| `publisherExtraSpaRedirectUris` | `["http://localhost:5174"]` | Extra SPA redirects of the Publisher Console app for local development; dev only, `[]` in prod |
| `graphApplicationPermissions` | `["User.Read.All", "GroupMember.Read.All"]` | Requested from customer tenants |
| `valedoFederatedPrincipalIds`, `marketplaceFederatedPrincipalIds` | `[]` | Managed identity principal IDs trusted as credentials |
| `termsOfServiceUrl`, `privacyStatementUrl` | `""` | Shown on consent screens; empty leaves them unset |

The example files differ only in the hosts (`dev.` names in dev) and the localhost redirects, which only the dev file sets. `environmentName`, `valedoAppId` and `publisherAppId` are not in the parameters files: the script fills them in.

Public hosts (the decision is README §4.7). The Publisher API keeps its `azurewebsites.net` host until its public host is decided (README §13). The prod example file uses the names without `dev.`; the dev example file uses the `dev.` names.

| Host (prod) | Host (dev) | Serves |
| - | - | - |
| `valedo.uniwave.ai` | `dev.valedo.uniwave.ai` | Valedo.API, add-in assets, Marketplace webhook; `addinDomain` |
| `admin.valedo.uniwave.ai` | `admin.dev.valedo.uniwave.ai` | Admin Portal, Marketplace landing page, admin-consent callback |
| `console.valedo.uniwave.ai` | `console.dev.valedo.uniwave.ai` | Publisher Console |
| `www.valedo.uniwave.ai` | `www.dev.valedo.uniwave.ai` | Public marketing site (`src/web-site`, static): product, pricing, contact, privacy policy, terms, user and admin guides. Hosted on its own Static Web App (`infra/main.bicep`), published by `deploy-environment.yml`. Its `/privacy/` and `/terms/` URLs are the ones to give Partner Center and the Entra app branding. |

Leave `addinDomain` empty until `uniwave.ai` (or `valedo.uniwave.ai`) is verified as a custom domain in the publisher tenant, and create the public DNS records for these hosts.

The script looks up each app by `uniqueName` (`valedo-{env}`, `valedo-marketplace-{env}`, `valedo-publisher-{env}`), so an existing app is updated in place. Its app ID, roles and consents stay the same. Only "not found" counts as missing; any other lookup error stops the run. A missing app is created bare, then a later deployment applies its full definition with the new app ID, because an identifier URI must contain the app's own ID. Microsoft Graph also rejects pre-authorizing a scope in the request that adds it, so while the Valedo app lacks its `access_as_user` scope, the script deploys the scope first and pre-authorizes the Office clients in a final pass. A fresh environment takes three passes; a redeploy takes one. The deployment also merges the `AzureAd__Instance`, `AzureAd__TenantId`, `AzureAd__ClientId` and `AzureAd__Audience` app settings into the environment's two web apps (`{prefix}-app-valedo-…` and `{prefix}-app-publisher-valedo-…`, derived from the resource group name), so both web apps must exist: deploy the infrastructure first. The script prints the IDs to copy into other configuration; the current values are recorded in [Entra app registrations](https://docs.valedo.uniwave.ai/developers/entra-apps.md). Domain verification, publisher verification, role assignments for staff and the Partner Center fields stay manual; see [Marketplace and tenant setup](https://docs.valedo.uniwave.ai/publisher-guide/marketplace-setup.md).

The Graph Bicep extension (`infra/bicepconfig.json`) does not support `what-if`, so there is no preview flag. The deployment is idempotent, and re-running it after a failed first run is safe.

> [!WARNING]
>
> ### Client secrets
>
> Microsoft Graph Bicep cannot issue client secrets, so after the Bicep passes the script adds one to `valedo-{env}` and `valedo-marketplace-{env}` with the Graph `addPassword` action and writes it straight into the resource group's Key Vault (README §4.7). **Dev is the exception:** the script skips this step for dev (also with `-s`), so the dev apps have no client secrets and the dev Key Vault holds none. Only prod gets them:
>
> | App | Key Vault secret | Tags |
> | - | - | - |
> | `valedo-{env}` | `valedo-client-secret` | `appId`, `keyId`, `uniqueName` |
> | `valedo-marketplace-{env}` | `valedo-marketplace-client-secret` | `appId`, `keyId`, `uniqueName` |
>
> * A secret lives one year. The script keeps the stored secret while it is valid for more than 30 days and its `keyId` still exists on the app; otherwise it issues a new one and stores it as a new version. Older credentials stay on the app until they expire, so hosts using versionless Key Vault references keep working during rotation.
> * The Key Vault secret's expiry equals the credential's end date, and the credential's display name is `key-vault:<vault>/<secret>`, so either side points to the other.
> * Before touching secrets, the script waits up to 5 minutes for Key Vault access, because in CI the Secrets Officer role is assigned by the infrastructure step just before and takes a few minutes to propagate.
> * The secret value is never printed. If it cannot be written to Key Vault, the script removes the new credential from the app again and fails.
> * `-s` (`.sh`) or `-SecretsOnly` (`.ps1`) runs only this step, without deploying `infra/entra.bicep`, for example to rotate a secret by hand.
>
> Never change `uniqueName` or the GUID seeds in `infra/entra.bicep`. Scope and app-role IDs are deterministic GUIDs; changing either creates new registrations or IDs and breaks existing role assignments and consents.
>
> The customer roles were consolidated into `Valedo.Admin` and `Valedo.User`. `Valedo.User` deliberately keeps the former `Valedo.Editor` role ID, so existing editors retain access. Before deploying this role change to an environment that has existing assignments, reassign every former `Valedo.Viewer` principal to `Valedo.User`; the removed viewer role ID is not accepted by the application. Publisher role values now use the `Valedo.Publisher.*` prefix while retaining their existing deterministic IDs.

## Deploying dev end to end

Run these commands from the repository root. Before you start, create the two dev parameters files from their examples and check them:

```bash theme={null}
cp infra/parameters/main.dev.parameters.example.json infra/parameters/main.dev.parameters.json     # replace <prefix>, e.g. uw
cp infra/parameters/entra.dev.parameters.example.json infra/parameters/entra.dev.parameters.json
```

`infra/parameters/main.dev.parameters.json` must have no `<...>` placeholders left. `infra/parameters/entra.dev.parameters.json` already holds the dev hosts; empty `addinDomain` until `dev.valedo.uniwave.ai` is verified in the publisher tenant.

```bash theme={null}
az login                                   # select a subscription in the publisher tenant
az account show --query name -o tsv        # confirm the subscription

# 1. Azure resources: preview first, then deploy
infra/deployment-dev.sh -w   # what-if only, creates nothing
infra/deployment-dev.sh      # runs what-if again, then deploys

# 2. Entra app registrations, after step 1 because the resource group must exist
infra/entra-deployment-dev.sh
```

PowerShell equivalents:

```powershell theme={null}
./infra/deployment-dev.ps1 -WhatIfOnly
./infra/deployment-dev.ps1
./infra/entra-deployment-dev.ps1
```

Prod uses the same steps with `deployment-prod` and `entra-deployment-prod`.

* Dev deploys to `uw-rg-valedo-weu-dev`, the Key Vault is `uw-kv-valedo-weu-dev`, and every other resource name also ends in `-dev`.
* If a soft-deleted `uw-kv-valedo-weu-dev` exists, the script asks you to type its name before it purges it. `--purge-deleted-keyvault` / `-PurgeDeletedKeyVault` skips that prompt.
* The Entra script works out the resource group from `prefix` and `regionCode` in the same environment's main parameters file (`infra/parameters/main.dev.parameters.json` for dev). If you don't have that file locally, pass `-g uw-rg-valedo-weu-dev` (`-ResourceGroup`).
* The Entra script updates `valedo-dev`, `valedo-marketplace-dev` and `valedo-publisher-dev` in place and prints their app IDs and identifier URIs. Copy those values into the dev configuration and finish the [manual setup steps](https://docs.valedo.uniwave.ai/publisher-guide/marketplace-setup.md).

## Deployment from GitHub Actions

`deploy.yml` runs CI once, works out what changed since the environment's last successful deployment, and calls `deploy-environment.yml` for the target environment. Only the changed parts are deployed: infrastructure and Entra app registrations, Valedo.API, the Publisher API, the Worker and the public site.

| Workflow | Runs on | Result |
| - | - | - |
| `.github/workflows/ci.yml` | Pull requests to `main` or `develop`, a manual run, and called by `deploy.yml` | Stamps the build version (see [Versioning](#versioning)); `dotnet build` (Release, warnings as errors) and `dotnet test`; fails if `src/Valedo.Api/openapi/` or `src/Valedo.Publisher.Api/openapi/` changes during the build; `npm ci`, `typecheck`, `test` and `build` for all workspaces; fails if `npm run generate:api` changes `src/web-shared/src/api`; compiles `infra/main.bicep` and `infra/entra.bicep` without deploying them. Packages are published as artifacts only when `deploy.yml` calls it, not on pull requests |
| `.github/workflows/deploy.yml` | Push or merge to `develop`, a pull request merged into `main`, or a manual run | Resolves the environment and the changed deployables, runs CI, calls `deploy-environment.yml`, and on `prod` tags the commit `v{version}` |
| `.github/workflows/deploy-environment.yml` | Called by `deploy.yml` with one environment and the change flags | `infra/deployment-{env}.sh` (what-if, then Incremental deploy) and `infra/entra-deployment-{env}.sh` when `infra` changed; then, in parallel, the zip deploys of the changed apps, each followed by a smoke test; finally moves the `deployed/{env}` tag |

| Trigger | Deploys | Order |
| - | - | - |
| Push or merge to `develop` | `dev` | CI → `dev` |
| Pull request merged into `main` | `prod` only | CI → `prod` → tag `v{version}`; `dev` already ran this code from `develop` |
| Manual run, `dev` | `dev` | CI → `dev` (any branch) |
| Manual run, `prod` | `prod` | CI → `prod` (only `main`) |

| Environment | Azure resource group | Entra apps |
| - | - | - |
| `dev` | `uw-rg-valedo-weu-dev` | `valedo-dev`, `valedo-marketplace-dev`, `valedo-publisher-dev` |
| `prod` | `uw-rg-valedo-weu` | `valedo-prod`, `valedo-marketplace-prod`, `valedo-publisher-prod` |

### What a run deploys

`.github/scripts/detect-changes.sh` diffs `HEAD` against the `deployed/{env}` tag, which marks the commit the environment last ran successfully, and sets one flag per deployable:

| Flag | Set when these paths changed |
| - | - |
| `infra` (infrastructure and Entra apps) | `infra/`, `deploy-environment.yml` |
| `api` | `src/Valedo.Api/` or a shared path |
| `publisher` | `src/Valedo.Publisher.Api/` or a shared path |
| `worker` | `src/Valedo.Worker/` or a shared path |
| `site` | `src/web-site/`, `docs/user-guide/`, `docs/admin-guide/`, `docs/images/`, `package.json`, `package-lock.json`, `deploy-environment.yml` |

Shared paths are `src/Valedo.Domain/`, `src/Valedo.Application/`, `src/Valedo.Infrastructure/`, `Directory.Build.props`, `Directory.Packages.props`, `global.json`, `version.json` and `deploy-environment.yml`. A change there redeploys all three .NET hosts.

* No `deployed/{env}` tag yet (first run of an environment), or a manual run with **Deploy everything** ticked: everything is deployed.
* The tag moves only when every selected job succeeded. A failed run leaves the tag where it was, so the next run repeats the failed work.
* A promotion to `prod` covers every change since the last `prod` deployment, because the diff runs against `deployed/prod`.
* To redeploy one environment completely, for example after changing a GitHub environment variable, run the workflow manually with **Deploy everything**.

Rules the workflow enforces:

* `prod` deploys only from `main`, including manual runs.
* `prod` deploys only commits that reached `main` through a merged pull request. A direct push to `main` fails the run before anything is deployed. Also block direct pushes with a branch protection rule on `main`.
* A merge into `main` deploys `prod` without redeploying `dev`: `develop` already deployed and tested that code on `dev`. Put required reviewers on the GitHub environment `prod` to add a manual approval before the deployment starts.
* Each stage runs only if the previous one succeeded: a failed test, Bicep compile or infrastructure deployment stops the run before the Entra apps or the applications change.
* Runs on the same branch and the same environment don't overlap; a newer run waits for the running one.
* The workflow never purges a Key Vault. If a soft-deleted vault with the environment's name exists, the infrastructure step fails; purge or recover it by hand (see [Key Vault and soft delete](#key-vault-and-soft-delete)).

The front ends (`src/addin`, the Admin Portal and the Publisher Console) are built and checked in CI but not deployed from this workflow yet.

```mermaid theme={null}
flowchart LR
    A[Push to develop or PR merged into main] --> R[Resolve environment, check merged PR on main]
    R --> C[detect-changes.sh: diff against deployed/ENV tag]
    A --> B[ci.yml: stamp version, build, test, drift checks, Bicep compile]
    C --> D
    B --> D[deploy-environment.yml: dev on develop, prod on main]
    subgraph deploy-environment.yml
      I[infra and Entra scripts, only if infra changed] --> P[Changed apps in parallel: deploy, smoke test]
      P --> M[Move deployed/ENV tag]
    end
    D --> I
    M --> T[prod only: tag v-version]
```

### Versioning

The product version is `MAJOR.MINOR.PATCH`. Nobody edits the patch number or raises the version in pull requests:

* `MAJOR.MINOR` is the release line in `version.json` at the repository root. Raise it by hand only for a feature release or a breaking change: `npm run version:bump -- minor` or `-- major` (this resets the patch to 0), then commit `version.json`.
* `PATCH` is the number of commits on the built commit (`git rev-list --count HEAD`). `scripts/stamp-version.mjs` writes `MAJOR.MINOR.PATCH` into `version.json` in the CI workspace before building, so one commit has exactly one version and it only grows. The stamped file is never committed.

Both consumers read the stamped file, so the version is set once per build:

* .NET: `Directory.Build.props` sets `Version` for every project; the assemblies carry `{version}+{commit}` as their informational version.
* Admin Portal and Publisher Console: each `vite.config.ts` injects the version at build time, and `AppVersion` from `@valedo/web-shared` shows it in the footer.

After a successful `prod` deployment, `deploy.yml` tags the commit `v{version}`; the version in the tag is the one CI stamped into the packages. Locally, `version.json` holds the release line with patch `0`; `npm run version:stamp` writes the real version into it (needs a full clone; don't commit the result).

### Parameters in the workflow

The real parameters files are gitignored, so the workflow builds them in the run:

* **Infrastructure:** it copies `infra/parameters/main[.dev].parameters.example.json` and sets `prefix`, `appServiceSkuName` and, if the variables are set, `location`, `regionCode` and `developerPrincipalIds` from the environment variables below. It fails if the resource group name built from these values differs from `AZURE_RESOURCE_GROUP`.
* **Entra:** it uses `infra/parameters/entra[.dev].parameters.example.json` as committed. Those files hold the environment's hosts and no secrets, so change hosts, redirect URIs or federated principal IDs there, in a pull request.

> [!WARNING]
>
> Set `APP_SERVICE_SKU_NAME` to the SKU the environment really runs (for example `B1` in prod). The example files say `B1`, and the deployment applies whatever SKU it is given.

### Environment variables

Each GitHub environment (`dev`, `prod`) holds these **variables**. They are not secrets; none of them is a credential.

| Variable | Used by | Value |
| - | - | - |
| `AZURE_TENANT_ID` | all jobs | Publisher tenant ID |
| `AZURE_SUBSCRIPTION_ID` | all jobs | Subscription that holds the resource group |
| `AZURE_RESOURCE_GROUP` | all jobs | `{prefix}-rg-valedo-{regionCode}` (prod), `{prefix}-rg-valedo-{regionCode}-dev` (dev) |
| `AZURE_INFRA_CLIENT_ID` | infrastructure and Entra | Client ID of the infrastructure deployment identity |
| `VALEDO_PREFIX` | infrastructure | Name prefix, for example `uw` |
| `APP_SERVICE_SKU_NAME` | infrastructure | App Service plan SKU, for example `B1` (dev) or `B1` (prod) |
| `VALEDO_LOCATION` | infrastructure | Optional; defaults to the example file (`westeurope`) |
| `VALEDO_REGION_CODE` | infrastructure | Optional; defaults to the example file (`weu`) |
| `DEVELOPER_PRINCIPAL_IDS` | infrastructure | Optional, dev only; comma-separated Entra object IDs for `developerPrincipalIds`; don't set it in `prod` ([Developer data access](#developer-data-access)) |
| `AZURE_CLIENT_ID` | application deploy | Client ID of the application deployment identity |
| `API_APP_NAME` | application deploy | `{prefix}-app-valedo-{regionCode}` (prod), `{prefix}-app-valedo-{regionCode}-dev` (dev) |
| `PUBLISHER_API_APP_NAME` | application deploy | `{prefix}-app-publisher-valedo-{regionCode}` (prod), `{prefix}-app-publisher-valedo-{regionCode}-dev` (dev) |
| `SITE_CUSTOM_HOSTNAME` | infrastructure | Optional; custom host of the public site (see [Custom domains](#custom-domains)) |
| `SITE_APP_NAME` | application deploy | `{prefix}-swa-site-valedo-{regionCode}` (prod), `{prefix}-swa-site-valedo-{regionCode}-dev` (dev) |
| `WORKER_APP_NAME` | application deploy | `{prefix}-func-valedo-{regionCode}` (prod), `{prefix}-func-valedo-{regionCode}-dev` (dev) |

### One-time setup per environment

An operator does this once per environment; no workflow does it. Two identities keep the application deployment least-privileged: one deploys only packages, the other deploys infrastructure and Entra apps.

1. Run the infrastructure and Entra scripts by hand once ([Deploying dev end to end](#deploying-dev-end-to-end)), so the resource group, the web apps and the app registrations exist.
2. **Application deployment identity:** create an app registration (or user-assigned managed identity) in the publisher tenant with a federated credential, subject `repo:uab-uniwave@129962912/Valedo@1440127505:environment:<env>` (the organization uses immutable IDs in OIDC subjects; the plain `repo:uab-uniwave/Valedo:…` form fails with `AADSTS700213`) (issuer `https://token.actions.githubusercontent.com`, audience `api://AzureADTokenExchange`). Assign it **Website Contributor** on the two web apps and the Worker function app, and a role that includes `Microsoft.Web/staticSites/listSecrets/action` (for example Contributor) on the public site's Static Web App only. The site job reads that Static Web App's deployment token with it at run time. Its client ID goes into `AZURE_CLIENT_ID`.
3. **Infrastructure deployment identity:** create a second app registration with the same kind of federated credential. Its client ID goes into `AZURE_INFRA_CLIENT_ID`. Grant it:
   * **Contributor** and **Role Based Access Control Administrator** on the environment's resource group. `infra/main.bicep` creates role assignments, and the script runs `az group create` on the existing group. Limit the RBAC Administrator assignment to the roles the templates assign if your policy requires it: Monitoring Metrics Publisher, Key Vault Secrets User and Secrets Officer, Storage Blob Data Owner, Storage Queue Data Contributor and Storage Table Data Contributor.
   * The Microsoft Graph application permission **Application.ReadWrite.All** and **DelegatedPermissionGrant.ReadWrite.All**, with admin consent, or the Entra role **Cloud Application Administrator**. `Application.ReadWrite.OwnedBy` is not enough unless the identity is added as an owner of the three existing registrations.
   * **Key Vault Secrets Officer** on the environment's Key Vault, to store the client secrets. `infra/main.bicep` assigns it to the deploying principal, so the infrastructure step grants it automatically.
4. In GitHub, create the `dev` or `prod` environment and add the variables above.
5. Protect `prod` with required reviewers and a deployment-branch rule for `main`. The `dev` environment must allow `develop` (and `main` only if you run manual `dev` deployments from it). Add a branch protection rule (or ruleset) on `main` that requires a pull request and the CI checks and allows no bypass, and one on `develop` if you want every dev deployment to come from a pull request.

> [!NOTE]
>
> OpenID Connect means no client secret or publish profile is stored in GitHub.

> [!NOTE]
>
> Required reviewers approve each job that uses the `prod` environment, so a prod run asks twice: before the infrastructure and Entra step, and before the application deploy. Both prompts appear only after `dev` has been deployed from the same commit. The infrastructure job prints the what-if output before it deploys, but in the same job, after approval. To review what-if before approving, run `infra/deployment-prod.sh -w` by hand first.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.