diff --git a/.github/skills/integration-test-prerequisites/SKILL.md b/.github/skills/integration-test-prerequisites/SKILL.md index 1da7e5ff..66d2b7dc 100644 --- a/.github/skills/integration-test-prerequisites/SKILL.md +++ b/.github/skills/integration-test-prerequisites/SKILL.md @@ -1,6 +1,6 @@ --- name: "integration-test-prerequisites" -description: "Set up Azure and GitHub prerequisites for integration workflows using a user-assigned managed identity, OIDC federated credentials, RBAC roles, and environment secrets. Use when troubleshooting AADSTS70025/AADSTS700213 or authorization failures during integration-test or integration-redact-secrets workflow runs." +description: "Set up Azure and GitHub prerequisites for integration and release-tests workflows using a user-assigned managed identity, OIDC federated credentials, RBAC roles, and environment secrets. Use when troubleshooting AADSTS70025/AADSTS700213 or authorization failures during integration-test or release-tests workflow runs." domain: "ci-cd" confidence: "high" source: "manual + observed from integration-test OIDC and RBAC troubleshooting" @@ -10,14 +10,19 @@ source: "manual + observed from integration-test OIDC and RBAC troubleshooting" Use this skill when preparing or repairing prerequisites for: -- `.github/workflows/integration-test.yml` -- `.github/workflows/integration-redact-secrets.yml` +- `.github/workflows/test-round-trip.yml` — Extract→Publish round-trip +- `.github/workflows/test-redact-secrets.yml` — Secret redaction validation +- `.github/workflows/test-all.yml` — Orchestrator that calls CI, then both integration-test workflows sequentially + +All integration-test workflows share the same GitHub environment (`integration-test`) and Azure identity. Setting up prerequisites once covers all three workflows. These workflows expect: -- OIDC login through `azure/login@v2` -- GitHub environment `integration-test` (shared by both workflows) +- OIDC login through `azure/login@v3` +- GitHub environment `integration-test` (shared by all integration workflows) - Azure identity with enough permissions to deploy resources and create role assignments in test resource groups +The `test-all.yml` orchestrator calls `ci.yml` (no Azure prereqs), then `test-round-trip.yml` and `test-redact-secrets.yml`. The called workflows access secrets directly from the `integration-test` environment — no secret pass-through from the orchestrator is needed. + Preferred identity model: user-assigned managed identity (UAMI). ## Required Inputs @@ -135,12 +140,19 @@ gh secret set AZURE_SUBSCRIPTION_ID \ --env "${GITHUB_ENVIRONMENT}" \ --body "${SUBSCRIPTION_ID}" +# Optional — falls back to apim-integration-test@contoso.com if not set gh secret set APIM_PUBLISHER_EMAIL \ --repo "${GITHUB_OWNER}/${GITHUB_REPO}" \ --env "${GITHUB_ENVIRONMENT}" \ --body "${APIM_PUBLISHER_EMAIL}" ``` +### 6) Verify Called Workflows Can Access Environment Secrets + +The `test-all.yml` orchestrator does **not** pass secrets to called workflows. Instead, the called workflows (`test-round-trip.yml`, `test-redact-secrets.yml`) access secrets directly via their `environment: integration-test` declaration. Each called workflow includes a "Validate Required Secrets" step that fails fast with a clear error if any secret is missing. + +No repo-level secrets are required — all secrets are scoped to the `integration-test` environment. + ## Verification ```bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee39a479..3a9c9659 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: pull_request: branches: - main + workflow_call: permissions: contents: read diff --git a/.github/workflows/test-all.yml b/.github/workflows/test-all.yml new file mode 100644 index 00000000..9d383a96 --- /dev/null +++ b/.github/workflows/test-all.yml @@ -0,0 +1,68 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +# =========================================================================== +# Release Tests — Run all test suites in order before a release. +# =========================================================================== +# Executes unit tests, build-package integration tests, and the full +# extract→publish round-trip integration test sequentially. Fails fast: +# subsequent stages are skipped if any prior stage fails. +# =========================================================================== + +name: "Test: Release Tests" + +on: + schedule: + - cron: '0 9 1 * *' # 1st of every month at 9:00 UTC + workflow_dispatch: + inputs: + sku: + description: 'APIM SKU for all-types integration test' + required: true + type: choice + options: + - Standard + - StandardV2 + - Premium + - PremiumV2 + default: StandardV2 + location: + description: 'Azure region for all-types integration test' + required: false + type: string + default: 'centralus' + log_level: + description: 'Log level' + required: false + type: choice + options: + - Info + - Verbose + - Debug + default: Verbose + +permissions: + contents: read + id-token: write + +jobs: + ci: + name: CI (Unit + Build Package Tests) + uses: ./.github/workflows/ci.yml + + all-types-integration: + name: All-Types Integration Test + needs: ci + uses: ./.github/workflows/test-round-trip.yml + with: + sku: ${{ inputs.sku || vars.DEFAULT_SKU || 'StandardV2' }} + location: ${{ inputs.location || vars.DEFAULT_LOCATION || 'centralus' }} + log_level: ${{ inputs.log_level || vars.DEFAULT_LOG_LEVEL || 'Verbose' }} + + redact-secrets-integration: + name: Redact Secrets Integration Test + needs: all-types-integration + uses: ./.github/workflows/test-redact-secrets.yml + with: + sku: ${{ inputs.sku || vars.DEFAULT_SKU || 'StandardV2' }} + location: ${{ inputs.location || vars.DEFAULT_LOCATION || 'centralus' }} + log_level: ${{ inputs.log_level || vars.DEFAULT_LOG_LEVEL || 'Verbose' }} diff --git a/.github/workflows/integration-redact-secrets.yml b/.github/workflows/test-redact-secrets.yml similarity index 59% rename from .github/workflows/integration-redact-secrets.yml rename to .github/workflows/test-redact-secrets.yml index 983dc6e7..ec227bd2 100644 --- a/.github/workflows/integration-redact-secrets.yml +++ b/.github/workflows/test-redact-secrets.yml @@ -22,9 +22,13 @@ on: type: string default: 'centralus' log_level: - description: 'PowerShell log level (Info, Verbose, Debug)' + description: 'Log level' required: false - type: string + type: choice + options: + - Info + - Verbose + - Debug default: Verbose workflow_call: @@ -40,21 +44,10 @@ on: type: string default: 'centralus' log_level: - description: 'PowerShell log level (Info, Verbose, Debug)' + description: 'Log level' required: false type: string default: Verbose - secrets: - AZURE_CLIENT_ID: - required: true - AZURE_TENANT_ID: - required: true - AZURE_SUBSCRIPTION_ID: - required: true - APIM_PUBLISHER_EMAIL: - required: true - APIM_SKU: - required: false permissions: id-token: write @@ -64,6 +57,10 @@ concurrency: group: integration-redact-secrets cancel-in-progress: false +env: + DEFAULT_LOCATION: centralus + DEFAULT_PUBLISHER_EMAIL: apim-integration-test@contoso.com + jobs: redact-secrets-test: name: Extract Secret Redaction @@ -82,22 +79,17 @@ jobs: - run: npm ci && npm run build - - name: Resolve Workflow Settings - id: settings - shell: pwsh + - name: Validate Required Secrets + shell: bash run: | - $logLevel = '${{ inputs.log_level }}' - if ([string]::IsNullOrWhiteSpace($logLevel)) { $logLevel = 'Verbose' } - if ($logLevel -notin @('Info', 'Verbose', 'Debug')) { - throw "Invalid log_level '$logLevel'. Allowed values: Info, Verbose, Debug." - } - - $skuName = '${{ secrets.APIM_SKU }}' - if ([string]::IsNullOrWhiteSpace($skuName)) { $skuName = '${{ inputs.sku }}' } - if ([string]::IsNullOrWhiteSpace($skuName)) { $skuName = 'StandardV2' } - - "logLevel=$logLevel" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "skuName=$skuName" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + missing=() + [[ -z "${{ secrets.AZURE_CLIENT_ID }}" ]] && missing+=("AZURE_CLIENT_ID") + [[ -z "${{ secrets.AZURE_TENANT_ID }}" ]] && missing+=("AZURE_TENANT_ID") + [[ -z "${{ secrets.AZURE_SUBSCRIPTION_ID }}" ]] && missing+=("AZURE_SUBSCRIPTION_ID") + if [[ ${#missing[@]} -gt 0 ]]; then + echo "::error::Missing required secrets: ${missing[*]}" + exit 1 + fi - name: Azure Login uses: azure/login@v3 @@ -106,6 +98,17 @@ jobs: tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + - name: Validate Azure Region + shell: bash + run: | + LOCATION="${{ inputs.location }}" + if [[ -z "$LOCATION" ]]; then LOCATION="${{ env.DEFAULT_LOCATION }}"; fi + VALID_LOCATIONS=$(az account list-locations --query "[].name" -o tsv) + if ! echo "$VALID_LOCATIONS" | grep -qx "$LOCATION"; then + echo "::error::Invalid Azure region: '$LOCATION'. Run 'az account list-locations --query [].name' to see valid options." + exit 1 + fi + - name: Run redaction integration test shell: pwsh env: @@ -114,7 +117,7 @@ jobs: run: | ./tests/integration/redact-secrets/run-redact-secrets-test.ps1 ` -SourceSubscriptionId '${{ secrets.AZURE_SUBSCRIPTION_ID }}' ` - -PublisherEmail '${{ secrets.APIM_PUBLISHER_EMAIL }}' ` - -SkuName '${{ steps.settings.outputs.skuName }}' ` + -PublisherEmail '${{ secrets.APIM_PUBLISHER_EMAIL || env.DEFAULT_PUBLISHER_EMAIL }}' ` + -SkuName '${{ inputs.sku }}' ` -Location '${{ inputs.location }}' ` - -LogLevel '${{ steps.settings.outputs.logLevel }}' + -LogLevel '${{ inputs.log_level }}' diff --git a/.github/workflows/integration-test.yml b/.github/workflows/test-round-trip.yml similarity index 82% rename from .github/workflows/integration-test.yml rename to .github/workflows/test-round-trip.yml index 35948228..7e4bb652 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/test-round-trip.yml @@ -8,7 +8,7 @@ # # Required secrets (in the 'integration-test' environment): # - AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID (OIDC login) -# - APIM_PUBLISHER_EMAIL +# - APIM_PUBLISHER_EMAIL (optional; defaults to apim-integration-test@contoso.com) # # Required federated credential subject for GitHub OIDC: # - Match the subject claim format configured for this repo/org. @@ -44,9 +44,13 @@ on: type: string default: 'centralus' log_level: - description: 'PowerShell log level (Info, Verbose, Debug)' + description: 'Log level' required: false - type: string + type: choice + options: + - Info + - Verbose + - Debug default: Verbose workflow_call: @@ -62,21 +66,10 @@ on: type: string default: 'centralus' log_level: - description: 'PowerShell log level (Info, Verbose, Debug)' + description: 'Log level' required: false type: string default: Verbose - secrets: - AZURE_CLIENT_ID: - required: true - AZURE_TENANT_ID: - required: true - AZURE_SUBSCRIPTION_ID: - required: true - APIM_PUBLISHER_EMAIL: - required: true - APIM_SKU: - required: false permissions: id-token: write @@ -87,6 +80,8 @@ concurrency: cancel-in-progress: false # Don't cancel — would leave resources running env: + DEFAULT_LOCATION: centralus + DEFAULT_PUBLISHER_EMAIL: apim-integration-test@contoso.com SOURCE_RG: rg-apiops-bvt-src-${{ github.run_id }} TARGET_RG: rg-apiops-bvt-tgt-${{ github.run_id }} SOURCE_APIM: apiops-bvt-src-${{ github.run_id }} @@ -110,22 +105,17 @@ jobs: - run: npm ci && npm run build - - name: Resolve Workflow Settings - id: settings - shell: pwsh + - name: Validate Required Secrets + shell: bash run: | - $logLevel = '${{ inputs.log_level }}' - if ([string]::IsNullOrWhiteSpace($logLevel)) { $logLevel = 'Verbose' } - if ($logLevel -notin @('Info', 'Verbose', 'Debug')) { - throw "Invalid log_level '$logLevel'. Allowed values: Info, Verbose, Debug." - } - - $skuName = '${{ secrets.APIM_SKU }}' - if ([string]::IsNullOrWhiteSpace($skuName)) { $skuName = '${{ inputs.sku }}' } - if ([string]::IsNullOrWhiteSpace($skuName)) { $skuName = 'StandardV2' } - - "logLevel=$logLevel" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "skuName=$skuName" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + missing=() + [[ -z "${{ secrets.AZURE_CLIENT_ID }}" ]] && missing+=("AZURE_CLIENT_ID") + [[ -z "${{ secrets.AZURE_TENANT_ID }}" ]] && missing+=("AZURE_TENANT_ID") + [[ -z "${{ secrets.AZURE_SUBSCRIPTION_ID }}" ]] && missing+=("AZURE_SUBSCRIPTION_ID") + if [[ ${#missing[@]} -gt 0 ]]; then + echo "::error::Missing required secrets: ${missing[*]}" + exit 1 + fi - name: Azure Login uses: azure/login@v3 @@ -134,6 +124,17 @@ jobs: tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + - name: Validate Azure Region + shell: bash + run: | + LOCATION="${{ inputs.location }}" + if [[ -z "$LOCATION" ]]; then LOCATION="${{ env.DEFAULT_LOCATION }}"; fi + VALID_LOCATIONS=$(az account list-locations --query "[].name" -o tsv) + if ! echo "$VALID_LOCATIONS" | grep -qx "$LOCATION"; then + echo "::error::Invalid Azure region: '$LOCATION'. Run 'az account list-locations --query [].name' to see valid options." + exit 1 + fi + - name: Run Round-Trip Phase 1 (Deploy) id: phase1 shell: pwsh @@ -145,10 +146,10 @@ jobs: TargetApimName = '${{ env.TARGET_APIM }}' SourceSubscriptionId = '${{ secrets.AZURE_SUBSCRIPTION_ID }}' TargetSubscriptionId = '${{ secrets.AZURE_SUBSCRIPTION_ID }}' - SkuName = '${{ steps.settings.outputs.skuName }}' + SkuName = '${{ inputs.sku }}' Location = '${{ inputs.location }}' - LogLevel = '${{ steps.settings.outputs.logLevel }}' - PublisherEmail = '${{ secrets.APIM_PUBLISHER_EMAIL }}' + LogLevel = '${{ inputs.log_level }}' + PublisherEmail = '${{ secrets.APIM_PUBLISHER_EMAIL || env.DEFAULT_PUBLISHER_EMAIL }}' } ./tests/integration/all-resource-types/phases/run-phase1-deploy.ps1 @params @@ -174,7 +175,7 @@ jobs: -SourceSubscriptionId '${{ steps.phase1.outputs.sourceSubscriptionId }}' ` -SourceResourceGroup '${{ steps.phase1.outputs.sourceResourceGroup }}' ` -SourceApimName '${{ steps.phase1.outputs.sourceApimName }}' ` - -LogLevel '${{ steps.settings.outputs.logLevel }}' ` + -LogLevel '${{ inputs.log_level }}' ` -ExtractOutputDir './extracted-artifacts' - name: Run Round-Trip Phase 3 (Validate Extract) @@ -186,7 +187,7 @@ jobs: ./tests/integration/all-resource-types/phases/run-phase3-validate-extract.ps1 ` -SkuName $skuName ` - -LogLevel '${{ steps.settings.outputs.logLevel }}' ` + -LogLevel '${{ inputs.log_level }}' ` -ExtractOutputDir $extractOutputDir - name: Run Round-Trip Phase 4 (Create Overrides) @@ -199,7 +200,7 @@ jobs: ./tests/integration/all-resource-types/phases/run-phase4-create-overrides.ps1 ` -TargetSubscriptionId '${{ steps.phase1.outputs.targetSubscriptionId }}' ` -TargetResourceGroup '${{ steps.phase1.outputs.targetResourceGroup }}' ` - -LogLevel '${{ steps.settings.outputs.logLevel }}' ` + -LogLevel '${{ inputs.log_level }}' ` -ExtractOutputDir $extractOutputDir - name: Run Round-Trip Phase 5 (Publish) @@ -216,7 +217,7 @@ jobs: -TargetResourceGroup '${{ steps.phase1.outputs.targetResourceGroup }}' ` -TargetApimName '${{ steps.phase1.outputs.targetApimName }}' ` -OverrideFile '${{ steps.phase4.outputs.overrideFile }}' ` - -LogLevel '${{ steps.settings.outputs.logLevel }}' ` + -LogLevel '${{ inputs.log_level }}' ` -ExtractOutputDir $extractOutputDir - name: Run Round-Trip Phase 6 (Compare) @@ -230,7 +231,7 @@ jobs: -TargetSubscriptionId '${{ steps.phase1.outputs.targetSubscriptionId }}' ` -TargetResourceGroup '${{ steps.phase1.outputs.targetResourceGroup }}' ` -TargetApimName '${{ steps.phase1.outputs.targetApimName }}' ` - -LogLevel '${{ steps.settings.outputs.logLevel }}' + -LogLevel '${{ inputs.log_level }}' - name: Run Round-Trip Phase 7 (Teardown) if: always()