diff --git a/src/services/identity-guide-service.ts b/src/services/identity-guide-service.ts index fabd7bc..72d9d4c 100644 --- a/src/services/identity-guide-service.ts +++ b/src/services/identity-guide-service.ts @@ -382,7 +382,9 @@ done rm -f env-body.json \`\`\` -**Note:** Environment approvals and checks must be configured via the Azure DevOps UI (Project Settings > Environments). +**To require human approval before deploying to an environment:** +1. Go to **Pipelines > Environments > ** in Azure DevOps. +2. Open **Approvals and checks** and add an **Approvals** check with the required approvers. --- diff --git a/src/templates/azure-devops/publish-pipeline.ts b/src/templates/azure-devops/publish-pipeline.ts index 573c715..d5c906a 100644 --- a/src/templates/azure-devops/publish-pipeline.ts +++ b/src/templates/azure-devops/publish-pipeline.ts @@ -12,10 +12,16 @@ export function generatePublishPipeline(config: PublishPipelineConfig): string { const envValues = config.environments.map((env) => ` - '${env}'`).join('\n'); const stages = config.environments.map((env, idx) => { - const dependsOn = idx === 0 ? '' : ` dependsOn: Publish_${config.environments[idx - 1]}\n`; + const previousEnvironment = idx > 0 ? config.environments[idx - 1] : null; + const dependsOnProp = previousEnvironment ? ` dependsOn: Publish_${previousEnvironment}\n` : ''; + const approvalComment = previousEnvironment + ? ` # To require human approval before this stage: + # Go to Pipelines > Environments > ${env} in Azure DevOps and add an approval check. +` + : ''; - return `${dependsOn}- stage: Publish_${env} - displayName: 'Publish to ${env}' + return `- stage: Publish_${env} +${dependsOnProp}${approvalComment} displayName: 'Publish to ${env}' condition: or(eq('\${{ parameters.ENVIRONMENT }}', '${env}'), eq('\${{ parameters.ENVIRONMENT }}', 'all')) variables: - group: apim-${env} diff --git a/src/templates/copilot/identity-setup-prompt.ts b/src/templates/copilot/identity-setup-prompt.ts index 60d9dce..83397ed 100644 --- a/src/templates/copilot/identity-setup-prompt.ts +++ b/src/templates/copilot/identity-setup-prompt.ts @@ -48,6 +48,43 @@ gh secret set APIM_RESOURCE_GROUP_${env.toUpperCase()} --body "\${APIM_RG_${env. gh secret set APIM_SERVICE_NAME_${env.toUpperCase()} --body "\${APIM_NAME_${env.toUpperCase()}}" --env ${env}` ).join('\n\n'); + const ghEnvironmentSetupCmds = config.environments.map((env) => + `# ${env} environment: create + baseline protection via GitHub API +cat > env-${env}-config.json <<'JSON' +{ + "wait_timer": 0, + "prevent_self_review": true, + "deployment_branch_policy": { + "protected_branches": false, + "custom_branch_policies": true + } +} +JSON + +gh api --method PUT "repos/\${GITHUB_ORG}/\${GITHUB_REPO}/environments/${env}" --input env-${env}-config.json + +# Restrict deployments to main branch +gh api --method POST "repos/\${GITHUB_ORG}/\${GITHUB_REPO}/environments/${env}/deployment-branch-policies" -f name='main' -f type='branch' + +# Optional: require deployment approval reviewers (replace IDs with your approver IDs) +# Resolve a user ID: gh api "users/" --jq .id +cat > env-${env}-reviewers.json <<'JSON' +{ + "wait_timer": 0, + "prevent_self_review": true, + "reviewers": [ + { "type": "User", "id": 12345678 } + ], + "deployment_branch_policy": { + "protected_branches": false, + "custom_branch_policies": true + } +} +JSON + +gh api --method PUT "repos/\${GITHUB_ORG}/\${GITHUB_REPO}/environments/${env}" --input env-${env}-reviewers.json` + ).join('\n\n'); + return `# Setup GitHub Actions Identity for APIOps > **How to use:** Open this file in VS Code with GitHub Copilot and ask @@ -238,15 +275,59 @@ ${envFedCreds} --- -## Step 5 — Create GitHub Environments +## Step 5 — Configure GitHub Environments with CLI (Create + Protection + Approvals) + +> Copilot: run these command-line commands (do not use manual UI-only setup) to create environments and configure protection rules. +**On macOS/Linux (Bash):** \`\`\`bash +${ghEnvironmentSetupCmds} +\`\`\` + +**On Windows (PowerShell):** +\`\`\`powershell ${config.environments.map((env) => - `# Create the ${env} environment (requires GitHub CLI) -gh api --method PUT "repos/\${GITHUB_ORG}/\${GITHUB_REPO}/environments/${env}"` + `# ${env} environment: create + baseline protection via GitHub API +@' +{ + "wait_timer": 0, + "prevent_self_review": true, + "deployment_branch_policy": { + "protected_branches": false, + "custom_branch_policies": true + } +} +'@ | Set-Content -Path env-${env}-config.json + +gh api --method PUT "repos/\${GITHUB_ORG}/\${GITHUB_REPO}/environments/${env}" --input env-${env}-config.json + +# Restrict deployments to main branch +gh api --method POST "repos/\${GITHUB_ORG}/\${GITHUB_REPO}/environments/${env}/deployment-branch-policies" -f name='main' -f type='branch' + +# Optional: require deployment approval reviewers (replace IDs with your approver IDs) +# Resolve a user ID: gh api "users/" --jq .id +@' +{ + "wait_timer": 0, + "prevent_self_review": true, + "reviewers": [ + { "type": "User", "id": 12345678 } + ], + "deployment_branch_policy": { + "protected_branches": false, + "custom_branch_policies": true + } +} +'@ | Set-Content -Path env-${env}-reviewers.json + +gh api --method PUT "repos/\${GITHUB_ORG}/\${GITHUB_REPO}/environments/${env}" --input env-${env}-reviewers.json` ).join('\n\n')} \`\`\` +> Rerun note: environment PUT calls are idempotent, but branch-policy creation can return "already exists" on reruns after the first successful create; treat that as expected when main is already configured. + +> If reviewer configuration is restricted by repository plan/policy, keep environment creation and branch-policy commands in CLI and apply required reviewers using the same API payload once policy allows it. + --- ## Step 6 — Set GitHub Repository Secrets diff --git a/src/templates/github-actions/publish-workflow.ts b/src/templates/github-actions/publish-workflow.ts index 189d197..875e0c5 100644 --- a/src/templates/github-actions/publish-workflow.ts +++ b/src/templates/github-actions/publish-workflow.ts @@ -12,19 +12,22 @@ export function generatePublishWorkflow(config: PublishWorkflowConfig): string { const envChoices = config.environments.map((env) => ` - ${env}`).join('\n'); const envJobs = config.environments.map((env, idx) => { - const autoDeployComment = idx === 0 - ? ` # To enable automatic deployment on push to main, uncomment the condition below: - # if: github.event.inputs.ENVIRONMENT == '${env}' || github.event_name == 'push'` - : ` # To enable automatic deployment on push to main, uncomment the condition below: - # if: github.event.inputs.ENVIRONMENT == '${env}' || github.event_name == 'push' - # And change needs to: needs: [get-commit, publish-${config.environments[idx - 1]}]`; + const previousEnvironment = idx > 0 ? config.environments[idx - 1] : null; + const needs = previousEnvironment ? `[get-commit, publish-${previousEnvironment}]` : 'get-commit'; + + const jobComment = idx === 0 + ? ` # Automatically deploys to ${env} on push to main (incremental mode) or when selected via workflow_dispatch` + : ` # Deploys to ${env} after ${previousEnvironment} succeeds (sequential promotion). + # Configure environment protection rules to require approval before deploying to ${env}.`; + + const jobCondition = `github.event.inputs.ENVIRONMENT == '${env}' || github.event_name == 'push'`; return ` publish-${env}: -${autoDeployComment} - if: github.event.inputs.ENVIRONMENT == '${env}' +${jobComment} + if: ${jobCondition} runs-on: ubuntu-latest environment: ${env} - needs: get-commit + needs: ${needs} steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/tests/unit/services/identity-guide-service.test.ts b/tests/unit/services/identity-guide-service.test.ts index 9e36c83..24e0a51 100644 --- a/tests/unit/services/identity-guide-service.test.ts +++ b/tests/unit/services/identity-guide-service.test.ts @@ -126,5 +126,17 @@ describe('identity-guide-service', () => { expect(guide).toContain('my-rg'); }); + it('should include Azure DevOps approvals and checks guidance for human approval', () => { + const guide = identityGuideService.generateAzureDevOpsGuide( + 'sub-12345', + 'my-rg', + ['dev', 'prod'] + ); + expect(guide).toContain('Pipelines > Environments > '); + expect(guide).toContain('Approvals and checks'); + expect(guide).toContain('Approvals'); + expect(guide).toContain('required approvers'); + }); + }); }); diff --git a/tests/unit/templates/azure-devops/publish-pipeline.test.ts b/tests/unit/templates/azure-devops/publish-pipeline.test.ts index 010176f..a187ae7 100644 --- a/tests/unit/templates/azure-devops/publish-pipeline.test.ts +++ b/tests/unit/templates/azure-devops/publish-pipeline.test.ts @@ -209,5 +209,17 @@ describe('azure-devops/publish-pipeline', () => { expect(pipeline).toContain('npm ci'); expect(pipeline).toContain('npx apiops publish'); }); + + it('should include approval guidance comment for non-first stages', () => { + const pipeline = generatePublishPipeline({ + artifactDir: './apim-artifacts', + environments: ['dev', 'staging', 'prod'], + }); + // Non-first stages should have a comment guiding users to configure approval checks + expect(pipeline).toContain('Pipelines > Environments > staging'); + expect(pipeline).toContain('Pipelines > Environments > prod'); + // First stage (dev) should not mention the dev environment in an approval context + expect(pipeline).not.toContain('Pipelines > Environments > dev'); + }); }); }); diff --git a/tests/unit/templates/copilot/identity-setup-prompt.test.ts b/tests/unit/templates/copilot/identity-setup-prompt.test.ts index 6879003..4cbb6c8 100644 --- a/tests/unit/templates/copilot/identity-setup-prompt.test.ts +++ b/tests/unit/templates/copilot/identity-setup-prompt.test.ts @@ -66,6 +66,15 @@ describe('copilot/identity-setup-prompt', () => { expect(prompt).toContain('gh api --method PUT'); expect(prompt).toContain('environments/dev'); expect(prompt).toContain('environments/prod'); + expect(prompt).toContain('Configure GitHub Environments with CLI'); + expect(prompt).toContain('do not use manual UI-only setup'); + expect(prompt).toContain('deployment-branch-policies'); + expect(prompt).toContain('prevent_self_review'); + expect(prompt).toContain('"reviewers"'); + expect(prompt).toContain('gh api "users/" --jq .id'); + expect(prompt).toContain("gh api --method POST \"repos/${GITHUB_ORG}/${GITHUB_REPO}/environments/dev/deployment-branch-policies\""); + expect(prompt).toContain("gh api --method POST \"repos/${GITHUB_ORG}/${GITHUB_REPO}/environments/prod/deployment-branch-policies\""); + expect(prompt).toContain("-f name='main' -f type='branch'"); }); it('should include gh secret set commands for repository secrets', () => { diff --git a/tests/unit/templates/github-actions/publish-workflow.test.ts b/tests/unit/templates/github-actions/publish-workflow.test.ts index e372a8a..748935a 100644 --- a/tests/unit/templates/github-actions/publish-workflow.test.ts +++ b/tests/unit/templates/github-actions/publish-workflow.test.ts @@ -92,11 +92,12 @@ describe('github-actions/publish-workflow', () => { expect(workflow).toContain('environment: prod'); }); - it('should chain jobs with needs dependencies', () => { + it('should chain subsequent environment jobs on the previous environment', () => { const workflow = generatePublishWorkflow({ artifactDir: './apim-artifacts', environments: ['dev', 'staging', 'prod'], }); + // Subsequent environments depend on both get-commit and the previous env job expect(workflow).toContain('needs: [get-commit, publish-dev]'); expect(workflow).toContain('needs: [get-commit, publish-staging]'); }); @@ -106,6 +107,7 @@ describe('github-actions/publish-workflow', () => { artifactDir: './apim-artifacts', environments: ['dev', 'prod'], }); + // First env uses simple `needs: get-commit`; subsequent envs use array form with chaining expect(workflow).toContain('needs: get-commit'); }); @@ -203,5 +205,46 @@ describe('github-actions/publish-workflow', () => { expect(workflow).toContain('npm install'); expect(workflow).toContain('npx apiops publish'); }); + + it('should enable first environment to run automatically on push to main', () => { + const workflow = generatePublishWorkflow({ + artifactDir: './apim-artifacts', + environments: ['dev', 'prod'], + }); + // First environment's if-condition must include the push event trigger + expect(workflow).toContain("ENVIRONMENT == 'dev' || github.event_name == 'push'"); + }); + + it('should enable all environments to run on push for sequential promotion', () => { + const workflow = generatePublishWorkflow({ + artifactDir: './apim-artifacts', + environments: ['dev', 'staging', 'prod'], + }); + // All environments must run on push so the "Review deployments" approval flow works + expect(workflow).toContain("ENVIRONMENT == 'dev' || github.event_name == 'push'"); + expect(workflow).toContain("ENVIRONMENT == 'staging' || github.event_name == 'push'"); + expect(workflow).toContain("ENVIRONMENT == 'prod' || github.event_name == 'push'"); + }); + + it('should include generic approval guidance for non-first environments without GitHub settings steps', () => { + const workflow = generatePublishWorkflow({ + artifactDir: './apim-artifacts', + environments: ['dev', 'staging'], + }); + expect(workflow).toContain('Configure environment protection rules to require approval before deploying to staging.'); + expect(workflow).not.toContain('Settings > Environments > staging'); + expect(workflow).not.toContain('Required reviewers'); + }); + + it('should pass commit_id on push trigger via incremental step condition', () => { + const workflow = generatePublishWorkflow({ + artifactDir: './apim-artifacts', + environments: ['dev'], + }); + // The incremental step condition is true when COMMIT_ID_CHOICE is empty (push trigger), + // so --commit-id will be passed automatically on push. + expect(workflow).toContain("COMMIT_ID_CHOICE != 'publish-all-artifacts-in-repo'"); + expect(workflow).toContain('--commit-id ${{ needs.get-commit.outputs.commit_id }}'); + }); }); });