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 `