diff --git a/.github/workflows/docs-site-deploy.yml b/.github/workflows/docs-site-deploy.yml
index a462178..3255746 100644
--- a/.github/workflows/docs-site-deploy.yml
+++ b/.github/workflows/docs-site-deploy.yml
@@ -1,15 +1,30 @@
-# Docs Site — Azure Static Web Apps Deploy
+# Docs Site — Azure Static Web Apps Deploy (canary + prod)
#
-# Builds the Astro/Starlight docs site and deploys to Azure Static Web Apps.
+# Builds the Astro/Starlight docs site once and deploys to the appropriate
+# Azure Static Web App. All Azure auth flows through workload-identity
+# federation (OIDC) against the shared `Otto E2E GitHub Actions` SP — no
+# long-lived deployment tokens are stored anywhere in this repo.
#
# Triggers:
-# - push to main (changes under docs-site/**) → production deploy
-# - pull_request → preview environment
-# - workflow_dispatch → manual deploy
+# push (main, docs-site/**) → canary auto-deploy
+# pull_request (opened/sync/reopened) → canary preview environment
+# pull_request (closed) → tear down preview environment
+# workflow_dispatch (target=canary|prod) → manual deploy (prod requires
+# approval via the `docs-prod`
+# GitHub environment)
#
-# Required GitHub secret:
-# AZURE_STATIC_WEB_APPS_API_TOKEN — set automatically when you link the
-# SWA resource to this repo, or add manually from the Azure portal.
+# Required repo configuration (Settings → Secrets and variables → Actions):
+# Secrets: AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID
+# Optional vars: DOCS_SITE_URL (canonical site URL used in )
+#
+# Required GitHub environment: `docs-prod` (with required reviewers).
+#
+# Resources:
+# Canary SWA: otto-docs-canary-swa (RG: otto-docs-canary-rg)
+# hostname: lemon-mud-0e10bdd1e.7.azurestaticapps.net
+# Prod SWA: otto-docs-swa (RG: otto-docs-rg)
+# hostname: kind-river-0d9e9ac1e.7.azurestaticapps.net
+# fronted by AFD `otto-portal-afd` at https://auto.azure.com/docs/
name: 'Docs Site: Deploy'
@@ -25,14 +40,24 @@ on:
- 'docs-site/**'
- '.github/workflows/docs-site-deploy.yml'
workflow_dispatch:
+ inputs:
+ target:
+ description: 'Deploy target'
+ type: choice
+ options: [canary, prod]
+ default: canary
+# Default permissions = read only; jobs opt-in to what they need.
permissions:
contents: read
- pull-requests: write
+# Concurrency by resolved target so that:
+# - PRs: each PR has its own group (preview deploy can be cancelled by a new push)
+# - push to main and manual canary: share the `canary` group (cancel one another)
+# - manual prod: lives in its own `prod` group and is never auto-cancelled
concurrency:
- group: docs-site-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.ref }}
- cancel-in-progress: true
+ group: docs-site-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || (github.event_name == 'workflow_dispatch' && inputs.target) || 'canary' }}
+ cancel-in-progress: ${{ !(github.event_name == 'workflow_dispatch' && inputs.target == 'prod') }}
env:
NODE_VERSION: '22'
@@ -40,12 +65,16 @@ env:
DOCS_OUTPUT_LOCATION: 'dist'
jobs:
- build_and_deploy:
+ # --------------------------------------------------------------------------
+ # Build once. The same artifact is deployed to whichever target the job
+ # graph selects. `base: '/docs'` is baked into astro.config.mjs so there's
+ # no per-target build divergence.
+ # --------------------------------------------------------------------------
+ build:
+ name: 'Build'
if: github.event_name != 'pull_request' || github.event.action != 'closed'
- name: 'Build and deploy'
runs-on: ubuntu-latest
timeout-minutes: 15
-
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -57,10 +86,12 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
+ cache: 'npm'
+ cache-dependency-path: ${{ env.DOCS_APP_LOCATION }}/package-lock.json
- name: Install dependencies
working-directory: ${{ env.DOCS_APP_LOCATION }}
- run: npm install --no-audit --no-fund
+ run: npm ci --no-audit --no-fund
- name: Type-check and lint content
working-directory: ${{ env.DOCS_APP_LOCATION }}
@@ -72,32 +103,207 @@ jobs:
DOCS_SITE_URL: ${{ vars.DOCS_SITE_URL || '' }}
run: npm run build
- # The SWA deploy action with skip_app_build: true treats app_location
- # as the folder containing index.html. Stage the built output at a
- # workspace-root path so the action finds it reliably.
- - name: Stage build output for SWA
+ - name: Upload built site
+ uses: actions/upload-artifact@v4
+ with:
+ name: docs-dist
+ path: ${{ env.DOCS_APP_LOCATION }}/${{ env.DOCS_OUTPUT_LOCATION }}
+ retention-days: 7
+ if-no-files-found: error
+
+ # --------------------------------------------------------------------------
+ # Deploy → canary. Triggered by push to main, by PR previews from in-repo
+ # branches, or by manual dispatch with target=canary (also restricted to
+ # main because that's the only ref the SP's federated credential covers
+ # for non-PR runs). Fork PRs lack access to secrets and so this job will
+ # skip cleanly (azure/login won't be invoked).
+ # --------------------------------------------------------------------------
+ deploy_canary:
+ name: 'Deploy → canary'
+ needs: build
+ if: |
+ (github.event_name == 'push') ||
+ (github.event_name == 'pull_request' &&
+ github.event.action != 'closed' &&
+ github.event.pull_request.head.repo.full_name == github.repository) ||
+ (github.event_name == 'workflow_dispatch' &&
+ inputs.target == 'canary' &&
+ github.ref == 'refs/heads/main')
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ id-token: write
+ pull-requests: write
+ env:
+ SWA_NAME: otto-docs-canary-swa
+ SWA_RG: otto-docs-canary-rg
+ steps:
+ - name: Download built site
+ uses: actions/download-artifact@v4
+ with:
+ name: docs-dist
+ path: dist
+
+ - name: Verify Azure auth secrets are configured
+ shell: bash
+ env:
+ AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
+ AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
+ AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+ run: |
+ set -euo pipefail
+ missing=()
+ [[ -z "$AZURE_CLIENT_ID" ]] && missing+=("AZURE_CLIENT_ID")
+ [[ -z "$AZURE_TENANT_ID" ]] && missing+=("AZURE_TENANT_ID")
+ [[ -z "$AZURE_SUBSCRIPTION_ID" ]] && missing+=("AZURE_SUBSCRIPTION_ID")
+ if (( ${#missing[@]} > 0 )); then
+ echo "::error::Missing required repo secrets: ${missing[*]}"
+ echo "::error::See docs-site/INFRA.md → 'Repo-side one-time setup' for how to configure these."
+ exit 1
+ fi
+
+ - name: Azure login (OIDC)
+ uses: azure/login@v2
+ with:
+ client-id: ${{ secrets.AZURE_CLIENT_ID }}
+ tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+ subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+
+ - name: Fetch SWA deployment token (JIT)
+ id: token
+ shell: bash
+ run: |
+ set -euo pipefail
+ TOKEN=$(az staticwebapp secrets list \
+ --name "$SWA_NAME" --resource-group "$SWA_RG" \
+ --query properties.apiKey -o tsv)
+ echo "::add-mask::$TOKEN"
+ echo "swa_token=$TOKEN" >> "$GITHUB_OUTPUT"
+
+ - name: Deploy to Azure Static Web Apps (canary)
+ uses: Azure/static-web-apps-deploy@v1
+ with:
+ azure_static_web_apps_api_token: ${{ steps.token.outputs.swa_token }}
+ repo_token: ${{ secrets.GITHUB_TOKEN }}
+ action: 'upload'
+ app_location: 'dist'
+ skip_app_build: true
+ skip_api_build: true
+
+ # --------------------------------------------------------------------------
+ # Deploy → prod. Only triggered manually. The `docs-prod` GitHub environment
+ # gates this job with required reviewers, so production never deploys without
+ # an explicit approval click.
+ # --------------------------------------------------------------------------
+ deploy_prod:
+ name: 'Deploy → prod'
+ needs: build
+ if: github.event_name == 'workflow_dispatch' && inputs.target == 'prod'
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ environment:
+ name: docs-prod
+ url: https://auto.azure.com/docs/
+ permissions:
+ contents: read
+ id-token: write
+ env:
+ SWA_NAME: otto-docs-swa
+ SWA_RG: otto-docs-rg
+ steps:
+ - name: Download built site
+ uses: actions/download-artifact@v4
+ with:
+ name: docs-dist
+ path: dist
+
+ - name: Verify Azure auth secrets are configured
+ shell: bash
+ env:
+ AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
+ AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
+ AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+ run: |
+ set -euo pipefail
+ missing=()
+ [[ -z "$AZURE_CLIENT_ID" ]] && missing+=("AZURE_CLIENT_ID")
+ [[ -z "$AZURE_TENANT_ID" ]] && missing+=("AZURE_TENANT_ID")
+ [[ -z "$AZURE_SUBSCRIPTION_ID" ]] && missing+=("AZURE_SUBSCRIPTION_ID")
+ if (( ${#missing[@]} > 0 )); then
+ echo "::error::Missing required repo secrets: ${missing[*]}"
+ echo "::error::See docs-site/INFRA.md → 'Repo-side one-time setup' for how to configure these."
+ exit 1
+ fi
+
+ - name: Azure login (OIDC)
+ uses: azure/login@v2
+ with:
+ client-id: ${{ secrets.AZURE_CLIENT_ID }}
+ tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+ subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+
+ - name: Fetch SWA deployment token (JIT)
+ id: token
+ shell: bash
run: |
- rm -rf swa-staging
- cp -r "${DOCS_APP_LOCATION}/${DOCS_OUTPUT_LOCATION}" swa-staging
+ set -euo pipefail
+ TOKEN=$(az staticwebapp secrets list \
+ --name "$SWA_NAME" --resource-group "$SWA_RG" \
+ --query properties.apiKey -o tsv)
+ echo "::add-mask::$TOKEN"
+ echo "swa_token=$TOKEN" >> "$GITHUB_OUTPUT"
- - name: Deploy to Azure Static Web Apps
+ - name: Deploy to Azure Static Web Apps (prod)
uses: Azure/static-web-apps-deploy@v1
with:
- azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
+ azure_static_web_apps_api_token: ${{ steps.token.outputs.swa_token }}
repo_token: ${{ secrets.GITHUB_TOKEN }}
action: 'upload'
- app_location: 'swa-staging'
+ app_location: 'dist'
skip_app_build: true
skip_api_build: true
+ # --------------------------------------------------------------------------
+ # Close PR preview. Only same-repo PRs created a preview; only same-repo
+ # PR closures can tear one down (forks can't access the token).
+ # --------------------------------------------------------------------------
close_pr_preview:
- if: github.event_name == 'pull_request' && github.event.action == 'closed'
name: 'Close PR preview'
+ if: |
+ github.event_name == 'pull_request' &&
+ github.event.action == 'closed' &&
+ github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
timeout-minutes: 5
+ permissions:
+ contents: read
+ id-token: write
+ pull-requests: write
+ env:
+ SWA_NAME: otto-docs-canary-swa
+ SWA_RG: otto-docs-canary-rg
steps:
+ - name: Azure login (OIDC)
+ uses: azure/login@v2
+ with:
+ client-id: ${{ secrets.AZURE_CLIENT_ID }}
+ tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+ subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+
+ - name: Fetch SWA deployment token (JIT)
+ id: token
+ shell: bash
+ run: |
+ set -euo pipefail
+ TOKEN=$(az staticwebapp secrets list \
+ --name "$SWA_NAME" --resource-group "$SWA_RG" \
+ --query properties.apiKey -o tsv)
+ echo "::add-mask::$TOKEN"
+ echo "swa_token=$TOKEN" >> "$GITHUB_OUTPUT"
+
- name: Close preview environment
uses: Azure/static-web-apps-deploy@v1
with:
- azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
+ azure_static_web_apps_api_token: ${{ steps.token.outputs.swa_token }}
action: 'close'
diff --git a/docs-site/INFRA.md b/docs-site/INFRA.md
index d232fc9..d20933c 100644
--- a/docs-site/INFRA.md
+++ b/docs-site/INFRA.md
@@ -1,131 +1,194 @@
-# Otto Docs Site — Canary Infrastructure
+# Otto Docs Site — Infrastructure
-The docs site is hosted on an Azure Static Web App in the **Otto canary
-subscription**. SWA is the right fit because the docs site is pure static
-output — atomic deploys, free preview environments per PR, no warmup, and
-no traffic-shifting machinery to maintain.
+The docs site is an Astro/Starlight build hosted on Azure Static Web Apps.
+There are two SWAs — one for canary (auto-deploys on every push to `main`)
+and one for prod (gated, manual). Both build from the same source with the
+same `base: '/docs'` config; prod is fronted by Azure Front Door at
+`https://auto.azure.com/docs/*`.
-This is **independent of the portal canary** (which uses ACA + Front Door).
-The two pipelines never share resources, so a docs ship can never block
-portal/runtime releases.
+This is independent of the portal — a docs deploy can never block a runtime
+release.
## Resource map
-| Item | Value |
-| --- | --- |
-| Subscription | `Logic Apps Co-Pilot - Stage` (`49cff46c-0b82-4a1b-a533-ede69747bb0b`) |
-| Tenant | `72f988bf-86f1-41af-91ab-2d7cd011db47` |
-| Resource group | `otto-docs-canary-rg` (`westus2`) |
-| Static Web App | `otto-docs-canary-swa` (Free SKU) |
-| Default hostname | `https://lemon-mud-0e10bdd1e.7.azurestaticapps.net` |
-| Tags | `environment=canary`, `project=otto-docs`, `team=serverless-paas`, `owner=krmitta@microsoft.com` |
+| Item | Canary | Prod |
+| --- | --- | --- |
+| Subscription | `Logic Apps Co-Pilot - Stage` (`49cff46c-0b82-4a1b-a533-ede69747bb0b`) | same |
+| Tenant | `72f988bf-86f1-41af-91ab-2d7cd011db47` | same |
+| Resource group | `otto-docs-canary-rg` (`westus2`) | `otto-docs-rg` (`westus2`) |
+| Static Web App | `otto-docs-canary-swa` (Free SKU) | `otto-docs-swa` (Free SKU) |
+| Default hostname | `https://lemon-mud-0e10bdd1e.7.azurestaticapps.net` | `https://kind-river-0d9e9ac1e.7.azurestaticapps.net` |
+| Public URL | (same as default hostname) | `https://auto.azure.com/docs/` via AFD `otto-portal-afd` |
+| Tags | `environment=canary` | `environment=prod` |
+
+Both SWAs share `project=otto-docs`, `team=serverless-paas`,
+`owner=krmitta@microsoft.com`.
+
+The canary URL serves the same `/docs/`-prefixed build as prod; a `/ → /docs/`
+redirect in `public/staticwebapp.config.json` keeps bare-root URLs working
+on canary while AFD does the path mapping for prod.
## CI/CD
| Item | Value |
| --- | --- |
| Workflow | [`.github/workflows/docs-site-deploy.yml`](../.github/workflows/docs-site-deploy.yml) |
-| Trigger | `push` to `main` on `docs-site/**`; `pull_request` for previews; `workflow_dispatch` for manual deploys |
| Deploy action | [`Azure/static-web-apps-deploy@v1`](https://github.com/Azure/static-web-apps-deploy) |
-| Auth | GitHub OIDC — no long-lived deployment token stored anywhere |
+| Build | Single Astro build per run; the same artifact deploys to canary and/or prod. |
+| Auth | GitHub OIDC — no long-lived deployment tokens stored anywhere. |
+
+| Trigger | Action |
+| --- | --- |
+| `push` to `main` on `docs-site/**` | Build → deploy canary |
+| `pull_request` from in-repo branch | Build → deploy preview environment on canary SWA |
+| `pull_request` from fork | Build only (no secret access) |
+| `pull_request` closed | Tear down preview environment |
+| `workflow_dispatch` target=`canary` | Build → deploy canary |
+| `workflow_dispatch` target=`prod` | Build → deploy prod (gated by `docs-prod` environment reviewers) |
### Auth model (no stored credentials)
-The workflow uses the **same OIDC service principal as portal canary** so there are no docs-specific secrets to manage:
+All Azure operations flow through the shared **Otto E2E GitHub Actions** SP
+(reused across docs and portal canary workflows). There are no docs-specific
+GitHub secrets beyond the org-wide Azure identity.
| Identity | Otto E2E GitHub Actions (`appId e8323013-aff3-46b9-8f7e-96ea41edf48a`) |
| --- | --- |
| Tenant | `72f988bf-86f1-41af-91ab-2d7cd011db47` |
-| GH secrets reused | `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_SUBSCRIPTION_ID` (already provisioned for portal workflows) |
-| Federated credentials | `ref:refs/heads/main` (push + workflow_dispatch), `pull_request` (PR previews — added by docs onboarding) |
-| Role on docs RG | `Contributor` scoped only to `otto-docs-canary-rg` |
+| Required GitHub secrets | `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_SUBSCRIPTION_ID` |
+| Federated credentials | `ref:refs/heads/main` (push), `pull_request` (PR previews), `environment:docs-prod` (gated prod deploys) |
+| Role on canary RG | `Contributor` scoped only to `otto-docs-canary-rg` |
+| Role on prod RG | `Contributor` scoped only to `otto-docs-rg` |
-How a deploy authenticates end-to-end:
+End-to-end auth flow per deploy:
1. GitHub Actions mints a short-lived OIDC token for the workflow run.
-2. `azure/login@v2` exchanges that token (via the federated credential trust) for an Azure access token tied to the **Otto E2E GitHub Actions** SP.
-3. The workflow uses the access token to call `az staticwebapp secrets list`, which returns the SWA deployment token (just-in-time, never stored).
-4. The deployment token is masked in logs and handed to `Azure/static-web-apps-deploy@v1`.
-5. SWA performs an atomic deploy.
+2. `azure/login@v2` exchanges that token (via the federated credential trust)
+ for an Azure access token tied to the SP.
+3. The workflow calls `az staticwebapp secrets list` against the target SWA
+ (canary or prod RG depending on the job) to fetch the deployment token
+ just-in-time. The token is masked in logs and never persisted.
+4. `Azure/static-web-apps-deploy@v1` performs an atomic upload.
-If the SWA deployment token leaks (or just for hygiene), rotate with:
+To rotate (or after a suspected leak):
```bash
-az staticwebapp secrets reset-api-key \
- --name otto-docs-canary-swa \
- --resource-group otto-docs-canary-rg
+# Canary
+az staticwebapp secrets reset-api-key --name otto-docs-canary-swa --resource-group otto-docs-canary-rg
+
+# Prod
+az staticwebapp secrets reset-api-key --name otto-docs-swa --resource-group otto-docs-rg
```
-No workflow or GH-secret change is needed after rotation — the workflow fetches the new token on the next run.
+No workflow or GH-secret change is needed after rotation — the workflow
+fetches the new token on the next run.
-PRs each get a free preview environment hosted by SWA. Closing or merging the
-PR automatically tears it down (the `close_pr_preview` job).
+### Preview environments for in-repo PRs
-## One-time setup (already done)
+In-repo PRs each get a free preview environment hosted by the canary SWA.
+Fork PRs build but don't deploy (GitHub does not pass secrets to fork
+workflows, by design). Maintainers can manually deploy a fork PR's branch
+with a `workflow_dispatch` run after review.
-The canary RG, SWA, federated credential, and RBAC were provisioned with these
-commands. Recorded here for reproducibility / disaster recovery:
+Closing or merging the PR automatically tears down the preview (the
+`close_pr_preview` job).
+
+## One-time setup (recorded for disaster recovery)
```bash
-# Switch to the canary subscription
-az account set --subscription 49cff46c-0b82-4a1b-a533-ede69747bb0b
+SUB=49cff46c-0b82-4a1b-a533-ede69747bb0b
+az account set --subscription "$SUB"
-# Resource group
-az group create \
- --name otto-docs-canary-rg \
- --location westus2 \
+# ---- Resource groups -----------------------------------------------------
+az group create --name otto-docs-canary-rg --location westus2 \
--tags environment=canary project=otto-docs team=serverless-paas owner=krmitta@microsoft.com
-# Static Web App (Free SKU — no SLA but plenty for docs)
-az staticwebapp create \
- --name otto-docs-canary-swa \
- --resource-group otto-docs-canary-rg \
- --location westus2 \
- --sku Free \
+az group create --name otto-docs-rg --location westus2 \
+ --tags environment=prod project=otto-docs team=serverless-paas owner=krmitta@microsoft.com
+
+# ---- Static Web Apps (Free SKU — no SLA, plenty for docs) ---------------
+az staticwebapp create --name otto-docs-canary-swa --resource-group otto-docs-canary-rg \
+ --location westus2 --sku Free \
--tags environment=canary project=otto-docs team=serverless-paas owner=krmitta@microsoft.com
-# Reuse the existing "Otto E2E GitHub Actions" SP — it already backs
-# AZURE_CLIENT_ID/AZURE_TENANT_ID/AZURE_SUBSCRIPTION_ID for the portal workflows.
-SP_OBJ_ID=$(az ad sp show --id e8323013-aff3-46b9-8f7e-96ea41edf48a --query id -o tsv)
-SP_APP_OBJ=bb38355a-93e6-40d9-8b6a-47edbe32f017 # application objectId
-
-# Grant Contributor on the docs RG only (least privilege)
-az role assignment create \
- --assignee-object-id "$SP_OBJ_ID" \
- --assignee-principal-type ServicePrincipal \
- --role Contributor \
- --scope /subscriptions/49cff46c-0b82-4a1b-a533-ede69747bb0b/resourceGroups/otto-docs-canary-rg
-
-# Add the pull_request federated credential (existing creds covered main+canary)
-az ad app federated-credential create --id "$SP_APP_OBJ" --parameters '{
- "name": "docs-pull-request",
- "issuer": "https://token.actions.githubusercontent.com",
- "subject": "repo:serverless-paas-balam/project-otto:pull_request",
- "description": "Docs site SWA PR preview deploys",
- "audiences": ["api://AzureADTokenExchange"]
-}'
+az staticwebapp create --name otto-docs-swa --resource-group otto-docs-rg \
+ --location westus2 --sku Free \
+ --tags environment=prod project=otto-docs team=serverless-paas owner=krmitta@microsoft.com
+
+# ---- SP identity (reuses the existing Otto E2E SP) -----------------------
+SP_APP_ID=e8323013-aff3-46b9-8f7e-96ea41edf48a
+SP_APP_OBJ=bb38355a-93e6-40d9-8b6a-47edbe32f017
+SP_OBJ_ID=$(az ad sp show --id "$SP_APP_ID" --query id -o tsv)
+
+# Contributor scoped to each docs RG (least privilege)
+for RG in otto-docs-canary-rg otto-docs-rg; do
+ az role assignment create \
+ --assignee-object-id "$SP_OBJ_ID" \
+ --assignee-principal-type ServicePrincipal \
+ --role Contributor \
+ --scope "/subscriptions/$SUB/resourceGroups/$RG"
+done
+
+# Federated credentials for this repo (Azure/Logic-Apps-Automation)
+for cred in \
+ '{"name":"laa-main","issuer":"https://token.actions.githubusercontent.com","subject":"repo:Azure/Logic-Apps-Automation:ref:refs/heads/main","description":"docs canary deploys on main push","audiences":["api://AzureADTokenExchange"]}' \
+ '{"name":"laa-pull-request","issuer":"https://token.actions.githubusercontent.com","subject":"repo:Azure/Logic-Apps-Automation:pull_request","description":"docs PR preview deploys","audiences":["api://AzureADTokenExchange"]}' \
+ '{"name":"laa-env-docs-prod","issuer":"https://token.actions.githubusercontent.com","subject":"repo:Azure/Logic-Apps-Automation:environment:docs-prod","description":"docs prod gated deploys","audiences":["api://AzureADTokenExchange"]}'
+do
+ az ad app federated-credential create --id "$SP_APP_OBJ" --parameters "$cred"
+done
```
-No new GH secret was added — the workflow uses the existing `AZURE_CLIENT_ID`,
-`AZURE_TENANT_ID`, `AZURE_SUBSCRIPTION_ID` secrets shared with the portal
-workflows.
+Repo-side one-time setup (GitHub UI — requires repo admin):
+
+1. **Settings → Secrets and variables → Actions → New repository secret**, add
+ - `AZURE_CLIENT_ID = e8323013-aff3-46b9-8f7e-96ea41edf48a`
+ - `AZURE_TENANT_ID = 72f988bf-86f1-41af-91ab-2d7cd011db47`
+ - `AZURE_SUBSCRIPTION_ID = 49cff46c-0b82-4a1b-a533-ede69747bb0b`
+2. **Settings → Environments → New environment** named `docs-prod`,
+ configure **Required reviewers** (recommended: 1+ team maintainer).
+ Optionally restrict to `main`-only deploy branch.
+
+## Front Door routing (prod only)
+
+Prod is served at `https://auto.azure.com/docs/*` by the `otto-portal-afd`
+profile (RG `otto-portal-rg`). The relevant pieces:
+
+| AFD piece | Value |
+| --- | --- |
+| Profile | `otto-portal-afd` |
+| Endpoint | `otto-portal` (default host `otto-portal-dxcfeqbkf6fuc4am.b02.azurefd.net`) |
+| Custom domain | `auto-azure-com` (`auto.azure.com`, AFD-managed cert, TLS 1.2) |
+| Origin group | `otto-docs-origins` (probe `HEAD /docs/`) |
+| Origin | `otto-docs-swa-origin` → `kind-river-0d9e9ac1e.7.azurestaticapps.net` |
+| Route | `docs-route` — `patternsToMatch: ['/docs', '/docs/*']`, HTTPS only |
+
+The portal's `default-route` (matching `/*` → portal ACA origins) and the
+`docs-route` coexist on the same endpoint; AFD chooses the more specific
+pattern, so `/docs/foo` always goes to the SWA and `/anything-else` always
+goes to the portal.
-## Custom domain (future)
+## Custom domain (legacy reference)
-The SWA currently uses its auto-generated hostname. To attach `docs.otto.dev`
-(or similar) later:
+The canary SWA currently uses its auto-generated hostname only. To attach
+a separate canary domain later:
-1. Add a `CNAME docs → lemon-mud-0e10bdd1e.7.azurestaticapps.net` DNS record.
-2. `az staticwebapp hostname set --name otto-docs-canary-swa --resource-group otto-docs-canary-rg --hostname docs.otto.dev --validation-method cname-delegation`
-3. Update `DOCS_SITE_URL` repo variable in GitHub to the new origin.
+```bash
+az staticwebapp hostname set --name otto-docs-canary-swa \
+ --resource-group otto-docs-canary-rg \
+ --hostname docs-canary.auto.azure.com \
+ --validation-method cname-delegation
+```
## Operational notes
-- **Cost**: Free SKU = $0/month, 100 GB bandwidth, no SLA. Upgrade to
- Standard ($9/month) when we want SLA, custom auth, or private endpoint.
-- **Cache**: SWA serves cached static content from the edge. The deploy
- step rolls in atomically — no manual cache purge needed.
-- **Logs**: Kudu logs are visible in the Azure Portal under the SWA's
- *Overview → Browse* deployment history.
-- **Alerts**: Not configured yet. We can add an App Insights availability
- test later if docs become production-critical.
+- **Cost**: Both SWAs are Free SKU (`$0`/month, 100 GB bandwidth, no SLA).
+ Upgrade either to Standard (`$9`/month) for SLA, private endpoints, or
+ custom auth.
+- **Cache**: SWA serves cached static content from the edge; deploys roll
+ in atomically — no manual purge needed. AFD also caches static responses
+ per the route's compression / query-string-caching settings.
+- **Logs**: Deployment history is visible in the Azure Portal under each
+ SWA's *Overview → Browse* tab.
+- **Alerts**: Not configured. We can add an Application Insights availability
+ test (or simple AFD-side healthProbe) later if docs become production-critical.
diff --git a/docs-site/README.md b/docs-site/README.md
index db4f01a..eab7e0e 100644
--- a/docs-site/README.md
+++ b/docs-site/README.md
@@ -37,13 +37,16 @@ npm run dev # http://localhost:4321 — hot reload
```
docs-site/
├── README.md ← this file (entry point + conventions)
-├── INFRA.md ← canary resources + deploy recovery
-├── astro.config.mjs ← site config + sidebar groups
+├── INFRA.md ← canary + prod resources + deploy recovery
+├── staticwebapp.config.json ← SWA security headers + caching + video MIME / range + `/` → `/docs/` redirect (postbuild copies this into `dist/`)
+├── astro.config.mjs ← site config + sidebar groups (built with `base: '/docs'`, `outDir: './dist/docs'`)
├── package.json
├── tsconfig.json
-├── public/
+├── scripts/
+│ ├── postbuild.mjs ← copies staticwebapp.config.json into dist/ root after `astro build`
+│ └── remarkBasePrefix.mjs ← remark plugin: prepends `/docs` to plain markdown links so content stays portable
+├── public/ ← copied as-is into `dist/docs/` (so URLs match the `/docs/*` AFD route)
│ ├── favicon.svg ← product icon (reused from portal)
-│ ├── staticwebapp.config.json ← SWA security headers + caching + video MIME / range
│ └── videos/ ← self-hosted MP4 clips (see ./public/videos/README.md)
└── src/
├── assets/
@@ -198,7 +201,7 @@ JS + CSS lazy-load only on pages that actually contain a video.
| Capability | Notes |
| --- | --- |
| Unified controls | Play / pause / seek / time / mute / volume / captions / settings / PiP / AirPlay / fullscreen — same UI for MP4, YouTube, and Vimeo. |
-| HTTP-range buffering | `Accept-Ranges: bytes` is set on `/videos/*` by `public/staticwebapp.config.json`, so the player only fetches the bytes it needs for the user's current playback position. |
+| HTTP-range buffering | `Accept-Ranges: bytes` is set on `/docs/videos/*` by `staticwebapp.config.json`, so the player only fetches the bytes it needs for the user's current playback position. |
| Lazy load | `import('plyr')` runs only when the page actually has a `[data-docs-video]` host. Pages without a video stay zero-cost. |
| Idempotent init | The init script marks each host with `data-plyr-initialised`, so client-side route changes (Starlight prefetching) don't double-mount the player. |
| Themed | Plyr's CSS variables are bound to the Starlight palette in the component's `