Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/services/identity-guide-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 > <environment-name>** in Azure DevOps.
2. Open **Approvals and checks** and add an **Approvals** check with the required approvers.

---

Expand Down
12 changes: 9 additions & 3 deletions src/templates/azure-devops/publish-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
87 changes: 84 additions & 3 deletions src/templates/copilot/identity-setup-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<github-login>" --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
Expand Down Expand Up @@ -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/<github-login>" --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
Expand Down
21 changes: 12 additions & 9 deletions src/templates/github-actions/publish-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions tests/unit/services/identity-guide-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 > <environment-name>');
expect(guide).toContain('Approvals and checks');
expect(guide).toContain('Approvals');
expect(guide).toContain('required approvers');
});

});
});
12 changes: 12 additions & 0 deletions tests/unit/templates/azure-devops/publish-pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
9 changes: 9 additions & 0 deletions tests/unit/templates/copilot/identity-setup-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<github-login>" --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', () => {
Expand Down
45 changes: 44 additions & 1 deletion tests/unit/templates/github-actions/publish-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]');
});
Expand All @@ -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');
});

Expand Down Expand Up @@ -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 }}');
});
});
});
Loading