From 2508e79d06fa430461b7ea4a55d85d590fa0dbba Mon Sep 17 00:00:00 2001 From: Elizabeth Maher Date: Mon, 22 Jun 2026 19:51:49 -0700 Subject: [PATCH 01/15] feat(init): rename output files and rewrite manual guides for UI focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename manual guide: IDENTITY-SETUP-GITHUB.md → APIOPS-WORKFLOW-IDENTITY-SETUP.md - Rename manual guide: IDENTITY-SETUP-AZDO.md → APIOPS-PIPELINE-IDENTITY-SETUP.md - Rename prompt: apiops-setup-identity.prompt.md → apiops-setup-{workflow|pipeline}-identity.prompt.md - Rewrite manual guides to focus on web UI steps with documentation links - Add note about identity distinction (Azure access vs PR creation) - Add reference to prompt file in manual guides Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/commands/init.md | 9 +- .../air-gapped-azure-devops-local-registry.md | 18 +- ...air-gapped-azure-devops-offline-tarball.md | 18 +- src/cli/init-command.ts | 10 +- src/services/identity-guide-service.ts | 49 ++-- src/services/init-service.ts | 39 +++- .../identity-setup-prompt-azure-devops.md | 12 +- .../identity-setup-prompt-github-actions.md | 10 + .../copilot/identity-setup-prompt.ts | 30 +++ .../identity/identity-guide-azure-devops.md | 214 +++++++----------- .../identity/identity-guide-github-actions.md | 142 ++++++------ .../identity-setup-azure-devops-core.md | 6 +- .../services/identity-guide-service.test.ts | 59 ++--- tests/unit/services/init-service.test.ts | 38 +++- .../copilot/identity-setup-prompt.test.ts | 17 ++ 15 files changed, 371 insertions(+), 300 deletions(-) diff --git a/docs/commands/init.md b/docs/commands/init.md index 4879c4eb..b039126f 100644 --- a/docs/commands/init.md +++ b/docs/commands/init.md @@ -82,7 +82,8 @@ In interactive mode (the default when running in a terminal), `apiops init` prom | `.github/workflows/run-apim-publisher.yml` | Workflow to publish artifacts to APIM | | `configuration.extractor.yaml` | Sample filter configuration for extraction | | `configuration.{env}.yaml` | Override templates per environment (e.g., `configuration.dev.yaml`, `configuration.prod.yaml`) | -| `IDENTITY-SETUP-GITHUB.md` | Step-by-step guide for configuring federated credentials | +| `.github/prompts/apiops-setup-workflow-identity.prompt.md` | Copilot prompt for GitHub Actions identity setup | +| `APIOPS-WORKFLOW-IDENTITY-SETUP.md` | Step-by-step guide for configuring GitHub Actions Azure access and workflow identity | ### Azure DevOps (`--ci azure-devops`) @@ -92,13 +93,13 @@ In interactive mode (the default when running in a terminal), `apiops init` prom | `.azdo/pipelines/run-apim-publisher.yml` | Pipeline to publish artifacts to APIM | | `configuration.extractor.yaml` | Sample filter configuration for extraction | | `configuration.{env}.yaml` | Override templates per environment | -| `IDENTITY-SETUP-AZDO.md` | Step-by-step guide for configuring service connections | +| `.github/prompts/apiops-setup-pipeline-identity.prompt.md` | Copilot prompt for Azure DevOps identity setup | +| `APIOPS-PIPELINE-IDENTITY-SETUP.md` | Step-by-step guide for configuring Azure DevOps pipeline identity and repo permissions | ### Both platforms | File | Purpose | |------|---------| -| `.github/prompts/apiops-setup-identity.prompt.md` | Copilot prompt for identity setup | | `.github/prompts/apiops-configure-filter.prompt.md` | Copilot prompt for creating extraction filter files | | `.github/prompts/apiops-configure-overrides.prompt.md` | Copilot prompt for creating environment override files | | `/` | Empty artifact directory (default: `./apim-artifacts`) | @@ -118,7 +119,7 @@ If you pass `--cli-package `, the tarball is copied into a `.apiops/` dire ## Next steps after init -1. **Set up identity** — Follow the generated `IDENTITY-SETUP-*.md` guide to configure Azure credentials for your CI/CD platform. Or use the `.github/prompts/apiops-setup-identity.prompt.md` Copilot prompt. +1. **Set up identity** — Follow the generated `APIOPS-*-IDENTITY-SETUP.md` guide or provider-specific Copilot prompt to configure Azure credentials for your CI/CD platform. 2. **Extract your first snapshot** — Run [`apiops extract`](./extract.md) to pull your current APIM configuration into the artifact directory. 3. **Configure filters** — Edit `configuration.extractor.yaml` to control which resources are extracted. Use the `.github/prompts/apiops-configure-filter.prompt.md` Copilot prompt for guided setup. 4. **Commit and push** — Check the generated files into version control. diff --git a/docs/walkthrough/air-gapped-azure-devops-local-registry.md b/docs/walkthrough/air-gapped-azure-devops-local-registry.md index eca55db7..7d7185a2 100644 --- a/docs/walkthrough/air-gapped-azure-devops-local-registry.md +++ b/docs/walkthrough/air-gapped-azure-devops-local-registry.md @@ -162,11 +162,11 @@ This generates: | File | Purpose | |------|---------| | `package.json` | Declares the CLI as a dependency | -| `pipelines/run-extractor.yaml` | Extract pipeline | -| `pipelines/run-publisher.yaml` | Publish pipeline | +| `.azdo/pipelines/run-apim-extractor.yml` | Extract pipeline | +| `.azdo/pipelines/run-apim-publisher.yml` | Publish pipeline | | `configuration.*.yaml` | Override templates | -Follow the remaining instructions listed in created `IDENTITY-SETUP-AZDO.md` or run `/apiops-setup-identity` prompt. This creates the necessary variable groups and and service connections. +Follow the remaining instructions listed in created `APIOPS-PIPELINE-IDENTITY-SETUP.md` or run `/apiops-setup-pipeline-identity` prompt. This creates the necessary variable groups and and service connections. ### 2.2 Generate the Lock File @@ -178,7 +178,7 @@ This creates `package-lock.json`. Commit it — the lock file is **required** fo ### 2.3 Modify Pipelines for Air-Gapped Operation -The generated pipelines (`pipelines/run-extractor.yaml` and `pipelines/run-publisher.yaml`) need the following edits: +The generated pipelines (`.azdo/pipelines/run-apim-extractor.yml` and `.azdo/pipelines/run-apim-publisher.yml`) need the following edits: | Edit | What to Change | |------|----------------| @@ -207,8 +207,8 @@ Commit the files required to run the local-registry workflow on self-hosted agen | `.npmrc` | Points npm to the local Azure Artifacts feed (`registry=...`, `always-auth=true`). | | `package.json` | Declares the CLI dependency. | | `package-lock.json` | Required for deterministic installs with `npm ci`. | -| `pipelines/run-extractor.yaml` | Azure DevOps extract pipeline definition. | -| `pipelines/run-publisher.yaml` | Azure DevOps publish pipeline definition. | +| `.azdo/pipelines/run-apim-extractor.yml` | Azure DevOps extract pipeline definition. | +| `.azdo/pipelines/run-apim-publisher.yml` | Azure DevOps publish pipeline definition. | | `configuration.*.yaml` | Generated environment override templates. | ```bash @@ -216,8 +216,8 @@ git add \ .npmrc \ package.json \ package-lock.json \ - pipelines/run-extractor.yaml \ - pipelines/run-publisher.yaml \ + .azdo/pipelines/run-apim-extractor.yml \ + .azdo/pipelines/run-apim-publisher.yml \ configuration.*.yaml git commit -m "chore: commit local-registry apiops bootstrap files" git push @@ -245,7 +245,7 @@ Verify the following: ## 4 - Finish `apiops init` for pipeline -If not already done, while on the air-gapped network, follow the remaining instructions listed in created `IDENTITY-SETUP-AZDO.md`. This creates the necessary variable groups and and service connections. +If not already done, while on the air-gapped network, follow the remaining instructions listed in created `APIOPS-PIPELINE-IDENTITY-SETUP.md`. This creates the necessary variable groups and and service connections. --- diff --git a/docs/walkthrough/air-gapped-azure-devops-offline-tarball.md b/docs/walkthrough/air-gapped-azure-devops-offline-tarball.md index 6bda8cbe..41c912f9 100644 --- a/docs/walkthrough/air-gapped-azure-devops-offline-tarball.md +++ b/docs/walkthrough/air-gapped-azure-devops-offline-tarball.md @@ -78,11 +78,11 @@ This command generates: | File | Purpose | |------|---------| | `package.json` | Declares the CLI as a `file:` dependency pointing at the tarball | -| `pipelines/run-extractor.yaml` | Extract pipeline | -| `pipelines/run-publisher.yaml` | Publish pipeline | +| `.azdo/pipelines/run-apim-extractor.yml` | Extract pipeline | +| `.azdo/pipelines/run-apim-publisher.yml` | Publish pipeline | | `configuration.*.yaml` | Override templates | -Follow the remaining instructions listed in created `IDENTITY-SETUP-AZDO.md` or run `/apiops-setup-identity` prompt. This creates the necessary variable groups and and service connections. +Follow the remaining instructions listed in created `APIOPS-PIPELINE-IDENTITY-SETUP.md` or run `/apiops-setup-pipeline-identity` prompt. This creates the necessary variable groups and and service connections. ### 2.2 Generate the Lock File and Pre-Stage the npm Cache @@ -97,7 +97,7 @@ npm ci # populates ~/.npm/_cacache/ with every package the lock file refe ### 2.3 Modify Pipelines for Air-Gapped Operation -The generated pipelines (`pipelines/run-extractor.yaml` and `pipelines/run-publisher.yaml`) need the following edits: +The generated pipelines (`.azdo/pipelines/run-apim-extractor.yml` and `.azdo/pipelines/run-apim-publisher.yml`) need the following edits: | Edit | What to Change | |------|----------------| @@ -114,8 +114,8 @@ For the offline-tarball workflow, commit the files that make the pipeline fully | `.apiops/peterhauge-apiops-cli-.tgz` | CLI package consumed by the pipelines. | | `package.json` | Contains the `file:` dependency pointing to the tarball. | | `package-lock.json` | Required for deterministic offline installs with `npm ci --offline`. | -| `pipelines/run-extractor.yaml` | Azure DevOps extract pipeline definition. | -| `pipelines/run-publisher.yaml` | Azure DevOps publish pipeline definition. | +| `.azdo/pipelines/run-apim-extractor.yml` | Azure DevOps extract pipeline definition. | +| `.azdo/pipelines/run-apim-publisher.yml` | Azure DevOps publish pipeline definition. | | `configuration.*.yaml` | Generated environment override templates. | ```bash @@ -123,8 +123,8 @@ git add \ .apiops/peterhauge-apiops-cli-*.tgz \ package.json \ package-lock.json \ - pipelines/run-extractor.yaml \ - pipelines/run-publisher.yaml \ + .azdo/pipelines/run-apim-extractor.yml \ + .azdo/pipelines/run-apim-publisher.yml \ configuration.*.yaml git commit -m "chore: commit offline-tarball apiops bootstrap files" git push @@ -164,7 +164,7 @@ tar -xzf npm-cacache.tar.gz -C ~/.npm ## 4 - Finish `apiops init` for pipeline -If not already done, while on the air-gapped network, follow the remaining instructions listed in created `IDENTITY-SETUP-AZDO.md`. This creates the necessary variable groups and and service connections. +If not already done, while on the air-gapped network, follow the remaining instructions listed in created `APIOPS-PIPELINE-IDENTITY-SETUP.md`. This creates the necessary variable groups and and service connections. ## 5 — Commit and Validate diff --git a/src/cli/init-command.ts b/src/cli/init-command.ts index 7a15e15c..6d815361 100644 --- a/src/cli/init-command.ts +++ b/src/cli/init-command.ts @@ -83,18 +83,18 @@ export function createInitCommand(): Command { generatedFiles.directories.forEach((dir) => logger.info(` - ${dir.startsWith('./') ? dir : './' + dir}`)); // Determine which CI provider was actually used by checking generated files - const isGitHub = allFiles.some((f) => f.includes('IDENTITY-SETUP-GITHUB.md')); + const isGitHub = allFiles.some((f) => f.includes('APIOPS-WORKFLOW-IDENTITY-SETUP.md')); logger.info('\nNext steps:'); logger.info(' 1. Review and customize the generated configuration files'); logger.info(' 2. Commit the generated files to your repository'); logger.info(' 3. Set up CI/CD identity authentication:'); if (isGitHub) { - logger.info(' - Follow ./IDENTITY-SETUP-GITHUB.md for manual setup, OR'); - logger.info(' - Open ./.github/prompts/apiops-setup-identity.prompt.md with GitHub Copilot for guided setup'); + logger.info(' - Follow ./APIOPS-WORKFLOW-IDENTITY-SETUP.md for manual setup, OR'); + logger.info(' - Open ./.github/prompts/apiops-setup-workflow-identity.prompt.md with GitHub Copilot for guided setup'); } else { - logger.info(' - Follow ./IDENTITY-SETUP-AZDO.md for manual setup, OR'); - logger.info(' - Open ./.github/prompts/apiops-setup-identity.prompt.md with GitHub Copilot for guided setup'); + logger.info(' - Follow ./APIOPS-PIPELINE-IDENTITY-SETUP.md for manual setup, OR'); + logger.info(' - Open ./.github/prompts/apiops-setup-pipeline-identity.prompt.md with GitHub Copilot for guided setup'); } logger.info(''); } catch (error) { diff --git a/src/services/identity-guide-service.ts b/src/services/identity-guide-service.ts index 366c1839..06328e13 100644 --- a/src/services/identity-guide-service.ts +++ b/src/services/identity-guide-service.ts @@ -7,7 +7,6 @@ */ import { - azureDevOpsIdentitySetupCoreTemplate, azureDevOpsIdentityGuideTemplate, githubActionsIdentityGuideTemplate, } from '../templates/generated/embedded-markdown.js'; @@ -31,46 +30,44 @@ class IdentityGuideServiceImpl implements IdentityGuideService { resourceGroup: string, environments: string[] ): string { - const federatedCredentialsPerEnvironment = environments.map((env) => `az ad app federated-credential create \\ - --id "$APP_ID" \\ - --parameters '{ - "name": "github-env-${env}", - "issuer": "https://token.actions.githubusercontent.com", - "subject": "repo:'"$GITHUB_ORG"'/'"$GITHUB_REPO"':environment:${env}", - "audiences": ["api://AzureADTokenExchange"] - }'`).join('\n\n'); + const federatedCredentialRows = environments.map((env) => + `| \`${env}\` | \`github-env-${env}\` | \`repo:/:environment:${env}\` |` + ).join('\n'); - const environmentSecrets = environments.map((env) => ` -**For ${env} environment:** -- \`APIM_RESOURCE_GROUP_${env.toUpperCase()}\`: Resource group for ${env} -- \`APIM_SERVICE_NAME_${env.toUpperCase()}\`: APIM service name for ${env} + const environmentSecrets = environments.map((env) => `### ${env} environment +- Create a GitHub environment named \`${env}\` in **Settings** → **Environments** +- Add environment secret \`AZURE_SUBSCRIPTION_ID\` with the subscription ID used for ${env} +- Add environment secret \`APIM_RESOURCE_GROUP_${env.toUpperCase()}\` with the APIM resource group for ${env} +- Add environment secret \`APIM_SERVICE_NAME_${env.toUpperCase()}\` with the APIM service name for ${env} `).join('\n'); + const environmentAzureAccessNotes = environments.map((env) => + `- **${env}** — assign **Reader** on the resource group you will store in \`APIM_RESOURCE_GROUP_${env.toUpperCase()}\`, then assign **API Management Service Contributor** on the APIM instance you will store in \`APIM_SERVICE_NAME_${env.toUpperCase()}\`.` + ).join('\n'); + return renderTemplate(githubActionsIdentityGuideTemplate, { SUBSCRIPTION_ID: subscriptionId, RESOURCE_GROUP: resourceGroup, - FEDERATED_CREDENTIALS_PER_ENV: federatedCredentialsPerEnvironment, - ENVIRONMENT_SECRETS: environmentSecrets, + FEDERATED_CREDENTIAL_ROWS: federatedCredentialRows, + ENVIRONMENT_SECRET_SECTIONS: environmentSecrets, + ENVIRONMENT_AZURE_ACCESS_NOTES: environmentAzureAccessNotes, }); } generateAzureDevOpsGuide( environments: string[] ): string { - const environmentsArrayPowerShell = environments - .map((environment) => `"${environment}"`) - .join(', '); - const environmentsArrayBash = environments - .map((environment) => `"${environment}"`) - .join(' '); + const serviceConnectionRows = environments.map((environment) => + `| \`${environment}\` | \`AZURE_SERVICE_CONNECTION_${environment.toUpperCase()}\` | Azure Resource Manager → Workload identity federation → subscription that contains the ${environment} APIM instance |` + ).join('\n'); - const coreSteps = renderTemplate(azureDevOpsIdentitySetupCoreTemplate, { - ENVIRONMENTS_ARRAY_POWERSHELL: environmentsArrayPowerShell, - ENVIRONMENTS_ARRAY_BASH: environmentsArrayBash, - }); + const variableGroupRows = environments.map((environment) => + `| \`${environment}\` | \`apim-${environment}\` | \`AZURE_SERVICE_CONNECTION\`, \`AZURE_SUBSCRIPTION_ID\`, \`APIM_RESOURCE_GROUP\`, \`APIM_SERVICE_NAME\`, \`APIM_RESOURCE_GROUP_${environment.toUpperCase()}\`, \`APIM_SERVICE_NAME_${environment.toUpperCase()}\` |` + ).join('\n'); return renderTemplate(azureDevOpsIdentityGuideTemplate, { - AZURE_DEVOPS_CORE_STEPS: coreSteps, + SERVICE_CONNECTION_ROWS: serviceConnectionRows, + VARIABLE_GROUP_ROWS: variableGroupRows, }); } } diff --git a/src/services/init-service.ts b/src/services/init-service.ts index 210d6637..9cd07653 100644 --- a/src/services/init-service.ts +++ b/src/services/init-service.ts @@ -145,6 +145,10 @@ class InitServiceImpl implements InitService { '.github/workflows/run-apim-publisher.yml' ); const promptFile = path.join( + config.outputDir, + '.github/prompts/apiops-setup-workflow-identity.prompt.md' + ); + const legacyPromptFile = path.join( config.outputDir, '.github/prompts/apiops-setup-identity.prompt.md' ); @@ -157,6 +161,10 @@ class InitServiceImpl implements InitService { '.github/prompts/apiops-configure-overrides.prompt.md' ); const identityGuide = path.join( + config.outputDir, + 'APIOPS-WORKFLOW-IDENTITY-SETUP.md' + ); + const legacyIdentityGuide = path.join( config.outputDir, 'IDENTITY-SETUP-GITHUB.md' ); @@ -176,9 +184,15 @@ class InitServiceImpl implements InitService { if (await this.fileExists(overridesPromptFile)) { conflictingFiles.push(overridesPromptFile); } + if (await this.fileExists(legacyPromptFile)) { + conflictingFiles.push(legacyPromptFile); + } if (await this.fileExists(identityGuide)) { conflictingFiles.push(identityGuide); } + if (await this.fileExists(legacyIdentityGuide)) { + conflictingFiles.push(legacyIdentityGuide); + } } else if (config.ciProvider === 'azure-devops') { const extractPipeline = path.join( config.outputDir, @@ -189,10 +203,18 @@ class InitServiceImpl implements InitService { '.azdo/pipelines/run-apim-publisher.yml' ); const identityGuide = path.join( + config.outputDir, + 'APIOPS-PIPELINE-IDENTITY-SETUP.md' + ); + const legacyIdentityGuide = path.join( config.outputDir, 'IDENTITY-SETUP-AZDO.md' ); const promptFile = path.join( + config.outputDir, + '.github/prompts/apiops-setup-pipeline-identity.prompt.md' + ); + const legacyPromptFile = path.join( config.outputDir, '.github/prompts/apiops-setup-identity.prompt.md' ); @@ -214,6 +236,9 @@ class InitServiceImpl implements InitService { if (await this.fileExists(identityGuide)) { conflictingFiles.push(identityGuide); } + if (await this.fileExists(legacyIdentityGuide)) { + conflictingFiles.push(legacyIdentityGuide); + } if (await this.fileExists(promptFile)) { conflictingFiles.push(promptFile); } @@ -223,6 +248,9 @@ class InitServiceImpl implements InitService { if (await this.fileExists(overridesPromptFile)) { conflictingFiles.push(overridesPromptFile); } + if (await this.fileExists(legacyPromptFile)) { + conflictingFiles.push(legacyPromptFile); + } } // Check for config files @@ -394,9 +422,12 @@ class InitServiceImpl implements InitService { }); const promptsDir = path.join(config.outputDir, '.github/prompts'); await fs.mkdir(promptsDir, { recursive: true }); - const promptPath = path.join(promptsDir, 'apiops-setup-identity.prompt.md'); + const promptFileName = config.ciProvider === 'github-actions' + ? 'apiops-setup-workflow-identity.prompt.md' + : 'apiops-setup-pipeline-identity.prompt.md'; + const promptPath = path.join(promptsDir, promptFileName); await fs.writeFile(promptPath, promptContent); - generatedFiles.configs.push('.github/prompts/apiops-setup-identity.prompt.md'); + generatedFiles.configs.push(`.github/prompts/${promptFileName}`); } private async generateCopilotConfigurationPrompts( @@ -468,8 +499,8 @@ class InitServiceImpl implements InitService { // Save guide to file const guideFileName = config.ciProvider === 'github-actions' - ? 'IDENTITY-SETUP-GITHUB.md' - : 'IDENTITY-SETUP-AZDO.md'; + ? 'APIOPS-WORKFLOW-IDENTITY-SETUP.md' + : 'APIOPS-PIPELINE-IDENTITY-SETUP.md'; const guidePath = path.join(config.outputDir, guideFileName); await fs.writeFile(guidePath, guide); generatedFiles.configs.push(guideFileName); diff --git a/src/templates/copilot/identity-setup-prompt-azure-devops.md b/src/templates/copilot/identity-setup-prompt-azure-devops.md index 9db87a19..88799b56 100644 --- a/src/templates/copilot/identity-setup-prompt-azure-devops.md +++ b/src/templates/copilot/identity-setup-prompt-azure-devops.md @@ -4,6 +4,8 @@ > Copilot to help you run through the steps. Copilot will prompt you for > required values and generate exact CLI commands for your environment. +> **Important identity distinction:** The Azure app registration/service principal created in this flow is only for Azure and APIM access. Repository contributions and pull request creation come from the Azure DevOps Build Service identity, which must be granted repo permissions separately. + ## Goal Configure workload identity federation (OIDC), Azure DevOps federated service connections, @@ -88,4 +90,12 @@ az pipelines create --name "apiops-publish" --repository "$REPO_NAME" --branch m Verify pipelines were created: ```bash az pipelines list --query "[].name" -o table -``` \ No newline at end of file +``` + +--- + +## UI Reference Context + +Use this UI-oriented reference when you need to explain where a setting lives in the Azure portal or Azure DevOps web portal while automating the setup. + +{{IDENTITY_GUIDE_CONTEXT}} \ No newline at end of file diff --git a/src/templates/copilot/identity-setup-prompt-github-actions.md b/src/templates/copilot/identity-setup-prompt-github-actions.md index e2f28e31..f3a2dd61 100644 --- a/src/templates/copilot/identity-setup-prompt-github-actions.md +++ b/src/templates/copilot/identity-setup-prompt-github-actions.md @@ -4,6 +4,8 @@ > Copilot to help you run through the steps. Copilot will prompt you for > the required values and generate the exact CLI commands for your environment. +> **Important identity distinction:** `GITHUB_TOKEN` handles pull request creation automatically. The Azure app registration/service principal in this flow is only for Azure and APIM access. + ## Goal Configure Azure AD federated credentials and GitHub repository secrets so the @@ -224,3 +226,11 @@ The generated workflows expect these secrets: ### Per-Environment Secrets {{ENV_SECRETS_REFERENCE}} + +--- + +## UI Reference Context + +Use this UI-oriented reference when you need to explain where a setting lives in the Azure portal or GitHub web UI while automating the setup. + +{{IDENTITY_GUIDE_CONTEXT}} diff --git a/src/templates/copilot/identity-setup-prompt.ts b/src/templates/copilot/identity-setup-prompt.ts index 41734811..8e190824 100644 --- a/src/templates/copilot/identity-setup-prompt.ts +++ b/src/templates/copilot/identity-setup-prompt.ts @@ -11,10 +11,12 @@ import { azureDevOpsIdentitySetupCoreTemplate, + azureDevOpsIdentityGuideTemplate, copilotAzureDevOpsIdentitySetupPromptTemplate, copilotGithubEnvironmentFederatedCredentialTemplate, copilotGithubEnvironmentSecretCommandsTemplate, copilotGitHubActionsIdentitySetupPromptTemplate, + githubActionsIdentityGuideTemplate, } from '../generated/embedded-markdown.js'; import { renderTemplate } from '../../lib/render-template.js'; @@ -37,8 +39,18 @@ export function generateIdentitySetupPrompt(config: IdentitySetupPromptConfig): ENVIRONMENTS_ARRAY_BASH: environmentsArrayBash, }); + const manualGuideContext = renderTemplate(azureDevOpsIdentityGuideTemplate, { + SERVICE_CONNECTION_ROWS: config.environments.map((environment) => + `| \`${environment}\` | \`AZURE_SERVICE_CONNECTION_${environment.toUpperCase()}\` | Azure Resource Manager → Workload identity federation → subscription that contains the ${environment} APIM instance |` + ).join('\n'), + VARIABLE_GROUP_ROWS: config.environments.map((environment) => + `| \`${environment}\` | \`apim-${environment}\` | \`AZURE_SERVICE_CONNECTION\`, \`AZURE_SUBSCRIPTION_ID\`, \`APIM_RESOURCE_GROUP\`, \`APIM_SERVICE_NAME\`, \`APIM_RESOURCE_GROUP_${environment.toUpperCase()}\`, \`APIM_SERVICE_NAME_${environment.toUpperCase()}\` |` + ).join('\n'), + }); + return renderTemplate(copilotAzureDevOpsIdentitySetupPromptTemplate, { AZURE_DEVOPS_CORE_STEPS: coreSteps, + IDENTITY_GUIDE_CONTEXT: manualGuideContext, }); } @@ -95,6 +107,23 @@ az role assignment create \\ \`\`\`` ).join('\n\n'); + const manualGuideContext = renderTemplate(githubActionsIdentityGuideTemplate, { + SUBSCRIPTION_ID: '', + RESOURCE_GROUP: '', + FEDERATED_CREDENTIAL_ROWS: config.environments.map((env) => + `| \`${env}\` | \`github-env-${env}\` | \`repo:/:environment:${env}\` |` + ).join('\n'), + ENVIRONMENT_AZURE_ACCESS_NOTES: config.environments.map((env) => + `- **${env}** — assign **Reader** on the resource group you will store in \`APIM_RESOURCE_GROUP_${env.toUpperCase()}\`, then assign **API Management Service Contributor** on the APIM instance you will store in \`APIM_SERVICE_NAME_${env.toUpperCase()}\`.` + ).join('\n'), + ENVIRONMENT_SECRET_SECTIONS: config.environments.map((env) => `### ${env} environment +- Create a GitHub environment named \`${env}\` in **Settings** → **Environments** +- Add environment secret \`AZURE_SUBSCRIPTION_ID\` with the subscription ID used for ${env} +- Add environment secret \`APIM_RESOURCE_GROUP_${env.toUpperCase()}\` with the APIM resource group for ${env} +- Add environment secret \`APIM_SERVICE_NAME_${env.toUpperCase()}\` with the APIM service name for ${env} +`).join('\n'), + }); + return renderTemplate(copilotGitHubActionsIdentitySetupPromptTemplate, { ENV_SECRETS_REFERENCE: envSecrets, ENV_FEDERATED_CREDENTIALS: envFedCreds, @@ -104,5 +133,6 @@ az role assignment create \\ ENV_APIM_TABLE_ROWS: envApimTableRows, ENV_READER_ROLE_SNIPPETS: envReaderRoleSnippets, ENV_APIM_ROLE_SNIPPETS: envApimRoleSnippets, + IDENTITY_GUIDE_CONTEXT: manualGuideContext, }); } diff --git a/src/templates/identity/identity-guide-azure-devops.md b/src/templates/identity/identity-guide-azure-devops.md index 0643b2c1..75d2e270 100644 --- a/src/templates/identity/identity-guide-azure-devops.md +++ b/src/templates/identity/identity-guide-azure-devops.md @@ -1,164 +1,114 @@ -# Azure DevOps Identity Setup Guide +# APIOps Azure DevOps identity setup guide -{{AZURE_DEVOPS_CORE_STEPS}} +> Prefer an automated walkthrough? Open `.github/prompts/apiops-setup-pipeline-identity.prompt.md` in VS Code with GitHub Copilot and ask Copilot to guide or automate the setup for you. -## Step 10: Enable Pipeline Contributions +> **Important identity distinction:** In Azure DevOps, the Azure app registration/service principal is for Azure and APIM access only. Repository contributions and pull request creation come from the project **Build Service identity**, which must be granted repository permissions separately. -Grant the Build Service permission to contribute to the repository. This allows pipelines to push commits (e.g., extracted API artifacts). +## Before you start -First, get the project and repository IDs: +- Access to the **Azure portal** +- Access to the **Azure DevOps web portal** +- Permission to create or manage app registrations in Microsoft Entra ID +- Permission to create Azure DevOps service connections, variable groups, environments, and pipelines -**PowerShell:** -```powershell -$PROJECT_ID = az devops project show --project $AZDO_PROJECT --query id -o tsv -$REPO_NAME = $AZDO_PROJECT # Change if your repo name differs from project name -$REPO_ID = az repos show --repository $REPO_NAME --query id -o tsv -``` +Helpful documentation: -**Git Bash:** -```bash -PROJECT_ID=$(az devops project show --project "$AZDO_PROJECT" --query id -o tsv) -REPO_NAME="$AZDO_PROJECT" # Change if your repo name differs from project name -REPO_ID=$(az repos show --repository "$REPO_NAME" --query id -o tsv) -``` +- [Create and manage app registrations in the Azure portal](https://learn.microsoft.com/entra/identity-platform/quickstart-register-app) +- [Assign Azure roles using the Azure portal](https://learn.microsoft.com/azure/role-based-access-control/role-assignments-portal) +- [Create Azure Resource Manager service connections with workload identity federation](https://learn.microsoft.com/azure/devops/pipelines/library/connect-to-azure) +- [Create variable groups in Azure Pipelines](https://learn.microsoft.com/azure/devops/pipelines/library/variable-groups) +- [Set repository permissions in Azure DevOps](https://learn.microsoft.com/azure/devops/repos/git/set-git-repository-permissions) -Next, find the Build Service identity descriptor: +## Step 1: Review the generated pipeline files in your repo -**PowerShell:** -```powershell -$GRAPH_USERS = az devops invoke --area graph --resource users --query-parameters 'api-version=7.1-preview.1' --http-method GET -o json | ConvertFrom-Json -$BUILD_SERVICE_NAME = "$AZDO_PROJECT Build Service ($ORG_NAME)" -$BUILD_SERVICE_DESCRIPTOR = ($GRAPH_USERS.value | Where-Object { $_.displayName -eq $BUILD_SERVICE_NAME }).descriptor -``` +1. Commit and push the generated files to your Azure DevOps repository. +2. Confirm these files are available in the repo browser: + - `.azdo/pipelines/run-apim-extractor.yml` + - `.azdo/pipelines/run-apim-publisher.yml` +3. Note the environment names you chose when running `apiops init` because you will create matching service connections, variable groups, and approvals. -**Git Bash:** -```bash -BUILD_SERVICE_NAME="$AZDO_PROJECT Build Service ($ORG_NAME)" -BUILD_SERVICE_DESCRIPTOR=$(az devops invoke --area graph --resource users --query-parameters 'api-version=7.1-preview.1' --http-method GET -o json | grep -B5 "\"displayName\": \"$BUILD_SERVICE_NAME\"" | grep '"descriptor"' | head -1 | cut -d'"' -f4) -``` +## Step 2: Create or choose an app registration in Azure portal -Finally, grant the Contribute permission (bit 4) on the repository: +1. Open the **Azure portal**. +2. Go to **Microsoft Entra ID** → **App registrations** → **New registration**. +3. Enter a friendly name such as `apiops-azdo-sp`. +4. Register the application and open its overview page. +5. Record: + - **Application (client) ID** + - **Directory (tenant) ID** +6. You do not need to create a client secret for this workload identity federation flow. -**PowerShell:** -```powershell -$GIT_REPOS_NAMESPACE = az devops security permission namespace list --query "[?name=='Git Repositories'].namespaceId" -o tsv -$TOKEN = "repoV2/$PROJECT_ID/$REPO_ID" -az devops security permission update --namespace-id $GIT_REPOS_NAMESPACE --subject $BUILD_SERVICE_DESCRIPTOR --token $TOKEN --allow-bit 4 -``` +## Step 3: Grant the Azure identity access to each APIM environment -**Git Bash:** -```bash -GIT_REPOS_NAMESPACE=$(az devops security permission namespace list --query "[?name=='Git Repositories'].namespaceId" -o tsv) -TOKEN="repoV2/$PROJECT_ID/$REPO_ID" -az devops security permission update --namespace-id "$GIT_REPOS_NAMESPACE" --subject "$BUILD_SERVICE_DESCRIPTOR" --token "$TOKEN" --allow-bit 4 -``` +For each environment, use the **Azure portal** to grant: -Verify the permission was set: +- **Reader** on the resource group that contains the APIM instance +- **API Management Service Contributor** on the APIM service itself -**PowerShell:** -```powershell -az devops security permission show --namespace-id $GIT_REPOS_NAMESPACE --subject $BUILD_SERVICE_DESCRIPTOR --token $TOKEN --query "[].acesDictionary.*.resolvedPermissions" -o json -``` +Use **Access control (IAM)** on each resource group and APIM instance to create the assignments. -**Git Bash:** -```bash -az devops security permission show --namespace-id "$GIT_REPOS_NAMESPACE" --subject "$BUILD_SERVICE_DESCRIPTOR" --token "$TOKEN" --query "[].acesDictionary.*.resolvedPermissions" -o json -``` +## Step 4: Create one Azure service connection per environment ---- +1. In **Azure DevOps**, open **Project settings** → **Service connections** → **New service connection**. +2. Choose **Azure Resource Manager**. +3. Select **Workload identity federation**. +4. Use the app registration from Step 2. +5. Create one service connection per environment using the exact names below unless you also plan to edit the generated pipeline YAML and variable-group values: -## Step 11: Verify Setup +| Environment | Required service connection name | Scope guidance | +|---|---|---| +{{SERVICE_CONNECTION_ROWS}} -Verify all resources were created correctly: +6. Complete the validation flow in Azure DevOps so the service connection can issue the issuer and subject values required for federation. +7. If Azure DevOps asks you to finish the federated credential in Azure, follow the linked experience or copy the issuer/subject into the app registration's **Federated credentials** blade in the Azure portal. -**Service Connections:** -```bash -az devops service-endpoint list --query "[].name" -o table -``` +## Step 5: Create variable groups in Azure DevOps -**Variable Groups:** -```bash -az pipelines variable-group list --query "[].name" -o table -``` +1. Go to **Pipelines** → **Library** → **+ Variable group**. +2. Create one variable group per environment: -**Environments:** +| Environment | Variable group name | Required variables | +|---|---|---| +{{VARIABLE_GROUP_ROWS}} -**PowerShell:** -```powershell -(az devops invoke --area environments --resource environments --route-parameters project=$AZDO_PROJECT --http-method GET --api-version 7.1 -o json | ConvertFrom-Json).value | Select-Object name -``` +3. Include both the non-suffixed variables used by the extractor pipeline and the environment-suffixed APIM variables used by the publish pipeline. +4. Authorize each variable group for pipeline use when Azure DevOps prompts you. -**Git Bash:** -```bash -az devops invoke --area environments --resource environments --route-parameters project="$AZDO_PROJECT" --http-method GET --api-version 7.1 -o json | grep -o '"name": *"[^"]*"' | cut -d'"' -f4 -``` +## Step 6: Create Azure DevOps environments and approvals -**Service Principal Role Assignment:** +1. Go to **Pipelines** → **Environments**. +2. Create an environment for each deployment target used by the publish pipeline. +3. Add approvals and checks for production or other protected environments as needed by your release process. -**PowerShell:** -```powershell -az role assignment list --assignee $APP_ID --query "[].{Role:roleDefinitionName, Scope:scope}" -o table -``` +## Step 7: Grant repository permissions to the Build Service identity -**Git Bash:** -```bash -az role assignment list --assignee "$APP_ID" --query "[].{Role:roleDefinitionName, Scope:scope}" -o table -``` +1. In **Azure DevOps**, open **Project settings** → **Repositories** → your repository → **Security**. +2. Find the entry named **` Build Service ()`**. +3. Grant the minimum permissions required for your workflow, typically: + - **Contribute** + - **Create branch** + - **Create pull request** +4. If the pipeline can read APIM but cannot push extracted artifacts or open a PR, this is usually the missing step because the Build Service identity is separate from the Azure app registration. -**Final Test:** Run the extract pipeline manually to verify end-to-end authentication and permissions. +## Step 8: Create the pipelines in Azure DevOps ---- +1. Go to **Pipelines** → **Create Pipeline**. +2. Choose your repository. +3. Select **Existing Azure Pipelines YAML file**. +4. Create a pipeline for each generated YAML file: + - `.azdo/pipelines/run-apim-extractor.yml` + - `.azdo/pipelines/run-apim-publisher.yml` +5. Save without running if you still need approvals or variable authorization. -## Step 12: Create Pipelines +## Step 9: Verify the setup -Create Azure Pipelines from the YAML files in your repository. +1. Run the extractor pipeline manually. +2. Confirm the pipeline can authenticate to Azure and read/write the expected APIM resources. +3. Confirm it can push artifacts and create a pull request in Azure DevOps. +4. If Azure access works but PR creation fails, re-check repository permissions for the Build Service identity. -**Prerequisites:** Ensure your pipeline YAML files are committed to the repository (e.g., `.azdo/pipelines/run-apim-extractor.yml`, `.azdo/pipelines/run-apim-publisher.yml`). +## Security notes -**Create Extract Pipeline:** - -**PowerShell:** -```powershell -az pipelines create --name "apiops-extract" --repository $REPO_NAME --branch main --yml-path ".azdo/pipelines/run-apim-extractor.yml" --repository-type tfsgit --skip-first-run true -``` - -**Git Bash:** -```bash -az pipelines create --name "apiops-extract" --repository "$REPO_NAME" --branch main --yml-path ".azdo/pipelines/run-apim-extractor.yml" --repository-type tfsgit --skip-first-run true -``` - -**Create Publish Pipeline:** - -**PowerShell:** -```powershell -az pipelines create --name "apiops-publish" --repository $REPO_NAME --branch main --yml-path ".azdo/pipelines/run-apim-publisher.yml" --repository-type tfsgit --skip-first-run true -``` - -**Git Bash:** -```bash -az pipelines create --name "apiops-publish" --repository "$REPO_NAME" --branch main --yml-path ".azdo/pipelines/run-apim-publisher.yml" --repository-type tfsgit --skip-first-run true -``` - -**Verify pipelines were created:** -```bash -az pipelines list --query "[].name" -o table -``` - -**Run the extract pipeline:** - -**PowerShell:** -```powershell -az pipelines run --name "apiops-extract" -``` - -**Git Bash:** -```bash -az pipelines run --name "apiops-extract" -``` - ---- - -## Security Notes -- Enable environment approvals for production deployments -- Prefer workload identity federation (OIDC) over client secrets -- Review RBAC assignments regularly +- Prefer workload identity federation over client secrets. +- Scope Azure roles and service connections only to the subscriptions and APIM instances each environment needs. +- Review Build Service repository permissions regularly and remove extra rights if they are no longer needed. diff --git a/src/templates/identity/identity-guide-github-actions.md b/src/templates/identity/identity-guide-github-actions.md index 4cf2c5ca..af901ed5 100644 --- a/src/templates/identity/identity-guide-github-actions.md +++ b/src/templates/identity/identity-guide-github-actions.md @@ -1,96 +1,94 @@ -# GitHub Actions Identity Setup Guide +# APIOps GitHub Actions identity setup guide -## Prerequisites -- Azure subscription: {{SUBSCRIPTION_ID}} -- Resource group: {{RESOURCE_GROUP}} -- GitHub repository with OIDC enabled +> Prefer an automated walkthrough? Open `.github/prompts/apiops-setup-workflow-identity.prompt.md` in VS Code with GitHub Copilot and ask Copilot to guide or automate the setup for you. -## Step 1: Create Service Principal +> **Important identity distinction:** In GitHub Actions, the Azure app registration/service principal is only for Azure and APIM access. Pull request creation is handled separately by the workflow's `GITHUB_TOKEN`, so Azure RBAC alone will not give the workflow permission to open PRs. -Run the following Azure CLI commands to create a service principal with federated credentials: +## Before you start -```bash -# Set variables -SUBSCRIPTION_ID="{{SUBSCRIPTION_ID}}" -RESOURCE_GROUP="{{RESOURCE_GROUP}}" -APP_NAME="apiops-github-sp" -GITHUB_ORG="" -GITHUB_REPO="" +- Azure subscription: `{{SUBSCRIPTION_ID}}` +- Resource group containing an APIM instance: `{{RESOURCE_GROUP}}` +- GitHub repository admin access +- Permission in Microsoft Entra ID to create or manage app registrations -# Create Azure AD Application -APP_ID=$(az ad app create \ - --display-name "$APP_NAME" \ - --query appId -o tsv) +Helpful documentation: -# Create Service Principal -az ad sp create --id "$APP_ID" +- [Use OpenID Connect with Azure Login in GitHub Actions](https://learn.microsoft.com/azure/developer/github/connect-from-azure-openid-connect) +- [Create and manage app registrations in the Azure portal](https://learn.microsoft.com/entra/identity-platform/quickstart-register-app) +- [Assign Azure roles using the Azure portal](https://learn.microsoft.com/azure/role-based-access-control/role-assignments-portal) +- [Manage GitHub Actions secrets](https://docs.github.com/actions/security-guides/encrypted-secrets) +- [Manage GitHub environments](https://docs.github.com/actions/deployment/targeting-different-environments/using-environments-for-deployment) -# Get Service Principal Object ID -SP_OBJECT_ID=$(az ad sp show --id "$APP_ID" --query id -o tsv) +## Step 1: Review the generated workflow files in GitHub -echo "Application (client) ID: $APP_ID" -echo "Service Principal Object ID: $SP_OBJECT_ID" -``` +1. Commit and push the generated files to your repository. +2. Open your repository in the **GitHub web UI**. +3. Go to **Actions** and confirm you can see: + - **Run APIM Extractor** + - **Run APIM Publisher** +4. If your organization requires approval for new workflows, complete that approval before continuing. -## Step 2: Assign RBAC Roles +## Step 2: Create or choose an app registration in Azure portal -Grant the service principal "API Management Service Contributor" role on your APIM instance: +1. Open the **Azure portal**. +2. Go to **Microsoft Entra ID** → **App registrations** → **New registration**. +3. Enter a friendly name such as `apiops-github-sp`. +4. Register the application and open its overview page. +5. Record these values for later: + - **Application (client) ID** + - **Directory (tenant) ID** -```bash -# Get APIM resource ID -APIM_RESOURCE_ID=$(az apim show \ - --resource-group "$RESOURCE_GROUP" \ - --name "" \ - --query id -o tsv) +## Step 3: Grant the Azure identity access to APIM -# Assign role -az role assignment create \ - --assignee "$APP_ID" \ - --role "API Management Service Contributor" \ - --scope "$APIM_RESOURCE_ID" -``` +1. In the **Azure portal**, open your API Management service. +2. Go to **Access control (IAM)** → **Add role assignment**. +3. For every environment configured by `apiops init`, assign access to the app registration or service principal: +{{ENVIRONMENT_AZURE_ACCESS_NOTES}} +4. If you are starting with a single shared APIM resource in resource group `{{RESOURCE_GROUP}}` and subscription `{{SUBSCRIPTION_ID}}`, those placeholders are the first values to replace. +5. Wait a few minutes for RBAC changes to propagate before you test the workflow. -## Step 3: Configure Federated Credentials +## Step 4: Add federated credentials in Azure portal -Set up OIDC federation for GitHub Actions: +1. Return to **Microsoft Entra ID** → **App registrations** → your app. +2. Open **Certificates & secrets** → **Federated credentials** → **Add credential**. +3. Add one credential for the main branch publish workflow: + - **Scenario**: **GitHub Actions deploying Azure resources** + - **Organization**: your GitHub org or user + - **Repository**: your repository + - **Entity type**: **Branch** + - **Branch name**: `main` + - **Credential name**: `github-main-branch` +4. Add one credential for each GitHub environment used by publish workflows: -```bash -# For main branch deployments -az ad app federated-credential create \ - --id "$APP_ID" \ - --parameters '{ - "name": "github-main-branch", - "issuer": "https://token.actions.githubusercontent.com", - "subject": "repo:'"$GITHUB_ORG"'/'"$GITHUB_REPO"':ref:refs/heads/main", - "audiences": ["api://AzureADTokenExchange"] - }' +| GitHub environment | Suggested credential name | Subject value | +|---|---|---| +{{FEDERATED_CREDENTIAL_ROWS}} -# For environment deployments (repeat for each environment) -{{FEDERATED_CREDENTIALS_PER_ENV}} -``` +## Step 5: Create GitHub environments in the web UI -## Step 4: Configure GitHub Secrets +1. In GitHub, go to **Settings** → **Environments**. +2. Create an environment for each deployment target used by `apiops init`. +3. Add protection rules such as required reviewers for production environments if your process needs approvals. -Add the following secrets to your GitHub repository (Settings → Secrets and variables → Actions): +## Step 6: Add repository and environment secrets in GitHub -### Repository Secrets: -- `AZURE_CLIENT_ID`: $APP_ID (from Step 1) -- `AZURE_TENANT_ID`: Run `az account show --query tenantId -o tsv` -- `AZURE_SUBSCRIPTION_ID`: {{SUBSCRIPTION_ID}} +1. Go to **Settings** → **Secrets and variables** → **Actions**. +2. Under **Repository secrets**, create: + - `AZURE_CLIENT_ID` — the Application (client) ID from the app registration + - `AZURE_TENANT_ID` — the Directory (tenant) ID from the app registration +3. Then configure the environment secrets below: -### Environment-Specific Secrets: -{{ENVIRONMENT_SECRETS}} +{{ENVIRONMENT_SECRET_SECTIONS}} -### Extract Workflow Secrets: -- `APIM_RESOURCE_GROUP`: Default resource group for extract -- `APIM_SERVICE_NAME`: Default APIM service name for extract +## Step 7: Verify the workflow end to end -## Step 5: Verify Setup - -Test the authentication by running a workflow manually or pushing to main branch. +1. Go to **Actions** → **Run APIM Extractor** → **Run workflow**. +2. Select the environment you configured in GitHub and choose whether to run **Extract All APIs** or use `configuration.extractor.yaml`. +3. Confirm the workflow can authenticate to Azure, export artifacts, and create a pull request. +4. If the Azure login succeeds but the PR step fails, review repository permissions for `GITHUB_TOKEN` and any branch protection rules because PR creation is not controlled by the Azure identity. ## Security Notes -- Use GitHub Environments for production deployments with required reviewers -- Review federated credential subjects periodically (no secrets to rotate — OIDC authentication has no stored credentials) -- Review RBAC role assignments regularly and remove any no longer needed -- Use least-privilege RBAC assignments + +- Prefer OIDC/federated credentials over client secrets so there is no stored Azure credential to rotate. +- Grant the workflow identity only the Azure roles it actually needs by following a least-privilege approach. +- Periodically review GitHub environments, repository secrets, and Azure role assignments. diff --git a/src/templates/shared/identity-setup-azure-devops-core.md b/src/templates/shared/identity-setup-azure-devops-core.md index b876fec8..3216a80b 100644 --- a/src/templates/shared/identity-setup-azure-devops-core.md +++ b/src/templates/shared/identity-setup-azure-devops-core.md @@ -344,13 +344,13 @@ az devops service-endpoint list --query "[].name" -o table ## Step 8: Create Variable Groups -Create one variable group per environment. Each group uses the **non-suffixed** variable names expected by the pipelines (`APIM_RESOURCE_GROUP`, `APIM_SERVICE_NAME`, `AZURE_SUBSCRIPTION_ID`, `AZURE_SERVICE_CONNECTION`). +Create one variable group per environment. Each group includes the extractor pipeline's **non-suffixed** variables (`APIM_RESOURCE_GROUP`, `APIM_SERVICE_NAME`, `AZURE_SUBSCRIPTION_ID`, `AZURE_SERVICE_CONNECTION`) plus the publish pipeline's environment-suffixed APIM variables (`APIM_RESOURCE_GROUP_`, `APIM_SERVICE_NAME_`). **PowerShell:** ```powershell foreach ($env in $ENVIRONMENTS) { $envUpper = $env.ToUpper() - az pipelines variable-group create --name "apim-$env" --variables AZURE_SUBSCRIPTION_ID=$($APIM_SUBSCRIPTIONS[$env]) APIM_RESOURCE_GROUP=$($APIM_RESOURCE_GROUPS[$env]) APIM_SERVICE_NAME=$($APIM_SERVICE_NAMES[$env]) AZURE_SERVICE_CONNECTION="AZURE_SERVICE_CONNECTION_$envUpper" + az pipelines variable-group create --name "apim-$env" --variables AZURE_SUBSCRIPTION_ID=$($APIM_SUBSCRIPTIONS[$env]) APIM_RESOURCE_GROUP=$($APIM_RESOURCE_GROUPS[$env]) APIM_SERVICE_NAME=$($APIM_SERVICE_NAMES[$env]) APIM_RESOURCE_GROUP_$envUpper=$($APIM_RESOURCE_GROUPS[$env]) APIM_SERVICE_NAME_$envUpper=$($APIM_SERVICE_NAMES[$env]) AZURE_SERVICE_CONNECTION="AZURE_SERVICE_CONNECTION_$envUpper" } ``` @@ -358,7 +358,7 @@ foreach ($env in $ENVIRONMENTS) { ```bash for env in "${ENVIRONMENTS[@]}"; do env_upper=$(echo "$env" | tr '[:lower:]' '[:upper:]') - az pipelines variable-group create --name "apim-$env" --variables AZURE_SUBSCRIPTION_ID="${APIM_SUBSCRIPTIONS[$env]}" APIM_RESOURCE_GROUP="${APIM_RESOURCE_GROUPS[$env]}" APIM_SERVICE_NAME="${APIM_SERVICE_NAMES[$env]}" AZURE_SERVICE_CONNECTION="AZURE_SERVICE_CONNECTION_$env_upper" + az pipelines variable-group create --name "apim-$env" --variables AZURE_SUBSCRIPTION_ID="${APIM_SUBSCRIPTIONS[$env]}" APIM_RESOURCE_GROUP="${APIM_RESOURCE_GROUPS[$env]}" APIM_SERVICE_NAME="${APIM_SERVICE_NAMES[$env]}" APIM_RESOURCE_GROUP_$env_upper="${APIM_RESOURCE_GROUPS[$env]}" APIM_SERVICE_NAME_$env_upper="${APIM_SERVICE_NAMES[$env]}" AZURE_SERVICE_CONNECTION="AZURE_SERVICE_CONNECTION_$env_upper" done ``` diff --git a/tests/unit/services/identity-guide-service.test.ts b/tests/unit/services/identity-guide-service.test.ts index 03d78e49..003eb2ce 100644 --- a/tests/unit/services/identity-guide-service.test.ts +++ b/tests/unit/services/identity-guide-service.test.ts @@ -27,49 +27,46 @@ describe('identity-guide-service', () => { expect(guide).toContain('my-rg'); }); - it('should include service principal creation steps', () => { + it('should mention the Copilot prompt file and UI flow', () => { const guide = identityGuideService.generateGitHubActionsGuide( 'sub-12345', 'my-rg', ['dev'] ); - expect(guide).toContain('Create Service Principal'); - expect(guide).toContain('az ad app create'); - expect(guide).toContain('az ad sp create'); + expect(guide).toContain('.github/prompts/apiops-setup-workflow-identity.prompt.md'); + expect(guide).toContain('Azure portal'); + expect(guide).toContain('GitHub web UI'); }); - it('should include RBAC role assignment steps', () => { + it('should explain the GitHub identity distinction', () => { const guide = identityGuideService.generateGitHubActionsGuide( 'sub-12345', 'my-rg', ['dev'] ); - expect(guide).toContain('Assign RBAC Roles'); - expect(guide).toContain('API Management Service Contributor'); - expect(guide).toContain('az role assignment create'); + expect(guide).toContain('GITHUB_TOKEN'); + expect(guide).toContain('only for Azure and APIM access'); }); - it('should include federated credentials setup', () => { + it('should include portal-based Azure access steps', () => { const guide = identityGuideService.generateGitHubActionsGuide( 'sub-12345', 'my-rg', ['dev'] ); - expect(guide).toContain('Configure Federated Credentials'); - expect(guide).toContain('az ad app federated-credential create'); - expect(guide).toContain('token.actions.githubusercontent.com'); + expect(guide).toContain('API Management Service Contributor'); + expect(guide).toContain('Access control (IAM)'); + expect(guide).toContain('Federated credentials'); }); - it('should include GitHub secrets configuration', () => { + it('should include documentation links', () => { const guide = identityGuideService.generateGitHubActionsGuide( 'sub-12345', 'my-rg', ['dev'] ); - expect(guide).toContain('Configure GitHub Secrets'); - expect(guide).toContain('AZURE_CLIENT_ID'); - expect(guide).toContain('AZURE_TENANT_ID'); - expect(guide).toContain('AZURE_SUBSCRIPTION_ID'); + expect(guide).toContain('https://learn.microsoft.com/'); + expect(guide).toContain('https://docs.github.com/'); }); it('should include environment-specific secrets for each environment', () => { @@ -120,25 +117,29 @@ describe('identity-guide-service', () => { }); describe('generateAzureDevOpsGuide', () => { - it('should ask for per-environment subscription and resource details', () => { + it('should mention the Copilot prompt file and UI flow', () => { const guide = identityGuideService.generateAzureDevOpsGuide(['dev', 'prod']); - expect(guide).toContain('APIM_SUBSCRIPTION_'); - expect(guide).toContain('APIM_RG_'); - expect(guide).toContain('APIM_NAME_'); + expect(guide).toContain('.github/prompts/apiops-setup-pipeline-identity.prompt.md'); + expect(guide).toContain('Azure DevOps web portal'); + expect(guide).toContain('Azure portal'); }); - it('should create non-suffixed variable groups per environment', () => { + it('should explain the Azure DevOps identity distinction', () => { const guide = identityGuideService.generateAzureDevOpsGuide(['dev', 'prod']); - expect(guide).toContain('--name "apim-$env"'); - expect(guide).toContain('APIM_RESOURCE_GROUP='); - expect(guide).toContain('APIM_SERVICE_NAME='); - expect(guide).toContain('AZURE_SUBSCRIPTION_ID='); + expect(guide).toContain('Build Service identity'); + expect(guide).toContain('separate from the Azure app registration'); + expect(guide).toContain('Create pull request'); }); - it('should render environment arrays for PowerShell and Bash', () => { + it('should include environment-specific service connections and variable groups', () => { const guide = identityGuideService.generateAzureDevOpsGuide(['dev', 'prod']); - expect(guide).toContain('$ENVIRONMENTS = @("dev", "prod")'); - expect(guide).toContain('ENVIRONMENTS=("dev" "prod")'); + expect(guide).toContain('AZURE_SERVICE_CONNECTION_DEV'); + expect(guide).toContain('AZURE_SERVICE_CONNECTION_PROD'); + expect(guide).toContain('apim-dev'); + expect(guide).toContain('apim-prod'); + expect(guide).toContain('AZURE_SERVICE_CONNECTION'); + expect(guide).toContain('APIM_RESOURCE_GROUP_DEV'); + expect(guide).toContain('APIM_SERVICE_NAME_PROD'); }); it('should render all template placeholders', () => { diff --git a/tests/unit/services/init-service.test.ts b/tests/unit/services/init-service.test.ts index d9b3f89b..a0a62d09 100644 --- a/tests/unit/services/init-service.test.ts +++ b/tests/unit/services/init-service.test.ts @@ -247,6 +247,30 @@ describe('init-service', () => { await expect(initService.run(config)).resolves.toBeDefined(); }); + it('should treat legacy GitHub identity files as conflicts', async () => { + vi.mocked(fs.access).mockImplementation(async (filePath: PathLike) => { + const p = filePath.toString(); + if (p === TEST_CLI_PACKAGE_RESOLVED || p.includes('apiops-setup-identity.prompt.md')) { + return Promise.resolve(); + } + throw new Error('ENOENT'); + }); + + const config: InitConfig = { + ciProvider: 'github-actions', + nonInteractive: true, + artifactDir: './apim-artifacts', + environments: ['dev'], + outputDir: '/test', + cliPackage: TEST_CLI_PACKAGE, + force: false, + }; + + await expect(initService.run(config)).rejects.toThrow( + 'Use --force to overwrite existing files' + ); + }); + it('should throw when config files exist and --force is not set', async () => { // Mock file exists for filter config and the CLI tarball vi.mocked(fs.access).mockImplementation(async (filePath: PathLike) => { @@ -310,7 +334,7 @@ describe('init-service', () => { await initService.run(config); const guideCalls = vi.mocked(fs.writeFile).mock.calls.filter( - (call) => call[0] === path.join('/test', 'IDENTITY-SETUP-GITHUB.md') + (call) => call[0] === path.join('/test', 'APIOPS-WORKFLOW-IDENTITY-SETUP.md') ); expect(guideCalls).toHaveLength(1); }); @@ -329,7 +353,7 @@ describe('init-service', () => { await initService.run(config); const guideCalls = vi.mocked(fs.writeFile).mock.calls.filter( - (call) => call[0] === path.join('/test', 'IDENTITY-SETUP-AZDO.md') + (call) => call[0] === path.join('/test', 'APIOPS-PIPELINE-IDENTITY-SETUP.md') ); expect(guideCalls).toHaveLength(1); }); @@ -347,14 +371,15 @@ describe('init-service', () => { const result = await initService.run(config); - expect(result.configs).toContain('.github/prompts/apiops-setup-identity.prompt.md'); + expect(result.configs).toContain('.github/prompts/apiops-setup-workflow-identity.prompt.md'); const promptCalls = vi.mocked(fs.writeFile).mock.calls.filter( - (call) => call[0] === path.join('/test', '.github/prompts/apiops-setup-identity.prompt.md') + (call) => call[0] === path.join('/test', '.github/prompts/apiops-setup-workflow-identity.prompt.md') ); expect(promptCalls).toHaveLength(1); const content = promptCalls[0][1] as string; expect(content).toContain('Setup GitHub Actions Identity'); expect(content).toContain('gh secret set'); + expect(content).toContain('pull request creation automatically'); }); it('should generate Copilot configuration prompts for GitHub Actions', async () => { @@ -399,14 +424,15 @@ describe('init-service', () => { const result = await initService.run(config); - expect(result.configs).toContain('.github/prompts/apiops-setup-identity.prompt.md'); + expect(result.configs).toContain('.github/prompts/apiops-setup-pipeline-identity.prompt.md'); const promptCalls = vi.mocked(fs.writeFile).mock.calls.filter( - (call) => call[0] === path.join('/test', '.github/prompts/apiops-setup-identity.prompt.md') + (call) => call[0] === path.join('/test', '.github/prompts/apiops-setup-pipeline-identity.prompt.md') ); expect(promptCalls).toHaveLength(1); const content = promptCalls[0][1] as string; expect(content).toContain('Setup Azure DevOps Identity for APIOps'); expect(content).toContain('az devops service-endpoint create --service-endpoint-configuration'); + expect(content).toContain('Build Service identity'); }); it('should detect conflicts for Copilot configuration prompts', async () => { diff --git a/tests/unit/templates/copilot/identity-setup-prompt.test.ts b/tests/unit/templates/copilot/identity-setup-prompt.test.ts index c1985516..24f13a2d 100644 --- a/tests/unit/templates/copilot/identity-setup-prompt.test.ts +++ b/tests/unit/templates/copilot/identity-setup-prompt.test.ts @@ -111,6 +111,13 @@ describe('copilot/identity-setup-prompt', () => { expect(prompt).toContain('Open this file in VS Code with GitHub Copilot'); }); + it('should include the GitHub identity distinction note and UI guide context', () => { + const prompt = generateIdentitySetupPrompt({ environments: ['dev'] }); + expect(prompt).toContain('pull request creation automatically'); + expect(prompt).toContain('## UI Reference Context'); + expect(prompt).toContain('APIOps GitHub Actions identity setup guide'); + }); + it('should include tool authentication check in Step 0', () => { const prompt = generateIdentitySetupPrompt({ environments: ['dev'] }); expect(prompt).toContain('## Step 0 — Tool Authentication Check'); @@ -165,6 +172,16 @@ describe('copilot/identity-setup-prompt', () => { expect(prompt).not.toContain('gh secret set'); }); + it('should include the Azure DevOps identity distinction note and UI guide context', () => { + const prompt = generateIdentitySetupPrompt({ + environments: ['dev', 'prod'], + ciProvider: 'azure-devops', + }); + expect(prompt).toContain('Build Service identity'); + expect(prompt).toContain('## UI Reference Context'); + expect(prompt).toContain('APIOps Azure DevOps identity setup guide'); + }); + it('should ask Copilot to gather per-environment APIM info for each ADO environment', () => { const prompt = generateIdentitySetupPrompt({ environments: ['dev', 'prod'], From 7d06bf6d940904018858c93332a06c691ead3b85 Mon Sep 17 00:00:00 2001 From: Elizabeth Maher Date: Mon, 22 Jun 2026 20:56:19 -0700 Subject: [PATCH 02/15] docs: update Azure DevOps identity guide title Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/templates/identity/identity-guide-azure-devops.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/templates/identity/identity-guide-azure-devops.md b/src/templates/identity/identity-guide-azure-devops.md index 75d2e270..702b202d 100644 --- a/src/templates/identity/identity-guide-azure-devops.md +++ b/src/templates/identity/identity-guide-azure-devops.md @@ -1,4 +1,4 @@ -# APIOps Azure DevOps identity setup guide +# Identity setup guide for APIOps extract and publish Azure DevOps Pipelines > Prefer an automated walkthrough? Open `.github/prompts/apiops-setup-pipeline-identity.prompt.md` in VS Code with GitHub Copilot and ask Copilot to guide or automate the setup for you. From 423dffe03d84f07778e5d3ccd3b35e4eec3ad358 Mon Sep 17 00:00:00 2001 From: Elizabeth Maher Date: Mon, 22 Jun 2026 20:58:50 -0700 Subject: [PATCH 03/15] docs: add official doc links to all UI navigation steps Adds a reference link to the relevant Microsoft/GitHub docs page above every section that instructs the user to navigate a web UI, so readers have a fallback if the UI changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../identity/identity-guide-azure-devops.md | 14 ++++++++++++++ .../identity/identity-guide-github-actions.md | 10 ++++++++++ .../copilot/identity-setup-prompt.test.ts | 2 +- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/templates/identity/identity-guide-azure-devops.md b/src/templates/identity/identity-guide-azure-devops.md index 702b202d..47779f3f 100644 --- a/src/templates/identity/identity-guide-azure-devops.md +++ b/src/templates/identity/identity-guide-azure-devops.md @@ -29,6 +29,8 @@ Helpful documentation: ## Step 2: Create or choose an app registration in Azure portal +> 📖 [Register an application in the Azure portal](https://learn.microsoft.com/entra/identity-platform/quickstart-register-app) + 1. Open the **Azure portal**. 2. Go to **Microsoft Entra ID** → **App registrations** → **New registration**. 3. Enter a friendly name such as `apiops-azdo-sp`. @@ -40,6 +42,8 @@ Helpful documentation: ## Step 3: Grant the Azure identity access to each APIM environment +> 📖 [Assign Azure roles using the Azure portal](https://learn.microsoft.com/azure/role-based-access-control/role-assignments-portal) + For each environment, use the **Azure portal** to grant: - **Reader** on the resource group that contains the APIM instance @@ -49,6 +53,8 @@ Use **Access control (IAM)** on each resource group and APIM instance to create ## Step 4: Create one Azure service connection per environment +> 📖 [Create an Azure Resource Manager service connection using workload identity federation](https://learn.microsoft.com/azure/devops/pipelines/library/connect-to-azure) + 1. In **Azure DevOps**, open **Project settings** → **Service connections** → **New service connection**. 2. Choose **Azure Resource Manager**. 3. Select **Workload identity federation**. @@ -64,6 +70,8 @@ Use **Access control (IAM)** on each resource group and APIM instance to create ## Step 5: Create variable groups in Azure DevOps +> 📖 [Add and use variable groups in Azure Pipelines](https://learn.microsoft.com/azure/devops/pipelines/library/variable-groups) + 1. Go to **Pipelines** → **Library** → **+ Variable group**. 2. Create one variable group per environment: @@ -76,12 +84,16 @@ Use **Access control (IAM)** on each resource group and APIM instance to create ## Step 6: Create Azure DevOps environments and approvals +> 📖 [Create and target an environment](https://learn.microsoft.com/azure/devops/pipelines/process/environments) + 1. Go to **Pipelines** → **Environments**. 2. Create an environment for each deployment target used by the publish pipeline. 3. Add approvals and checks for production or other protected environments as needed by your release process. ## Step 7: Grant repository permissions to the Build Service identity +> 📖 [Set repository permissions in Azure DevOps](https://learn.microsoft.com/azure/devops/repos/git/set-git-repository-permissions) + 1. In **Azure DevOps**, open **Project settings** → **Repositories** → your repository → **Security**. 2. Find the entry named **` Build Service ()`**. 3. Grant the minimum permissions required for your workflow, typically: @@ -92,6 +104,8 @@ Use **Access control (IAM)** on each resource group and APIM instance to create ## Step 8: Create the pipelines in Azure DevOps +> 📖 [Create your first pipeline](https://learn.microsoft.com/azure/devops/pipelines/create-first-pipeline) + 1. Go to **Pipelines** → **Create Pipeline**. 2. Choose your repository. 3. Select **Existing Azure Pipelines YAML file**. diff --git a/src/templates/identity/identity-guide-github-actions.md b/src/templates/identity/identity-guide-github-actions.md index af901ed5..a11ee920 100644 --- a/src/templates/identity/identity-guide-github-actions.md +++ b/src/templates/identity/identity-guide-github-actions.md @@ -30,6 +30,8 @@ Helpful documentation: ## Step 2: Create or choose an app registration in Azure portal +> 📖 [Register an application in the Azure portal](https://learn.microsoft.com/entra/identity-platform/quickstart-register-app) + 1. Open the **Azure portal**. 2. Go to **Microsoft Entra ID** → **App registrations** → **New registration**. 3. Enter a friendly name such as `apiops-github-sp`. @@ -40,6 +42,8 @@ Helpful documentation: ## Step 3: Grant the Azure identity access to APIM +> 📖 [Assign Azure roles using the Azure portal](https://learn.microsoft.com/azure/role-based-access-control/role-assignments-portal) + 1. In the **Azure portal**, open your API Management service. 2. Go to **Access control (IAM)** → **Add role assignment**. 3. For every environment configured by `apiops init`, assign access to the app registration or service principal: @@ -49,6 +53,8 @@ Helpful documentation: ## Step 4: Add federated credentials in Azure portal +> 📖 [Configure a federated identity credential on an app](https://learn.microsoft.com/entra/workload-id/workload-identity-federation-create-trust) + 1. Return to **Microsoft Entra ID** → **App registrations** → your app. 2. Open **Certificates & secrets** → **Federated credentials** → **Add credential**. 3. Add one credential for the main branch publish workflow: @@ -66,12 +72,16 @@ Helpful documentation: ## Step 5: Create GitHub environments in the web UI +> 📖 [Using environments for deployment](https://docs.github.com/actions/deployment/targeting-different-environments/using-environments-for-deployment) + 1. In GitHub, go to **Settings** → **Environments**. 2. Create an environment for each deployment target used by `apiops init`. 3. Add protection rules such as required reviewers for production environments if your process needs approvals. ## Step 6: Add repository and environment secrets in GitHub +> 📖 [Using secrets in GitHub Actions](https://docs.github.com/actions/security-guides/using-secrets-in-github-actions) + 1. Go to **Settings** → **Secrets and variables** → **Actions**. 2. Under **Repository secrets**, create: - `AZURE_CLIENT_ID` — the Application (client) ID from the app registration diff --git a/tests/unit/templates/copilot/identity-setup-prompt.test.ts b/tests/unit/templates/copilot/identity-setup-prompt.test.ts index 24f13a2d..c12ed476 100644 --- a/tests/unit/templates/copilot/identity-setup-prompt.test.ts +++ b/tests/unit/templates/copilot/identity-setup-prompt.test.ts @@ -179,7 +179,7 @@ describe('copilot/identity-setup-prompt', () => { }); expect(prompt).toContain('Build Service identity'); expect(prompt).toContain('## UI Reference Context'); - expect(prompt).toContain('APIOps Azure DevOps identity setup guide'); + expect(prompt).toContain('Identity setup guide for APIOps extract and publish Azure DevOps Pipelines'); }); it('should ask Copilot to gather per-environment APIM info for each ADO environment', () => { From e3c5712c6034a38bdc88ce106d25a30b1e6afd76 Mon Sep 17 00:00:00 2001 From: Elizabeth Maher Date: Mon, 29 Jun 2026 11:11:38 -0700 Subject: [PATCH 04/15] refactor: remove legacy file conflict detection from init apiops init is only run once, so no need to check for old file names. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/services/init-service.ts | 28 ------------------------ tests/unit/services/init-service.test.ts | 24 -------------------- 2 files changed, 52 deletions(-) diff --git a/src/services/init-service.ts b/src/services/init-service.ts index 9cd07653..406baad3 100644 --- a/src/services/init-service.ts +++ b/src/services/init-service.ts @@ -148,10 +148,6 @@ class InitServiceImpl implements InitService { config.outputDir, '.github/prompts/apiops-setup-workflow-identity.prompt.md' ); - const legacyPromptFile = path.join( - config.outputDir, - '.github/prompts/apiops-setup-identity.prompt.md' - ); const filterPromptFile = path.join( config.outputDir, '.github/prompts/apiops-configure-filter.prompt.md' @@ -164,10 +160,6 @@ class InitServiceImpl implements InitService { config.outputDir, 'APIOPS-WORKFLOW-IDENTITY-SETUP.md' ); - const legacyIdentityGuide = path.join( - config.outputDir, - 'IDENTITY-SETUP-GITHUB.md' - ); if (await this.fileExists(extractWorkflow)) { conflictingFiles.push(extractWorkflow); @@ -184,15 +176,9 @@ class InitServiceImpl implements InitService { if (await this.fileExists(overridesPromptFile)) { conflictingFiles.push(overridesPromptFile); } - if (await this.fileExists(legacyPromptFile)) { - conflictingFiles.push(legacyPromptFile); - } if (await this.fileExists(identityGuide)) { conflictingFiles.push(identityGuide); } - if (await this.fileExists(legacyIdentityGuide)) { - conflictingFiles.push(legacyIdentityGuide); - } } else if (config.ciProvider === 'azure-devops') { const extractPipeline = path.join( config.outputDir, @@ -206,18 +192,10 @@ class InitServiceImpl implements InitService { config.outputDir, 'APIOPS-PIPELINE-IDENTITY-SETUP.md' ); - const legacyIdentityGuide = path.join( - config.outputDir, - 'IDENTITY-SETUP-AZDO.md' - ); const promptFile = path.join( config.outputDir, '.github/prompts/apiops-setup-pipeline-identity.prompt.md' ); - const legacyPromptFile = path.join( - config.outputDir, - '.github/prompts/apiops-setup-identity.prompt.md' - ); const filterPromptFile = path.join( config.outputDir, '.github/prompts/apiops-configure-filter.prompt.md' @@ -236,9 +214,6 @@ class InitServiceImpl implements InitService { if (await this.fileExists(identityGuide)) { conflictingFiles.push(identityGuide); } - if (await this.fileExists(legacyIdentityGuide)) { - conflictingFiles.push(legacyIdentityGuide); - } if (await this.fileExists(promptFile)) { conflictingFiles.push(promptFile); } @@ -248,9 +223,6 @@ class InitServiceImpl implements InitService { if (await this.fileExists(overridesPromptFile)) { conflictingFiles.push(overridesPromptFile); } - if (await this.fileExists(legacyPromptFile)) { - conflictingFiles.push(legacyPromptFile); - } } // Check for config files diff --git a/tests/unit/services/init-service.test.ts b/tests/unit/services/init-service.test.ts index a0a62d09..912a9892 100644 --- a/tests/unit/services/init-service.test.ts +++ b/tests/unit/services/init-service.test.ts @@ -247,30 +247,6 @@ describe('init-service', () => { await expect(initService.run(config)).resolves.toBeDefined(); }); - it('should treat legacy GitHub identity files as conflicts', async () => { - vi.mocked(fs.access).mockImplementation(async (filePath: PathLike) => { - const p = filePath.toString(); - if (p === TEST_CLI_PACKAGE_RESOLVED || p.includes('apiops-setup-identity.prompt.md')) { - return Promise.resolve(); - } - throw new Error('ENOENT'); - }); - - const config: InitConfig = { - ciProvider: 'github-actions', - nonInteractive: true, - artifactDir: './apim-artifacts', - environments: ['dev'], - outputDir: '/test', - cliPackage: TEST_CLI_PACKAGE, - force: false, - }; - - await expect(initService.run(config)).rejects.toThrow( - 'Use --force to overwrite existing files' - ); - }); - it('should throw when config files exist and --force is not set', async () => { // Mock file exists for filter config and the CLI tarball vi.mocked(fs.access).mockImplementation(async (filePath: PathLike) => { From ed1f04fe63407a3b44ab8e03247c88df4972bea4 Mon Sep 17 00:00:00 2001 From: Elizabeth Maher Date: Mon, 29 Jun 2026 13:53:14 -0700 Subject: [PATCH 05/15] refactor: rename pipeline files to run-apiops-extractor/publish Renames generated pipeline YAML filenames from run-apim-* to run-apiops-extractor.yml and run-apiops-publish.yml across all source, docs, and tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/commands/init.md | 8 +++---- .../air-gapped-azure-devops-local-registry.md | 14 +++++------ ...air-gapped-azure-devops-offline-tarball.md | 14 +++++------ src/services/init-service.ts | 24 +++++++++---------- .../identity-setup-prompt-azure-devops.md | 8 +++---- .../identity/identity-guide-azure-devops.md | 8 +++---- tests/unit/services/init-service.test.ts | 12 +++++----- 7 files changed, 44 insertions(+), 44 deletions(-) diff --git a/docs/commands/init.md b/docs/commands/init.md index b039126f..db63d303 100644 --- a/docs/commands/init.md +++ b/docs/commands/init.md @@ -78,8 +78,8 @@ In interactive mode (the default when running in a terminal), `apiops init` prom | File | Purpose | |------|---------| -| `.github/workflows/run-apim-extractor.yml` | Workflow to extract APIM artifacts | -| `.github/workflows/run-apim-publisher.yml` | Workflow to publish artifacts to APIM | +| `.github/workflows/run-apiops-extractor.yml` | Workflow to extract APIM artifacts | +| `.github/workflows/run-apiops-publish.yml` | Workflow to publish artifacts to APIM | | `configuration.extractor.yaml` | Sample filter configuration for extraction | | `configuration.{env}.yaml` | Override templates per environment (e.g., `configuration.dev.yaml`, `configuration.prod.yaml`) | | `.github/prompts/apiops-setup-workflow-identity.prompt.md` | Copilot prompt for GitHub Actions identity setup | @@ -89,8 +89,8 @@ In interactive mode (the default when running in a terminal), `apiops init` prom | File | Purpose | |------|---------| -| `.azdo/pipelines/run-apim-extractor.yml` | Pipeline to extract APIM artifacts | -| `.azdo/pipelines/run-apim-publisher.yml` | Pipeline to publish artifacts to APIM | +| `.azdo/pipelines/run-apiops-extractor.yml` | Pipeline to extract APIM artifacts | +| `.azdo/pipelines/run-apiops-publish.yml` | Pipeline to publish artifacts to APIM | | `configuration.extractor.yaml` | Sample filter configuration for extraction | | `configuration.{env}.yaml` | Override templates per environment | | `.github/prompts/apiops-setup-pipeline-identity.prompt.md` | Copilot prompt for Azure DevOps identity setup | diff --git a/docs/walkthrough/air-gapped-azure-devops-local-registry.md b/docs/walkthrough/air-gapped-azure-devops-local-registry.md index 7d7185a2..a11288f9 100644 --- a/docs/walkthrough/air-gapped-azure-devops-local-registry.md +++ b/docs/walkthrough/air-gapped-azure-devops-local-registry.md @@ -162,8 +162,8 @@ This generates: | File | Purpose | |------|---------| | `package.json` | Declares the CLI as a dependency | -| `.azdo/pipelines/run-apim-extractor.yml` | Extract pipeline | -| `.azdo/pipelines/run-apim-publisher.yml` | Publish pipeline | +| `.azdo/pipelines/run-apiops-extractor.yml` | Extract pipeline | +| `.azdo/pipelines/run-apiops-publish.yml` | Publish pipeline | | `configuration.*.yaml` | Override templates | Follow the remaining instructions listed in created `APIOPS-PIPELINE-IDENTITY-SETUP.md` or run `/apiops-setup-pipeline-identity` prompt. This creates the necessary variable groups and and service connections. @@ -178,7 +178,7 @@ This creates `package-lock.json`. Commit it — the lock file is **required** fo ### 2.3 Modify Pipelines for Air-Gapped Operation -The generated pipelines (`.azdo/pipelines/run-apim-extractor.yml` and `.azdo/pipelines/run-apim-publisher.yml`) need the following edits: +The generated pipelines (`.azdo/pipelines/run-apiops-extractor.yml` and `.azdo/pipelines/run-apiops-publish.yml`) need the following edits: | Edit | What to Change | |------|----------------| @@ -207,8 +207,8 @@ Commit the files required to run the local-registry workflow on self-hosted agen | `.npmrc` | Points npm to the local Azure Artifacts feed (`registry=...`, `always-auth=true`). | | `package.json` | Declares the CLI dependency. | | `package-lock.json` | Required for deterministic installs with `npm ci`. | -| `.azdo/pipelines/run-apim-extractor.yml` | Azure DevOps extract pipeline definition. | -| `.azdo/pipelines/run-apim-publisher.yml` | Azure DevOps publish pipeline definition. | +| `.azdo/pipelines/run-apiops-extractor.yml` | Azure DevOps extract pipeline definition. | +| `.azdo/pipelines/run-apiops-publish.yml` | Azure DevOps publish pipeline definition. | | `configuration.*.yaml` | Generated environment override templates. | ```bash @@ -216,8 +216,8 @@ git add \ .npmrc \ package.json \ package-lock.json \ - .azdo/pipelines/run-apim-extractor.yml \ - .azdo/pipelines/run-apim-publisher.yml \ + .azdo/pipelines/run-apiops-extractor.yml \ + .azdo/pipelines/run-apiops-publish.yml \ configuration.*.yaml git commit -m "chore: commit local-registry apiops bootstrap files" git push diff --git a/docs/walkthrough/air-gapped-azure-devops-offline-tarball.md b/docs/walkthrough/air-gapped-azure-devops-offline-tarball.md index 41c912f9..0fd39a1a 100644 --- a/docs/walkthrough/air-gapped-azure-devops-offline-tarball.md +++ b/docs/walkthrough/air-gapped-azure-devops-offline-tarball.md @@ -78,8 +78,8 @@ This command generates: | File | Purpose | |------|---------| | `package.json` | Declares the CLI as a `file:` dependency pointing at the tarball | -| `.azdo/pipelines/run-apim-extractor.yml` | Extract pipeline | -| `.azdo/pipelines/run-apim-publisher.yml` | Publish pipeline | +| `.azdo/pipelines/run-apiops-extractor.yml` | Extract pipeline | +| `.azdo/pipelines/run-apiops-publish.yml` | Publish pipeline | | `configuration.*.yaml` | Override templates | Follow the remaining instructions listed in created `APIOPS-PIPELINE-IDENTITY-SETUP.md` or run `/apiops-setup-pipeline-identity` prompt. This creates the necessary variable groups and and service connections. @@ -97,7 +97,7 @@ npm ci # populates ~/.npm/_cacache/ with every package the lock file refe ### 2.3 Modify Pipelines for Air-Gapped Operation -The generated pipelines (`.azdo/pipelines/run-apim-extractor.yml` and `.azdo/pipelines/run-apim-publisher.yml`) need the following edits: +The generated pipelines (`.azdo/pipelines/run-apiops-extractor.yml` and `.azdo/pipelines/run-apiops-publish.yml`) need the following edits: | Edit | What to Change | |------|----------------| @@ -114,8 +114,8 @@ For the offline-tarball workflow, commit the files that make the pipeline fully | `.apiops/peterhauge-apiops-cli-.tgz` | CLI package consumed by the pipelines. | | `package.json` | Contains the `file:` dependency pointing to the tarball. | | `package-lock.json` | Required for deterministic offline installs with `npm ci --offline`. | -| `.azdo/pipelines/run-apim-extractor.yml` | Azure DevOps extract pipeline definition. | -| `.azdo/pipelines/run-apim-publisher.yml` | Azure DevOps publish pipeline definition. | +| `.azdo/pipelines/run-apiops-extractor.yml` | Azure DevOps extract pipeline definition. | +| `.azdo/pipelines/run-apiops-publish.yml` | Azure DevOps publish pipeline definition. | | `configuration.*.yaml` | Generated environment override templates. | ```bash @@ -123,8 +123,8 @@ git add \ .apiops/peterhauge-apiops-cli-*.tgz \ package.json \ package-lock.json \ - .azdo/pipelines/run-apim-extractor.yml \ - .azdo/pipelines/run-apim-publisher.yml \ + .azdo/pipelines/run-apiops-extractor.yml \ + .azdo/pipelines/run-apiops-publish.yml \ configuration.*.yaml git commit -m "chore: commit offline-tarball apiops bootstrap files" git push diff --git a/src/services/init-service.ts b/src/services/init-service.ts index 406baad3..4685f340 100644 --- a/src/services/init-service.ts +++ b/src/services/init-service.ts @@ -138,11 +138,11 @@ class InitServiceImpl implements InitService { if (config.ciProvider === 'github-actions') { const extractWorkflow = path.join( config.outputDir, - '.github/workflows/run-apim-extractor.yml' + '.github/workflows/run-apiops-extractor.yml' ); const publishWorkflow = path.join( config.outputDir, - '.github/workflows/run-apim-publisher.yml' + '.github/workflows/run-apiops-publish.yml' ); const promptFile = path.join( config.outputDir, @@ -182,11 +182,11 @@ class InitServiceImpl implements InitService { } else if (config.ciProvider === 'azure-devops') { const extractPipeline = path.join( config.outputDir, - '.azdo/pipelines/run-apim-extractor.yml' + '.azdo/pipelines/run-apiops-extractor.yml' ); const publishPipeline = path.join( config.outputDir, - '.azdo/pipelines/run-apim-publisher.yml' + '.azdo/pipelines/run-apiops-publish.yml' ); const identityGuide = path.join( config.outputDir, @@ -332,9 +332,9 @@ class InitServiceImpl implements InitService { artifactDir: config.artifactDir, }; const extractContent = generateExtractWorkflow(extractWorkflowConfig); - const extractPath = path.join(workflowsDir, 'run-apim-extractor.yml'); + const extractPath = path.join(workflowsDir, 'run-apiops-extractor.yml'); await fs.writeFile(extractPath, extractContent); - generatedFiles.pipelines.push('.github/workflows/run-apim-extractor.yml'); + generatedFiles.pipelines.push('.github/workflows/run-apiops-extractor.yml'); // Publish workflow const publishWorkflowConfig: PublishWorkflowConfig = { @@ -342,9 +342,9 @@ class InitServiceImpl implements InitService { environments: config.environments, }; const publishContent = generatePublishWorkflow(publishWorkflowConfig); - const publishPath = path.join(workflowsDir, 'run-apim-publisher.yml'); + const publishPath = path.join(workflowsDir, 'run-apiops-publish.yml'); await fs.writeFile(publishPath, publishContent); - generatedFiles.pipelines.push('.github/workflows/run-apim-publisher.yml'); + generatedFiles.pipelines.push('.github/workflows/run-apiops-publish.yml'); await this.generateCopilotIdentitySetupPrompt(config, generatedFiles); await this.generateCopilotConfigurationPrompts(config, generatedFiles); @@ -366,9 +366,9 @@ class InitServiceImpl implements InitService { environments: config.environments, }; const extractContent = generateExtractPipeline(extractPipelineConfig); - const extractPath = path.join(pipelinesDir, 'run-apim-extractor.yml'); + const extractPath = path.join(pipelinesDir, 'run-apiops-extractor.yml'); await fs.writeFile(extractPath, extractContent); - generatedFiles.pipelines.push('.azdo/pipelines/run-apim-extractor.yml'); + generatedFiles.pipelines.push('.azdo/pipelines/run-apiops-extractor.yml'); // Publish pipeline const publishPipelineConfig: PublishPipelineConfig = { @@ -376,9 +376,9 @@ class InitServiceImpl implements InitService { environments: config.environments, }; const publishContent = generatePublishPipeline(publishPipelineConfig); - const publishPath = path.join(pipelinesDir, 'run-apim-publisher.yml'); + const publishPath = path.join(pipelinesDir, 'run-apiops-publish.yml'); await fs.writeFile(publishPath, publishContent); - generatedFiles.pipelines.push('.azdo/pipelines/run-apim-publisher.yml'); + generatedFiles.pipelines.push('.azdo/pipelines/run-apiops-publish.yml'); await this.generateCopilotIdentitySetupPrompt(config, generatedFiles); await this.generateCopilotConfigurationPrompts(config, generatedFiles); diff --git a/src/templates/copilot/identity-setup-prompt-azure-devops.md b/src/templates/copilot/identity-setup-prompt-azure-devops.md index 88799b56..25df8d39 100644 --- a/src/templates/copilot/identity-setup-prompt-azure-devops.md +++ b/src/templates/copilot/identity-setup-prompt-azure-devops.md @@ -75,16 +75,16 @@ Create Azure Pipelines from the YAML files in your repository. ```powershell $REPO_NAME = $AZDO_PROJECT -az pipelines create --name "apiops-extract" --repository $REPO_NAME --branch main --yml-path ".azdo/pipelines/run-apim-extractor.yml" --repository-type tfsgit --skip-first-run true -az pipelines create --name "apiops-publish" --repository $REPO_NAME --branch main --yml-path ".azdo/pipelines/run-apim-publisher.yml" --repository-type tfsgit --skip-first-run true +az pipelines create --name "apiops-extract" --repository $REPO_NAME --branch main --yml-path ".azdo/pipelines/run-apiops-extractor.yml" --repository-type tfsgit --skip-first-run true +az pipelines create --name "apiops-publish" --repository $REPO_NAME --branch main --yml-path ".azdo/pipelines/run-apiops-publish.yml" --repository-type tfsgit --skip-first-run true ``` **Git Bash:** ```bash REPO_NAME="$AZDO_PROJECT" -az pipelines create --name "apiops-extract" --repository "$REPO_NAME" --branch main --yml-path ".azdo/pipelines/run-apim-extractor.yml" --repository-type tfsgit --skip-first-run true -az pipelines create --name "apiops-publish" --repository "$REPO_NAME" --branch main --yml-path ".azdo/pipelines/run-apim-publisher.yml" --repository-type tfsgit --skip-first-run true +az pipelines create --name "apiops-extract" --repository "$REPO_NAME" --branch main --yml-path ".azdo/pipelines/run-apiops-extractor.yml" --repository-type tfsgit --skip-first-run true +az pipelines create --name "apiops-publish" --repository "$REPO_NAME" --branch main --yml-path ".azdo/pipelines/run-apiops-publish.yml" --repository-type tfsgit --skip-first-run true ``` Verify pipelines were created: diff --git a/src/templates/identity/identity-guide-azure-devops.md b/src/templates/identity/identity-guide-azure-devops.md index 47779f3f..8eb80730 100644 --- a/src/templates/identity/identity-guide-azure-devops.md +++ b/src/templates/identity/identity-guide-azure-devops.md @@ -23,8 +23,8 @@ Helpful documentation: 1. Commit and push the generated files to your Azure DevOps repository. 2. Confirm these files are available in the repo browser: - - `.azdo/pipelines/run-apim-extractor.yml` - - `.azdo/pipelines/run-apim-publisher.yml` + - `.azdo/pipelines/run-apiops-extractor.yml` + - `.azdo/pipelines/run-apiops-publish.yml` 3. Note the environment names you chose when running `apiops init` because you will create matching service connections, variable groups, and approvals. ## Step 2: Create or choose an app registration in Azure portal @@ -110,8 +110,8 @@ Use **Access control (IAM)** on each resource group and APIM instance to create 2. Choose your repository. 3. Select **Existing Azure Pipelines YAML file**. 4. Create a pipeline for each generated YAML file: - - `.azdo/pipelines/run-apim-extractor.yml` - - `.azdo/pipelines/run-apim-publisher.yml` + - `.azdo/pipelines/run-apiops-extractor.yml` + - `.azdo/pipelines/run-apiops-publish.yml` 5. Save without running if you still need approvals or variable authorization. ## Step 9: Verify the setup diff --git a/tests/unit/services/init-service.test.ts b/tests/unit/services/init-service.test.ts index 912a9892..54aa5c87 100644 --- a/tests/unit/services/init-service.test.ts +++ b/tests/unit/services/init-service.test.ts @@ -104,8 +104,8 @@ describe('init-service', () => { const result = await initService.run(config); - expect(result.pipelines).toContain('.github/workflows/run-apim-extractor.yml'); - expect(result.pipelines).toContain('.github/workflows/run-apim-publisher.yml'); + expect(result.pipelines).toContain('.github/workflows/run-apiops-extractor.yml'); + expect(result.pipelines).toContain('.github/workflows/run-apiops-publish.yml'); }); it('should generate Azure DevOps pipelines when ciProvider is azure-devops', async () => { @@ -121,8 +121,8 @@ describe('init-service', () => { const result = await initService.run(config); - expect(result.pipelines).toContain('.azdo/pipelines/run-apim-extractor.yml'); - expect(result.pipelines).toContain('.azdo/pipelines/run-apim-publisher.yml'); + expect(result.pipelines).toContain('.azdo/pipelines/run-apiops-extractor.yml'); + expect(result.pipelines).toContain('.azdo/pipelines/run-apiops-publish.yml'); }); it('should generate filter configuration file', async () => { @@ -202,7 +202,7 @@ describe('init-service', () => { // Mock file exists for extract workflow and the CLI tarball vi.mocked(fs.access).mockImplementation(async (filePath: PathLike) => { const p = filePath.toString(); - if (p === TEST_CLI_PACKAGE_RESOLVED || p.includes('run-apim-extractor.yml')) { + if (p === TEST_CLI_PACKAGE_RESOLVED || p.includes('run-apiops-extractor.yml')) { return Promise.resolve(); } throw new Error('ENOENT'); @@ -227,7 +227,7 @@ describe('init-service', () => { // Mock file exists for extract workflow and the CLI tarball vi.mocked(fs.access).mockImplementation(async (filePath: PathLike) => { const p = filePath.toString(); - if (p === TEST_CLI_PACKAGE_RESOLVED || p.includes('run-apim-extractor.yml')) { + if (p === TEST_CLI_PACKAGE_RESOLVED || p.includes('run-apiops-extractor.yml')) { return Promise.resolve(); } throw new Error('ENOENT'); From a7313fa47784bbba5996992e99fd9f9a3a1f99cd Mon Sep 17 00:00:00 2001 From: Elizabeth Maher Date: Mon, 29 Jun 2026 13:56:59 -0700 Subject: [PATCH 06/15] fix: rename run-apiops-publish.yml to run-apiops-publisher.yml Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/commands/init.md | 4 ++-- .../air-gapped-azure-devops-local-registry.md | 8 ++++---- .../air-gapped-azure-devops-offline-tarball.md | 8 ++++---- src/services/init-service.ts | 12 ++++++------ .../copilot/identity-setup-prompt-azure-devops.md | 4 ++-- .../identity/identity-guide-azure-devops.md | 4 ++-- tests/unit/services/init-service.test.ts | 4 ++-- 7 files changed, 22 insertions(+), 22 deletions(-) diff --git a/docs/commands/init.md b/docs/commands/init.md index db63d303..3c963ae3 100644 --- a/docs/commands/init.md +++ b/docs/commands/init.md @@ -79,7 +79,7 @@ In interactive mode (the default when running in a terminal), `apiops init` prom | File | Purpose | |------|---------| | `.github/workflows/run-apiops-extractor.yml` | Workflow to extract APIM artifacts | -| `.github/workflows/run-apiops-publish.yml` | Workflow to publish artifacts to APIM | +| `.github/workflows/run-apiops-publisher.yml` | Workflow to publish artifacts to APIM | | `configuration.extractor.yaml` | Sample filter configuration for extraction | | `configuration.{env}.yaml` | Override templates per environment (e.g., `configuration.dev.yaml`, `configuration.prod.yaml`) | | `.github/prompts/apiops-setup-workflow-identity.prompt.md` | Copilot prompt for GitHub Actions identity setup | @@ -90,7 +90,7 @@ In interactive mode (the default when running in a terminal), `apiops init` prom | File | Purpose | |------|---------| | `.azdo/pipelines/run-apiops-extractor.yml` | Pipeline to extract APIM artifacts | -| `.azdo/pipelines/run-apiops-publish.yml` | Pipeline to publish artifacts to APIM | +| `.azdo/pipelines/run-apiops-publisher.yml` | Pipeline to publish artifacts to APIM | | `configuration.extractor.yaml` | Sample filter configuration for extraction | | `configuration.{env}.yaml` | Override templates per environment | | `.github/prompts/apiops-setup-pipeline-identity.prompt.md` | Copilot prompt for Azure DevOps identity setup | diff --git a/docs/walkthrough/air-gapped-azure-devops-local-registry.md b/docs/walkthrough/air-gapped-azure-devops-local-registry.md index a11288f9..b6f3d71d 100644 --- a/docs/walkthrough/air-gapped-azure-devops-local-registry.md +++ b/docs/walkthrough/air-gapped-azure-devops-local-registry.md @@ -163,7 +163,7 @@ This generates: |------|---------| | `package.json` | Declares the CLI as a dependency | | `.azdo/pipelines/run-apiops-extractor.yml` | Extract pipeline | -| `.azdo/pipelines/run-apiops-publish.yml` | Publish pipeline | +| `.azdo/pipelines/run-apiops-publisher.yml` | Publish pipeline | | `configuration.*.yaml` | Override templates | Follow the remaining instructions listed in created `APIOPS-PIPELINE-IDENTITY-SETUP.md` or run `/apiops-setup-pipeline-identity` prompt. This creates the necessary variable groups and and service connections. @@ -178,7 +178,7 @@ This creates `package-lock.json`. Commit it — the lock file is **required** fo ### 2.3 Modify Pipelines for Air-Gapped Operation -The generated pipelines (`.azdo/pipelines/run-apiops-extractor.yml` and `.azdo/pipelines/run-apiops-publish.yml`) need the following edits: +The generated pipelines (`.azdo/pipelines/run-apiops-extractor.yml` and `.azdo/pipelines/run-apiops-publisher.yml`) need the following edits: | Edit | What to Change | |------|----------------| @@ -208,7 +208,7 @@ Commit the files required to run the local-registry workflow on self-hosted agen | `package.json` | Declares the CLI dependency. | | `package-lock.json` | Required for deterministic installs with `npm ci`. | | `.azdo/pipelines/run-apiops-extractor.yml` | Azure DevOps extract pipeline definition. | -| `.azdo/pipelines/run-apiops-publish.yml` | Azure DevOps publish pipeline definition. | +| `.azdo/pipelines/run-apiops-publisher.yml` | Azure DevOps publish pipeline definition. | | `configuration.*.yaml` | Generated environment override templates. | ```bash @@ -217,7 +217,7 @@ git add \ package.json \ package-lock.json \ .azdo/pipelines/run-apiops-extractor.yml \ - .azdo/pipelines/run-apiops-publish.yml \ + .azdo/pipelines/run-apiops-publisher.yml \ configuration.*.yaml git commit -m "chore: commit local-registry apiops bootstrap files" git push diff --git a/docs/walkthrough/air-gapped-azure-devops-offline-tarball.md b/docs/walkthrough/air-gapped-azure-devops-offline-tarball.md index 0fd39a1a..6703d7cc 100644 --- a/docs/walkthrough/air-gapped-azure-devops-offline-tarball.md +++ b/docs/walkthrough/air-gapped-azure-devops-offline-tarball.md @@ -79,7 +79,7 @@ This command generates: |------|---------| | `package.json` | Declares the CLI as a `file:` dependency pointing at the tarball | | `.azdo/pipelines/run-apiops-extractor.yml` | Extract pipeline | -| `.azdo/pipelines/run-apiops-publish.yml` | Publish pipeline | +| `.azdo/pipelines/run-apiops-publisher.yml` | Publish pipeline | | `configuration.*.yaml` | Override templates | Follow the remaining instructions listed in created `APIOPS-PIPELINE-IDENTITY-SETUP.md` or run `/apiops-setup-pipeline-identity` prompt. This creates the necessary variable groups and and service connections. @@ -97,7 +97,7 @@ npm ci # populates ~/.npm/_cacache/ with every package the lock file refe ### 2.3 Modify Pipelines for Air-Gapped Operation -The generated pipelines (`.azdo/pipelines/run-apiops-extractor.yml` and `.azdo/pipelines/run-apiops-publish.yml`) need the following edits: +The generated pipelines (`.azdo/pipelines/run-apiops-extractor.yml` and `.azdo/pipelines/run-apiops-publisher.yml`) need the following edits: | Edit | What to Change | |------|----------------| @@ -115,7 +115,7 @@ For the offline-tarball workflow, commit the files that make the pipeline fully | `package.json` | Contains the `file:` dependency pointing to the tarball. | | `package-lock.json` | Required for deterministic offline installs with `npm ci --offline`. | | `.azdo/pipelines/run-apiops-extractor.yml` | Azure DevOps extract pipeline definition. | -| `.azdo/pipelines/run-apiops-publish.yml` | Azure DevOps publish pipeline definition. | +| `.azdo/pipelines/run-apiops-publisher.yml` | Azure DevOps publish pipeline definition. | | `configuration.*.yaml` | Generated environment override templates. | ```bash @@ -124,7 +124,7 @@ git add \ package.json \ package-lock.json \ .azdo/pipelines/run-apiops-extractor.yml \ - .azdo/pipelines/run-apiops-publish.yml \ + .azdo/pipelines/run-apiops-publisher.yml \ configuration.*.yaml git commit -m "chore: commit offline-tarball apiops bootstrap files" git push diff --git a/src/services/init-service.ts b/src/services/init-service.ts index 4685f340..0adc8dd2 100644 --- a/src/services/init-service.ts +++ b/src/services/init-service.ts @@ -142,7 +142,7 @@ class InitServiceImpl implements InitService { ); const publishWorkflow = path.join( config.outputDir, - '.github/workflows/run-apiops-publish.yml' + '.github/workflows/run-apiops-publisher.yml' ); const promptFile = path.join( config.outputDir, @@ -186,7 +186,7 @@ class InitServiceImpl implements InitService { ); const publishPipeline = path.join( config.outputDir, - '.azdo/pipelines/run-apiops-publish.yml' + '.azdo/pipelines/run-apiops-publisher.yml' ); const identityGuide = path.join( config.outputDir, @@ -342,9 +342,9 @@ class InitServiceImpl implements InitService { environments: config.environments, }; const publishContent = generatePublishWorkflow(publishWorkflowConfig); - const publishPath = path.join(workflowsDir, 'run-apiops-publish.yml'); + const publishPath = path.join(workflowsDir, 'run-apiops-publisher.yml'); await fs.writeFile(publishPath, publishContent); - generatedFiles.pipelines.push('.github/workflows/run-apiops-publish.yml'); + generatedFiles.pipelines.push('.github/workflows/run-apiops-publisher.yml'); await this.generateCopilotIdentitySetupPrompt(config, generatedFiles); await this.generateCopilotConfigurationPrompts(config, generatedFiles); @@ -376,9 +376,9 @@ class InitServiceImpl implements InitService { environments: config.environments, }; const publishContent = generatePublishPipeline(publishPipelineConfig); - const publishPath = path.join(pipelinesDir, 'run-apiops-publish.yml'); + const publishPath = path.join(pipelinesDir, 'run-apiops-publisher.yml'); await fs.writeFile(publishPath, publishContent); - generatedFiles.pipelines.push('.azdo/pipelines/run-apiops-publish.yml'); + generatedFiles.pipelines.push('.azdo/pipelines/run-apiops-publisher.yml'); await this.generateCopilotIdentitySetupPrompt(config, generatedFiles); await this.generateCopilotConfigurationPrompts(config, generatedFiles); diff --git a/src/templates/copilot/identity-setup-prompt-azure-devops.md b/src/templates/copilot/identity-setup-prompt-azure-devops.md index 25df8d39..206105e3 100644 --- a/src/templates/copilot/identity-setup-prompt-azure-devops.md +++ b/src/templates/copilot/identity-setup-prompt-azure-devops.md @@ -76,7 +76,7 @@ Create Azure Pipelines from the YAML files in your repository. $REPO_NAME = $AZDO_PROJECT az pipelines create --name "apiops-extract" --repository $REPO_NAME --branch main --yml-path ".azdo/pipelines/run-apiops-extractor.yml" --repository-type tfsgit --skip-first-run true -az pipelines create --name "apiops-publish" --repository $REPO_NAME --branch main --yml-path ".azdo/pipelines/run-apiops-publish.yml" --repository-type tfsgit --skip-first-run true +az pipelines create --name "apiops-publish" --repository $REPO_NAME --branch main --yml-path ".azdo/pipelines/run-apiops-publisher.yml" --repository-type tfsgit --skip-first-run true ``` **Git Bash:** @@ -84,7 +84,7 @@ az pipelines create --name "apiops-publish" --repository $REPO_NAME --branch mai REPO_NAME="$AZDO_PROJECT" az pipelines create --name "apiops-extract" --repository "$REPO_NAME" --branch main --yml-path ".azdo/pipelines/run-apiops-extractor.yml" --repository-type tfsgit --skip-first-run true -az pipelines create --name "apiops-publish" --repository "$REPO_NAME" --branch main --yml-path ".azdo/pipelines/run-apiops-publish.yml" --repository-type tfsgit --skip-first-run true +az pipelines create --name "apiops-publish" --repository "$REPO_NAME" --branch main --yml-path ".azdo/pipelines/run-apiops-publisher.yml" --repository-type tfsgit --skip-first-run true ``` Verify pipelines were created: diff --git a/src/templates/identity/identity-guide-azure-devops.md b/src/templates/identity/identity-guide-azure-devops.md index 8eb80730..8095f04c 100644 --- a/src/templates/identity/identity-guide-azure-devops.md +++ b/src/templates/identity/identity-guide-azure-devops.md @@ -24,7 +24,7 @@ Helpful documentation: 1. Commit and push the generated files to your Azure DevOps repository. 2. Confirm these files are available in the repo browser: - `.azdo/pipelines/run-apiops-extractor.yml` - - `.azdo/pipelines/run-apiops-publish.yml` + - `.azdo/pipelines/run-apiops-publisher.yml` 3. Note the environment names you chose when running `apiops init` because you will create matching service connections, variable groups, and approvals. ## Step 2: Create or choose an app registration in Azure portal @@ -111,7 +111,7 @@ Use **Access control (IAM)** on each resource group and APIM instance to create 3. Select **Existing Azure Pipelines YAML file**. 4. Create a pipeline for each generated YAML file: - `.azdo/pipelines/run-apiops-extractor.yml` - - `.azdo/pipelines/run-apiops-publish.yml` + - `.azdo/pipelines/run-apiops-publisher.yml` 5. Save without running if you still need approvals or variable authorization. ## Step 9: Verify the setup diff --git a/tests/unit/services/init-service.test.ts b/tests/unit/services/init-service.test.ts index 54aa5c87..37ea7545 100644 --- a/tests/unit/services/init-service.test.ts +++ b/tests/unit/services/init-service.test.ts @@ -105,7 +105,7 @@ describe('init-service', () => { const result = await initService.run(config); expect(result.pipelines).toContain('.github/workflows/run-apiops-extractor.yml'); - expect(result.pipelines).toContain('.github/workflows/run-apiops-publish.yml'); + expect(result.pipelines).toContain('.github/workflows/run-apiops-publisher.yml'); }); it('should generate Azure DevOps pipelines when ciProvider is azure-devops', async () => { @@ -122,7 +122,7 @@ describe('init-service', () => { const result = await initService.run(config); expect(result.pipelines).toContain('.azdo/pipelines/run-apiops-extractor.yml'); - expect(result.pipelines).toContain('.azdo/pipelines/run-apiops-publish.yml'); + expect(result.pipelines).toContain('.azdo/pipelines/run-apiops-publisher.yml'); }); it('should generate filter configuration file', async () => { From 7d64320380bfb0cf705b7f22db77b269cfae8dbb Mon Sep 17 00:00:00 2001 From: Elizabeth Maher Date: Mon, 29 Jun 2026 14:02:03 -0700 Subject: [PATCH 07/15] refactor: remove manualGuideContext injection from prompt files Remove the UI Reference Context section that injected the entire manual guide into the Copilot prompt files. Keeps prompts lean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../identity-setup-prompt-azure-devops.md | 10 +------ .../identity-setup-prompt-github-actions.md | 8 ----- .../copilot/identity-setup-prompt.ts | 30 ------------------- .../copilot/identity-setup-prompt.test.ts | 6 +--- 4 files changed, 2 insertions(+), 52 deletions(-) diff --git a/src/templates/copilot/identity-setup-prompt-azure-devops.md b/src/templates/copilot/identity-setup-prompt-azure-devops.md index 206105e3..81908113 100644 --- a/src/templates/copilot/identity-setup-prompt-azure-devops.md +++ b/src/templates/copilot/identity-setup-prompt-azure-devops.md @@ -90,12 +90,4 @@ az pipelines create --name "apiops-publish" --repository "$REPO_NAME" --branch m Verify pipelines were created: ```bash az pipelines list --query "[].name" -o table -``` - ---- - -## UI Reference Context - -Use this UI-oriented reference when you need to explain where a setting lives in the Azure portal or Azure DevOps web portal while automating the setup. - -{{IDENTITY_GUIDE_CONTEXT}} \ No newline at end of file +``` \ No newline at end of file diff --git a/src/templates/copilot/identity-setup-prompt-github-actions.md b/src/templates/copilot/identity-setup-prompt-github-actions.md index f3a2dd61..a2ba1c42 100644 --- a/src/templates/copilot/identity-setup-prompt-github-actions.md +++ b/src/templates/copilot/identity-setup-prompt-github-actions.md @@ -226,11 +226,3 @@ The generated workflows expect these secrets: ### Per-Environment Secrets {{ENV_SECRETS_REFERENCE}} - ---- - -## UI Reference Context - -Use this UI-oriented reference when you need to explain where a setting lives in the Azure portal or GitHub web UI while automating the setup. - -{{IDENTITY_GUIDE_CONTEXT}} diff --git a/src/templates/copilot/identity-setup-prompt.ts b/src/templates/copilot/identity-setup-prompt.ts index 8e190824..41734811 100644 --- a/src/templates/copilot/identity-setup-prompt.ts +++ b/src/templates/copilot/identity-setup-prompt.ts @@ -11,12 +11,10 @@ import { azureDevOpsIdentitySetupCoreTemplate, - azureDevOpsIdentityGuideTemplate, copilotAzureDevOpsIdentitySetupPromptTemplate, copilotGithubEnvironmentFederatedCredentialTemplate, copilotGithubEnvironmentSecretCommandsTemplate, copilotGitHubActionsIdentitySetupPromptTemplate, - githubActionsIdentityGuideTemplate, } from '../generated/embedded-markdown.js'; import { renderTemplate } from '../../lib/render-template.js'; @@ -39,18 +37,8 @@ export function generateIdentitySetupPrompt(config: IdentitySetupPromptConfig): ENVIRONMENTS_ARRAY_BASH: environmentsArrayBash, }); - const manualGuideContext = renderTemplate(azureDevOpsIdentityGuideTemplate, { - SERVICE_CONNECTION_ROWS: config.environments.map((environment) => - `| \`${environment}\` | \`AZURE_SERVICE_CONNECTION_${environment.toUpperCase()}\` | Azure Resource Manager → Workload identity federation → subscription that contains the ${environment} APIM instance |` - ).join('\n'), - VARIABLE_GROUP_ROWS: config.environments.map((environment) => - `| \`${environment}\` | \`apim-${environment}\` | \`AZURE_SERVICE_CONNECTION\`, \`AZURE_SUBSCRIPTION_ID\`, \`APIM_RESOURCE_GROUP\`, \`APIM_SERVICE_NAME\`, \`APIM_RESOURCE_GROUP_${environment.toUpperCase()}\`, \`APIM_SERVICE_NAME_${environment.toUpperCase()}\` |` - ).join('\n'), - }); - return renderTemplate(copilotAzureDevOpsIdentitySetupPromptTemplate, { AZURE_DEVOPS_CORE_STEPS: coreSteps, - IDENTITY_GUIDE_CONTEXT: manualGuideContext, }); } @@ -107,23 +95,6 @@ az role assignment create \\ \`\`\`` ).join('\n\n'); - const manualGuideContext = renderTemplate(githubActionsIdentityGuideTemplate, { - SUBSCRIPTION_ID: '', - RESOURCE_GROUP: '', - FEDERATED_CREDENTIAL_ROWS: config.environments.map((env) => - `| \`${env}\` | \`github-env-${env}\` | \`repo:/:environment:${env}\` |` - ).join('\n'), - ENVIRONMENT_AZURE_ACCESS_NOTES: config.environments.map((env) => - `- **${env}** — assign **Reader** on the resource group you will store in \`APIM_RESOURCE_GROUP_${env.toUpperCase()}\`, then assign **API Management Service Contributor** on the APIM instance you will store in \`APIM_SERVICE_NAME_${env.toUpperCase()}\`.` - ).join('\n'), - ENVIRONMENT_SECRET_SECTIONS: config.environments.map((env) => `### ${env} environment -- Create a GitHub environment named \`${env}\` in **Settings** → **Environments** -- Add environment secret \`AZURE_SUBSCRIPTION_ID\` with the subscription ID used for ${env} -- Add environment secret \`APIM_RESOURCE_GROUP_${env.toUpperCase()}\` with the APIM resource group for ${env} -- Add environment secret \`APIM_SERVICE_NAME_${env.toUpperCase()}\` with the APIM service name for ${env} -`).join('\n'), - }); - return renderTemplate(copilotGitHubActionsIdentitySetupPromptTemplate, { ENV_SECRETS_REFERENCE: envSecrets, ENV_FEDERATED_CREDENTIALS: envFedCreds, @@ -133,6 +104,5 @@ az role assignment create \\ ENV_APIM_TABLE_ROWS: envApimTableRows, ENV_READER_ROLE_SNIPPETS: envReaderRoleSnippets, ENV_APIM_ROLE_SNIPPETS: envApimRoleSnippets, - IDENTITY_GUIDE_CONTEXT: manualGuideContext, }); } diff --git a/tests/unit/templates/copilot/identity-setup-prompt.test.ts b/tests/unit/templates/copilot/identity-setup-prompt.test.ts index c12ed476..ab257a2b 100644 --- a/tests/unit/templates/copilot/identity-setup-prompt.test.ts +++ b/tests/unit/templates/copilot/identity-setup-prompt.test.ts @@ -111,11 +111,9 @@ describe('copilot/identity-setup-prompt', () => { expect(prompt).toContain('Open this file in VS Code with GitHub Copilot'); }); - it('should include the GitHub identity distinction note and UI guide context', () => { + it('should include the GitHub identity distinction note', () => { const prompt = generateIdentitySetupPrompt({ environments: ['dev'] }); expect(prompt).toContain('pull request creation automatically'); - expect(prompt).toContain('## UI Reference Context'); - expect(prompt).toContain('APIOps GitHub Actions identity setup guide'); }); it('should include tool authentication check in Step 0', () => { @@ -178,8 +176,6 @@ describe('copilot/identity-setup-prompt', () => { ciProvider: 'azure-devops', }); expect(prompt).toContain('Build Service identity'); - expect(prompt).toContain('## UI Reference Context'); - expect(prompt).toContain('Identity setup guide for APIOps extract and publish Azure DevOps Pipelines'); }); it('should ask Copilot to gather per-environment APIM info for each ADO environment', () => { From 27196e173664599492497143ffda5d26d067c93f Mon Sep 17 00:00:00 2001 From: Elizabeth Maher Date: Mon, 29 Jun 2026 14:08:10 -0700 Subject: [PATCH 08/15] refactor: remove all token injection from manual identity guides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual guide markdown files are now fully static with generic 'for each environment' instructions. No more dynamic rendering — the identity-guide-service just returns the template content as-is. This makes the guides easier to maintain. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/services/identity-guide-service.ts | 61 ++------- src/services/init-service.ts | 16 +-- .../identity/identity-guide-azure-devops.md | 26 ++-- .../identity/identity-guide-github-actions.md | 30 ++--- .../services/identity-guide-service.test.ts | 119 +++++------------- 5 files changed, 68 insertions(+), 184 deletions(-) diff --git a/src/services/identity-guide-service.ts b/src/services/identity-guide-service.ts index 06328e13..d8aa8165 100644 --- a/src/services/identity-guide-service.ts +++ b/src/services/identity-guide-service.ts @@ -2,73 +2,26 @@ // Licensed under the MIT license. /** * Identity setup guide generator - * Step-by-step instructions for service principal, RBAC, federated credentials, - * pipeline secrets/service connections. Optional az CLI automation per FR-021. + * Returns the static manual guide content for the selected CI provider. */ import { azureDevOpsIdentityGuideTemplate, githubActionsIdentityGuideTemplate, } from '../templates/generated/embedded-markdown.js'; -import { renderTemplate } from '../lib/render-template.js'; export interface IdentityGuideService { - generateGitHubActionsGuide( - subscriptionId: string, - resourceGroup: string, - environments: string[] - ): string; - - generateAzureDevOpsGuide( - environments: string[] - ): string; + generateGitHubActionsGuide(): string; + generateAzureDevOpsGuide(): string; } class IdentityGuideServiceImpl implements IdentityGuideService { - generateGitHubActionsGuide( - subscriptionId: string, - resourceGroup: string, - environments: string[] - ): string { - const federatedCredentialRows = environments.map((env) => - `| \`${env}\` | \`github-env-${env}\` | \`repo:/:environment:${env}\` |` - ).join('\n'); - - const environmentSecrets = environments.map((env) => `### ${env} environment -- Create a GitHub environment named \`${env}\` in **Settings** → **Environments** -- Add environment secret \`AZURE_SUBSCRIPTION_ID\` with the subscription ID used for ${env} -- Add environment secret \`APIM_RESOURCE_GROUP_${env.toUpperCase()}\` with the APIM resource group for ${env} -- Add environment secret \`APIM_SERVICE_NAME_${env.toUpperCase()}\` with the APIM service name for ${env} -`).join('\n'); - - const environmentAzureAccessNotes = environments.map((env) => - `- **${env}** — assign **Reader** on the resource group you will store in \`APIM_RESOURCE_GROUP_${env.toUpperCase()}\`, then assign **API Management Service Contributor** on the APIM instance you will store in \`APIM_SERVICE_NAME_${env.toUpperCase()}\`.` - ).join('\n'); - - return renderTemplate(githubActionsIdentityGuideTemplate, { - SUBSCRIPTION_ID: subscriptionId, - RESOURCE_GROUP: resourceGroup, - FEDERATED_CREDENTIAL_ROWS: federatedCredentialRows, - ENVIRONMENT_SECRET_SECTIONS: environmentSecrets, - ENVIRONMENT_AZURE_ACCESS_NOTES: environmentAzureAccessNotes, - }); + generateGitHubActionsGuide(): string { + return githubActionsIdentityGuideTemplate; } - generateAzureDevOpsGuide( - environments: string[] - ): string { - const serviceConnectionRows = environments.map((environment) => - `| \`${environment}\` | \`AZURE_SERVICE_CONNECTION_${environment.toUpperCase()}\` | Azure Resource Manager → Workload identity federation → subscription that contains the ${environment} APIM instance |` - ).join('\n'); - - const variableGroupRows = environments.map((environment) => - `| \`${environment}\` | \`apim-${environment}\` | \`AZURE_SERVICE_CONNECTION\`, \`AZURE_SUBSCRIPTION_ID\`, \`APIM_RESOURCE_GROUP\`, \`APIM_SERVICE_NAME\`, \`APIM_RESOURCE_GROUP_${environment.toUpperCase()}\`, \`APIM_SERVICE_NAME_${environment.toUpperCase()}\` |` - ).join('\n'); - - return renderTemplate(azureDevOpsIdentityGuideTemplate, { - SERVICE_CONNECTION_ROWS: serviceConnectionRows, - VARIABLE_GROUP_ROWS: variableGroupRows, - }); + generateAzureDevOpsGuide(): string { + return azureDevOpsIdentityGuideTemplate; } } diff --git a/src/services/init-service.ts b/src/services/init-service.ts index 0adc8dd2..b3d8e865 100644 --- a/src/services/init-service.ts +++ b/src/services/init-service.ts @@ -35,10 +35,6 @@ import { generateIdentitySetupPrompt } from '../templates/copilot/identity-setup import { generateConfigureFilterPrompt } from '../templates/copilot/configure-filter-prompt.js'; import { generateConfigureOverridesPrompt } from '../templates/copilot/configure-overrides-prompt.js'; -/** Placeholder values used in generated identity setup guides */ -const PLACEHOLDER_SUBSCRIPTION_ID = ''; -const PLACEHOLDER_RESOURCE_GROUP = ''; - export interface GeneratedFiles { pipelines: string[]; configs: string[]; @@ -453,19 +449,11 @@ class InitServiceImpl implements InitService { * Save identity setup guide to file and tell user where to find it */ private async outputIdentityGuide(config: InitConfig, generatedFiles: GeneratedFiles): Promise { - // Use placeholder values for the guide — users replace these with their actual Azure details - const subscriptionId = PLACEHOLDER_SUBSCRIPTION_ID; - const resourceGroup = PLACEHOLDER_RESOURCE_GROUP; - let guide: string; if (config.ciProvider === 'github-actions') { - guide = identityGuideService.generateGitHubActionsGuide( - subscriptionId, - resourceGroup, - config.environments - ); + guide = identityGuideService.generateGitHubActionsGuide(); } else { - guide = identityGuideService.generateAzureDevOpsGuide(config.environments); + guide = identityGuideService.generateAzureDevOpsGuide(); } // Save guide to file diff --git a/src/templates/identity/identity-guide-azure-devops.md b/src/templates/identity/identity-guide-azure-devops.md index 8095f04c..ddf550b7 100644 --- a/src/templates/identity/identity-guide-azure-devops.md +++ b/src/templates/identity/identity-guide-azure-devops.md @@ -55,16 +55,13 @@ Use **Access control (IAM)** on each resource group and APIM instance to create > 📖 [Create an Azure Resource Manager service connection using workload identity federation](https://learn.microsoft.com/azure/devops/pipelines/library/connect-to-azure) +For each environment, do the following steps: + 1. In **Azure DevOps**, open **Project settings** → **Service connections** → **New service connection**. 2. Choose **Azure Resource Manager**. 3. Select **Workload identity federation**. 4. Use the app registration from Step 2. -5. Create one service connection per environment using the exact names below unless you also plan to edit the generated pipeline YAML and variable-group values: - -| Environment | Required service connection name | Scope guidance | -|---|---|---| -{{SERVICE_CONNECTION_ROWS}} - +5. Name the service connection `AZURE_SERVICE_CONNECTION_` (replace `` with the upper-case environment name) unless you plan to edit the generated pipeline YAML. 6. Complete the validation flow in Azure DevOps so the service connection can issue the issuer and subject values required for federation. 7. If Azure DevOps asks you to finish the federated credential in Azure, follow the linked experience or copy the issuer/subject into the app registration's **Federated credentials** blade in the Azure portal. @@ -72,14 +69,17 @@ Use **Access control (IAM)** on each resource group and APIM instance to create > 📖 [Add and use variable groups in Azure Pipelines](https://learn.microsoft.com/azure/devops/pipelines/library/variable-groups) -1. Go to **Pipelines** → **Library** → **+ Variable group**. -2. Create one variable group per environment: - -| Environment | Variable group name | Required variables | -|---|---|---| -{{VARIABLE_GROUP_ROWS}} +For each environment, do the following steps: -3. Include both the non-suffixed variables used by the extractor pipeline and the environment-suffixed APIM variables used by the publish pipeline. +1. Go to **Pipelines** → **Library** → **+ Variable group**. +2. Name the variable group `apim-` (e.g. `apim-dev`, `apim-prod`). +3. Add the following variables: + - `AZURE_SERVICE_CONNECTION` — the service connection name from Step 4 + - `AZURE_SUBSCRIPTION_ID` — the Azure subscription ID for that environment + - `APIM_RESOURCE_GROUP` — the resource group containing the APIM instance + - `APIM_SERVICE_NAME` — the APIM service name + - `APIM_RESOURCE_GROUP_` — same value, upper-case environment suffix (used by the publish pipeline) + - `APIM_SERVICE_NAME_` — same value, upper-case environment suffix (used by the publish pipeline) 4. Authorize each variable group for pipeline use when Azure DevOps prompts you. ## Step 6: Create Azure DevOps environments and approvals diff --git a/src/templates/identity/identity-guide-github-actions.md b/src/templates/identity/identity-guide-github-actions.md index a11ee920..e80e2422 100644 --- a/src/templates/identity/identity-guide-github-actions.md +++ b/src/templates/identity/identity-guide-github-actions.md @@ -6,8 +6,7 @@ ## Before you start -- Azure subscription: `{{SUBSCRIPTION_ID}}` -- Resource group containing an APIM instance: `{{RESOURCE_GROUP}}` +- An Azure subscription with an API Management instance - GitHub repository admin access - Permission in Microsoft Entra ID to create or manage app registrations @@ -44,11 +43,12 @@ Helpful documentation: > 📖 [Assign Azure roles using the Azure portal](https://learn.microsoft.com/azure/role-based-access-control/role-assignments-portal) -1. In the **Azure portal**, open your API Management service. +For each environment, do the following steps: + +1. In the **Azure portal**, open the resource group that contains the APIM instance for that environment. 2. Go to **Access control (IAM)** → **Add role assignment**. -3. For every environment configured by `apiops init`, assign access to the app registration or service principal: -{{ENVIRONMENT_AZURE_ACCESS_NOTES}} -4. If you are starting with a single shared APIM resource in resource group `{{RESOURCE_GROUP}}` and subscription `{{SUBSCRIPTION_ID}}`, those placeholders are the first values to replace. +3. Assign **Reader** on the resource group to the app registration. +4. Open the APIM service itself and assign **API Management Service Contributor** to the app registration. 5. Wait a few minutes for RBAC changes to propagate before you test the workflow. ## Step 4: Add federated credentials in Azure portal @@ -64,18 +64,17 @@ Helpful documentation: - **Entity type**: **Branch** - **Branch name**: `main` - **Credential name**: `github-main-branch` -4. Add one credential for each GitHub environment used by publish workflows: - -| GitHub environment | Suggested credential name | Subject value | -|---|---|---| -{{FEDERATED_CREDENTIAL_ROWS}} +4. For each environment, add one additional federated credential: + - **Entity type**: **Environment** + - **Environment name**: the GitHub environment name (must match what you create in Step 5) + - **Credential name**: `github-env-` ## Step 5: Create GitHub environments in the web UI > 📖 [Using environments for deployment](https://docs.github.com/actions/deployment/targeting-different-environments/using-environments-for-deployment) 1. In GitHub, go to **Settings** → **Environments**. -2. Create an environment for each deployment target used by `apiops init`. +2. For each environment, create a GitHub environment with the same name used during `apiops init`. 3. Add protection rules such as required reviewers for production environments if your process needs approvals. ## Step 6: Add repository and environment secrets in GitHub @@ -86,9 +85,10 @@ Helpful documentation: 2. Under **Repository secrets**, create: - `AZURE_CLIENT_ID` — the Application (client) ID from the app registration - `AZURE_TENANT_ID` — the Directory (tenant) ID from the app registration -3. Then configure the environment secrets below: - -{{ENVIRONMENT_SECRET_SECTIONS}} +3. For each environment, add the following **environment secrets**: + - `AZURE_SUBSCRIPTION_ID` — the Azure subscription ID for that environment + - `APIM_RESOURCE_GROUP_` — the resource group containing the APIM instance (replace `` with the upper-case environment name) + - `APIM_SERVICE_NAME_` — the APIM service name (replace `` with the upper-case environment name) ## Step 7: Verify the workflow end to end diff --git a/tests/unit/services/identity-guide-service.test.ts b/tests/unit/services/identity-guide-service.test.ts index 003eb2ce..5f63a25a 100644 --- a/tests/unit/services/identity-guide-service.test.ts +++ b/tests/unit/services/identity-guide-service.test.ts @@ -9,143 +9,86 @@ import { identityGuideService } from '../../../src/services/identity-guide-servi describe('identity-guide-service', () => { describe('generateGitHubActionsGuide', () => { - it('should include subscription ID in guide', () => { - const guide = identityGuideService.generateGitHubActionsGuide( - 'sub-12345', - 'my-rg', - ['dev', 'prod'] - ); - expect(guide).toContain('sub-12345'); - }); - - it('should include resource group in guide', () => { - const guide = identityGuideService.generateGitHubActionsGuide( - 'sub-12345', - 'my-rg', - ['dev'] - ); - expect(guide).toContain('my-rg'); + it('should return static GitHub Actions guide content', () => { + const guide = identityGuideService.generateGitHubActionsGuide(); + expect(guide).toContain('APIOps GitHub Actions identity setup guide'); }); it('should mention the Copilot prompt file and UI flow', () => { - const guide = identityGuideService.generateGitHubActionsGuide( - 'sub-12345', - 'my-rg', - ['dev'] - ); + const guide = identityGuideService.generateGitHubActionsGuide(); expect(guide).toContain('.github/prompts/apiops-setup-workflow-identity.prompt.md'); expect(guide).toContain('Azure portal'); expect(guide).toContain('GitHub web UI'); }); it('should explain the GitHub identity distinction', () => { - const guide = identityGuideService.generateGitHubActionsGuide( - 'sub-12345', - 'my-rg', - ['dev'] - ); + const guide = identityGuideService.generateGitHubActionsGuide(); expect(guide).toContain('GITHUB_TOKEN'); expect(guide).toContain('only for Azure and APIM access'); }); it('should include portal-based Azure access steps', () => { - const guide = identityGuideService.generateGitHubActionsGuide( - 'sub-12345', - 'my-rg', - ['dev'] - ); + const guide = identityGuideService.generateGitHubActionsGuide(); expect(guide).toContain('API Management Service Contributor'); expect(guide).toContain('Access control (IAM)'); expect(guide).toContain('Federated credentials'); }); it('should include documentation links', () => { - const guide = identityGuideService.generateGitHubActionsGuide( - 'sub-12345', - 'my-rg', - ['dev'] - ); + const guide = identityGuideService.generateGitHubActionsGuide(); expect(guide).toContain('https://learn.microsoft.com/'); expect(guide).toContain('https://docs.github.com/'); }); - it('should include environment-specific secrets for each environment', () => { - const guide = identityGuideService.generateGitHubActionsGuide( - 'sub-12345', - 'my-rg', - ['dev', 'staging', 'prod'] - ); - expect(guide).toContain('dev environment'); - expect(guide).toContain('staging environment'); - expect(guide).toContain('prod environment'); - expect(guide).toContain('APIM_RESOURCE_GROUP_DEV'); - expect(guide).toContain('APIM_RESOURCE_GROUP_STAGING'); - expect(guide).toContain('APIM_RESOURCE_GROUP_PROD'); + it('should describe environment secrets generically', () => { + const guide = identityGuideService.generateGitHubActionsGuide(); + expect(guide).toContain('APIM_RESOURCE_GROUP_'); + expect(guide).toContain('APIM_SERVICE_NAME_'); + expect(guide).toContain('For each environment'); }); it('should include security notes', () => { - const guide = identityGuideService.generateGitHubActionsGuide( - 'sub-12345', - 'my-rg', - ['dev'] - ); + const guide = identityGuideService.generateGitHubActionsGuide(); expect(guide).toContain('Security Notes'); expect(guide).toContain('least-privilege'); }); - it('should create federated credential for each environment', () => { - const guide = identityGuideService.generateGitHubActionsGuide( - 'sub-12345', - 'my-rg', - ['dev', 'prod'] - ); - expect(guide).toContain('github-env-dev'); - expect(guide).toContain('github-env-prod'); - expect(guide).toContain('environment:dev'); - expect(guide).toContain('environment:prod'); - }); - - it('should render all template placeholders', () => { - const guide = identityGuideService.generateGitHubActionsGuide( - 'sub-12345', - 'my-rg', - ['dev'] - ); - expect(guide).not.toContain('{{'); - expect(guide).not.toContain('}}'); + it('should not contain any template placeholders', () => { + const guide = identityGuideService.generateGitHubActionsGuide(); + expect(guide).not.toMatch(/\{\{[^}]+\}\}/); }); }); describe('generateAzureDevOpsGuide', () => { + it('should return static Azure DevOps guide content', () => { + const guide = identityGuideService.generateAzureDevOpsGuide(); + expect(guide).toContain('Identity setup guide for APIOps extract and publish Azure DevOps Pipelines'); + }); + it('should mention the Copilot prompt file and UI flow', () => { - const guide = identityGuideService.generateAzureDevOpsGuide(['dev', 'prod']); + const guide = identityGuideService.generateAzureDevOpsGuide(); expect(guide).toContain('.github/prompts/apiops-setup-pipeline-identity.prompt.md'); - expect(guide).toContain('Azure DevOps web portal'); + expect(guide).toContain('Azure DevOps'); expect(guide).toContain('Azure portal'); }); it('should explain the Azure DevOps identity distinction', () => { - const guide = identityGuideService.generateAzureDevOpsGuide(['dev', 'prod']); + const guide = identityGuideService.generateAzureDevOpsGuide(); expect(guide).toContain('Build Service identity'); expect(guide).toContain('separate from the Azure app registration'); expect(guide).toContain('Create pull request'); }); - it('should include environment-specific service connections and variable groups', () => { - const guide = identityGuideService.generateAzureDevOpsGuide(['dev', 'prod']); - expect(guide).toContain('AZURE_SERVICE_CONNECTION_DEV'); - expect(guide).toContain('AZURE_SERVICE_CONNECTION_PROD'); - expect(guide).toContain('apim-dev'); - expect(guide).toContain('apim-prod'); - expect(guide).toContain('AZURE_SERVICE_CONNECTION'); - expect(guide).toContain('APIM_RESOURCE_GROUP_DEV'); - expect(guide).toContain('APIM_SERVICE_NAME_PROD'); + it('should describe service connections and variable groups generically', () => { + const guide = identityGuideService.generateAzureDevOpsGuide(); + expect(guide).toContain('AZURE_SERVICE_CONNECTION_'); + expect(guide).toContain('apim-'); + expect(guide).toContain('For each environment'); }); - it('should render all template placeholders', () => { - const guide = identityGuideService.generateAzureDevOpsGuide(['dev']); + it('should not contain any template placeholders', () => { + const guide = identityGuideService.generateAzureDevOpsGuide(); expect(guide).not.toMatch(/\{\{[^}]+\}\}/); }); - }); }); From 14cb38f3a05554a41303681725ee3101f08cc752 Mon Sep 17 00:00:00 2001 From: Elizabeth Maher Date: Mon, 29 Jun 2026 14:12:46 -0700 Subject: [PATCH 09/15] refactor: inline Azure DevOps core template into prompt file Combines src/templates/shared/identity-setup-azure-devops-core.md directly into the Azure DevOps prompt template, eliminating a level of indirection. The two dynamic tokens (ENVIRONMENTS_ARRAY_POWERSHELL/BASH) are now rendered directly in the prompt template. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/embed-markdown-templates.mjs | 4 - .../identity-setup-prompt-azure-devops.md | 449 +++++++++++++++++- .../copilot/identity-setup-prompt.ts | 7 +- .../identity-setup-azure-devops-core.md | 448 ----------------- 4 files changed, 449 insertions(+), 459 deletions(-) delete mode 100644 src/templates/shared/identity-setup-azure-devops-core.md diff --git a/scripts/embed-markdown-templates.mjs b/scripts/embed-markdown-templates.mjs index 9f8af03f..5f4411a8 100644 --- a/scripts/embed-markdown-templates.mjs +++ b/scripts/embed-markdown-templates.mjs @@ -21,10 +21,6 @@ const templates = [ exportName: 'copilotConfigureOverridesPromptTemplate', path: 'src/templates/copilot/configure-overrides-prompt.md', }, - { - exportName: 'azureDevOpsIdentitySetupCoreTemplate', - path: 'src/templates/shared/identity-setup-azure-devops-core.md', - }, { exportName: 'githubActionsIdentityGuideTemplate', path: 'src/templates/identity/identity-guide-github-actions.md', diff --git a/src/templates/copilot/identity-setup-prompt-azure-devops.md b/src/templates/copilot/identity-setup-prompt-azure-devops.md index 81908113..20f70a4d 100644 --- a/src/templates/copilot/identity-setup-prompt-azure-devops.md +++ b/src/templates/copilot/identity-setup-prompt-azure-devops.md @@ -12,7 +12,454 @@ Configure workload identity federation (OIDC), Azure DevOps federated service co and variable groups for APIOps extract and publish pipelines. -{{AZURE_DEVOPS_CORE_STEPS}} +## Prerequisites +- Azure DevOps organization and project +- Azure CLI installed and authenticated (`az login`) +- `az devops` extension (`az extension add --name azure-devops`) + +This flow is designed for Microsoft-hosted or self-hosted agents and uses workload identity federation (OIDC) instead of managed identity. + +> **Note:** All commands are shown for both **PowerShell** and **Git Bash** where syntax differs. + +--- + +## Step 1: Gather Per-Environment Information + +**Copilot:** Ask the user for the following values before proceeding. Store each answer for use in later steps. + +For each environment, provide either **Option A** (three separate values) or **Option B** (a single full APIM resource ID in the form `/subscriptions//resourceGroups//providers/Microsoft.ApiManagement/service/`). Copilot will parse Option B into the individual components automatically. + +| Variable | Description | Example | +|----------|-------------|---------| +| `APP_NAME` | Display name for the Entra application | `apiops-azdo-sp` | +| `AZDO_ORG` | Azure DevOps organization URL | `https://dev.azure.com/my-org` | +| `AZDO_PROJECT` | Azure DevOps project name | `my-project` | +| `TENANT_ID` | Tenant ID that should own the app/service connection | `11111111-2222-3333-4444-555555555555` | + +For each environment in the configured list, gather these values (where `` is the upper-case environment name): +- `APIM_SUBSCRIPTION_` +- `APIM_RG_` +- `APIM_NAME_` +- `APIM_RESOURCE_ID_` *(optional Option B shorthand)* + +--- + +## Step 2: Set Variables + +**PowerShell:** +```powershell +$APP_NAME = "apiops-azdo-sp" +$AZDO_ORG = "" +$AZDO_PROJECT = "" +$TENANT_ID = "" +$ENVIRONMENTS = @({{ENVIRONMENTS_ARRAY_POWERSHELL}}) + +# Fill these maps with values for each environment. +$APIM_SUBSCRIPTIONS = @{} +$APIM_RESOURCE_GROUPS = @{} +$APIM_SERVICE_NAMES = @{} + +foreach ($env in $ENVIRONMENTS) { + # Option A: provide values directly. + $APIM_SUBSCRIPTIONS[$env] = "" + $APIM_RESOURCE_GROUPS[$env] = "" + $APIM_SERVICE_NAMES[$env] = "" + + # Option B: if APIM resource ID is provided, parse it into the same maps. + # $resourceId = "/subscriptions//resourceGroups//providers/Microsoft.ApiManagement/service/" + # if ($resourceId) { + # $parts = $resourceId.Trim('/') -split '/' + # $APIM_SUBSCRIPTIONS[$env] = $parts[1] + # $APIM_RESOURCE_GROUPS[$env] = $parts[3] + # $APIM_SERVICE_NAMES[$env] = $parts[7] + # } +} +``` + +**Git Bash:** +```bash +APP_NAME="apiops-azdo-sp" +AZDO_ORG="" +AZDO_PROJECT="" +TENANT_ID="" +ENVIRONMENTS=({{ENVIRONMENTS_ARRAY_BASH}}) + +# Fill these maps with values for each environment. +declare -A APIM_SUBSCRIPTIONS +declare -A APIM_RESOURCE_GROUPS +declare -A APIM_SERVICE_NAMES + +for env in "${ENVIRONMENTS[@]}"; do + # Option A: provide values directly. + APIM_SUBSCRIPTIONS["$env"]="" + APIM_RESOURCE_GROUPS["$env"]="" + APIM_SERVICE_NAMES["$env"]="" + + # Option B: if APIM resource ID is provided, parse it into the same maps. + # resource_id="/subscriptions//resourceGroups//providers/Microsoft.ApiManagement/service/" + # if [[ -n "$resource_id" ]]; then + # IFS='/' read -r _ subscriptions sub resourceGroups rg providers provider service svc <<< "$resource_id" + # APIM_SUBSCRIPTIONS["$env"]="$sub" + # APIM_RESOURCE_GROUPS["$env"]="$rg" + # APIM_SERVICE_NAMES["$env"]="$svc" + # fi +done +``` + +--- + +## Step 3: Configure Azure DevOps CLI + +Install the extension (works in both shells): +```bash +az extension add --name azure-devops +``` + +Install the Azure DevOps Replace Tokens extension (required by publish pipeline): + +**PowerShell:** +```powershell +az devops extension install --publisher-id qetza --extension-id replacetokens +``` + +**Git Bash:** +```bash +az devops extension install --publisher-id qetza --extension-id replacetokens +``` + +Set organization defaults: + +For self-hosted Azure DevOps Server, use your server/collection URL format: +- `https:///` + +**PowerShell:** +```powershell +az devops configure --defaults organization=$AZDO_ORG project=$AZDO_PROJECT +$ORG_NAME = $AZDO_ORG -replace 'https://dev\.azure\.com/', '' +``` + +**Git Bash:** +```bash +az devops configure --defaults organization="$AZDO_ORG" project="$AZDO_PROJECT" +ORG_NAME="${AZDO_ORG##*/}" +``` + +**Self-hosted note:** If your server URL includes a collection segment (for example, `https://ado.contoso.local/DefaultCollection`), set `ORG_NAME` to the value expected in the Build Service identity display name for your server/project. + +--- + +## Step 4: Verify Tenant ID + +Before creating identity objects, confirm you are logged into the intended tenant. + +**PowerShell:** +```powershell +$CURRENT_TENANT_ID = az account show --query tenantId -o tsv +Write-Host "Current tenant: $CURRENT_TENANT_ID" +if ($CURRENT_TENANT_ID -ne $TENANT_ID) { + throw "Tenant mismatch. Expected $TENANT_ID but got $CURRENT_TENANT_ID. Run: az login --tenant $TENANT_ID" +} +``` + +**Git Bash:** +```bash +CURRENT_TENANT_ID=$(az account show --query tenantId -o tsv) +echo "Current tenant: $CURRENT_TENANT_ID" +if [[ "$CURRENT_TENANT_ID" != "$TENANT_ID" ]]; then + echo "Tenant mismatch. Expected $TENANT_ID but got $CURRENT_TENANT_ID" + echo "Run: az login --tenant $TENANT_ID" + exit 1 +fi +``` + +--- + +## Step 5: Create Entra Application and Service Principal (No Secret) + +> ⚠️ **Error Handling:** If any command fails, stop immediately and show the user the full error output verbatim. Do NOT retry silently. + +**PowerShell:** +```powershell +az ad app create --display-name $APP_NAME | Out-Null +$APP_ID = az ad app list --display-name $APP_NAME --query "[0].appId" -o tsv +az ad sp create --id $APP_ID | Out-Null +Write-Host "App ID: $APP_ID" +Write-Host "Tenant ID: $TENANT_ID" +``` + +**Git Bash:** +```bash +az ad app create --display-name "$APP_NAME" >/dev/null +APP_ID=$(az ad app list --display-name "$APP_NAME" --query "[0].appId" -o tsv) +az ad sp create --id "$APP_ID" >/dev/null +echo "App ID: $APP_ID" +echo "Tenant ID: $TENANT_ID" +``` + +No client secret is required in this flow. + +--- + +## Step 6: Assign RBAC Roles Per Environment + +Grant the service principal **Reader** on each resource group and **API Management Service Contributor** on each APIM instance. + +### PowerShell +```powershell +foreach ($env in $ENVIRONMENTS) { + az role assignment create --assignee "$APP_ID" --role "Reader" --scope "/subscriptions/$($APIM_SUBSCRIPTIONS[$env])/resourceGroups/$($APIM_RESOURCE_GROUPS[$env])" + az role assignment create --assignee "$APP_ID" --role "API Management Service Contributor" --scope "/subscriptions/$($APIM_SUBSCRIPTIONS[$env])/resourceGroups/$($APIM_RESOURCE_GROUPS[$env])/providers/Microsoft.ApiManagement/service/$($APIM_SERVICE_NAMES[$env])" +} +``` + +### Git Bash +```bash +for env in "${ENVIRONMENTS[@]}"; do + az role assignment create --assignee "$APP_ID" --role "Reader" --scope "/subscriptions/${APIM_SUBSCRIPTIONS[$env]}/resourceGroups/${APIM_RESOURCE_GROUPS[$env]}" + az role assignment create --assignee "$APP_ID" --role "API Management Service Contributor" --scope "/subscriptions/${APIM_SUBSCRIPTIONS[$env]}/resourceGroups/${APIM_RESOURCE_GROUPS[$env]}/providers/Microsoft.ApiManagement/service/${APIM_SERVICE_NAMES[$env]}" +done +``` + +--- + +## Step 7: Create Workload Identity Federation Service Connections + +Create one service connection per environment, each scoped to that environment's subscription, +using Azure Resource Manager with Workload Identity Federation. + +This step is fully automatable with `az devops service-endpoint create`. +After each endpoint is created, capture its generated issuer/subject and create +the corresponding federated credential in Entra. + +**PowerShell:** +```powershell +foreach ($env in $ENVIRONMENTS) { + $envUpper = $env.ToUpper() + $name = "AZURE_SERVICE_CONNECTION_$envUpper" + $subscriptionName = az account show --subscription $($APIM_SUBSCRIPTIONS[$env]) --query name -o tsv + + $payload = @{ + name = $name + type = "azurerm" + url = "https://management.azure.com/" + authorization = @{ + scheme = "WorkloadIdentityFederation" + parameters = @{ + tenantid = $TENANT_ID + serviceprincipalid = $APP_ID + } + } + data = @{ + environment = "AzureCloud" + identityType = "AppRegistrationManual" + scopeLevel = "Subscription" + subscriptionId = $APIM_SUBSCRIPTIONS[$env] + subscriptionName = $subscriptionName + } + } | ConvertTo-Json -Depth 8 + + $file = "se-$env.json" + $payload | Out-File -Encoding utf8 -FilePath $file + az devops service-endpoint create --service-endpoint-configuration $file | Out-Null + Remove-Item $file -ErrorAction SilentlyContinue + + $endpoint = az devops service-endpoint list --query "[?name=='$name'] | [0]" -o json | ConvertFrom-Json + $issuer = $endpoint.authorization.parameters.workloadIdentityFederationIssuer + $subject = $endpoint.authorization.parameters.workloadIdentityFederationSubject + + $FED_CRED_NAME = "azdo-$env" + + $payload = @{ + name = $FED_CRED_NAME + issuer = $issuer + subject = $subject + audiences = @("api://AzureADTokenExchange") + } | ConvertTo-Json -Depth 5 + + az ad app federated-credential create --id $APP_ID --parameters $payload +} +``` + +**Git Bash:** +```bash +for env in "${ENVIRONMENTS[@]}"; do + env_upper=$(echo "$env" | tr '[:lower:]' '[:upper:]') + name="AZURE_SERVICE_CONNECTION_$env_upper" + subscription_name=$(az account show --subscription "${APIM_SUBSCRIPTIONS[$env]}" --query name -o tsv) + + cat > "se-$env.json" </dev/null + rm -f "se-$env.json" + + issuer=$(az devops service-endpoint list --query "[?name=='$name'] | [0].authorization.parameters.workloadIdentityFederationIssuer" -o tsv) + subject=$(az devops service-endpoint list --query "[?name=='$name'] | [0].authorization.parameters.workloadIdentityFederationSubject" -o tsv) + + FED_CRED_NAME="azdo-$env" + + az ad app federated-credential create \ + --id "$APP_ID" \ + --parameters "{\"name\":\"$FED_CRED_NAME\",\"issuer\":\"$issuer\",\"subject\":\"$subject\",\"audiences\":[\"api://AzureADTokenExchange\"]}" +done +``` + +Authorize service connections for all pipelines (prevents first-run permission prompts): + +**PowerShell:** +```powershell +foreach ($env in $ENVIRONMENTS) { + $envUpper = $env.ToUpper() + $name = "AZURE_SERVICE_CONNECTION_$envUpper" + $id = az devops service-endpoint list --query "[?name=='$name'].id | [0]" -o tsv + if ($id) { + az devops service-endpoint update --id $id --enable-for-all true | Out-Null + } +} +``` + +**Git Bash:** +```bash +for env in "${ENVIRONMENTS[@]}"; do + env_upper=$(echo "$env" | tr '[:lower:]' '[:upper:]') + name="AZURE_SERVICE_CONNECTION_$env_upper" + id=$(az devops service-endpoint list --query "[?name=='$name'].id | [0]" -o tsv) + if [[ -n "$id" ]]; then + az devops service-endpoint update --id "$id" --enable-for-all true >/dev/null + fi +done +``` + +Verify: +```bash +az devops service-endpoint list --query "[].name" -o table +``` + +--- + +## Step 8: Create Variable Groups + +Create one variable group per environment. Each group includes the extractor pipeline's **non-suffixed** variables (`APIM_RESOURCE_GROUP`, `APIM_SERVICE_NAME`, `AZURE_SUBSCRIPTION_ID`, `AZURE_SERVICE_CONNECTION`) plus the publish pipeline's environment-suffixed APIM variables (`APIM_RESOURCE_GROUP_`, `APIM_SERVICE_NAME_`). + +**PowerShell:** +```powershell +foreach ($env in $ENVIRONMENTS) { + $envUpper = $env.ToUpper() + az pipelines variable-group create --name "apim-$env" --variables AZURE_SUBSCRIPTION_ID=$($APIM_SUBSCRIPTIONS[$env]) APIM_RESOURCE_GROUP=$($APIM_RESOURCE_GROUPS[$env]) APIM_SERVICE_NAME=$($APIM_SERVICE_NAMES[$env]) APIM_RESOURCE_GROUP_$envUpper=$($APIM_RESOURCE_GROUPS[$env]) APIM_SERVICE_NAME_$envUpper=$($APIM_SERVICE_NAMES[$env]) AZURE_SERVICE_CONNECTION="AZURE_SERVICE_CONNECTION_$envUpper" +} +``` + +**Git Bash:** +```bash +for env in "${ENVIRONMENTS[@]}"; do + env_upper=$(echo "$env" | tr '[:lower:]' '[:upper:]') + az pipelines variable-group create --name "apim-$env" --variables AZURE_SUBSCRIPTION_ID="${APIM_SUBSCRIPTIONS[$env]}" APIM_RESOURCE_GROUP="${APIM_RESOURCE_GROUPS[$env]}" APIM_SERVICE_NAME="${APIM_SERVICE_NAMES[$env]}" APIM_RESOURCE_GROUP_$env_upper="${APIM_RESOURCE_GROUPS[$env]}" APIM_SERVICE_NAME_$env_upper="${APIM_SERVICE_NAMES[$env]}" AZURE_SERVICE_CONNECTION="AZURE_SERVICE_CONNECTION_$env_upper" +done +``` + +Authorize all groups for pipeline use: + +**PowerShell:** +```powershell +$groupIds = az pipelines variable-group list --query "[].id" -o tsv +foreach ($id in $groupIds) { + az pipelines variable-group update --group-id $id --authorize true +} +``` + +**Git Bash:** +```bash +for id in $(az pipelines variable-group list --query "[].id" -o tsv); do + az pipelines variable-group update --group-id "$id" --authorize true +done +``` + +Verify: +```bash +az pipelines variable-group list --query "[].name" -o table +``` + +--- + +## Step 9 (in pipeline): Create Environments + +Create deployment environments in Azure DevOps: + +**PowerShell:** +```powershell +foreach ($env in $ENVIRONMENTS) { + $body = "{\"name\": \"$env\"}" + $body | Out-File -Encoding utf8 -FilePath env-body.json + az devops invoke --area environments --resource environments --route-parameters project=$AZDO_PROJECT --http-method POST --api-version 7.1 --in-file env-body.json +} +Remove-Item env-body.json -ErrorAction SilentlyContinue +``` + +**Git Bash:** +```bash +for env in "${ENVIRONMENTS[@]}"; do + echo "{\"name\": \"$env\"}" > env-body.json + az devops invoke --area environments --resource environments --route-parameters project="$AZDO_PROJECT" --http-method POST --api-version 7.1 --in-file env-body.json +done +rm -f env-body.json +``` + +Authorize each environment for all pipelines (prevents first-run permission prompts): + +**PowerShell:** +```powershell +$ADO_RESOURCE = "499b84ac-1321-427f-aa17-267ca6975798" +$TOKEN = az account get-access-token --resource $ADO_RESOURCE --query accessToken -o tsv + +foreach ($env in $ENVIRONMENTS) { + $envId = az devops invoke --area environments --resource environments --route-parameters project=$AZDO_PROJECT --query-parameters "api-version=7.1" --query "value[?name=='$env'].id | [0]" -o tsv + if ($envId) { + $url = "$AZDO_ORG/$AZDO_PROJECT/_apis/pipelines/pipelinePermissions/environment/$envId?api-version=7.1-preview.1" + $body = '{"allPipelines":{"authorized":true}}' + Invoke-RestMethod -Method Patch -Uri $url -Headers @{ Authorization = "Bearer $TOKEN" } -ContentType "application/json" -Body $body | Out-Null + } +} +``` + +**Git Bash:** +```bash +ADO_RESOURCE="499b84ac-1321-427f-aa17-267ca6975798" +TOKEN=$(az account get-access-token --resource "$ADO_RESOURCE" --query accessToken -o tsv) + +for env in "${ENVIRONMENTS[@]}"; do + env_id=$(az devops invoke --area environments --resource environments --route-parameters project="$AZDO_PROJECT" --query-parameters "api-version=7.1" --query "value[?name=='$env'].id | [0]" -o tsv) + if [[ -n "$env_id" ]]; then + curl -sS -X PATCH \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + "$AZDO_ORG/$AZDO_PROJECT/_apis/pipelines/pipelinePermissions/environment/$env_id?api-version=7.1-preview.1" \ + -d '{"allPipelines":{"authorized":true}}' >/dev/null + fi +done +``` + +**Note:** Environment approvals and checks still must be configured via the Azure DevOps UI. + +--- ## Step 10: Enable Pipeline Contributions diff --git a/src/templates/copilot/identity-setup-prompt.ts b/src/templates/copilot/identity-setup-prompt.ts index 41734811..39230bb4 100644 --- a/src/templates/copilot/identity-setup-prompt.ts +++ b/src/templates/copilot/identity-setup-prompt.ts @@ -10,7 +10,6 @@ */ import { - azureDevOpsIdentitySetupCoreTemplate, copilotAzureDevOpsIdentitySetupPromptTemplate, copilotGithubEnvironmentFederatedCredentialTemplate, copilotGithubEnvironmentSecretCommandsTemplate, @@ -32,14 +31,10 @@ export function generateIdentitySetupPrompt(config: IdentitySetupPromptConfig): .map((environment) => `"${environment}"`) .join(' '); - const coreSteps = renderTemplate(azureDevOpsIdentitySetupCoreTemplate, { + return renderTemplate(copilotAzureDevOpsIdentitySetupPromptTemplate, { ENVIRONMENTS_ARRAY_POWERSHELL: environmentsArrayPowerShell, ENVIRONMENTS_ARRAY_BASH: environmentsArrayBash, }); - - return renderTemplate(copilotAzureDevOpsIdentitySetupPromptTemplate, { - AZURE_DEVOPS_CORE_STEPS: coreSteps, - }); } const envSecrets = config.environments.map((env) => diff --git a/src/templates/shared/identity-setup-azure-devops-core.md b/src/templates/shared/identity-setup-azure-devops-core.md deleted file mode 100644 index 3216a80b..00000000 --- a/src/templates/shared/identity-setup-azure-devops-core.md +++ /dev/null @@ -1,448 +0,0 @@ -## Prerequisites -- Azure DevOps organization and project -- Azure CLI installed and authenticated (`az login`) -- `az devops` extension (`az extension add --name azure-devops`) - -This flow is designed for Microsoft-hosted or self-hosted agents and uses workload identity federation (OIDC) instead of managed identity. - -> **Note:** All commands are shown for both **PowerShell** and **Git Bash** where syntax differs. - ---- - -## Step 1: Gather Per-Environment Information - -**Copilot:** Ask the user for the following values before proceeding. Store each answer for use in later steps. - -For each environment, provide either **Option A** (three separate values) or **Option B** (a single full APIM resource ID in the form `/subscriptions//resourceGroups//providers/Microsoft.ApiManagement/service/`). Copilot will parse Option B into the individual components automatically. - -| Variable | Description | Example | -|----------|-------------|---------| -| `APP_NAME` | Display name for the Entra application | `apiops-azdo-sp` | -| `AZDO_ORG` | Azure DevOps organization URL | `https://dev.azure.com/my-org` | -| `AZDO_PROJECT` | Azure DevOps project name | `my-project` | -| `TENANT_ID` | Tenant ID that should own the app/service connection | `11111111-2222-3333-4444-555555555555` | - -For each environment in the configured list, gather these values (where `` is the upper-case environment name): -- `APIM_SUBSCRIPTION_` -- `APIM_RG_` -- `APIM_NAME_` -- `APIM_RESOURCE_ID_` *(optional Option B shorthand)* - ---- - -## Step 2: Set Variables - -**PowerShell:** -```powershell -$APP_NAME = "apiops-azdo-sp" -$AZDO_ORG = "" -$AZDO_PROJECT = "" -$TENANT_ID = "" -$ENVIRONMENTS = @({{ENVIRONMENTS_ARRAY_POWERSHELL}}) - -# Fill these maps with values for each environment. -$APIM_SUBSCRIPTIONS = @{} -$APIM_RESOURCE_GROUPS = @{} -$APIM_SERVICE_NAMES = @{} - -foreach ($env in $ENVIRONMENTS) { - # Option A: provide values directly. - $APIM_SUBSCRIPTIONS[$env] = "" - $APIM_RESOURCE_GROUPS[$env] = "" - $APIM_SERVICE_NAMES[$env] = "" - - # Option B: if APIM resource ID is provided, parse it into the same maps. - # $resourceId = "/subscriptions//resourceGroups//providers/Microsoft.ApiManagement/service/" - # if ($resourceId) { - # $parts = $resourceId.Trim('/') -split '/' - # $APIM_SUBSCRIPTIONS[$env] = $parts[1] - # $APIM_RESOURCE_GROUPS[$env] = $parts[3] - # $APIM_SERVICE_NAMES[$env] = $parts[7] - # } -} -``` - -**Git Bash:** -```bash -APP_NAME="apiops-azdo-sp" -AZDO_ORG="" -AZDO_PROJECT="" -TENANT_ID="" -ENVIRONMENTS=({{ENVIRONMENTS_ARRAY_BASH}}) - -# Fill these maps with values for each environment. -declare -A APIM_SUBSCRIPTIONS -declare -A APIM_RESOURCE_GROUPS -declare -A APIM_SERVICE_NAMES - -for env in "${ENVIRONMENTS[@]}"; do - # Option A: provide values directly. - APIM_SUBSCRIPTIONS["$env"]="" - APIM_RESOURCE_GROUPS["$env"]="" - APIM_SERVICE_NAMES["$env"]="" - - # Option B: if APIM resource ID is provided, parse it into the same maps. - # resource_id="/subscriptions//resourceGroups//providers/Microsoft.ApiManagement/service/" - # if [[ -n "$resource_id" ]]; then - # IFS='/' read -r _ subscriptions sub resourceGroups rg providers provider service svc <<< "$resource_id" - # APIM_SUBSCRIPTIONS["$env"]="$sub" - # APIM_RESOURCE_GROUPS["$env"]="$rg" - # APIM_SERVICE_NAMES["$env"]="$svc" - # fi -done -``` - ---- - -## Step 3: Configure Azure DevOps CLI - -Install the extension (works in both shells): -```bash -az extension add --name azure-devops -``` - -Install the Azure DevOps Replace Tokens extension (required by publish pipeline): - -**PowerShell:** -```powershell -az devops extension install --publisher-id qetza --extension-id replacetokens -``` - -**Git Bash:** -```bash -az devops extension install --publisher-id qetza --extension-id replacetokens -``` - -Set organization defaults: - -For self-hosted Azure DevOps Server, use your server/collection URL format: -- `https:///` - -**PowerShell:** -```powershell -az devops configure --defaults organization=$AZDO_ORG project=$AZDO_PROJECT -$ORG_NAME = $AZDO_ORG -replace 'https://dev\.azure\.com/', '' -``` - -**Git Bash:** -```bash -az devops configure --defaults organization="$AZDO_ORG" project="$AZDO_PROJECT" -ORG_NAME="${AZDO_ORG##*/}" -``` - -**Self-hosted note:** If your server URL includes a collection segment (for example, `https://ado.contoso.local/DefaultCollection`), set `ORG_NAME` to the value expected in the Build Service identity display name for your server/project. - ---- - -## Step 4: Verify Tenant ID - -Before creating identity objects, confirm you are logged into the intended tenant. - -**PowerShell:** -```powershell -$CURRENT_TENANT_ID = az account show --query tenantId -o tsv -Write-Host "Current tenant: $CURRENT_TENANT_ID" -if ($CURRENT_TENANT_ID -ne $TENANT_ID) { - throw "Tenant mismatch. Expected $TENANT_ID but got $CURRENT_TENANT_ID. Run: az login --tenant $TENANT_ID" -} -``` - -**Git Bash:** -```bash -CURRENT_TENANT_ID=$(az account show --query tenantId -o tsv) -echo "Current tenant: $CURRENT_TENANT_ID" -if [[ "$CURRENT_TENANT_ID" != "$TENANT_ID" ]]; then - echo "Tenant mismatch. Expected $TENANT_ID but got $CURRENT_TENANT_ID" - echo "Run: az login --tenant $TENANT_ID" - exit 1 -fi -``` - ---- - -## Step 5: Create Entra Application and Service Principal (No Secret) - -> ⚠️ **Error Handling:** If any command fails, stop immediately and show the user the full error output verbatim. Do NOT retry silently. - -**PowerShell:** -```powershell -az ad app create --display-name $APP_NAME | Out-Null -$APP_ID = az ad app list --display-name $APP_NAME --query "[0].appId" -o tsv -az ad sp create --id $APP_ID | Out-Null -Write-Host "App ID: $APP_ID" -Write-Host "Tenant ID: $TENANT_ID" -``` - -**Git Bash:** -```bash -az ad app create --display-name "$APP_NAME" >/dev/null -APP_ID=$(az ad app list --display-name "$APP_NAME" --query "[0].appId" -o tsv) -az ad sp create --id "$APP_ID" >/dev/null -echo "App ID: $APP_ID" -echo "Tenant ID: $TENANT_ID" -``` - -No client secret is required in this flow. - ---- - -## Step 6: Assign RBAC Roles Per Environment - -Grant the service principal **Reader** on each resource group and **API Management Service Contributor** on each APIM instance. - -### PowerShell -```powershell -foreach ($env in $ENVIRONMENTS) { - az role assignment create --assignee "$APP_ID" --role "Reader" --scope "/subscriptions/$($APIM_SUBSCRIPTIONS[$env])/resourceGroups/$($APIM_RESOURCE_GROUPS[$env])" - az role assignment create --assignee "$APP_ID" --role "API Management Service Contributor" --scope "/subscriptions/$($APIM_SUBSCRIPTIONS[$env])/resourceGroups/$($APIM_RESOURCE_GROUPS[$env])/providers/Microsoft.ApiManagement/service/$($APIM_SERVICE_NAMES[$env])" -} -``` - -### Git Bash -```bash -for env in "${ENVIRONMENTS[@]}"; do - az role assignment create --assignee "$APP_ID" --role "Reader" --scope "/subscriptions/${APIM_SUBSCRIPTIONS[$env]}/resourceGroups/${APIM_RESOURCE_GROUPS[$env]}" - az role assignment create --assignee "$APP_ID" --role "API Management Service Contributor" --scope "/subscriptions/${APIM_SUBSCRIPTIONS[$env]}/resourceGroups/${APIM_RESOURCE_GROUPS[$env]}/providers/Microsoft.ApiManagement/service/${APIM_SERVICE_NAMES[$env]}" -done -``` - ---- - -## Step 7: Create Workload Identity Federation Service Connections - -Create one service connection per environment, each scoped to that environment's subscription, -using Azure Resource Manager with Workload Identity Federation. - -This step is fully automatable with `az devops service-endpoint create`. -After each endpoint is created, capture its generated issuer/subject and create -the corresponding federated credential in Entra. - -**PowerShell:** -```powershell -foreach ($env in $ENVIRONMENTS) { - $envUpper = $env.ToUpper() - $name = "AZURE_SERVICE_CONNECTION_$envUpper" - $subscriptionName = az account show --subscription $($APIM_SUBSCRIPTIONS[$env]) --query name -o tsv - - $payload = @{ - name = $name - type = "azurerm" - url = "https://management.azure.com/" - authorization = @{ - scheme = "WorkloadIdentityFederation" - parameters = @{ - tenantid = $TENANT_ID - serviceprincipalid = $APP_ID - } - } - data = @{ - environment = "AzureCloud" - identityType = "AppRegistrationManual" - scopeLevel = "Subscription" - subscriptionId = $APIM_SUBSCRIPTIONS[$env] - subscriptionName = $subscriptionName - } - } | ConvertTo-Json -Depth 8 - - $file = "se-$env.json" - $payload | Out-File -Encoding utf8 -FilePath $file - az devops service-endpoint create --service-endpoint-configuration $file | Out-Null - Remove-Item $file -ErrorAction SilentlyContinue - - $endpoint = az devops service-endpoint list --query "[?name=='$name'] | [0]" -o json | ConvertFrom-Json - $issuer = $endpoint.authorization.parameters.workloadIdentityFederationIssuer - $subject = $endpoint.authorization.parameters.workloadIdentityFederationSubject - - $FED_CRED_NAME = "azdo-$env" - - $payload = @{ - name = $FED_CRED_NAME - issuer = $issuer - subject = $subject - audiences = @("api://AzureADTokenExchange") - } | ConvertTo-Json -Depth 5 - - az ad app federated-credential create --id $APP_ID --parameters $payload -} -``` - -**Git Bash:** -```bash -for env in "${ENVIRONMENTS[@]}"; do - env_upper=$(echo "$env" | tr '[:lower:]' '[:upper:]') - name="AZURE_SERVICE_CONNECTION_$env_upper" - subscription_name=$(az account show --subscription "${APIM_SUBSCRIPTIONS[$env]}" --query name -o tsv) - - cat > "se-$env.json" </dev/null - rm -f "se-$env.json" - - issuer=$(az devops service-endpoint list --query "[?name=='$name'] | [0].authorization.parameters.workloadIdentityFederationIssuer" -o tsv) - subject=$(az devops service-endpoint list --query "[?name=='$name'] | [0].authorization.parameters.workloadIdentityFederationSubject" -o tsv) - - FED_CRED_NAME="azdo-$env" - - az ad app federated-credential create \ - --id "$APP_ID" \ - --parameters "{\"name\":\"$FED_CRED_NAME\",\"issuer\":\"$issuer\",\"subject\":\"$subject\",\"audiences\":[\"api://AzureADTokenExchange\"]}" -done -``` - -Authorize service connections for all pipelines (prevents first-run permission prompts): - -**PowerShell:** -```powershell -foreach ($env in $ENVIRONMENTS) { - $envUpper = $env.ToUpper() - $name = "AZURE_SERVICE_CONNECTION_$envUpper" - $id = az devops service-endpoint list --query "[?name=='$name'].id | [0]" -o tsv - if ($id) { - az devops service-endpoint update --id $id --enable-for-all true | Out-Null - } -} -``` - -**Git Bash:** -```bash -for env in "${ENVIRONMENTS[@]}"; do - env_upper=$(echo "$env" | tr '[:lower:]' '[:upper:]') - name="AZURE_SERVICE_CONNECTION_$env_upper" - id=$(az devops service-endpoint list --query "[?name=='$name'].id | [0]" -o tsv) - if [[ -n "$id" ]]; then - az devops service-endpoint update --id "$id" --enable-for-all true >/dev/null - fi -done -``` - -Verify: -```bash -az devops service-endpoint list --query "[].name" -o table -``` - ---- - -## Step 8: Create Variable Groups - -Create one variable group per environment. Each group includes the extractor pipeline's **non-suffixed** variables (`APIM_RESOURCE_GROUP`, `APIM_SERVICE_NAME`, `AZURE_SUBSCRIPTION_ID`, `AZURE_SERVICE_CONNECTION`) plus the publish pipeline's environment-suffixed APIM variables (`APIM_RESOURCE_GROUP_`, `APIM_SERVICE_NAME_`). - -**PowerShell:** -```powershell -foreach ($env in $ENVIRONMENTS) { - $envUpper = $env.ToUpper() - az pipelines variable-group create --name "apim-$env" --variables AZURE_SUBSCRIPTION_ID=$($APIM_SUBSCRIPTIONS[$env]) APIM_RESOURCE_GROUP=$($APIM_RESOURCE_GROUPS[$env]) APIM_SERVICE_NAME=$($APIM_SERVICE_NAMES[$env]) APIM_RESOURCE_GROUP_$envUpper=$($APIM_RESOURCE_GROUPS[$env]) APIM_SERVICE_NAME_$envUpper=$($APIM_SERVICE_NAMES[$env]) AZURE_SERVICE_CONNECTION="AZURE_SERVICE_CONNECTION_$envUpper" -} -``` - -**Git Bash:** -```bash -for env in "${ENVIRONMENTS[@]}"; do - env_upper=$(echo "$env" | tr '[:lower:]' '[:upper:]') - az pipelines variable-group create --name "apim-$env" --variables AZURE_SUBSCRIPTION_ID="${APIM_SUBSCRIPTIONS[$env]}" APIM_RESOURCE_GROUP="${APIM_RESOURCE_GROUPS[$env]}" APIM_SERVICE_NAME="${APIM_SERVICE_NAMES[$env]}" APIM_RESOURCE_GROUP_$env_upper="${APIM_RESOURCE_GROUPS[$env]}" APIM_SERVICE_NAME_$env_upper="${APIM_SERVICE_NAMES[$env]}" AZURE_SERVICE_CONNECTION="AZURE_SERVICE_CONNECTION_$env_upper" -done -``` - -Authorize all groups for pipeline use: - -**PowerShell:** -```powershell -$groupIds = az pipelines variable-group list --query "[].id" -o tsv -foreach ($id in $groupIds) { - az pipelines variable-group update --group-id $id --authorize true -} -``` - -**Git Bash:** -```bash -for id in $(az pipelines variable-group list --query "[].id" -o tsv); do - az pipelines variable-group update --group-id "$id" --authorize true -done -``` - -Verify: -```bash -az pipelines variable-group list --query "[].name" -o table -``` - ---- - -## Step 9 (in pipeline): Create Environments - -Create deployment environments in Azure DevOps: - -**PowerShell:** -```powershell -foreach ($env in $ENVIRONMENTS) { - $body = "{\"name\": \"$env\"}" - $body | Out-File -Encoding utf8 -FilePath env-body.json - az devops invoke --area environments --resource environments --route-parameters project=$AZDO_PROJECT --http-method POST --api-version 7.1 --in-file env-body.json -} -Remove-Item env-body.json -ErrorAction SilentlyContinue -``` - -**Git Bash:** -```bash -for env in "${ENVIRONMENTS[@]}"; do - echo "{\"name\": \"$env\"}" > env-body.json - az devops invoke --area environments --resource environments --route-parameters project="$AZDO_PROJECT" --http-method POST --api-version 7.1 --in-file env-body.json -done -rm -f env-body.json -``` - -Authorize each environment for all pipelines (prevents first-run permission prompts): - -**PowerShell:** -```powershell -$ADO_RESOURCE = "499b84ac-1321-427f-aa17-267ca6975798" -$TOKEN = az account get-access-token --resource $ADO_RESOURCE --query accessToken -o tsv - -foreach ($env in $ENVIRONMENTS) { - $envId = az devops invoke --area environments --resource environments --route-parameters project=$AZDO_PROJECT --query-parameters "api-version=7.1" --query "value[?name=='$env'].id | [0]" -o tsv - if ($envId) { - $url = "$AZDO_ORG/$AZDO_PROJECT/_apis/pipelines/pipelinePermissions/environment/$envId?api-version=7.1-preview.1" - $body = '{"allPipelines":{"authorized":true}}' - Invoke-RestMethod -Method Patch -Uri $url -Headers @{ Authorization = "Bearer $TOKEN" } -ContentType "application/json" -Body $body | Out-Null - } -} -``` - -**Git Bash:** -```bash -ADO_RESOURCE="499b84ac-1321-427f-aa17-267ca6975798" -TOKEN=$(az account get-access-token --resource "$ADO_RESOURCE" --query accessToken -o tsv) - -for env in "${ENVIRONMENTS[@]}"; do - env_id=$(az devops invoke --area environments --resource environments --route-parameters project="$AZDO_PROJECT" --query-parameters "api-version=7.1" --query "value[?name=='$env'].id | [0]" -o tsv) - if [[ -n "$env_id" ]]; then - curl -sS -X PATCH \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - "$AZDO_ORG/$AZDO_PROJECT/_apis/pipelines/pipelinePermissions/environment/$env_id?api-version=7.1-preview.1" \ - -d '{"allPipelines":{"authorized":true}}' >/dev/null - fi -done -``` - -**Note:** Environment approvals and checks still must be configured via the Azure DevOps UI. - ---- From 78f15190bdea84ff35120d9be540920df5c4b292 Mon Sep 17 00:00:00 2001 From: Elizabeth Maher Date: Mon, 29 Jun 2026 14:16:19 -0700 Subject: [PATCH 10/15] docs: add agent behavior rules to prompt files Instructs the Copilot agent to complete one step at a time, confirm user input before proceeding, ask permission to move to the next step, and never combine steps automatically. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../copilot/identity-setup-prompt-azure-devops.md | 10 ++++++++++ .../copilot/identity-setup-prompt-github-actions.md | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/templates/copilot/identity-setup-prompt-azure-devops.md b/src/templates/copilot/identity-setup-prompt-azure-devops.md index 20f70a4d..82b053db 100644 --- a/src/templates/copilot/identity-setup-prompt-azure-devops.md +++ b/src/templates/copilot/identity-setup-prompt-azure-devops.md @@ -6,6 +6,14 @@ > **Important identity distinction:** The Azure app registration/service principal created in this flow is only for Azure and APIM access. Repository contributions and pull request creation come from the Azure DevOps Build Service identity, which must be granted repo permissions separately. +## Agent Behavior + +- **One step at a time.** Complete each step fully before moving to the next. +- **Confirm information.** After gathering user input, summarize what was provided and ask the user to confirm it is correct before proceeding. +- **Ask before proceeding.** At the end of each step, ask: "Step N is complete. Ready to proceed to Step N+1?" +- **Never combine steps.** Do not run commands from multiple steps together, even if they could be batched. +- **Stop on errors.** If any command fails, show the full error output and wait for the user to decide how to proceed. + ## Goal Configure workload identity federation (OIDC), Azure DevOps federated service connections, @@ -42,6 +50,8 @@ For each environment in the configured list, gather these values (where `` - `APIM_RESOURCE_ID_` *(optional Option B shorthand)* +**Copilot:** After collecting all values, present a summary table and ask: "Please confirm these values are correct before I proceed." + --- ## Step 2: Set Variables diff --git a/src/templates/copilot/identity-setup-prompt-github-actions.md b/src/templates/copilot/identity-setup-prompt-github-actions.md index a2ba1c42..f06c215a 100644 --- a/src/templates/copilot/identity-setup-prompt-github-actions.md +++ b/src/templates/copilot/identity-setup-prompt-github-actions.md @@ -6,6 +6,14 @@ > **Important identity distinction:** `GITHUB_TOKEN` handles pull request creation automatically. The Azure app registration/service principal in this flow is only for Azure and APIM access. +## Agent Behavior + +- **One step at a time.** Complete each step fully before moving to the next. +- **Confirm information.** After gathering user input, summarize what was provided and ask the user to confirm it is correct before proceeding. +- **Ask before proceeding.** At the end of each step, ask: "Step N is complete. Ready to proceed to Step N+1?" +- **Never combine steps.** Do not run commands from multiple steps together, even if they could be batched. +- **Stop on errors.** If any command fails, show the full error output and wait for the user to decide how to proceed. + ## Goal Configure Azure AD federated credentials and GitHub repository secrets so the @@ -86,6 +94,8 @@ each answer for use in later steps. | `APP_NAME` | Display name for the Azure AD application | `apiops-github-sp` | {{ENV_APIM_TABLE_ROWS}} +**Copilot:** After collecting all values, present a summary table and ask: "Please confirm these values are correct before I proceed." + --- ## Step 2 — Create Azure AD Application & Service Principal From e9455113a498c4eeb17d412eba60dc62f4c4951b Mon Sep 17 00:00:00 2001 From: Elizabeth Maher Date: Mon, 29 Jun 2026 14:20:24 -0700 Subject: [PATCH 11/15] docs: emphasize environment names note as IMPORTANT Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/templates/identity/identity-guide-azure-devops.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/templates/identity/identity-guide-azure-devops.md b/src/templates/identity/identity-guide-azure-devops.md index ddf550b7..602b8120 100644 --- a/src/templates/identity/identity-guide-azure-devops.md +++ b/src/templates/identity/identity-guide-azure-devops.md @@ -25,7 +25,7 @@ Helpful documentation: 2. Confirm these files are available in the repo browser: - `.azdo/pipelines/run-apiops-extractor.yml` - `.azdo/pipelines/run-apiops-publisher.yml` -3. Note the environment names you chose when running `apiops init` because you will create matching service connections, variable groups, and approvals. +3. **IMPORTANT:** Note the environment names you chose when running `apiops init` because you will create matching service connections, variable groups, and approvals. ## Step 2: Create or choose an app registration in Azure portal From d5e4793854b20023a15a9d26f058380c7a72de55 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:34:53 +0000 Subject: [PATCH 12/15] docs: fix duplicated and typo in walkthroughs --- docs/walkthrough/air-gapped-azure-devops-local-registry.md | 4 ++-- docs/walkthrough/air-gapped-azure-devops-offline-tarball.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/walkthrough/air-gapped-azure-devops-local-registry.md b/docs/walkthrough/air-gapped-azure-devops-local-registry.md index b6f3d71d..c61ade03 100644 --- a/docs/walkthrough/air-gapped-azure-devops-local-registry.md +++ b/docs/walkthrough/air-gapped-azure-devops-local-registry.md @@ -166,7 +166,7 @@ This generates: | `.azdo/pipelines/run-apiops-publisher.yml` | Publish pipeline | | `configuration.*.yaml` | Override templates | -Follow the remaining instructions listed in created `APIOPS-PIPELINE-IDENTITY-SETUP.md` or run `/apiops-setup-pipeline-identity` prompt. This creates the necessary variable groups and and service connections. +Follow the remaining instructions listed in created `APIOPS-PIPELINE-IDENTITY-SETUP.md` or run `/apiops-setup-pipeline-identity` prompt. This creates the necessary variable groups and service connections. ### 2.2 Generate the Lock File @@ -245,7 +245,7 @@ Verify the following: ## 4 - Finish `apiops init` for pipeline -If not already done, while on the air-gapped network, follow the remaining instructions listed in created `APIOPS-PIPELINE-IDENTITY-SETUP.md`. This creates the necessary variable groups and and service connections. +If not already done, while on the air-gapped network, follow the remaining instructions listed in created `APIOPS-PIPELINE-IDENTITY-SETUP.md`. This creates the necessary variable groups and service connections. --- diff --git a/docs/walkthrough/air-gapped-azure-devops-offline-tarball.md b/docs/walkthrough/air-gapped-azure-devops-offline-tarball.md index 6703d7cc..763001c9 100644 --- a/docs/walkthrough/air-gapped-azure-devops-offline-tarball.md +++ b/docs/walkthrough/air-gapped-azure-devops-offline-tarball.md @@ -82,7 +82,7 @@ This command generates: | `.azdo/pipelines/run-apiops-publisher.yml` | Publish pipeline | | `configuration.*.yaml` | Override templates | -Follow the remaining instructions listed in created `APIOPS-PIPELINE-IDENTITY-SETUP.md` or run `/apiops-setup-pipeline-identity` prompt. This creates the necessary variable groups and and service connections. +Follow the remaining instructions listed in created `APIOPS-PIPELINE-IDENTITY-SETUP.md` or run `/apiops-setup-pipeline-identity` prompt. This creates the necessary variable groups and service connections. ### 2.2 Generate the Lock File and Pre-Stage the npm Cache @@ -164,7 +164,7 @@ tar -xzf npm-cacache.tar.gz -C ~/.npm ## 4 - Finish `apiops init` for pipeline -If not already done, while on the air-gapped network, follow the remaining instructions listed in created `APIOPS-PIPELINE-IDENTITY-SETUP.md`. This creates the necessary variable groups and and service connections. +If not already done, while on the air-gapped network, follow the remaining instructions listed in created `APIOPS-PIPELINE-IDENTITY-SETUP.md`. This creates the necessary variable groups and service connections. ## 5 — Commit and Validate From be746c9c25553d7b6cd2a97339629ebf23737f5a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:48:06 +0000 Subject: [PATCH 13/15] fix: remove workflow guide path prefix from init output --- src/cli/init-command.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/init-command.ts b/src/cli/init-command.ts index 6d815361..d5802699 100644 --- a/src/cli/init-command.ts +++ b/src/cli/init-command.ts @@ -90,7 +90,7 @@ export function createInitCommand(): Command { logger.info(' 2. Commit the generated files to your repository'); logger.info(' 3. Set up CI/CD identity authentication:'); if (isGitHub) { - logger.info(' - Follow ./APIOPS-WORKFLOW-IDENTITY-SETUP.md for manual setup, OR'); + logger.info(' - Follow APIOPS-WORKFLOW-IDENTITY-SETUP.md for manual setup, OR'); logger.info(' - Open ./.github/prompts/apiops-setup-workflow-identity.prompt.md with GitHub Copilot for guided setup'); } else { logger.info(' - Follow ./APIOPS-PIPELINE-IDENTITY-SETUP.md for manual setup, OR'); From 0720b0d5f375fabf6916d7ed74a283c86e828e23 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:53:10 +0000 Subject: [PATCH 14/15] fix: update workflow prompt guidance text --- src/cli/init-command.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/init-command.ts b/src/cli/init-command.ts index d5802699..ea040523 100644 --- a/src/cli/init-command.ts +++ b/src/cli/init-command.ts @@ -91,7 +91,7 @@ export function createInitCommand(): Command { logger.info(' 3. Set up CI/CD identity authentication:'); if (isGitHub) { logger.info(' - Follow APIOPS-WORKFLOW-IDENTITY-SETUP.md for manual setup, OR'); - logger.info(' - Open ./.github/prompts/apiops-setup-workflow-identity.prompt.md with GitHub Copilot for guided setup'); + logger.info(' - Use github/prompts/apiops-setup-workflow-identity.prompt.md prompt file with GitHub Copilot for guided setup'); } else { logger.info(' - Follow ./APIOPS-PIPELINE-IDENTITY-SETUP.md for manual setup, OR'); logger.info(' - Open ./.github/prompts/apiops-setup-pipeline-identity.prompt.md with GitHub Copilot for guided setup'); From 74984739f1af65cea8eeb4ad2ca2dbcf971737c9 Mon Sep 17 00:00:00 2001 From: Elizabeth Maher Date: Tue, 30 Jun 2026 14:39:48 -0700 Subject: [PATCH 15/15] docs: add prompt files documentation (#201) * docs: add prompt files documentation Describes available prompt files, how to download them individually (bash and PowerShell), and how to use them in VS Code and GitHub Copilot CLI. * docs: add prompt-files.md to docs README structure --- docs/README.md | 1 + docs/guides/prompt-files.md | 83 +++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 docs/guides/prompt-files.md diff --git a/docs/README.md b/docs/README.md index 2bb6f2ad..90900afd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -66,6 +66,7 @@ docs/ │ ├── multi-team-workflows.md — Selective extraction, CODEOWNERS │ ├── code-first-workflow.md — IDE → git → CI/CD → APIM │ ├── token-substitution.md — Pipeline token/placeholder substitution +│ ├── prompt-files.md — Copilot prompt files for APIOps tasks │ └── migration-from-v1.md — Migrate from Azure/apiops toolkit ├── ci-cd/ │ ├── github-actions.md — GitHub Actions integration diff --git a/docs/guides/prompt-files.md b/docs/guides/prompt-files.md new file mode 100644 index 00000000..d958693b --- /dev/null +++ b/docs/guides/prompt-files.md @@ -0,0 +1,83 @@ +# Prompt Files + +APIOps provides [prompt files](https://code.visualstudio.com/docs/copilot/copilot-customization#_reusable-prompt-files-experimental) that guide GitHub Copilot through common APIOps configuration tasks. These are reusable `.prompt.md` files that give Copilot the context it needs to help you configure extraction filters, environment overrides, and CI/CD identity setup. + +## What are prompt files? + +Prompt files are markdown files with a `.prompt.md` extension that provide instructions and context to GitHub Copilot. When you open a prompt file in VS Code and invoke Copilot, it uses the file's content as context to guide you through a task interactively. + +## Available prompt files + +| File | Description | +|------|-------------| +| `apiops-configure-filter.prompt.md` | Guides Copilot through creating a `configuration.extractor.yaml` filter file to control which API Management resources are extracted. See also: [Filtering resources guide](./filtering-resources.md) | +| `apiops-configure-overrides.prompt.md` | Guides Copilot through creating environment override files (`configuration..yaml`) for promoting APIs across environments. See also: [Environment overrides guide](./environment-overrides.md) | +| `apiops-setup-workflow-identity-github-actions.prompt.md` | Walks through setting up Azure identity (app registration, federated credentials, RBAC) for GitHub Actions CI/CD pipelines. | +| `apiops-setup-workflow-identity-azure-devops.prompt.md` | Walks through setting up Azure identity (app registration, federated credentials, RBAC) for Azure DevOps CI/CD pipelines. | + +## Getting prompt files + +### Via `apiops init` (recommended) + +The easiest way to get prompt files is to run [`apiops init`](../commands/init.md), which scaffolds your repository with prompt files already in place at `.github/prompts/`. + +### Download individually + +If you already have an APIOps repository and want to add prompt files without re-running init, you can download them directly from this repository. + +#### Bash + +```bash +# Resolve repo root so this works from any subdirectory +repo_root="$(git rev-parse --show-toplevel)" + +# Create the prompts directory +mkdir -p "${repo_root}/.github/prompts" + +# Available prompt files — remove any you don't need +files=( + "apiops-configure-filter.prompt.md" + "apiops-configure-overrides.prompt.md" + # Remove the identity setup prompt that doesn't apply to your CI provider: + "apiops-setup-workflow-identity-github-actions.prompt.md" + "apiops-setup-workflow-identity-azure-devops.prompt.md" +) + +base_url="https://raw.githubusercontent.com/Azure/apiops-cli/main/src/templates/copilot" + +for file in "${files[@]}"; do + curl -sL "${base_url}/${file}" -o "${repo_root}/.github/prompts/${file}" + echo "Downloaded ${file}" +done +``` + +#### PowerShell + +```powershell +# Resolve repo root so this works from any subdirectory +$repoRoot = git rev-parse --show-toplevel + +# Create the prompts directory +New-Item -ItemType Directory -Path "$repoRoot/.github/prompts" -Force | Out-Null + +# Available prompt files — remove any you don't need +$files = @( + "apiops-configure-filter.prompt.md" + "apiops-configure-overrides.prompt.md" + # Remove the identity setup prompt that doesn't apply to your CI provider: + "apiops-setup-workflow-identity-github-actions.prompt.md" + "apiops-setup-workflow-identity-azure-devops.prompt.md" +) + +$baseUrl = "https://raw.githubusercontent.com/Azure/apiops-cli/main/src/templates/copilot" + +foreach ($file in $files) { + Invoke-WebRequest -Uri "$baseUrl/$file" -OutFile "$repoRoot/.github/prompts/$file" + Write-Host "Downloaded $file" +} +``` + +## Further reading + +- [VS Code: Reusable prompt files](https://code.visualstudio.com/docs/copilot/copilot-customization#_reusable-prompt-files-experimental) +- [GitHub Copilot in the CLI](https://docs.github.com/en/copilot/github-copilot-in-the-cli/using-github-copilot-in-the-cli)