Skip to main content
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. 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

  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.

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/. 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:
  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:
  3. Redeploy dev (infra/deployment-dev.sh), or let the next workflow run do it. For immediate access, deploy only the access module (Quickstart).
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.
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:

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.
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.

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.

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:
  • 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:
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.
PowerShell equivalents:
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.

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 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).
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.

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.
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.

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), 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.
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.