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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cli/azd/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
## 1.19.0-beta.1 (Unreleased)

### Features Added
* [[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:<owner>/<repo>:environment:<AZURE_ENV_NAME>`). (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

Expand Down
10 changes: 10 additions & 0 deletions cli/azd/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
27 changes: 27 additions & 0 deletions cli/azd/cmd/pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package cmd
import (
"context"
"fmt"
"os"

"github.com/MakeNowJust/heredoc/v2"
"github.com/azure/azure-dev/cli/azd/cmd/actions"
Expand All @@ -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) {
Expand Down Expand Up @@ -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. "+
Expand Down Expand Up @@ -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
}

Expand Down
2 changes: 2 additions & 0 deletions cli/azd/cmd/testdata/TestUsage-azd-pipeline-config.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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 <UUID>.
--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.
Expand Down
13 changes: 13 additions & 0 deletions cli/azd/docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<owner>/<repo>:environment:<AZURE_ENV_NAME>`) is added so workflows targeting that environment can obtain Azure tokens. If multiple azd environments are detected (multiple `.azure/<env>/.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:<slug>:ref:refs/heads/<branch>`) and pull request (`repo:<slug>: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).
6 changes: 6 additions & 0 deletions cli/azd/docs/manual-pipeline-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>/.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.

Expand Down
2 changes: 2 additions & 0 deletions cli/azd/docs/using-environment-secrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<owner>/<repo>:environment:<AZURE_ENV_NAME>` 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.
Expand Down
20 changes: 20 additions & 0 deletions cli/azd/internal/appdetect/appdetect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"embed"
"io/fs"
"os"
"os/exec"
"path/filepath"
"testing"

Expand Down Expand Up @@ -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)

Expand Down
51 changes: 51 additions & 0 deletions cli/azd/pkg/armmsi/armmsi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
58 changes: 58 additions & 0 deletions cli/azd/pkg/entraid/entraid.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading