Skip to content
Merged
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
33 changes: 29 additions & 4 deletions src/templates/copilot/identity-setup-prompt-github-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,24 +191,49 @@ az ad app federated-credential create `

> ⚠️ **Platform Note:** GitHub CLI secret commands work identically on all platforms, but variable syntax differs between Bash and PowerShell.

### Check for existing secrets before writing

**Never overwrite an existing secret silently.** First list repository and environment secret names (GitHub does not expose their values):

```bash
gh secret list --repo "${GITHUB_ORG}/${GITHUB_REPO}" --json name
{{GH_SECRET_ENV_LIST_COMMANDS}}
```

Compare the returned names with every secret this setup intends to create. For each conflict, ask the user to choose one option before running any `gh secret set` command:

1. **Overwrite** — replace the existing secret with the new value.
2. **Rename** — ask for or confirm an alternative name, such as `APIOPS_AZURE_CLIENT_ID`. Accept only GitHub secret names containing letters, numbers, and underscores, starting with a letter or underscore, and not starting with `GITHUB_`. For each scope, maintain a case-insensitive reserved-name set initialized from its inventory. Check the alternative against that set and add each chosen name immediately; if it is already reserved, resolve that conflict before writing.
3. **Reuse** — keep the existing secret and skip its `gh secret set` command.

Do not infer that an existing secret has the intended value because GitHub cannot return secret values. Reuse is an explicit user decision.

Keep a scope-aware mapping with the repository, scope (`repository` or `environment`), environment name when applicable, expected name, chosen name, and action. The same expected name may map differently in different environments.

If any secret is renamed, inspect generated workflow files under `.github/workflows/` and update references for that exact scope. Handle both literal expressions such as `secrets.AZURE_CLIENT_ID` and computed expressions such as `secrets[format('APIM_RESOURCE_GROUP_{0}', ...)]`. When an expected name maps differently by environment, replace the shared computed lookup with an environment-keyed expression that covers every environment mapping; retain the original computed lookup as the fallback for unchanged environments. Apply the same rule to environment-scoped `AZURE_SUBSCRIPTION_ID` renames. Show the complete mapping and proposed file changes, then obtain confirmation before editing. Do not replace unrelated text or secret names.

After all conflicts are resolved, run only the `gh secret set` commands approved by the user, using the mapped names where applicable.

**On macOS/Linux (Bash):**
```bash
# Repository-level secrets (shared across all workflows)
gh secret set AZURE_CLIENT_ID --body "$APP_ID"
gh secret set AZURE_TENANT_ID --body "${AZURE_TENANT_ID}"
gh secret set AZURE_CLIENT_ID --body "$APP_ID" --repo "${GITHUB_ORG}/${GITHUB_REPO}"
gh secret set AZURE_TENANT_ID --body "${AZURE_TENANT_ID}" --repo "${GITHUB_ORG}/${GITHUB_REPO}"

{{GH_SECRET_ENV_COMMANDS}}
```

**On Windows (PowerShell):**
```powershell
# Repository-level secrets (shared across all workflows)
gh secret set AZURE_CLIENT_ID --body $APP_ID
gh secret set AZURE_TENANT_ID --body "${AZURE_TENANT_ID}"
gh secret set AZURE_CLIENT_ID --body $APP_ID --repo "${GITHUB_ORG}/${GITHUB_REPO}"
gh secret set AZURE_TENANT_ID --body "${AZURE_TENANT_ID}" --repo "${GITHUB_ORG}/${GITHUB_REPO}"

{{GH_SECRET_ENV_COMMANDS}}
```

Summarize each secret as **created**, **overwritten**, **renamed**, or **reused**, and list any workflow files updated for renamed secrets. Never print secret values in the summary.

---

## Step 7 — Verify
Expand Down
14 changes: 14 additions & 0 deletions src/templates/copilot/identity-setup-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ export function generateIdentitySetupPrompt(config: IdentitySetupPromptConfig):
});
}

const invalidEnvironment = config.environments.find(
(environment) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(environment)
);
if (invalidEnvironment !== undefined) {
throw new Error(
`Invalid GitHub environment name "${invalidEnvironment}". Use letters, numbers, and underscores, starting with a letter or underscore.`
);
}

const envSecrets = config.environments.map((env) =>
`- \`AZURE_SUBSCRIPTION_ID\` — Azure subscription ID for **${env}** environment
- \`APIM_RESOURCE_GROUP_${env.toUpperCase()}\` — Resource group containing the **${env}** APIM instance
Expand All @@ -61,6 +70,10 @@ export function generateIdentitySetupPrompt(config: IdentitySetupPromptConfig):
gh api --method PUT "repos/\${GITHUB_ORG}/\${GITHUB_REPO}/environments/${env}"`
).join('\n\n');

const ghSecretEnvListCommands = config.environments.map((env) =>
`gh secret list --repo "\${GITHUB_ORG}/\${GITHUB_REPO}" --env "${env}" --json name`
).join('\n');

const envSubscriptionTableRows = config.environments.map((env) =>
`| \`AZURE_SUBSCRIPTION_ID_${env.toUpperCase()}\` | Azure subscription ID for **${env}** environment | \`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\` |`
).join('\n');
Expand Down Expand Up @@ -94,6 +107,7 @@ az role assignment create \\
ENV_SECRETS_REFERENCE: envSecrets,
ENV_FEDERATED_CREDENTIALS: envFedCreds,
GH_SECRET_ENV_COMMANDS: ghSecretEnvCmds,
GH_SECRET_ENV_LIST_COMMANDS: ghSecretEnvListCommands,
ENVIRONMENT_CREATION_COMMANDS: environmentCreationCommands,
ENV_SUBSCRIPTION_TABLE_ROWS: envSubscriptionTableRows,
ENV_APIM_TABLE_ROWS: envApimTableRows,
Expand Down
6 changes: 3 additions & 3 deletions src/templates/shared/github-environment-secret-commands.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# {{ENV}} environment secrets
gh secret set AZURE_SUBSCRIPTION_ID --body "${AZURE_SUBSCRIPTION_ID_{{ENV_UPPER}}}" --env {{ENV}}
gh secret set APIM_RESOURCE_GROUP_{{ENV_UPPER}} --body "${APIM_RG_{{ENV_UPPER}}}" --env {{ENV}}
gh secret set APIM_SERVICE_NAME_{{ENV_UPPER}} --body "${APIM_NAME_{{ENV_UPPER}}}" --env {{ENV}}
gh secret set AZURE_SUBSCRIPTION_ID --body "${AZURE_SUBSCRIPTION_ID_{{ENV_UPPER}}}" --env "{{ENV}}" --repo "${GITHUB_ORG}/${GITHUB_REPO}"
gh secret set APIM_RESOURCE_GROUP_{{ENV_UPPER}} --body "${APIM_RG_{{ENV_UPPER}}}" --env "{{ENV}}" --repo "${GITHUB_ORG}/${GITHUB_REPO}"
gh secret set APIM_SERVICE_NAME_{{ENV_UPPER}} --body "${APIM_NAME_{{ENV_UPPER}}}" --env "{{ENV}}" --repo "${GITHUB_ORG}/${GITHUB_REPO}"
59 changes: 59 additions & 0 deletions tests/unit/templates/copilot/identity-setup-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,65 @@ describe('copilot/identity-setup-prompt', () => {
expect(prompt).toContain('gh secret set AZURE_SUBSCRIPTION_ID');
});

it('should detect and resolve existing secret conflicts before writing', () => {
const prompt = generateIdentitySetupPrompt({ environments: ['dev', 'prod'] });

expect(prompt).toContain('Never overwrite an existing secret silently');
expect(prompt).toContain('gh secret list --repo');
expect(prompt).toContain('--env "dev" --json name');
expect(prompt).toContain('--env "prod" --json name');
expect(prompt).toContain('**Overwrite**');
expect(prompt).toContain('**Rename**');
expect(prompt).toContain('**Reuse**');
expect(prompt).toContain('skip its `gh secret set` command');
expect(prompt).toContain('case-insensitive reserved-name set');
expect(prompt).toContain('add each chosen name immediately');
expect(prompt).toContain('not starting with `GITHUB_`');
});

it('should update workflow references and summarize secret decisions', () => {
const prompt = generateIdentitySetupPrompt({ environments: ['dev'] });

expect(prompt).toContain('secrets.AZURE_CLIENT_ID');
expect(prompt).toContain('.github/workflows/');
expect(prompt).toContain("secrets[format('APIM_RESOURCE_GROUP_{0}', ...)]");
expect(prompt).toContain('environment-keyed expression that covers every environment mapping');
expect(prompt).toContain('retain the original computed lookup as the fallback');
expect(prompt).toContain('environment-scoped `AZURE_SUBSCRIPTION_ID` renames');
expect(prompt).toContain('scope-aware mapping');
expect(prompt).toContain('Show the complete mapping and proposed file changes');
expect(prompt).toContain('**created**, **overwritten**, **renamed**, or **reused**');
expect(prompt).toContain('Never print secret values');
});

it('should target the selected repository for every secret write', () => {
const prompt = generateIdentitySetupPrompt({ environments: ['dev'] });
const setCommands = prompt.split('\n').filter(line => line.startsWith('gh secret set '));
const listCommands = prompt.split('\n').filter(line => line.startsWith('gh secret list '));

expect(setCommands.length).toBeGreaterThan(0);
expect(listCommands.length).toBe(2);
expect(listCommands.every(line => line.includes('--repo "${GITHUB_ORG}/${GITHUB_REPO}"'))).toBe(true);
expect(setCommands.every(line => line.includes('--repo "${GITHUB_ORG}/${GITHUB_REPO}"'))).toBe(true);
expect(setCommands.filter(line => line.includes('--env ')).every(line => line.includes('--env "dev"'))).toBe(true);
});

it('should safely render custom environment names used in shell variables', () => {
const prompt = generateIdentitySetupPrompt({ environments: ['qa_east_2'] });

expect(prompt).toContain('--env "qa_east_2"');
expect(prompt).toContain('APIM_RESOURCE_GROUP_QA_EAST_2');
});

it('should reject GitHub environment names that are unsafe in generated commands', () => {
expect(() => generateIdentitySetupPrompt({ environments: ['qa east'] })).toThrow(
'Invalid GitHub environment name "qa east"'
);
expect(() => generateIdentitySetupPrompt({ environments: ['qa"east'] })).toThrow(
'Invalid GitHub environment name'
);
});

it('should include per-environment secret set commands', () => {
const prompt = generateIdentitySetupPrompt({ environments: ['dev', 'prod'] });
expect(prompt).toContain('gh secret set APIM_RESOURCE_GROUP_DEV');
Expand Down