From 9d8c5f4a9fb951023d3108ed5571795425157b27 Mon Sep 17 00:00:00 2001 From: Alexander Zaslonov Date: Thu, 27 Aug 2026 15:30:54 -0700 Subject: [PATCH] fix: preserve existing GitHub secrets during identity setup Closes #121 --- .../identity-setup-prompt-github-actions.md | 33 +++++++++-- .../copilot/identity-setup-prompt.ts | 14 +++++ .../github-environment-secret-commands.md | 6 +- .../copilot/identity-setup-prompt.test.ts | 59 +++++++++++++++++++ 4 files changed, 105 insertions(+), 7 deletions(-) diff --git a/src/templates/copilot/identity-setup-prompt-github-actions.md b/src/templates/copilot/identity-setup-prompt-github-actions.md index f06c215a..ba8c363d 100644 --- a/src/templates/copilot/identity-setup-prompt-github-actions.md +++ b/src/templates/copilot/identity-setup-prompt-github-actions.md @@ -191,11 +191,34 @@ 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}} ``` @@ -203,12 +226,14 @@ gh secret set AZURE_TENANT_ID --body "${AZURE_TENANT_ID}" **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 diff --git a/src/templates/copilot/identity-setup-prompt.ts b/src/templates/copilot/identity-setup-prompt.ts index 39230bb4..2ccc75ec 100644 --- a/src/templates/copilot/identity-setup-prompt.ts +++ b/src/templates/copilot/identity-setup-prompt.ts @@ -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 @@ -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'); @@ -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, diff --git a/src/templates/shared/github-environment-secret-commands.md b/src/templates/shared/github-environment-secret-commands.md index abb8525d..235f1fef 100644 --- a/src/templates/shared/github-environment-secret-commands.md +++ b/src/templates/shared/github-environment-secret-commands.md @@ -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}" diff --git a/tests/unit/templates/copilot/identity-setup-prompt.test.ts b/tests/unit/templates/copilot/identity-setup-prompt.test.ts index ab257a2b..b778705d 100644 --- a/tests/unit/templates/copilot/identity-setup-prompt.test.ts +++ b/tests/unit/templates/copilot/identity-setup-prompt.test.ts @@ -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');