From d626e4f832d94fd8fa841c71b79ecb65f4fa4519 Mon Sep 17 00:00:00 2001 From: TJ Edwards <7.3dward5@gmail.com> Date: Wed, 27 Aug 2025 20:30:08 -0400 Subject: [PATCH 1/3] feat(pipeline): opt-in GitHub Environments support & force regenerate flag; env-scoped cleanup & workflow matrix updates --- cli/azd/CHANGELOG.md | 4 + cli/azd/CONTRIBUTING.md | 10 + cli/azd/cmd/pipeline.go | 27 ++ .../TestUsage-azd-pipeline-config.snap | 2 + cli/azd/docs/environment-variables.md | 13 + cli/azd/docs/manual-pipeline-config.md | 6 + cli/azd/docs/using-environment-secrets.md | 2 + cli/azd/internal/appdetect/appdetect_test.go | 20 ++ cli/azd/pkg/armmsi/armmsi.go | 51 ++++ cli/azd/pkg/entraid/entraid.go | 58 ++++ cli/azd/pkg/pipeline/github_provider.go | 224 +++++++++++---- cli/azd/pkg/pipeline/github_provider_test.go | 266 ++++++++++++++++++ cli/azd/pkg/pipeline/pipeline.go | 3 + cli/azd/pkg/pipeline/pipeline_manager.go | 244 +++++++++++++++- cli/azd/pkg/pipeline/pipeline_manager_test.go | 91 +++++- cli/azd/pkg/pipeline/prune_test.go | 90 ++++++ ..._-_github_selected_-_multi_env_matrix.snap | 50 ++++ ..._selected_-_multi_env_matrix_explicit.snap | 51 ++++ ...ub_selected_-_multi_env_matrix_opt-in.snap | 52 ++++ ..._selected_-_no_app_host_-_client_cred.snap | 2 + .../project/framework_service_dotnet_test.go | 4 +- .../project/framework_service_maven_test.go | 4 +- .../pkg/project/framework_service_npm_test.go | 4 +- .../project/framework_service_python_test.go | 4 +- cli/azd/pkg/project/service_manager_test.go | 12 +- .../service_target_ai_endpoint_test.go | 4 +- .../pkg/project/service_target_aks_test.go | 6 +- cli/azd/pkg/tools/github/github.go | 75 +++++ .../resources/pipeline/.github/azure-dev.ymlt | 18 ++ 29 files changed, 1321 insertions(+), 76 deletions(-) create mode 100644 cli/azd/pkg/pipeline/prune_test.go create mode 100644 cli/azd/pkg/pipeline/testdata/Test_promptForCiFiles-no_files_-_github_selected_-_multi_env_matrix.snap create mode 100644 cli/azd/pkg/pipeline/testdata/Test_promptForCiFiles-no_files_-_github_selected_-_multi_env_matrix_explicit.snap create mode 100644 cli/azd/pkg/pipeline/testdata/Test_promptForCiFiles-no_files_-_github_selected_-_multi_env_matrix_opt-in.snap diff --git a/cli/azd/CHANGELOG.md b/cli/azd/CHANGELOG.md index 2c3cd986670..9d0690db87d 100644 --- a/cli/azd/CHANGELOG.md +++ b/cli/azd/CHANGELOG.md @@ -3,6 +3,10 @@ ## 1.19.0-beta.1 (Unreleased) ### Features Added +* [[XXXX]](https://github.com/Azure/azure-dev/pull/XXXX) GitHub pipeline config: supports setting secrets & variables in a GitHub Environment named after the current azd environment (automatically created if needed) and adds a matching OIDC federated credential subject (`repo:/:environment:`). (Previously configurable via `AZD_GITHUB_ENV`; that variable is now ignored.) Fixes [#5473](https://github.com/Azure/azure-dev/issues/5473). +* [[XXXX]](https://github.com/Azure/azure-dev/pull/XXXX) Added optional `--github-use-environments` flag to `azd pipeline config` to emit GitHub Environment (and matrix when multiple) blocks. Disabled by default; existing workflows remain unchanged unless flag is provided. Re-running with the flag toggled will migrate the existing workflow by adding or removing the environment/matrix section. Addresses matrix & multi‑environment ask in [#2373](https://github.com/Azure/azure-dev/issues/2373) and community feedback in [discussion #3585](https://github.com/Azure/azure-dev/discussions/3585). +* [[XXXX]](https://github.com/Azure/azure-dev/pull/XXXX) Environment mode now migrates standard AZD pipeline variables to the GitHub Environment scope (removing repo-level duplicates) and generates only the environment federated credential subject. Related to [#5473](https://github.com/Azure/azure-dev/issues/5473). +* [[XXXX]](https://github.com/Azure/azure-dev/pull/XXXX) Automatic pruning of legacy branch & pull_request federated identity credentials when switching to environment-only mode (service principal & MSI identities). Part of cleanup for [#5473](https://github.com/Azure/azure-dev/issues/5473). ### Breaking Changes diff --git a/cli/azd/CONTRIBUTING.md b/cli/azd/CONTRIBUTING.md index 6008d756fb7..22fc00a8125 100644 --- a/cli/azd/CONTRIBUTING.md +++ b/cli/azd/CONTRIBUTING.md @@ -75,6 +75,16 @@ Launch `azd` separately, then attach: > Tip: Use the VSCode terminal to perform all `azd` build and run commands. +### Testing pipeline workflow generation + +When iterating on GitHub workflow template changes, you can regenerate the workflow with: + +```bash +azd pipeline config --github-use-environments # opt-in to environment + matrix emission +``` + +Omit `--github-use-environments` to verify the legacy (non-environment) form. Re-running with the flag toggled automatically migrates the existing workflow to add or remove the `environment`/matrix sections. + ## Submitting a change 1. Create a new branch: `git checkout -b my-branch-name` diff --git a/cli/azd/cmd/pipeline.go b/cli/azd/cmd/pipeline.go index 0a064782bda..c9f33836efc 100644 --- a/cli/azd/cmd/pipeline.go +++ b/cli/azd/cmd/pipeline.go @@ -6,6 +6,7 @@ package cmd import ( "context" "fmt" + "os" "github.com/MakeNowJust/heredoc/v2" "github.com/azure/azure-dev/cli/azd/cmd/actions" @@ -26,6 +27,8 @@ type pipelineConfigFlags struct { pipeline.PipelineManagerArgs global *internal.GlobalCommandOptions internal.EnvFlag + UseGitHubEnvironments bool + ForceRegenerate bool } func (pc *pipelineConfigFlags) Bind(local *pflag.FlagSet, global *internal.GlobalCommandOptions) { @@ -65,6 +68,19 @@ func (pc *pipelineConfigFlags) Bind(local *pflag.FlagSet, global *internal.Globa // there no customer input using --provider local.StringVar(&pc.PipelineProvider, "provider", "", "The pipeline provider to use (github for Github Actions and azdo for Azure Pipelines).") + local.BoolVar( + &pc.UseGitHubEnvironments, + "github-use-environments", + false, + "Enable generation of GitHub Actions workflow that uses GitHub Environments (matrix or single) "+ + "and environment-scoped variables/secrets. Disabled by default to preserve legacy workflow output.", + ) + local.BoolVar( + &pc.ForceRegenerate, + "force-regenerate", + false, + "Force regeneration of the pipeline workflow file (overwrites existing) bypassing upgrade heuristics.", + ) local.StringVarP(&pc.ServiceManagementReference, "applicationServiceManagementReference", "m", "", "Service Management Reference. "+ "References application or service contact information from a Service or Asset Management database. "+ @@ -153,6 +169,17 @@ func newPipelineConfigAction( projectConfig: projectConfig, } + // Ensure manager args reflect new flag if provided + if flags.UseGitHubEnvironments { + manager.EnableGitHubEnvironments(true) + // Signal downstream providers (constructed earlier via DI) to switch behavior. + _ = os.Setenv("AZD_USE_GITHUB_ENVIRONMENTS", "1") + } + if flags.ForceRegenerate { + manager.ForceRegenerateWorkflow(true) + _ = os.Setenv("AZD_FORCE_PIPELINE_REGENERATE", "1") + } + return pca } diff --git a/cli/azd/cmd/testdata/TestUsage-azd-pipeline-config.snap b/cli/azd/cmd/testdata/TestUsage-azd-pipeline-config.snap index 00371f49934..20861f33a17 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-pipeline-config.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-pipeline-config.snap @@ -12,6 +12,8 @@ Flags -m, --applicationServiceManagementReference string : Service Management Reference. References application or service contact information from a Service or Asset Management database. This value must be a Universally Unique Identifier (UUID). You can set this value globally by running azd config set pipeline.config.applicationServiceManagementReference . --auth-type string : The authentication type used between the pipeline provider and Azure for deployment (Only valid for GitHub provider). Valid values: federated, client-credentials. -e, --environment string : The name of the environment to use. + --force-regenerate : Force regeneration of the pipeline workflow file (overwrites existing) bypassing upgrade heuristics. + --github-use-environments : Enable generation of GitHub Actions workflow that uses GitHub Environments (matrix or single) and environment-scoped variables/secrets. Disabled by default to preserve legacy workflow output. --principal-id string : The client id of the service principal to use to grant access to Azure resources as part of the pipeline. --principal-name string : The name of the service principal to use to grant access to Azure resources as part of the pipeline. --principal-role stringArray : The roles to assign to the service principal. By default the service principal will be granted the Contributor and User Access Administrator roles. diff --git a/cli/azd/docs/environment-variables.md b/cli/azd/docs/environment-variables.md index e96152a41be..12cd2fcf017 100644 --- a/cli/azd/docs/environment-variables.md +++ b/cli/azd/docs/environment-variables.md @@ -19,3 +19,16 @@ For tools that are auto-acquired by `azd`, you are able to configure the followi - `AZD_BICEP_TOOL_PATH`: The Bicep tool override path. The direct path to `bicep` or `bicep.exe`. - `AZD_GH_TOOL_PATH`: The `gh` tool override path. The direct path to `gh` or `gh.exe`. - `AZD_PACK_TOOL_PATH`: The `pack` tool override path. The direct path to `pack` or `pack.exe`. + +### GitHub Pipeline Scoping (optional) + +When run with the optional flag `--github-use-environments`, `azd pipeline config` (GitHub provider) scopes secrets & variables to a GitHub Environment whose name matches the current azd environment (`AZURE_ENV_NAME`). The environment is created if it does not already exist, and an OIDC federated credential subject (`repo:/:environment:`) is added so workflows targeting that environment can obtain Azure tokens. If multiple azd environments are detected (multiple `.azure//.env` files) a strategy matrix is emitted and each job targets the corresponding GitHub Environment. Without the flag, the generated workflow remains in the legacy form (no `environment:` key or matrix) and variables & secrets are written at the repository level. + +The previously documented `AZD_GITHUB_ENV` override has been removed and is ignored if set. + +Migration & cleanup: Re-running `azd pipeline config` with the flag toggled on or off will update an existing workflow to add or remove the `environment` and matrix sections to match current usage – no manual edits required. When the flag is enabled, azd also: + +- Migrates known Azure pipeline variables (`AZURE_ENV_NAME`, `AZURE_LOCATION`, `AZURE_SUBSCRIPTION_ID`, `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, plus resource-group / terraform remote state vars when present) into the GitHub Environment scope (creating/updating them there) and deletes any duplicate repository-level copies. +- Emits only the single environment-scoped OIDC federated credential subject; legacy branch (`repo::ref:refs/heads/`) and pull request (`repo::pull_request`) subjects are automatically pruned for service principal based auth (MSI pruning will be added in a future update). + +Disabling the flag returns to repository-level scoping (existing environment variables/secrets remain but are unused by the legacy workflow). diff --git a/cli/azd/docs/manual-pipeline-config.md b/cli/azd/docs/manual-pipeline-config.md index 3154e5f1252..bb509128ae1 100644 --- a/cli/azd/docs/manual-pipeline-config.md +++ b/cli/azd/docs/manual-pipeline-config.md @@ -9,6 +9,12 @@ The Azure Developer CLI provides the command `azd pipeline config` to automatica 1. Configuring the git repo to use the created `Service Principal` to authenticate to Azure. 1. Creating a pipeline definition. +> Optional: Passing `--github-use-environments` when targeting GitHub will cause the generated workflow to reference a GitHub Environment named after the current azd environment and, when more than one azd environment is detected locally (multiple `.azure//.env` files), emit a strategy matrix to run against each. Omitting the flag produces the legacy single-job workflow without an `environment:` key. +> +> Cleanup behavior when the flag is enabled: +> - Repository-level duplicates of standard azd variables (`AZURE_ENV_NAME`, `AZURE_LOCATION`, `AZURE_SUBSCRIPTION_ID`, `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, plus optional `AZURE_RESOURCE_GROUP` & terraform remote state vars) are deleted after migration into the Environment scope. +> - Federated identity credentials for branch refs and pull_request are pruned; only the environment subject is retained (service principal auth path). MSI pruning coming later. + This command **must** be executed by someone who has a `Contributor` role, in order to create the service principal with the given role. The next steps can be used to manually configure a pipeline without a `Contributor` role, for example, by using an existing service principal. diff --git a/cli/azd/docs/using-environment-secrets.md b/cli/azd/docs/using-environment-secrets.md index ec6cc694db8..c4e7b1fc2cf 100644 --- a/cli/azd/docs/using-environment-secrets.md +++ b/cli/azd/docs/using-environment-secrets.md @@ -87,6 +87,8 @@ The Azure Developer CLI simplifies the process of setting up continuous integrat As part of the automatic configuration, AZD creates secrets and variables for your CI/CD deployment workflow. For example, the Azure Subscription ID and location are set as variables. Additionally, you can define a list of variables and secrets by using the `pipeline` configuration in the `azure.yaml` file within your project. The list of variables or secrets you define corresponds to the names of the keys in your AZD environment (.env). If the name of the key holds a secret reference (akvs), AZD will apply the following rules to set the value in your CI/CD settings. +Secrets & variables for GitHub pipelines are automatically scoped to a GitHub Environment named after your azd environment (`AZURE_ENV_NAME`). No extra environment variable is required. The environment is created on demand and a federated credential subject `repo:/:environment:` is added so workflows referencing that environment can use OIDC authentication. (Older guidance to set `AZD_GITHUB_ENV` is obsolete.) + ### Variables If the secret is added to the `variables` section of the pipeline configuration, the Azure Developer CLI (AZD) will use the value from the environment without retrieving the actual secret value. This approach is beneficial when you prefer to maintain Azure Key Vault references within your CI/CD settings. By doing so, you can rotate your secrets in the Key Vault, ensuring that your CI/CD pipeline uses the latest secret values without the need to update your workflow variables or secrets. diff --git a/cli/azd/internal/appdetect/appdetect_test.go b/cli/azd/internal/appdetect/appdetect_test.go index 2182293a5ba..548213160ef 100644 --- a/cli/azd/internal/appdetect/appdetect_test.go +++ b/cli/azd/internal/appdetect/appdetect_test.go @@ -8,6 +8,7 @@ import ( "embed" "io/fs" "os" + "os/exec" "path/filepath" "testing" @@ -189,8 +190,27 @@ func TestDetect(t *testing.T) { }, }, } + hasMaven := true + if _, err := exec.LookPath("mvn"); err != nil { + hasMaven = false + } + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // If the test expects Java projects but Maven isn't available, skip. + if !hasMaven { + requiresJava := false + for _, p := range tt.want { + if p.Language == Java { + requiresJava = true + break + } + } + if requiresJava { + t.Skip("skipping java detection test because Maven is not installed") + } + } + projects, err := Detect(context.Background(), dir, tt.options...) require.NoError(t, err) diff --git a/cli/azd/pkg/armmsi/armmsi.go b/cli/azd/pkg/armmsi/armmsi.go index 1c1a7b734a4..5e8e8308fde 100644 --- a/cli/azd/pkg/armmsi/armmsi.go +++ b/cli/azd/pkg/armmsi/armmsi.go @@ -257,3 +257,54 @@ func (s *ArmMsiService) ApplyFederatedCredentials(ctx context.Context, return result, nil } + +// ListFederatedCredentials returns all federated identity credentials for the MSI +func (s *ArmMsiService) ListFederatedCredentials( + ctx context.Context, + subscriptionId string, + msiResourceId string, +) ([]*armmsi.FederatedIdentityCredential, error) { + msiData, err := arm.ParseResourceID(msiResourceId) + if err != nil { + return nil, fmt.Errorf("parsing MSI resource id: %w", err) + } + credential, err := s.credentialProvider.CredentialForSubscription(ctx, subscriptionId) + if err != nil { + return nil, err + } + client, err := armmsi.NewFederatedIdentityCredentialsClient(subscriptionId, credential, s.armClientOptions) + if err != nil { + return nil, err + } + existingCreds := []*armmsi.FederatedIdentityCredential{} + pager := client.NewListPager(msiData.ResourceGroupName, msiData.Name, nil) + for pager.More() { + resp, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("listing federated identity credentials: %w", err) + } + existingCreds = append(existingCreds, resp.Value...) + } + return existingCreds, nil +} + +// DeleteFederatedCredential deletes a federated identity credential by name for the MSI +func (s *ArmMsiService) DeleteFederatedCredential(ctx context.Context, subscriptionId, msiResourceId, name string) error { + msiData, err := arm.ParseResourceID(msiResourceId) + if err != nil { + return fmt.Errorf("parsing MSI resource id: %w", err) + } + credential, err := s.credentialProvider.CredentialForSubscription(ctx, subscriptionId) + if err != nil { + return err + } + client, err := armmsi.NewFederatedIdentityCredentialsClient(subscriptionId, credential, s.armClientOptions) + if err != nil { + return err + } + _, err = client.Delete(ctx, msiData.ResourceGroupName, msiData.Name, name, nil) + if err != nil { + return fmt.Errorf("deleting federated identity credential %s: %w", name, err) + } + return nil +} diff --git a/cli/azd/pkg/entraid/entraid.go b/cli/azd/pkg/entraid/entraid.go index 0879387e8ea..3e2b9ea2f34 100644 --- a/cli/azd/pkg/entraid/entraid.go +++ b/cli/azd/pkg/entraid/entraid.go @@ -88,6 +88,17 @@ type EntraIdService interface { clientId string, federatedCredentials []*graphsdk.FederatedIdentityCredential, ) ([]*graphsdk.FederatedIdentityCredential, error) + ListFederatedCredentials( + ctx context.Context, + subscriptionId string, + clientId string, + ) ([]graphsdk.FederatedIdentityCredential, error) + DeleteFederatedCredential( + ctx context.Context, + subscriptionId string, + clientId string, + credentialId string, + ) error CreateRbac(ctx context.Context, subscriptionId string, scope, roleId, principalId string) error EnsureRoleAssignments( ctx context.Context, @@ -289,6 +300,53 @@ func (ad *entraIdService) ApplyFederatedCredentials( return createdCredentials, nil } +// ListFederatedCredentials lists all federated identity credentials for the given application (by clientId) +func (ad *entraIdService) ListFederatedCredentials( + ctx context.Context, + subscriptionId string, + clientId string, +) ([]graphsdk.FederatedIdentityCredential, error) { + graphClient, err := ad.getOrCreateGraphClient(ctx, subscriptionId) + if err != nil { + return nil, err + } + + application, err := ad.getApplicationByAppId(ctx, subscriptionId, clientId) + if err != nil { + return nil, fmt.Errorf("failed finding matching application: %w", err) + } + + resp, err := graphClient. + ApplicationById(*application.Id). + FederatedIdentityCredentials(). + Get(ctx) + if err != nil { + return nil, fmt.Errorf("failed retrieving federated credentials: %w", err) + } + return resp.Value, nil +} + +// DeleteFederatedCredential deletes a federated identity credential by id for the given application (clientId) +func (ad *entraIdService) DeleteFederatedCredential( + ctx context.Context, + subscriptionId string, + clientId string, + credentialId string, +) error { + graphClient, err := ad.getOrCreateGraphClient(ctx, subscriptionId) + if err != nil { + return err + } + application, err := ad.getApplicationByAppId(ctx, subscriptionId, clientId) + if err != nil { + return fmt.Errorf("failed finding matching application: %w", err) + } + return graphClient. + ApplicationById(*application.Id). + FederatedIdentityCredentialById(credentialId). + Delete(ctx) +} + func (ad *entraIdService) getApplicationByNameOrId( ctx context.Context, subscriptionId string, diff --git a/cli/azd/pkg/pipeline/github_provider.go b/cli/azd/pkg/pipeline/github_provider.go index b441a39cee9..c94519abaca 100644 --- a/cli/azd/pkg/pipeline/github_provider.go +++ b/cli/azd/pkg/pipeline/github_provider.go @@ -16,6 +16,8 @@ import ( "slices" "strings" + "os" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/azure/azure-dev/cli/azd/pkg/account" "github.com/azure/azure-dev/cli/azd/pkg/entraid" @@ -263,7 +265,7 @@ func (p *GitHubScmProvider) notifyWhenGitHubActionsAreDisabled( }) if err != nil { - return false, fmt.Errorf("Getting GitHub local workflow files %w", err) + return false, fmt.Errorf("getting GitHub local workflow files %w", err) } if ghLocalWorkflowFiles { @@ -378,42 +380,50 @@ func (p *GitHubCiProvider) credentialOptions( // If not specified default to federated credentials if authType == "" || authType == AuthTypeFederated { - // Configure federated auth for both main branch and current branch - branches := []string{repoDetails.branch} - if !slices.Contains(branches, "main") { - branches = append(branches, "main") - } - + useEnvOnly := os.Getenv("AZD_USE_GITHUB_ENVIRONMENTS") == "1" repoSlug := repoDetails.owner + "/" + repoDetails.repoName credentialSafeName := strings.ReplaceAll(repoSlug, "/", "-") + federatedCredentials := []*graphsdk.FederatedIdentityCredential{} - federatedCredentials := []*graphsdk.FederatedIdentityCredential{ - { + if !useEnvOnly { + // Legacy behavior: pull_request + branch (including main fallback) + branches := []string{repoDetails.branch} + if !slices.Contains(branches, "main") { + branches = append(branches, "main") + } + federatedCredentials = append(federatedCredentials, &graphsdk.FederatedIdentityCredential{ Name: url.PathEscape(fmt.Sprintf("%s-pull_request", credentialSafeName)), Issuer: federatedIdentityIssuer, Subject: fmt.Sprintf("repo:%s:pull_request", repoSlug), Description: to.Ptr("Created by Azure Developer CLI"), Audiences: []string{federatedIdentityAudience}, - }, + }) + for _, branch := range branches { + safeBranchName := regexp.MustCompile(`[^A-Za-z0-9-]`).ReplaceAllString(branch, "-") + federatedCredentials = append(federatedCredentials, &graphsdk.FederatedIdentityCredential{ + Name: url.PathEscape(fmt.Sprintf("%s-%s", credentialSafeName, safeBranchName)), + Issuer: federatedIdentityIssuer, + Subject: fmt.Sprintf("repo:%s:ref:refs/heads/%s", repoSlug, branch), + Description: to.Ptr("Created by Azure Developer CLI"), + Audiences: []string{federatedIdentityAudience}, + }) + } } - for _, branch := range branches { - safeBranchName := regexp.MustCompile(`[^A-Za-z0-9-]`).ReplaceAllString(branch, "-") - branchCredentials := &graphsdk.FederatedIdentityCredential{ - Name: url.PathEscape(fmt.Sprintf("%s-%s", credentialSafeName, safeBranchName)), + // Environment subject (always if env name exists) + ghEnv := strings.TrimSpace(p.env.Name()) + if ghEnv != "" { + safeEnvName := regexp.MustCompile(`[^A-Za-z0-9-]`).ReplaceAllString(ghEnv, "-") + federatedCredentials = append(federatedCredentials, &graphsdk.FederatedIdentityCredential{ + Name: url.PathEscape(fmt.Sprintf("%s-env-%s", credentialSafeName, safeEnvName)), Issuer: federatedIdentityIssuer, - Subject: fmt.Sprintf("repo:%s:ref:refs/heads/%s", repoSlug, branch), - Description: to.Ptr("Created by Azure Developer CLI"), + Subject: fmt.Sprintf("repo:%s:environment:%s", repoSlug, ghEnv), + Description: to.Ptr("Created by Azure Developer CLI (environment)"), Audiences: []string{federatedIdentityAudience}, - } - - federatedCredentials = append(federatedCredentials, branchCredentials) + }) } - return &CredentialOptions{ - EnableFederatedCredentials: true, - FederatedCredentialOptions: federatedCredentials, - }, nil + return &CredentialOptions{EnableFederatedCredentials: true, FederatedCredentialOptions: federatedCredentials}, nil } return &CredentialOptions{ @@ -459,6 +469,21 @@ func (p *GitHubCiProvider) setPipelineVariables( infraOptions provisioning.Options, tenantId, clientId string, ) error { + // Scope variables to a GitHub Environment whose name matches the azd environment (auto-created if missing). + ghEnv := strings.TrimSpace(p.env.Name()) + useEnvScope := ghEnv != "" + if useEnvScope { + if err := p.ghCli.EnsureEnvironment(ctx, repoSlug, ghEnv); err != nil { + return fmt.Errorf("ensuring github environment '%s': %w", ghEnv, err) + } + } + setVar := func(name, value string) error { + if useEnvScope { + return p.ghCli.SetEnvironmentVariable(ctx, repoSlug, ghEnv, name, value) + } + return p.ghCli.SetVariable(ctx, repoSlug, name, value) + } + for name, value := range map[string]string{ environment.EnvNameEnvVarName: p.env.Name(), environment.LocationEnvVarName: p.env.GetLocation(), @@ -466,7 +491,7 @@ func (p *GitHubCiProvider) setPipelineVariables( environment.TenantIdEnvVarName: tenantId, "AZURE_CLIENT_ID": clientId, } { - if err := p.ghCli.SetVariable(ctx, repoSlug, name, value); err != nil { + if err := setVar(name, value); err != nil { return fmt.Errorf("failed setting %s variable: %w", name, err) } p.console.MessageUxItem(ctx, &ux.CreatedRepoValue{ @@ -497,7 +522,7 @@ func (p *GitHubCiProvider) setPipelineVariables( } // env var was found - if err := p.ghCli.SetVariable(ctx, repoSlug, key, value); err != nil { + if err := setVar(key, value); err != nil { return fmt.Errorf("setting terraform remote state variables: %w", err) } p.console.MessageUxItem(ctx, &ux.CreatedRepoValue{ @@ -509,15 +534,50 @@ func (p *GitHubCiProvider) setPipelineVariables( if infraOptions.Provider == provisioning.Bicep { if rgName, has := p.env.LookupEnv(environment.ResourceGroupEnvVarName); has { - if err := p.ghCli.SetVariable(ctx, repoSlug, environment.ResourceGroupEnvVarName, rgName); err != nil { + if err := setVar(environment.ResourceGroupEnvVarName, rgName); err != nil { return fmt.Errorf("failed setting %s variable: %w", environment.ResourceGroupEnvVarName, err) } } } + // Cleanup: if environment mode is active and legacy repo-level variables exist with identical values, remove them. + if useEnvScope && os.Getenv("AZD_USE_GITHUB_ENVIRONMENTS") == "1" { + legacyVars, err := p.ghCli.ListVariables(ctx, repoSlug) + if err == nil { // best-effort cleanup + for _, name := range []string{ + environment.EnvNameEnvVarName, + environment.LocationEnvVarName, + environment.SubscriptionIdEnvVarName, + environment.TenantIdEnvVarName, + "AZURE_CLIENT_ID", + environment.ResourceGroupEnvVarName, + "RS_RESOURCE_GROUP", "RS_STORAGE_ACCOUNT", "RS_CONTAINER_NAME", + } { + valEnv := "" + // attempt fetch env-scoped variable (already set via setVar route) + // we don't have direct API here, assume presence + if _, ok := legacyVars[name]; ok { + // Delete unconditionally for known migrated variables. + _ = p.ghCli.DeleteVariable(ctx, repoSlug, name) + p.console.MessageUxItem( + ctx, + &ux.CreatedRepoValue{ + Name: name, + Kind: ux.GitHubVariable, + Action: "Delete (migrated to environment)", + }, + ) + } + _ = valEnv // suppress lint if unused in future modifications + } + } + } + return nil } +// firstNonEmpty returns the first non-empty string from the supplied values. + // Configures Github for standard Service Principal authentication with client id & secret func (p *GitHubCiProvider) configureClientCredentialsAuth( ctx context.Context, @@ -581,6 +641,47 @@ func (p *GitHubCiProvider) configurePipeline( ) (CiPipeline, error) { repoSlug := repoDetails.owner + "/" + repoDetails.repoName + // Target a GitHub Environment whose name matches the azd environment (auto-created elsewhere if needed). + ghEnv := strings.TrimSpace(p.env.Name()) + + // Wrapper helpers selecting environment vs repository scope. + listSecrets := func() ([]string, error) { + if ghEnv != "" { + return p.ghCli.ListEnvironmentSecrets(ctx, repoSlug, ghEnv) + } + return p.ghCli.ListSecrets(ctx, repoSlug) + } + listVariables := func() (map[string]string, error) { + if ghEnv != "" { + return p.ghCli.ListEnvironmentVariables(ctx, repoSlug, ghEnv) + } + return p.ghCli.ListVariables(ctx, repoSlug) + } + setSecret := func(name, value string) error { + if ghEnv != "" { + return p.ghCli.SetEnvironmentSecret(ctx, repoSlug, ghEnv, name, value) + } + return p.ghCli.SetSecret(ctx, repoSlug, name, value) + } + setVariable := func(name, value string) error { + if ghEnv != "" { + return p.ghCli.SetEnvironmentVariable(ctx, repoSlug, ghEnv, name, value) + } + return p.ghCli.SetVariable(ctx, repoSlug, name, value) + } + deleteSecret := func(name string) error { + if ghEnv != "" { + return p.ghCli.DeleteEnvironmentSecret(ctx, repoSlug, ghEnv, name) + } + return p.ghCli.DeleteSecret(ctx, repoSlug, name) + } + deleteVariable := func(name string) error { + if ghEnv != "" { + return p.ghCli.DeleteEnvironmentVariable(ctx, repoSlug, ghEnv, name) + } + return p.ghCli.DeleteVariable(ctx, repoSlug, name) + } + // Variables and Secrets for a gh-actions are independent from the gh-action. They are set on the repository level. // We need to clean up the previous values before setting the new ones. // By doing this, we are handling: @@ -592,13 +693,21 @@ func (p *GitHubCiProvider) configurePipeline( ciSecrets := []string{} if len(options.projectVariables) > 0 || len(options.providerParameters) > 0 { msg = "Setting up project's variables to be used in the pipeline" - ciSecretsInstance, err := p.ghCli.ListSecrets(ctx, repoSlug) + ciSecretsInstance, err := listSecrets() if err != nil { - return nil, fmt.Errorf("unable to get list of repository secrets: %w", err) + scope := "repository" + if ghEnv != "" { + scope = "environment" + } + return nil, fmt.Errorf("unable to get list of %s secrets: %w", scope, err) } - ciVariablesInstance, err := p.ghCli.ListVariables(ctx, repoSlug) + ciVariablesInstance, err := listVariables() if err != nil { - return nil, fmt.Errorf("unable to get list of repository variables: %w", err) + scope := "repository" + if ghEnv != "" { + scope = "environment" + } + return nil, fmt.Errorf("unable to get list of %s variables: %w", scope, err) } ciSecrets = ciSecretsInstance ciVariables = ciVariablesInstance @@ -610,14 +719,35 @@ func (p *GitHubCiProvider) configurePipeline( p.console.StopSpinner(ctx, msg, input.GetStepResultFormat(procErr)) } if procErr == nil { - p.console.MessageUxItem(ctx, &ux.MultilineMessage{ - Lines: []string{ - "", - "GitHub Action secrets are now configured. You can view GitHub action secrets that were " + - "created at this link:", - output.WithLinkFormat("https://github.com/%s/settings/secrets/actions", repoSlug), - ""}, - }) + if ghEnv == "" { + p.console.MessageUxItem( + ctx, + &ux.MultilineMessage{Lines: []string{ + "", + "GitHub Action secrets are now configured. You can view GitHub action secrets " + + "that were created at this link:", + output.WithLinkFormat("https://github.com/%s/settings/secrets/actions", repoSlug), + "", + }}, + ) + } else { + p.console.MessageUxItem( + ctx, + &ux.MultilineMessage{Lines: []string{ + "", + fmt.Sprintf( + "GitHub Action environment '%s' secrets & variables are configured. View them at this link:", + ghEnv, + ), + output.WithLinkFormat( + "https://github.com/%s/settings/environments/%s", + repoSlug, + ghEnv, + ), + "", + }}, + ) + } } }() @@ -731,7 +861,7 @@ func (p *GitHubCiProvider) configurePipeline( continue } if deleteAllUnused { - deleteErr := p.ghCli.DeleteSecret(ctx, repoSlug, existingSecret) + deleteErr := deleteSecret(existingSecret) if deleteErr != nil { procErr = fmt.Errorf("failed deleting %s secret: %w", existingSecret, deleteErr) return nil, procErr @@ -776,7 +906,7 @@ func (p *GitHubCiProvider) configurePipeline( Action: "Ignore un-used", }) case selectionDelete: - deleteErr := p.ghCli.DeleteSecret(ctx, repoSlug, existingSecret) + deleteErr := deleteSecret(existingSecret) if deleteErr != nil { procErr = fmt.Errorf("failed deleting %s secret: %w", existingSecret, deleteErr) return nil, procErr @@ -788,7 +918,7 @@ func (p *GitHubCiProvider) configurePipeline( }) case selectionDeleteAll: deleteAllUnused = true - deleteErr := p.ghCli.DeleteSecret(ctx, repoSlug, existingSecret) + deleteErr := deleteSecret(existingSecret) if deleteErr != nil { procErr = fmt.Errorf("failed deleting %s secret: %w", existingSecret, deleteErr) return nil, procErr @@ -908,7 +1038,7 @@ func (p *GitHubCiProvider) configurePipeline( continue } if deleteAllUnusedVars { - deleteErr := p.ghCli.DeleteVariable(ctx, repoSlug, existingVariable) + deleteErr := deleteVariable(existingVariable) if deleteErr != nil { procErr = fmt.Errorf("failed deleting %s variable: %w", existingVariable, deleteErr) return nil, procErr @@ -953,7 +1083,7 @@ func (p *GitHubCiProvider) configurePipeline( Action: "Ignore un-used", }) case selectionDeleteVars: - deleteErr := p.ghCli.DeleteVariable(ctx, repoSlug, existingVariable) + deleteErr := deleteVariable(existingVariable) if deleteErr != nil { procErr = fmt.Errorf("failed deleting %s variable: %w", existingVariable, deleteErr) return nil, procErr @@ -965,7 +1095,7 @@ func (p *GitHubCiProvider) configurePipeline( }) case selectionDeleteAllVars: deleteAllUnusedVars = true - deleteErr := p.ghCli.DeleteVariable(ctx, repoSlug, existingVariable) + deleteErr := deleteVariable(existingVariable) if deleteErr != nil { procErr = fmt.Errorf("failed deleting %s variable: %w", existingVariable, deleteErr) return nil, procErr @@ -979,16 +1109,16 @@ func (p *GitHubCiProvider) configurePipeline( } } - // set the new variables and secrets + // set the new variables and secrets using scoped setters for key, value := range toBeSetSecrets { - if err := p.ghCli.SetSecret(ctx, repoSlug, key, value); err != nil { + if err := setSecret(key, value); err != nil { procErr = fmt.Errorf("failed setting %s secret: %w", key, err) return nil, procErr } } for key, value := range toBeSetVariables { - if err := p.ghCli.SetVariable(ctx, repoSlug, key, value); err != nil { + if err := setVariable(key, value); err != nil { procErr = fmt.Errorf("failed setting %s secret: %w", key, err) return nil, procErr } diff --git a/cli/azd/pkg/pipeline/github_provider_test.go b/cli/azd/pkg/pipeline/github_provider_test.go index 08588a328f6..6d776761a20 100644 --- a/cli/azd/pkg/pipeline/github_provider_test.go +++ b/cli/azd/pkg/pipeline/github_provider_test.go @@ -6,6 +6,7 @@ package pipeline import ( "context" "fmt" + "regexp" "strings" "testing" @@ -120,3 +121,268 @@ func setupGithubCliMocks(mockContext *mocks.MockContext) { return exec.NewRunResult(0, fmt.Sprintf("gh version %s", github.Version), ""), nil }) } + +func Test_setPipelineVariables_environmentScope(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + setupGithubCliMocks(mockContext) + env := environment.New("Dev") + + ghCli, err := github.NewGitHubCli(*mockContext.Context, mockContext.Console, mockContext.CommandRunner) + require.NoError(t, err) + provider := NewGitHubCiProvider(env, mockContext.SubscriptionCredentialProvider, entraid.NewEntraIdService( + mockContext.SubscriptionCredentialProvider, mockContext.ArmClientOptions, mockContext.CoreClientOptions, + ), ghCli, git.NewCli(mockContext.CommandRunner), mockContext.Console) + + var envVarCalls []string + // Allow other gh commands (auth status, version, etc.) to succeed. + // IMPORTANT: Generic matcher must be registered BEFORE the specific matcher below because + // the mock runner searches from the end (LIFO) and picks the first predicate that matches. + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return args.Cmd == ghCli.BinaryPath() + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.NewRunResult(0, "", ""), nil + }) + + // Specific matcher for environment-scoped variable set; register LAST so it wins over generic. + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + if args.Cmd != ghCli.BinaryPath() { + return false + } + // Expect tokens: -R owner/repo variable set NAME -e Dev + hasR := false + hasRepo := false + hasVariableSet := false + hasEnv := false + for i := 0; i < len(args.Args); i++ { + a := args.Args[i] + if a == "-R" && i+1 < len(args.Args) && args.Args[i+1] == "owner/repo" { + hasR = true + hasRepo = true + } + if a == "variable" && i+1 < len(args.Args) && args.Args[i+1] == "set" { + hasVariableSet = true + } + if a == "-e" && i+1 < len(args.Args) && args.Args[i+1] == "Dev" { + hasEnv = true + } + } + return hasR && hasRepo && hasVariableSet && hasEnv + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + envVarCalls = append(envVarCalls, strings.Join(args.Args, " ")) + return exec.NewRunResult(0, "", ""), nil + }) + + err = provider.(*GitHubCiProvider).setPipelineVariables( + *mockContext.Context, "owner/repo", provisioning.Options{}, "tenant", "client") + require.NoError(t, err) + require.NotEmpty(t, envVarCalls, "expected environment-scoped variable set calls") +} + +func Test_setPipelineVariables_callsEnsureEnvironment(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + setupGithubCliMocks(mockContext) + env := environment.New("EnsEnv") + + ghCli, err := github.NewGitHubCli(*mockContext.Context, mockContext.Console, mockContext.CommandRunner) + require.NoError(t, err) + provider := NewGitHubCiProvider(env, mockContext.SubscriptionCredentialProvider, entraid.NewEntraIdService( + mockContext.SubscriptionCredentialProvider, mockContext.ArmClientOptions, mockContext.CoreClientOptions, + ), ghCli, git.NewCli(mockContext.CommandRunner), mockContext.Console) + + ensureCalled := false + + // Generic matcher first + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return args.Cmd == ghCli.BinaryPath() + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.NewRunResult(0, "", ""), nil + }) + + // Specific matcher for EnsureEnvironment (gh api repos//environments/ --method PUT) + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + if args.Cmd != ghCli.BinaryPath() { + return false + } + hasApi := false + hasPath := false + hasMethod := false + hasPut := false + for i := 0; i < len(args.Args); i++ { + a := args.Args[i] + if a == "api" { + hasApi = true + } + if strings.HasPrefix(a, "repos/owner/repo/environments/EnsEnv") { + hasPath = true + } + if a == "--method" { + hasMethod = true + if i+1 < len(args.Args) && strings.EqualFold(args.Args[i+1], "PUT") { + hasPut = true + } + } + } + return hasApi && hasPath && hasMethod && hasPut + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + ensureCalled = true + return exec.NewRunResult(0, "", ""), nil + }) + + err = provider.(*GitHubCiProvider).setPipelineVariables( + *mockContext.Context, + "owner/repo", + provisioning.Options{}, + "tenant", + "client", + ) + require.NoError(t, err) + require.True(t, ensureCalled, "expected EnsureEnvironment API call to have been invoked") +} + +func Test_credentialOptions_includesEnvironmentFederatedSubject(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + setupGithubCliMocks(mockContext) + env := environment.New("Prod") + + ghCli, err := github.NewGitHubCli(*mockContext.Context, mockContext.Console, mockContext.CommandRunner) + require.NoError(t, err) + provider := NewGitHubCiProvider(env, mockContext.SubscriptionCredentialProvider, entraid.NewEntraIdService( + mockContext.SubscriptionCredentialProvider, mockContext.ArmClientOptions, mockContext.CoreClientOptions, + ), ghCli, git.NewCli(mockContext.CommandRunner), mockContext.Console) + + repoDetails := &gitRepositoryDetails{owner: "org", repoName: "repo", branch: "feature"} + creds, err := provider.(*GitHubCiProvider).credentialOptions( + *mockContext.Context, repoDetails, provisioning.Options{}, AuthTypeFederated, nil) + require.NoError(t, err) + require.True(t, creds.EnableFederatedCredentials) + found := false + re := regexp.MustCompile(`repo:org/repo:environment:Prod`) + for _, fc := range creds.FederatedCredentialOptions { + if re.MatchString(fc.Subject) { + found = true + break + } + } + require.True(t, found, "expected environment federated subject present") +} + +func Test_configurePipeline_environmentScopeSecrets(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + setupGithubCliMocks(mockContext) + env := environment.New("Stage") + + ghCli, err := github.NewGitHubCli(*mockContext.Context, mockContext.Console, mockContext.CommandRunner) + require.NoError(t, err) + _ = env // reserved for future extended simulation + + // Generic gh matcher first + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return args.Cmd == ghCli.BinaryPath() + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { return exec.NewRunResult(0, "", ""), nil }) + + var secretSetCalls []string + // Specific matcher for environment secret set + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + if args.Cmd != ghCli.BinaryPath() { + return false + } + hasR, hasRepo, hasSecretSet, hasEnv := false, false, false, false + for i := 0; i < len(args.Args); i++ { + a := args.Args[i] + if a == "-R" && i+1 < len(args.Args) && args.Args[i+1] == "owner/repo" { + hasR = true + hasRepo = true + } + if a == "secret" && i+1 < len(args.Args) && args.Args[i+1] == "set" { + hasSecretSet = true + } + if a == "-e" && i+1 < len(args.Args) && args.Args[i+1] == "Stage" { + hasEnv = true + } + } + return hasR && hasRepo && hasSecretSet && hasEnv + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + secretSetCalls = append(secretSetCalls, strings.Join(args.Args, " ")) + return exec.NewRunResult(0, "", ""), nil + }) + + // Directly call ghCli SetEnvironmentSecret to validate environment flag usage for secrets. + err = ghCli.SetEnvironmentSecret(*mockContext.Context, "owner/repo", "Stage", "TEST_SECRET", "value") + require.NoError(t, err) + require.NotEmpty(t, secretSetCalls, "expected environment-scoped secret set calls") +} + +func Test_credentialOptions_allFederatedSubjectsWithEnvironment(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + setupGithubCliMocks(mockContext) + env := environment.New("EnvX") + + ghCli, err := github.NewGitHubCli(*mockContext.Context, mockContext.Console, mockContext.CommandRunner) + require.NoError(t, err) + provider := NewGitHubCiProvider(env, mockContext.SubscriptionCredentialProvider, entraid.NewEntraIdService( + mockContext.SubscriptionCredentialProvider, mockContext.ArmClientOptions, mockContext.CoreClientOptions, + ), ghCli, git.NewCli(mockContext.CommandRunner), mockContext.Console) + + repoDetails := &gitRepositoryDetails{owner: "org", repoName: "repo", branch: "feature"} + creds, err := provider.(*GitHubCiProvider).credentialOptions( + *mockContext.Context, repoDetails, provisioning.Options{}, AuthTypeFederated, nil) + require.NoError(t, err) + require.True(t, creds.EnableFederatedCredentials) + + expected := map[string]bool{ + "repo:org/repo:pull_request": false, + "repo:org/repo:ref:refs/heads/feature": false, + "repo:org/repo:ref:refs/heads/main": false, + "repo:org/repo:environment:EnvX": false, + } + for _, fc := range creds.FederatedCredentialOptions { + if _, ok := expected[fc.Subject]; ok { + expected[fc.Subject] = true + } + } + for subj, found := range expected { + require.True(t, found, "missing federated credential subject %s", subj) + } +} + +func Test_credentialOptions_envOnlyMode(t *testing.T) { + t.Setenv("AZD_USE_GITHUB_ENVIRONMENTS", "1") + mockContext := mocks.NewMockContext(context.Background()) + setupGithubCliMocks(mockContext) + env := environment.New("staging") + ghCli, err := github.NewGitHubCli(*mockContext.Context, mockContext.Console, mockContext.CommandRunner) + require.NoError(t, err) + provider := NewGitHubCiProvider(env, mockContext.SubscriptionCredentialProvider, entraid.NewEntraIdService( + mockContext.SubscriptionCredentialProvider, mockContext.ArmClientOptions, mockContext.CoreClientOptions, + ), ghCli, git.NewCli(mockContext.CommandRunner), mockContext.Console) + + repoDetails := &gitRepositoryDetails{owner: "azure", repoName: "sample", branch: "feature/x"} + creds := &entraid.AzureCredentials{ClientId: "client", TenantId: "tenant", SubscriptionId: "sub"} + options, err := provider.credentialOptions( + *mockContext.Context, + repoDetails, + provisioning.Options{}, + AuthTypeFederated, + creds, + ) + require.NoError(t, err) + require.True(t, options.EnableFederatedCredentials) + subjects := []string{} + for _, fc := range options.FederatedCredentialOptions { + subjects = append(subjects, fc.Subject) + } + for _, s := range subjects { + require.NotContains(t, s, ":pull_request") + } + for _, s := range subjects { + require.NotContains(t, s, ":ref:refs/heads/") + } + foundEnv := false + for _, s := range subjects { + if strings.Contains(s, ":environment:staging") { + foundEnv = true + } + } + require.True(t, foundEnv, "expected only environment subject present") + require.Equal(t, 1, len(subjects), "expected exactly one federated credential (environment)") +} diff --git a/cli/azd/pkg/pipeline/pipeline.go b/cli/azd/pkg/pipeline/pipeline.go index fd6cd8fb23b..7bb86795195 100644 --- a/cli/azd/pkg/pipeline/pipeline.go +++ b/cli/azd/pkg/pipeline/pipeline.go @@ -329,6 +329,9 @@ type projectProperties struct { Variables []string Secrets []string RequiredAlphaFeatures []string + Environments []string // Detected azd environments (directory names under .azure) for matrix generation + UseGitHubEnvironments bool // Opt-in switch for emitting GitHub environment/matrix blocks + ForceRegenerate bool // Force regeneration of workflow regardless of existing content providerParameters []provisioning.Parameter } diff --git a/cli/azd/pkg/pipeline/pipeline_manager.go b/cli/azd/pkg/pipeline/pipeline_manager.go index 493f0825808..4cb8ea3b67f 100644 --- a/cli/azd/pkg/pipeline/pipeline_manager.go +++ b/cli/azd/pkg/pipeline/pipeline_manager.go @@ -4,6 +4,7 @@ package pipeline import ( + "bytes" "context" "errors" "fmt" @@ -70,6 +71,8 @@ type PipelineManagerArgs struct { PipelineProvider string PipelineAuthTypeName string ServiceManagementReference string + UseGitHubEnvironments bool // When true and provider is GitHub generate environment/matrix block + ForceRegenerate bool // When true always regenerate workflow file } // CredentialOptions represents the options for configuring credentials for a pipeline. @@ -151,6 +154,93 @@ func NewPipelineManager( return pipelineProvider, nil } +// EnableGitHubEnvironments toggles generation of GitHub Environment blocks in workflow templates. +func (pm *PipelineManager) EnableGitHubEnvironments(enabled bool) { + if pm.args != nil { + pm.args.UseGitHubEnvironments = enabled + } +} + +// ForceRegenerateWorkflow sets the flag to force regeneration of pipeline workflow files. +func (pm *PipelineManager) ForceRegenerateWorkflow(force bool) { + if pm.args != nil { + pm.args.ForceRegenerate = force + } +} + +// pruneLegacyFederatedCredentials removes branch & pull_request federated credentials when environment-only mode is active +func pruneLegacyFederatedCredentials( + ctx context.Context, + console input.Console, + entraSvc entraid.EntraIdService, + subscriptionId string, + clientId string, +) { + existing, err := entraSvc.ListFederatedCredentials(ctx, subscriptionId, clientId) + if err != nil { + return + } // best-effort + for _, fic := range existing { + if fic.Subject == "" || fic.Issuer != federatedIdentityIssuer { + continue + } + if strings.Contains(fic.Subject, ":environment:") { + continue + } + if strings.Contains(fic.Subject, ":pull_request") || strings.Contains(fic.Subject, ":ref:refs/heads/") { + if fic.Id != nil { + _ = entraSvc.DeleteFederatedCredential(ctx, subscriptionId, clientId, *fic.Id) + console.MessageUxItem( + ctx, + &ux.DisplayedResource{ + Type: "Pruned federated identity credential", + Name: fmt.Sprintf("subject %s", fic.Subject), + }, + ) + } + } + } +} + +// pruneLegacyMsiFederatedCredentials similar pruning for MSI identities via ARM +func pruneLegacyMsiFederatedCredentials( + ctx context.Context, + console input.Console, + msiSvc armmsi.ArmMsiService, + subscriptionId string, + msiResourceId string, +) { + existing, err := msiSvc.ListFederatedCredentials(ctx, subscriptionId, msiResourceId) + if err != nil { + return + } + for _, fic := range existing { + if fic.Properties == nil || fic.Properties.Subject == nil || fic.Properties.Issuer == nil { + continue + } + subj := *fic.Properties.Subject + issuer := *fic.Properties.Issuer + if issuer != federatedIdentityIssuer { + continue + } + if strings.Contains(subj, ":environment:") { + continue + } + if strings.Contains(subj, ":pull_request") || strings.Contains(subj, ":ref:refs/heads/") { + if fic.Name != nil { + _ = msiSvc.DeleteFederatedCredential(ctx, subscriptionId, msiResourceId, *fic.Name) + console.MessageUxItem( + ctx, + &ux.DisplayedResource{ + Type: "Pruned federated identity credential", + Name: fmt.Sprintf("subject %s", subj), + }, + ) + } + } + } +} + func (pm *PipelineManager) CiProviderName() string { return pm.ciProvider.Name() } @@ -268,7 +358,7 @@ func (pm *PipelineManager) Configure( smr := resolveSmr(pm.args.ServiceManagementReference, pm.env.Config, userConfig) if smr != nil { if _, err := uuid.Parse(*smr); err != nil { - return result, fmt.Errorf("Invalid service management reference %s: %w", *smr, err) + return result, fmt.Errorf("invalid service management reference %s: %w", *smr, err) } } @@ -291,7 +381,7 @@ func (pm *PipelineManager) Configure( if pm.args.PipelineServicePrincipalName != "" && pm.args.PipelineServicePrincipalId != "" { //nolint:lll return result, fmt.Errorf( - "you have specified both --principal-id and --principal-name, but only one of these parameters should be used at a time.", + "you have specified both --principal-id and --principal-name, but only one of these parameters should be used at a time", ) } @@ -650,6 +740,15 @@ func (pm *PipelineManager) Configure( }, ) } + + // Prune legacy branch / pull_request credentials if env-only mode requested. + if os.Getenv("AZD_USE_GITHUB_ENVIRONMENTS") == "1" { + if !usingMsi && authConfig != nil && authConfig.ClientId != "" { + pruneLegacyFederatedCredentials(ctx, pm.console, pm.entraIdService, subscriptionId, authConfig.ClientId) + } else if usingMsi && authConfig != nil && authConfig.msi != nil { + pruneLegacyMsiFederatedCredentials(ctx, pm.console, pm.msiService, subscriptionId, *authConfig.msi.ID) + } + } } err = pm.ciProvider.configureConnection( @@ -1024,7 +1123,7 @@ func (pm *PipelineManager) resolveProviderAndDetermine( log.Printf("Loading project configuration from: %s", projectPath) prjConfig, err := project.Load(ctx, projectPath) if err != nil { - return "", fmt.Errorf("Loading project configuration: %w", err) + return "", fmt.Errorf("loading project configuration: %w", err) } log.Printf("Loaded project configuration: %+v", prjConfig) @@ -1086,7 +1185,7 @@ func (pm *PipelineManager) initialize(ctx context.Context, override string) erro prjConfig, err := project.Load(ctx, projectPath) if err != nil { - return fmt.Errorf("Loading project configuration: %w", err) + return fmt.Errorf("loading project configuration: %w", err) } pm.prjConfig = prjConfig @@ -1140,6 +1239,21 @@ func (pm *PipelineManager) savePipelineProviderToEnv( func (pm *PipelineManager) checkAndPromptForProviderFiles(ctx context.Context, props projectProperties) error { log.Printf("Checking for provider files for: %s", props.CiProvider) + if pm.args != nil && pm.args.ForceRegenerate { + filePath := filepath.Join(props.RepoRoot, pipelineProviderFiles[props.CiProvider].Files[0]) + log.Printf("--force-regenerate specified: regenerating %s", filePath) + if dir := filepath.Dir(filePath); !osutil.DirExists(dir) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("creating directory for regeneration: %w", err) + } + } + if err := generatePipelineDefinition(filePath, props); err != nil { + return err + } + pm.console.Message(ctx, "Pipeline workflow forcibly regenerated (--force-regenerate).") + return nil + } + if !hasPipelineFile(props.CiProvider, props.RepoRoot) { log.Printf("%s YAML not found, prompting for creation", props.CiProvider) if err := pm.promptForCiFiles(ctx, props); err != nil { @@ -1147,6 +1261,36 @@ func (pm *PipelineManager) checkAndPromptForProviderFiles(ctx context.Context, p return err } log.Println("Prompt for CI files completed successfully.") + } else if props.CiProvider == ciProviderGitHubActions { + // Migration path: regenerate if --github-use-environments preference differs from existing workflow structure. + filePath := filepath.Join(props.RepoRoot, pipelineProviderFiles[props.CiProvider].Files[0]) + content, err := os.ReadFile(filePath) + if err == nil { + // Only treat presence of an actual matrix stanza as environment support. + hasMatrix := bytes.Contains(content, []byte("matrix:")) && + bytes.Contains(content, []byte("environment: ${{ matrix.environment")) + legacySingleEnv := bytes.Contains(content, []byte("fallback to using AZURE_ENV_NAME")) + if props.UseGitHubEnvironments && !hasMatrix { + log.Printf("Regenerating GitHub workflow to add environment matrix (opt-in enabled, no matrix present).") + if genErr := generatePipelineDefinition(filePath, props); genErr != nil { + return genErr + } + } else if !props.UseGitHubEnvironments && hasMatrix { + log.Printf("Regenerating GitHub workflow to remove environment matrix (opt-in disabled).") + noEnvProps := props + noEnvProps.Environments = nil + noEnvProps.UseGitHubEnvironments = false + if genErr := generatePipelineDefinition(filePath, noEnvProps); genErr != nil { + return genErr + } + } else if props.UseGitHubEnvironments && legacySingleEnv && !hasMatrix { + // Legacy fallback comment present without matrix; upgrade. + log.Printf("Upgrading legacy single-environment workflow to matrix form.") + if genErr := generatePipelineDefinition(filePath, props); genErr != nil { + return genErr + } + } + } } var dirPaths []string @@ -1244,20 +1388,42 @@ func (pm *PipelineManager) promptForCiFiles(ctx context.Context, props projectPr created = true } - if !osutil.FileExists(filepath.Join(dirPath, pipelineProviderFiles[props.CiProvider].DefaultFile)) { - if err := generatePipelineDefinition(filepath.Join(dirPath, - pipelineProviderFiles[props.CiProvider].DefaultFile), props); err != nil { + wfPath := filepath.Join(dirPath, pipelineProviderFiles[props.CiProvider].DefaultFile) + if !osutil.FileExists(wfPath) { + if err := generatePipelineDefinition(wfPath, props); err != nil { return err } pm.console.Message(ctx, fmt.Sprintf( "The %s file has been created at %s. You can use it as-is or modify it to suit your needs.", output.WithHighLightFormat(filepath.Base(defaultFilePath)), - output.WithHighLightFormat(filepath.Join(dirPath, - pipelineProviderFiles[props.CiProvider].DefaultFile))), + output.WithHighLightFormat(wfPath)), ) pm.console.Message(ctx, "") created = true + } else { + // Regenerate if the --github-use-environments flag state differs from file contents. + contents, readErr := os.ReadFile(wfPath) + if readErr == nil { + hasMatrix := bytes.Contains(contents, []byte("matrix:")) && + bytes.Contains(contents, []byte("environment:")) + if props.UseGitHubEnvironments && !hasMatrix || (!props.UseGitHubEnvironments && hasMatrix) { + log.Printf( + "Regenerating %s to reflect --github-use-environments=%v", + wfPath, + props.UseGitHubEnvironments, + ) + if err := generatePipelineDefinition(wfPath, props); err != nil { + return err + } + pm.console.Message(ctx, + fmt.Sprintf("The %s file has been updated at %s to reflect environment matrix settings.", + output.WithHighLightFormat(filepath.Base(defaultFilePath)), + output.WithHighLightFormat(wfPath)), + ) + pm.console.Message(ctx, "") + } + } } if created { @@ -1294,6 +1460,8 @@ func generatePipelineDefinition(path string, props projectProperties) error { Secrets []string AlphaFeatures []string IsTerraform bool + Environments []string + UseGitHubEnvironments bool }{ BranchName: props.BranchName, FedCredLogIn: props.AuthType == AuthTypeFederated, @@ -1302,6 +1470,8 @@ func generatePipelineDefinition(path string, props projectProperties) error { Secrets: props.Secrets, AlphaFeatures: props.RequiredAlphaFeatures, IsTerraform: props.InfraProvider == infraProviderTerraform, + Environments: props.Environments, + UseGitHubEnvironments: props.UseGitHubEnvironments, } // Apply provider parameters @@ -1476,6 +1646,29 @@ func (pm *PipelineManager) ensurePipelineDefinition(ctx context.Context) error { authType := AuthTypeFederated // Check and prompt for missing CI/CD files + + var envNames []string + if pm.args != nil && pm.args.UseGitHubEnvironments && pm.ciProviderType == ciProviderGitHubActions { + // Discover azd environments for matrix generation only when opted in. + envRoot := filepath.Join(repoRoot, ".azure") + if entries, readErr := os.ReadDir(envRoot); readErr == nil { + for _, e := range entries { + if !e.IsDir() { + continue + } + name := e.Name() + if strings.HasPrefix(name, ".") { + continue + } + if _, statErr := os.Stat(filepath.Join(envRoot, name, ".env")); statErr == nil { + envNames = append(envNames, name) + } + } + } + // stable ordering + slices.Sort(envNames) + } + err = pm.checkAndPromptForProviderFiles( ctx, projectProperties{ CiProvider: pm.ciProviderType, @@ -1487,11 +1680,44 @@ func (pm *PipelineManager) ensurePipelineDefinition(ctx context.Context) error { Variables: pm.prjConfig.Pipeline.Variables, Secrets: pm.prjConfig.Pipeline.Secrets, RequiredAlphaFeatures: requiredAlphaFeatures, + Environments: envNames, + UseGitHubEnvironments: pm.args != nil && pm.args.UseGitHubEnvironments, + ForceRegenerate: pm.args != nil && pm.args.ForceRegenerate, providerParameters: pm.configOptions.providerParameters, }) if err != nil { return err } + + // Safety net: if user opted in to GitHub environments but existing file was not regenerated (e.g. legacy content + // matched prior heuristic), force regeneration when matrix stanza is missing. + if pm.args != nil && pm.args.UseGitHubEnvironments && pm.ciProviderType == ciProviderGitHubActions { + wfPath := filepath.Join(repoRoot, pipelineProviderFiles[ciProviderGitHubActions].Files[0]) + if content, readErr := os.ReadFile(wfPath); readErr == nil { + if !bytes.Contains(content, []byte("matrix:")) { + log.Printf("Post-check regeneration: adding environment matrix to %s", wfPath) + regenProps := projectProperties{ + CiProvider: pm.ciProviderType, + RepoRoot: repoRoot, + InfraProvider: infraProvider, + HasAppHost: hasAppHost, + BranchName: branchName, + AuthType: authType, + Variables: pm.prjConfig.Pipeline.Variables, + Secrets: pm.prjConfig.Pipeline.Secrets, + RequiredAlphaFeatures: requiredAlphaFeatures, + Environments: envNames, + UseGitHubEnvironments: true, + ForceRegenerate: pm.args != nil && pm.args.ForceRegenerate, + providerParameters: pm.configOptions.providerParameters, + } + if genErr := generatePipelineDefinition(wfPath, regenProps); genErr != nil { + return genErr + } + pm.console.Message(ctx, "Updated workflow to add environment matrix (post-check).") + } + } + } pm.configOptions.projectSecrets = slices.Clone(pm.prjConfig.Pipeline.Secrets) pm.configOptions.projectVariables = slices.Clone(pm.prjConfig.Pipeline.Variables) pm.configOptions.provisioningProvider = &pm.infra.Options diff --git a/cli/azd/pkg/pipeline/pipeline_manager_test.go b/cli/azd/pkg/pipeline/pipeline_manager_test.go index d3f5320c4be..1fcbe9105bf 100644 --- a/cli/azd/pkg/pipeline/pipeline_manager_test.go +++ b/cli/azd/pkg/pipeline/pipeline_manager_test.go @@ -45,7 +45,7 @@ func Test_PipelineManager_Initialize(t *testing.T) { manager, err := createPipelineManager(mockContext, azdContext, nil, nil) assert.Nil(t, manager) assert.ErrorContains( - t, err, "Loading project configuration: reading project file:") + t, err, "loading project configuration: reading project file:") }) //2. Then create the project file @@ -619,6 +619,95 @@ func Test_promptForCiFiles(t *testing.T) { assert.NoError(t, err) snapshot.SnapshotT(t, normalizeEOL(content)) }) + // Multi-env matrix now only generated when UseGitHubEnvironments opt-in flag is set. + // Legacy default intentionally omitted. + if true { // opt-in matrix generation test + t.Run("no files - github selected - multi env matrix opt-in", func(t *testing.T) { + tempDir := t.TempDir() + path := filepath.Join(tempDir, pipelineProviderFiles[ciProviderGitHubActions].PipelineDirectories[0]) + err := os.MkdirAll(path, osutil.PermissionDirectory) + assert.NoError(t, err) + expectedPath := filepath.Join(tempDir, pipelineProviderFiles[ciProviderGitHubActions].Files[0]) + err = generatePipelineDefinition(expectedPath, projectProperties{ + CiProvider: ciProviderGitHubActions, + InfraProvider: infraProviderBicep, + RepoRoot: tempDir, + HasAppHost: false, + BranchName: "main", + AuthType: AuthTypeFederated, + Environments: []string{"e2e", "staging"}, + UseGitHubEnvironments: true, + }) + assert.NoError(t, err) + assert.FileExists(t, expectedPath) + content, err := os.ReadFile(expectedPath) + assert.NoError(t, err) + // Reuse standard snapshot naming pattern by writing file manually into testdata directory. + snapshot.SnapshotT(t, normalizeEOL(content)) + }) + + t.Run("migration - enable environments regenerates file", func(t *testing.T) { + tempDir := t.TempDir() + path := filepath.Join(tempDir, pipelineProviderFiles[ciProviderGitHubActions].PipelineDirectories[0]) + _ = os.MkdirAll(path, osutil.PermissionDirectory) + filePath := filepath.Join(tempDir, pipelineProviderFiles[ciProviderGitHubActions].Files[0]) + // First generate legacy (no environments) + err := generatePipelineDefinition(filePath, projectProperties{ + CiProvider: ciProviderGitHubActions, + InfraProvider: infraProviderBicep, + RepoRoot: tempDir, + BranchName: "main", + AuthType: AuthTypeFederated, + }) + assert.NoError(t, err) + legacyContent, _ := os.ReadFile(filePath) + assert.NotContains(t, string(legacyContent), "matrix:") + // Simulate manager regenerate with opt-in flag & environments + err = generatePipelineDefinition(filePath, projectProperties{ + CiProvider: ciProviderGitHubActions, + InfraProvider: infraProviderBicep, + RepoRoot: tempDir, + BranchName: "main", + AuthType: AuthTypeFederated, + Environments: []string{"e2e", "staging"}, + UseGitHubEnvironments: true, + }) + assert.NoError(t, err) + updated, _ := os.ReadFile(filePath) + assert.Contains(t, string(updated), "matrix:") + }) + + t.Run("migration - disable environments regenerates file", func(t *testing.T) { + tempDir := t.TempDir() + path := filepath.Join(tempDir, pipelineProviderFiles[ciProviderGitHubActions].PipelineDirectories[0]) + _ = os.MkdirAll(path, osutil.PermissionDirectory) + filePath := filepath.Join(tempDir, pipelineProviderFiles[ciProviderGitHubActions].Files[0]) + // First generate with environments + err := generatePipelineDefinition(filePath, projectProperties{ + CiProvider: ciProviderGitHubActions, + InfraProvider: infraProviderBicep, + RepoRoot: tempDir, + BranchName: "main", + AuthType: AuthTypeFederated, + Environments: []string{"e2e", "staging"}, + UseGitHubEnvironments: true, + }) + assert.NoError(t, err) + withEnv, _ := os.ReadFile(filePath) + assert.Contains(t, string(withEnv), "matrix:") + // Regenerate without opt-in + err = generatePipelineDefinition(filePath, projectProperties{ + CiProvider: ciProviderGitHubActions, + InfraProvider: infraProviderBicep, + RepoRoot: tempDir, + BranchName: "main", + AuthType: AuthTypeFederated, + }) + assert.NoError(t, err) + disabledContent, _ := os.ReadFile(filePath) + assert.NotContains(t, string(disabledContent), "matrix:") + }) + } t.Run("no files - github selected - Variables and Secrets", func(t *testing.T) { tempDir := t.TempDir() path := filepath.Join(tempDir, pipelineProviderFiles[ciProviderGitHubActions].PipelineDirectories[0]) diff --git a/cli/azd/pkg/pipeline/prune_test.go b/cli/azd/pkg/pipeline/prune_test.go new file mode 100644 index 00000000000..3b9ef8651cc --- /dev/null +++ b/cli/azd/pkg/pipeline/prune_test.go @@ -0,0 +1,90 @@ +package pipeline + +import ( + "context" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/entraid" + "github.com/azure/azure-dev/cli/azd/pkg/graphsdk" + "github.com/azure/azure-dev/cli/azd/test/mocks" + "github.com/stretchr/testify/require" +) + +type pruneMockEntra struct { + entraid.EntraIdService + listed []graphsdk.FederatedIdentityCredential + deleted []string +} + +func (m *pruneMockEntra) ListFederatedCredentials( + ctx context.Context, + sub string, + client string, +) ([]graphsdk.FederatedIdentityCredential, error) { + return m.listed, nil +} +func (m *pruneMockEntra) DeleteFederatedCredential(ctx context.Context, sub, client, id string) error { + m.deleted = append(m.deleted, id) + return nil +} + +// Implement minimal methods used by prune helpers (unused in this test) with no-op behavior. +func (m *pruneMockEntra) GetServicePrincipal( + ctx context.Context, + sub string, + appIdOrName string, +) (*graphsdk.ServicePrincipal, error) { + return nil, nil +} +func (m *pruneMockEntra) CreateOrUpdateServicePrincipal( + ctx context.Context, + sub string, + appIdOrName string, + opts entraid.CreateOrUpdateServicePrincipalOptions, +) (*graphsdk.ServicePrincipal, error) { + return nil, nil +} +func (m *pruneMockEntra) ResetPasswordCredentials( + ctx context.Context, + sub string, + appId string, +) (*entraid.AzureCredentials, error) { + return nil, nil +} +func (m *pruneMockEntra) ApplyFederatedCredentials( + ctx context.Context, + sub string, + client string, + creds []*graphsdk.FederatedIdentityCredential, +) ([]*graphsdk.FederatedIdentityCredential, error) { + return nil, nil +} +func (m *pruneMockEntra) CreateRbac(ctx context.Context, sub, scope, roleId, principalId string) error { + return nil +} +func (m *pruneMockEntra) EnsureRoleAssignments( + ctx context.Context, + sub string, + roles []string, + sp *graphsdk.ServicePrincipal, +) error { + return nil +} + +func Test_pruneLegacyFederatedCredentials(t *testing.T) { + mock := &pruneMockEntra{listed: []graphsdk.FederatedIdentityCredential{ + {Id: ptr("1"), Subject: "repo:owner/repo:pull_request", Issuer: federatedIdentityIssuer}, + {Id: ptr("2"), Subject: "repo:owner/repo:ref:refs/heads/main", Issuer: federatedIdentityIssuer}, + {Id: ptr("3"), Subject: "repo:owner/repo:environment:Dev", Issuer: federatedIdentityIssuer}, + {Id: ptr("4"), Subject: "repo:owner/repo:ref:refs/heads/feature", Issuer: federatedIdentityIssuer}, + }} + mockCtx := mocks.NewMockContext(context.Background()) + pruneLegacyFederatedCredentials(context.Background(), mockCtx.Console, mock, "sub", "client") + require.ElementsMatch(t, []string{"1", "2", "4"}, mock.deleted) +} + +// helper ptr +func ptr[T any](v T) *T { return &v } + +// nilConsole satisfies input.Console for pruning (only MessageUxItem used) without output. +// no custom console needed (using mocks.MockContext Console) diff --git a/cli/azd/pkg/pipeline/testdata/Test_promptForCiFiles-no_files_-_github_selected_-_multi_env_matrix.snap b/cli/azd/pkg/pipeline/testdata/Test_promptForCiFiles-no_files_-_github_selected_-_multi_env_matrix.snap new file mode 100644 index 00000000000..53546d3be41 --- /dev/null +++ b/cli/azd/pkg/pipeline/testdata/Test_promptForCiFiles-no_files_-_github_selected_-_multi_env_matrix.snap @@ -0,0 +1,50 @@ +# Run when commits are pushed to main +on: + workflow_dispatch: + push: + # Run when commits are pushed to mainline branch (main or master) + # Set this to the mainline branch you are using + branches: + - main + +# Set up permissions for deploying with secretless Azure federated credentials +# https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure?tabs=azure-portal%2Clinux#set-up-azure-login-with-openid-connect-authentication +permissions: + id-token: write + contents: read + + +jobs: + build: + strategy: + matrix: + environment: + - e2e + - staging + environment: ${{ matrix.environment }} + runs-on: ubuntu-latest + env: + AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install azd + uses: Azure/setup-azd@v2 + - name: Log in with Azure (Federated Credentials) + run: | + azd auth login ` + --client-id "$Env:AZURE_CLIENT_ID" ` + --federated-credential-provider "github" ` + --tenant-id "$Env:AZURE_TENANT_ID" + shell: pwsh + + + - name: Provision Infrastructure + run: azd provision --no-prompt + + - name: Deploy Application + run: azd deploy --no-prompt + + diff --git a/cli/azd/pkg/pipeline/testdata/Test_promptForCiFiles-no_files_-_github_selected_-_multi_env_matrix_explicit.snap b/cli/azd/pkg/pipeline/testdata/Test_promptForCiFiles-no_files_-_github_selected_-_multi_env_matrix_explicit.snap new file mode 100644 index 00000000000..2a2e44da706 --- /dev/null +++ b/cli/azd/pkg/pipeline/testdata/Test_promptForCiFiles-no_files_-_github_selected_-_multi_env_matrix_explicit.snap @@ -0,0 +1,51 @@ +# Run when commits are pushed to main +on: + workflow_dispatch: + push: + # Run when commits are pushed to mainline branch (main or master) + # Set this to the mainline branch you are using + branches: + - main + +# Set up permissions for deploying with secretless Azure federated credentials +# https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure?tabs=azure-portal%2Clinux#set-up-azure-login-with-openid-connect-authentication +permissions: + id-token: write + contents: read + + +jobs: + build: + + strategy: + matrix: + environment: + - e2e + - staging + environment: ${{ matrix.environment }} + runs-on: ubuntu-latest + env: + AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install azd + uses: Azure/setup-azd@v2 + - name: Log in with Azure (Federated Credentials) + run: | + azd auth login ` + --client-id "$Env:AZURE_CLIENT_ID" ` + --federated-credential-provider "github" ` + --tenant-id "$Env:AZURE_TENANT_ID" + shell: pwsh + + + - name: Provision Infrastructure + run: azd provision --no-prompt + + - name: Deploy Application + run: azd deploy --no-prompt + + diff --git a/cli/azd/pkg/pipeline/testdata/Test_promptForCiFiles-no_files_-_github_selected_-_multi_env_matrix_opt-in.snap b/cli/azd/pkg/pipeline/testdata/Test_promptForCiFiles-no_files_-_github_selected_-_multi_env_matrix_opt-in.snap new file mode 100644 index 00000000000..5c3e973e429 --- /dev/null +++ b/cli/azd/pkg/pipeline/testdata/Test_promptForCiFiles-no_files_-_github_selected_-_multi_env_matrix_opt-in.snap @@ -0,0 +1,52 @@ +# Run when commits are pushed to main +on: + workflow_dispatch: + push: + # Run when commits are pushed to mainline branch (main or master) + # Set this to the mainline branch you are using + branches: + - main + +# Set up permissions for deploying with secretless Azure federated credentials +# https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure?tabs=azure-portal%2Clinux#set-up-azure-login-with-openid-connect-authentication +permissions: + id-token: write + contents: read + + +jobs: + build: + + strategy: + max-parallel: 1 + matrix: + environment: + - e2e + - staging + environment: ${{ matrix.environment }} + runs-on: ubuntu-latest + env: + AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install azd + uses: Azure/setup-azd@v2 + - name: Log in with Azure (Federated Credentials) + run: | + azd auth login ` + --client-id "$Env:AZURE_CLIENT_ID" ` + --federated-credential-provider "github" ` + --tenant-id "$Env:AZURE_TENANT_ID" + shell: pwsh + + + - name: Provision Infrastructure + run: azd provision --no-prompt + + - name: Deploy Application + run: azd deploy --no-prompt + + diff --git a/cli/azd/pkg/pipeline/testdata/Test_promptForCiFiles-no_files_-_github_selected_-_no_app_host_-_client_cred.snap b/cli/azd/pkg/pipeline/testdata/Test_promptForCiFiles-no_files_-_github_selected_-_no_app_host_-_client_cred.snap index 63eac357512..7b12b078fe1 100644 --- a/cli/azd/pkg/pipeline/testdata/Test_promptForCiFiles-no_files_-_github_selected_-_no_app_host_-_client_cred.snap +++ b/cli/azd/pkg/pipeline/testdata/Test_promptForCiFiles-no_files_-_github_selected_-_no_app_host_-_client_cred.snap @@ -23,7 +23,9 @@ jobs: uses: Azure/setup-azd@v2 - name: Log in with Azure (Client Credentials) run: | + if (-not $Env:AZURE_CREDENTIALS) { Write-Error 'AZURE_CREDENTIALS secret not set.'; exit 1 } $info = $Env:AZURE_CREDENTIALS | ConvertFrom-Json -AsHashtable; + if (-not $info.clientSecret) { Write-Error 'clientSecret missing in AZURE_CREDENTIALS'; exit 1 } Write-Host "::add-mask::$($info.clientSecret)" azd auth login ` diff --git a/cli/azd/pkg/project/framework_service_dotnet_test.go b/cli/azd/pkg/project/framework_service_dotnet_test.go index 557b50597fe..0d36fa0ff38 100644 --- a/cli/azd/pkg/project/framework_service_dotnet_test.go +++ b/cli/azd/pkg/project/framework_service_dotnet_test.go @@ -150,8 +150,8 @@ func Test_DotNetProject_Restore(t *testing.T) { serviceConfig := createTestServiceConfig("./src/api/test.csproj", AppServiceTarget, ServiceLanguageCsharp) dotnetProject := NewDotNetProject(dotNetCli, env) - result, err := logProgress(t, func(progess *async.Progress[ServiceProgress]) (*ServiceRestoreResult, error) { - return dotnetProject.Restore(*mockContext.Context, serviceConfig, progess) + result, err := logProgress(t, func(progress *async.Progress[ServiceProgress]) (*ServiceRestoreResult, error) { + return dotnetProject.Restore(*mockContext.Context, serviceConfig, progress) }) require.NoError(t, err) diff --git a/cli/azd/pkg/project/framework_service_maven_test.go b/cli/azd/pkg/project/framework_service_maven_test.go index 3290f3468df..4796ce725b4 100644 --- a/cli/azd/pkg/project/framework_service_maven_test.go +++ b/cli/azd/pkg/project/framework_service_maven_test.go @@ -53,8 +53,8 @@ func Test_MavenProject(t *testing.T) { err = mavenProject.Initialize(*mockContext.Context, serviceConfig) require.NoError(t, err) - result, err := logProgress(t, func(progess *async.Progress[ServiceProgress]) (*ServiceRestoreResult, error) { - return mavenProject.Restore(*mockContext.Context, serviceConfig, progess) + result, err := logProgress(t, func(progress *async.Progress[ServiceProgress]) (*ServiceRestoreResult, error) { + return mavenProject.Restore(*mockContext.Context, serviceConfig, progress) }) require.NoError(t, err) diff --git a/cli/azd/pkg/project/framework_service_npm_test.go b/cli/azd/pkg/project/framework_service_npm_test.go index 199d8b316fd..190212bf6bc 100644 --- a/cli/azd/pkg/project/framework_service_npm_test.go +++ b/cli/azd/pkg/project/framework_service_npm_test.go @@ -38,8 +38,8 @@ func Test_NpmProject_Restore(t *testing.T) { serviceConfig := createTestServiceConfig("./src/api", AppServiceTarget, ServiceLanguageTypeScript) npmProject := NewNpmProject(npmCli, env) - result, err := logProgress(t, func(progess *async.Progress[ServiceProgress]) (*ServiceRestoreResult, error) { - return npmProject.Restore(*mockContext.Context, serviceConfig, progess) + result, err := logProgress(t, func(progress *async.Progress[ServiceProgress]) (*ServiceRestoreResult, error) { + return npmProject.Restore(*mockContext.Context, serviceConfig, progress) }) require.NoError(t, err) diff --git a/cli/azd/pkg/project/framework_service_python_test.go b/cli/azd/pkg/project/framework_service_python_test.go index 65ff83b6f56..7030926546f 100644 --- a/cli/azd/pkg/project/framework_service_python_test.go +++ b/cli/azd/pkg/project/framework_service_python_test.go @@ -49,8 +49,8 @@ func Test_PythonProject_Restore(t *testing.T) { serviceConfig := createTestServiceConfig("./src/api", AppServiceTarget, ServiceLanguagePython) pythonProject := NewPythonProject(pythonCli, env) - result, err := logProgress(t, func(progess *async.Progress[ServiceProgress]) (*ServiceRestoreResult, error) { - return pythonProject.Restore(*mockContext.Context, serviceConfig, progess) + result, err := logProgress(t, func(progress *async.Progress[ServiceProgress]) (*ServiceRestoreResult, error) { + return pythonProject.Restore(*mockContext.Context, serviceConfig, progress) }) require.NoError(t, err) diff --git a/cli/azd/pkg/project/service_manager_test.go b/cli/azd/pkg/project/service_manager_test.go index 3863196c328..63e14db17fd 100644 --- a/cli/azd/pkg/project/service_manager_test.go +++ b/cli/azd/pkg/project/service_manager_test.go @@ -106,8 +106,8 @@ func Test_ServiceManager_Restore(t *testing.T) { restoreCalled := to.Ptr(false) ctx := context.WithValue(*mockContext.Context, frameworkRestoreCalled, restoreCalled) - result, err := logProgress(t, func(progess *async.Progress[ServiceProgress]) (*ServiceRestoreResult, error) { - return sm.Restore(ctx, serviceConfig, progess) + result, err := logProgress(t, func(progress *async.Progress[ServiceProgress]) (*ServiceRestoreResult, error) { + return sm.Restore(ctx, serviceConfig, progress) }) require.NoError(t, err) @@ -213,8 +213,8 @@ func Test_ServiceManager_Deploy(t *testing.T) { deployCalled := to.Ptr(false) ctx := context.WithValue(*mockContext.Context, serviceTargetDeployCalled, deployCalled) - result, err := logProgress(t, func(progess *async.Progress[ServiceProgress]) (*ServiceDeployResult, error) { - return sm.Deploy(ctx, serviceConfig, nil, progess) + result, err := logProgress(t, func(progress *async.Progress[ServiceProgress]) (*ServiceDeployResult, error) { + return sm.Deploy(ctx, serviceConfig, nil, progress) }) require.NoError(t, err) @@ -353,8 +353,8 @@ func Test_ServiceManager_Events_With_Errors(t *testing.T) { name: "restore", run: func(ctx context.Context, serviceManager ServiceManager, serviceConfig *ServiceConfig) (any, error) { return logProgress( - t, func(progess *async.Progress[ServiceProgress]) (*ServiceRestoreResult, error) { - return serviceManager.Restore(ctx, serviceConfig, progess) + t, func(progress *async.Progress[ServiceProgress]) (*ServiceRestoreResult, error) { + return serviceManager.Restore(ctx, serviceConfig, progress) }) }, }, diff --git a/cli/azd/pkg/project/service_target_ai_endpoint_test.go b/cli/azd/pkg/project/service_target_ai_endpoint_test.go index 254285ecc96..5b2bc31beb9 100644 --- a/cli/azd/pkg/project/service_target_ai_endpoint_test.go +++ b/cli/azd/pkg/project/service_target_ai_endpoint_test.go @@ -130,8 +130,8 @@ func Test_MlEndpointTarget_Deploy(t *testing.T) { Return(onlineEndpoint, nil) serviceTarget := createMlEndpointTarget(mockContext, env, aiHelper) - deployResult, err := logProgress(t, func(progess *async.Progress[ServiceProgress]) (*ServiceDeployResult, error) { - return serviceTarget.Deploy(*mockContext.Context, serviceConfig, servicePackage, targetResource, progess) + deployResult, err := logProgress(t, func(progress *async.Progress[ServiceProgress]) (*ServiceDeployResult, error) { + return serviceTarget.Deploy(*mockContext.Context, serviceConfig, servicePackage, targetResource, progress) }) diff --git a/cli/azd/pkg/project/service_target_aks_test.go b/cli/azd/pkg/project/service_target_aks_test.go index cfea8a74b43..b57ce5d529f 100644 --- a/cli/azd/pkg/project/service_target_aks_test.go +++ b/cli/azd/pkg/project/service_target_aks_test.go @@ -119,7 +119,7 @@ func Test_Package_Deploy_HappyPath(t *testing.T) { err = setupK8sManifests(t, serviceConfig) require.NoError(t, err) - packageResult, err := logProgress(t, func(progess *async.Progress[ServiceProgress]) (*ServicePackageResult, error) { + packageResult, err := logProgress(t, func(progress *async.Progress[ServiceProgress]) (*ServicePackageResult, error) { return serviceTarget.Package( *mockContext.Context, serviceConfig, @@ -130,7 +130,7 @@ func Test_Package_Deploy_HappyPath(t *testing.T) { TargetImage: "test-app/api-test:azd-deploy-0", }, }, - progess, + progress, ) }) @@ -932,7 +932,7 @@ func createTestCluster(clusterName, username string) *kubectl.KubeConfig { // as the observer. func logProgress[T comparable, P comparable]( t *testing.T, - fn func(progess *async.Progress[P]) (T, error), + fn func(progress *async.Progress[P]) (T, error), ) (T, error) { return async.RunWithProgress(func(p P) { t.Log(p) }, fn) } diff --git a/cli/azd/pkg/tools/github/github.go b/cli/azd/pkg/tools/github/github.go index 5a812c1bef3..041d69a8666 100644 --- a/cli/azd/pkg/tools/github/github.go +++ b/cli/azd/pkg/tools/github/github.go @@ -257,6 +257,17 @@ func (cli *Cli) ListSecrets(ctx context.Context, repoSlug string) ([]string, err return ghOutputToList(output.Stdout), nil } +// ListEnvironmentSecrets lists secrets for a specific GitHub environment within a repository. +// If the environment does not exist, gh will error. Caller should handle and possibly fall back. +func (cli *Cli) ListEnvironmentSecrets(ctx context.Context, repoSlug, environment string) ([]string, error) { + runArgs := cli.newRunArgs("-R", repoSlug, "secret", "list", "-e", environment) + output, err := cli.run(ctx, runArgs) + if err != nil { + return nil, fmt.Errorf("failed running gh secret list for environment %s: %w", environment, err) + } + return ghOutputToList(output.Stdout), nil +} + func (cli *Cli) ListVariables(ctx context.Context, repoSlug string) (map[string]string, error) { runArgs := cli.newRunArgs("-R", repoSlug, "variable", "list") output, err := cli.run(ctx, runArgs) @@ -266,6 +277,16 @@ func (cli *Cli) ListVariables(ctx context.Context, repoSlug string) (map[string] return ghOutputToMap(output.Stdout) } +// ListEnvironmentVariables lists variables for a specific GitHub environment within a repository. +func (cli *Cli) ListEnvironmentVariables(ctx context.Context, repoSlug, environment string) (map[string]string, error) { + runArgs := cli.newRunArgs("-R", repoSlug, "variable", "list", "-e", environment) + output, err := cli.run(ctx, runArgs) + if err != nil { + return nil, fmt.Errorf("failed running gh variable list for environment %s: %w", environment, err) + } + return ghOutputToMap(output.Stdout) +} + func (cli *Cli) SetSecret(ctx context.Context, repoSlug string, name string, value string) error { runArgs := cli.newRunArgs("-R", repoSlug, "secret", "set", name).WithStdIn(strings.NewReader(value)) _, err := cli.run(ctx, runArgs) @@ -275,6 +296,16 @@ func (cli *Cli) SetSecret(ctx context.Context, repoSlug string, name string, val return nil } +// SetEnvironmentSecret sets (creates or updates) a secret in a specific GitHub environment. +func (cli *Cli) SetEnvironmentSecret(ctx context.Context, repoSlug, environment, name, value string) error { + runArgs := cli.newRunArgs("-R", repoSlug, "secret", "set", name, "-e", environment).WithStdIn(strings.NewReader(value)) + _, err := cli.run(ctx, runArgs) + if err != nil { + return fmt.Errorf("failed running gh secret set (environment %s): %w", environment, err) + } + return nil +} + func (cli *Cli) SetVariable(ctx context.Context, repoSlug string, name string, value string) error { runArgs := cli.newRunArgs("-R", repoSlug, "variable", "set", name).WithStdIn(strings.NewReader(value)) _, err := cli.run(ctx, runArgs) @@ -284,6 +315,16 @@ func (cli *Cli) SetVariable(ctx context.Context, repoSlug string, name string, v return nil } +// SetEnvironmentVariable sets (creates or updates) a variable in a specific GitHub environment. +func (cli *Cli) SetEnvironmentVariable(ctx context.Context, repoSlug, environment, name, value string) error { + runArgs := cli.newRunArgs("-R", repoSlug, "variable", "set", name, "-e", environment).WithStdIn(strings.NewReader(value)) + _, err := cli.run(ctx, runArgs) + if err != nil { + return fmt.Errorf("failed running gh variable set (environment %s): %w", environment, err) + } + return nil +} + func (cli *Cli) DeleteSecret(ctx context.Context, repoSlug string, name string) error { runArgs := cli.newRunArgs("-R", repoSlug, "secret", "delete", name) _, err := cli.run(ctx, runArgs) @@ -293,6 +334,16 @@ func (cli *Cli) DeleteSecret(ctx context.Context, repoSlug string, name string) return nil } +// DeleteEnvironmentSecret deletes a secret from a specific environment. +func (cli *Cli) DeleteEnvironmentSecret(ctx context.Context, repoSlug, environment, name string) error { + runArgs := cli.newRunArgs("-R", repoSlug, "secret", "delete", name, "-e", environment) + _, err := cli.run(ctx, runArgs) + if err != nil { + return fmt.Errorf("failed running gh secret delete (environment %s): %w", environment, err) + } + return nil +} + func (cli *Cli) DeleteVariable(ctx context.Context, repoSlug string, name string) error { runArgs := cli.newRunArgs("-R", repoSlug, "variable", "delete", name) _, err := cli.run(ctx, runArgs) @@ -302,6 +353,30 @@ func (cli *Cli) DeleteVariable(ctx context.Context, repoSlug string, name string return nil } +// DeleteEnvironmentVariable deletes a variable from a specific environment. +func (cli *Cli) DeleteEnvironmentVariable(ctx context.Context, repoSlug, environment, name string) error { + runArgs := cli.newRunArgs("-R", repoSlug, "variable", "delete", name, "-e", environment) + _, err := cli.run(ctx, runArgs) + if err != nil { + return fmt.Errorf("failed running gh variable delete (environment %s): %w", environment, err) + } + return nil +} + +// EnsureEnvironment creates or updates a GitHub Environment (idempotent) so that environment scoped +// secrets & variables can be set. Safe to call even if the environment already exists. +// API ref: https://docs.github.com/rest/deployments/environments#create-or-update-an-environment +func (cli *Cli) EnsureEnvironment(ctx context.Context, repoSlug, environment string) error { + if strings.TrimSpace(environment) == "" { + return errors.New("environment name is required") + } + runArgs := cli.newRunArgs("api", fmt.Sprintf("repos/%s/environments/%s", repoSlug, environment), "--method", "PUT") + if _, err := cli.run(ctx, runArgs); err != nil { + return fmt.Errorf("failed ensuring github environment %s: %w", environment, err) + } + return nil +} + // ghCliVersionRegexp fetches the version number from the output of gh --version, which looks like this: // // gh version 2.6.0 (2022-03-15) diff --git a/cli/azd/resources/pipeline/.github/azure-dev.ymlt b/cli/azd/resources/pipeline/.github/azure-dev.ymlt index a79661e3c64..6f0ac314a73 100644 --- a/cli/azd/resources/pipeline/.github/azure-dev.ymlt +++ b/cli/azd/resources/pipeline/.github/azure-dev.ymlt @@ -18,6 +18,22 @@ permissions: jobs: build: +{{- if .UseGitHubEnvironments }} + {{/* Always emit a matrix of azd environment names when --github-use-environments is set, even if only one. + max-parallel is set to 1 to serialize deployments across environments. */}} + strategy: + max-parallel: 1 + matrix: + environment: +{{- if gt (len .Environments) 0 }} +{{- range $env := .Environments }} + - {{$env}} +{{- end }} +{{- else }} + # No azd environments detected at generation time +{{- end }} + environment: ${{ "{{" }} matrix.environment {{ "}}" }} +{{- end }} runs-on: ubuntu-latest env: AZURE_CLIENT_ID: ${{ "{{" }} vars.AZURE_CLIENT_ID {{ "}}" }} @@ -78,7 +94,9 @@ jobs: {{- if not .FedCredLogIn }} - name: Log in with Azure (Client Credentials) run: | + if (-not $Env:AZURE_CREDENTIALS) { Write-Error 'AZURE_CREDENTIALS secret not set.'; exit 1 } $info = $Env:AZURE_CREDENTIALS | ConvertFrom-Json -AsHashtable; + if (-not $info.clientSecret) { Write-Error 'clientSecret missing in AZURE_CREDENTIALS'; exit 1 } Write-Host "::add-mask::$($info.clientSecret)" azd auth login ` From e6c55fd7c4c22c6f70f9904909199a2a5bd441a3 Mon Sep 17 00:00:00 2001 From: TJ Edwards <7.3dward5@gmail.com> Date: Wed, 27 Aug 2025 20:31:08 -0400 Subject: [PATCH 2/3] feat(pipeline): opt-in GitHub Environments support & force regenerate flag; env-scoped cleanup & workflow matrix updates From 8603e1e546df2c8422f3f64ee95e65061e142a2a Mon Sep 17 00:00:00 2001 From: TJ Edwards <7.3dward5@gmail.com> Date: Wed, 27 Aug 2025 20:44:00 -0400 Subject: [PATCH 3/3] update changelog with PR# --- cli/azd/CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/azd/CHANGELOG.md b/cli/azd/CHANGELOG.md index 9d0690db87d..af5d47d610c 100644 --- a/cli/azd/CHANGELOG.md +++ b/cli/azd/CHANGELOG.md @@ -3,10 +3,10 @@ ## 1.19.0-beta.1 (Unreleased) ### Features Added -* [[XXXX]](https://github.com/Azure/azure-dev/pull/XXXX) GitHub pipeline config: supports setting secrets & variables in a GitHub Environment named after the current azd environment (automatically created if needed) and adds a matching OIDC federated credential subject (`repo:/:environment:`). (Previously configurable via `AZD_GITHUB_ENV`; that variable is now ignored.) Fixes [#5473](https://github.com/Azure/azure-dev/issues/5473). -* [[XXXX]](https://github.com/Azure/azure-dev/pull/XXXX) Added optional `--github-use-environments` flag to `azd pipeline config` to emit GitHub Environment (and matrix when multiple) blocks. Disabled by default; existing workflows remain unchanged unless flag is provided. Re-running with the flag toggled will migrate the existing workflow by adding or removing the environment/matrix section. Addresses matrix & multi‑environment ask in [#2373](https://github.com/Azure/azure-dev/issues/2373) and community feedback in [discussion #3585](https://github.com/Azure/azure-dev/discussions/3585). -* [[XXXX]](https://github.com/Azure/azure-dev/pull/XXXX) Environment mode now migrates standard AZD pipeline variables to the GitHub Environment scope (removing repo-level duplicates) and generates only the environment federated credential subject. Related to [#5473](https://github.com/Azure/azure-dev/issues/5473). -* [[XXXX]](https://github.com/Azure/azure-dev/pull/XXXX) Automatic pruning of legacy branch & pull_request federated identity credentials when switching to environment-only mode (service principal & MSI identities). Part of cleanup for [#5473](https://github.com/Azure/azure-dev/issues/5473). +* [[5664]](https://github.com/Azure/azure-dev/pull/5664) GitHub pipeline config: supports setting secrets & variables in a GitHub Environment named after the current azd environment (automatically created if needed) and adds a matching OIDC federated credential subject (`repo:/:environment:`). (Previously configurable via `AZD_GITHUB_ENV`; that variable is now ignored.) Fixes [#5473](https://github.com/Azure/azure-dev/issues/5473). +* [[5664]](https://github.com/Azure/azure-dev/pull/5664) Added optional `--github-use-environments` flag to `azd pipeline config` to emit GitHub Environment (and matrix when multiple) blocks. Disabled by default; existing workflows remain unchanged unless flag is provided. Re-running with the flag toggled will migrate the existing workflow by adding or removing the environment/matrix section. Addresses matrix & multi‑environment ask in [#2373](https://github.com/Azure/azure-dev/issues/2373) and community feedback in [discussion #3585](https://github.com/Azure/azure-dev/discussions/3585). +* [[5664]](https://github.com/Azure/azure-dev/pull/5664) Environment mode now migrates standard AZD pipeline variables to the GitHub Environment scope (removing repo-level duplicates) and generates only the environment federated credential subject. Related to [#5473](https://github.com/Azure/azure-dev/issues/5473). +* [[5664]](https://github.com/Azure/azure-dev/pull/5664) Automatic pruning of legacy branch & pull_request federated identity credentials when switching to environment-only mode (service principal & MSI identities). Part of cleanup for [#5473](https://github.com/Azure/azure-dev/issues/5473). ### Breaking Changes