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.
Each environment has its own script, so the environment is chosen by the script you run, never by a default:
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
- Take the environment from the script name (
deployment-devordeployment-prod) and pass it to Bicep asenvironmentName. - Check that you are signed in to Azure CLI and that the target subscription is selected.
- Create or confirm the environment’s resource group (see below).
- Run
what-ifand review the change. - Deploy
infra/main.bicepin Incremental mode.
Environments
The script name selects the environment and passes it to Bicep asenvironmentName; 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/.
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.
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.
Grant access in dev:
-
Add the developer’s Entra object ID to
developerPrincipalIdsininfra/parameters/main.dev.parameters.json. -
Add it to the GitHub
devenvironment, keeping the IDs already there: -
Redeploy dev (
infra/deployment-dev.sh), or let the next workflow run do it. For immediate access, deploy only the access module (Quickstart).
principalId belongs to one of the two prod App Service identities:
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.
Parameters
Ininfra/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.
Custom domains
Hostnames are optional Bicep parameters, set through GitHub environment variables so that no hostname lives in a parameters file:
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).
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.
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.
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.
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. Domain verification, publisher verification, role assignments for staff and the Partner Center fields stay manual; see Marketplace and tenant setup.
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.
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: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.
deployment-prod and entra-deployment-prod.
- Dev deploys to
uw-rg-valedo-weu-dev, the Key Vault isuw-kv-valedo-weu-dev, and every other resource name also ends in-dev. - If a soft-deleted
uw-kv-valedo-weu-devexists, the script asks you to type its name before it purges it.--purge-deleted-keyvault/-PurgeDeletedKeyVaultskips that prompt. - The Entra script works out the resource group from
prefixandregionCodein the same environment’s main parameters file (infra/parameters/main.dev.parameters.jsonfor 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-devandvaledo-publisher-devin place and prints their app IDs and identifier URIs. Copy those values into the dev configuration and finish the manual setup steps.
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.
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:
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
prodcovers every change since the lastproddeployment, because the diff runs againstdeployed/prod. - To redeploy one environment completely, for example after changing a GitHub environment variable, run the workflow manually with Deploy everything.
proddeploys only frommain, including manual runs.proddeploys only commits that reachedmainthrough a merged pull request. A direct push tomainfails the run before anything is deployed. Also block direct pushes with a branch protection rule onmain.- A merge into
maindeploysprodwithout redeployingdev:developalready deployed and tested that code ondev. Put required reviewers on the GitHub environmentprodto 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).
src/addin, the Admin Portal and the Publisher Console) are built and checked in CI but not deployed from this workflow yet.
Versioning
The product version isMAJOR.MINOR.PATCH. Nobody edits the patch number or raises the version in pull requests:
MAJOR.MINORis the release line inversion.jsonat the repository root. Raise it by hand only for a feature release or a breaking change:npm run version:bump -- minoror-- major(this resets the patch to 0), then commitversion.json.PATCHis the number of commits on the built commit (git rev-list --count HEAD).scripts/stamp-version.mjswritesMAJOR.MINOR.PATCHintoversion.jsonin the CI workspace before building, so one commit has exactly one version and it only grows. The stamped file is never committed.
- .NET:
Directory.Build.propssetsVersionfor every project; the assemblies carry{version}+{commit}as their informational version. - Admin Portal and Publisher Console: each
vite.config.tsinjects the version at build time, andAppVersionfrom@valedo/web-sharedshows it in the footer.
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.jsonand setsprefix,appServiceSkuNameand, if the variables are set,location,regionCodeanddeveloperPrincipalIdsfrom the environment variables below. It fails if the resource group name built from these values differs fromAZURE_RESOURCE_GROUP. - Entra: it uses
infra/parameters/entra[.dev].parameters.example.jsonas 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.
Environment variables
Each GitHub environment (dev, prod) holds these variables. They are not secrets; none of them is a credential.
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.- Run the infrastructure and Entra scripts by hand once (Deploying dev end to end), so the resource group, the web apps and the app registrations exist.
- 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 plainrepo:uab-uniwave/Valedo:…form fails withAADSTS700213) (issuerhttps://token.actions.githubusercontent.com, audienceapi://AzureADTokenExchange). Assign it Website Contributor on the two web apps and the Worker function app, and a role that includesMicrosoft.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 intoAZURE_CLIENT_ID. - 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.bicepcreates role assignments, and the script runsaz group createon 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.OwnedByis 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.bicepassigns it to the deploying principal, so the infrastructure step grants it automatically.
- Contributor and Role Based Access Control Administrator on the environment’s resource group.
- In GitHub, create the
devorprodenvironment and add the variables above. - Protect
prodwith required reviewers and a deployment-branch rule formain. Thedevenvironment must allowdevelop(andmainonly if you run manualdevdeployments from it). Add a branch protection rule (or ruleset) onmainthat requires a pull request and the CI checks and allows no bypass, and one ondevelopif you want every dev deployment to come from a pull request.
OpenID Connect means no client secret or publish profile is stored in GitHub.
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.