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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 28 additions & 6 deletions cli/azd/cmd/middleware/tool_first_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,19 @@ import (
// the timestamp of a completed first-run experience.
const configKeyFirstRunCompleted = "tool.firstRunCompleted"

// envKeySkipFirstRun is the environment variable that, when set to
// "true", suppresses the first-run tool check entirely.
// envKeySkipFirstRun is the environment variable that controls the
// first-run tool check.
//
// The first-run experience is OFF by default: absent an explicit
// AZD_SKIP_FIRST_RUN=false, it is skipped, so we never interrupt a user's
// command with an unsolicited prompt. Setting it to "true" is still honoured
// (and still reported distinctly in telemetry) for users who had already
// opted out.
//
// Note the asymmetry: tool_update_check.go reads this same variable with the
// ordinary default-false semantics ("true" means skip, unset means run),
// because background update checks are not disabled by default. Don't
// "fix" that inconsistency — it's deliberate.
const envKeySkipFirstRun = "AZD_SKIP_FIRST_RUN"

// Skip reason constants emitted via fields.ToolFirstRunSkipReasonKey so
Expand All @@ -54,7 +65,15 @@ const envKeySkipFirstRun = "AZD_SKIP_FIRST_RUN"
// A skip reason is *not* emitted for the child-action path: child actions
// (e.g. workflow steps) inherit the parent's first-run state and do not
// contribute useful signal to first-run adoption analysis.
//
// `default_disabled` and `env_var` are likewise distinct:
// - `default_disabled` is the ordinary path — the first-run experience is
// off unless AZD_SKIP_FIRST_RUN is explicitly "false", so an unset (or
// unparsable) value lands here. This is the overwhelmingly common case.
// - `env_var` is a deliberate AZD_SKIP_FIRST_RUN=true opt-out, which is
// what that reason has always meant.
const (
skipReasonDefaultDisabled = "default_disabled"
skipReasonEnvVar = "env_var"
skipReasonNoPrompt = "no_prompt"
skipReasonCICD = "ci_cd"
Expand Down Expand Up @@ -129,8 +148,12 @@ func (m *ToolFirstRunMiddleware) Run(ctx context.Context, nextFn NextFn) (*actio
// should be bypassed. The reasons are checked in order of cost (cheapest
// first).
func (m *ToolFirstRunMiddleware) shouldSkip(ctx context.Context) (string, bool) {
// 1. Env-var opt-out.
if skip, _ := strconv.ParseBool(os.Getenv(envKeySkipFirstRun)); skip {
// 1. Env-var gate. Opt-in: only an explicit AZD_SKIP_FIRST_RUN=false runs
// the experience; unset or unparsable means skip.
switch v, err := strconv.ParseBool(os.Getenv(envKeySkipFirstRun)); {
case err != nil:
return skipReasonDefaultDisabled, true
case v:
return skipReasonEnvVar, true
}

Expand Down Expand Up @@ -182,8 +205,7 @@ func (m *ToolFirstRunMiddleware) runFirstRunExperience(ctx context.Context) erro
"Discover and install Azure development tools such as Azure CLI, GitHub Copilot CLI, and Azure AI extensions.",
)
m.console.Message(ctx, output.WithGrayFormat(
"To skip this check, set %s or run %s.",
output.WithHighLightFormat("AZD_SKIP_FIRST_RUN=true"),
"To skip this check, run %s.",
output.WithHighLightFormat("azd config set tool.firstRunCompleted true"),
))
m.console.Message(ctx, "")
Expand Down
35 changes: 25 additions & 10 deletions cli/azd/cmd/middleware/tool_first_run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ func TestToolFirstRunMiddleware_SkipConditions(t *testing.T) {
childCtx bool // wrap context with WithChildAction
wantSkip bool
}{
{
// The default for everyone who hasn't opted in.
name: "SkipWhenEnvVarUnset",
setup: func(t *testing.T, _ *mockinput.MockConsole, _ config.Config, _ *internal.GlobalCommandOptions) {
os.Unsetenv(envKeySkipFirstRun)
},
wantSkip: true,
},
{
name: "SkipWhenEnvVarSet",
setup: func(t *testing.T, _ *mockinput.MockConsole, _ config.Config, _ *internal.GlobalCommandOptions) {
Expand Down Expand Up @@ -144,8 +152,8 @@ func TestToolFirstRunMiddleware_SkipConditions(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
clearCIVars(t)
t.Setenv(envKeySkipFirstRun, "")
os.Unsetenv(envKeySkipFirstRun)
// The experience is off unless explicitly opted in.
t.Setenv(envKeySkipFirstRun, "false")

console := mockinput.NewMockConsole()
cfg := config.NewEmptyConfig()
Expand Down Expand Up @@ -190,8 +198,8 @@ func TestToolFirstRunMiddleware_SkipConditions(t *testing.T) {
func TestToolFirstRunMiddleware_TriggersWhenNoSkip(t *testing.T) {
// Ensure no CI env vars interfere.
clearCIVars(t)
t.Setenv(envKeySkipFirstRun, "")
os.Unsetenv(envKeySkipFirstRun)
// The experience is off unless explicitly opted in.
t.Setenv(envKeySkipFirstRun, "false")

console := mockinput.NewMockConsole()
cfg := config.NewEmptyConfig()
Expand Down Expand Up @@ -230,6 +238,13 @@ func TestToolFirstRunMiddleware_EmitsSkipReason(t *testing.T) {
setup func(t *testing.T, console *mockinput.MockConsole, cfg config.Config, opts *internal.GlobalCommandOptions)
wantReason string
}{
{
name: "default_disabled",
setup: func(t *testing.T, _ *mockinput.MockConsole, _ config.Config, _ *internal.GlobalCommandOptions) {
os.Unsetenv(envKeySkipFirstRun)
},
wantReason: skipReasonDefaultDisabled,
},
{
name: "env_var",
setup: func(t *testing.T, _ *mockinput.MockConsole, _ config.Config, _ *internal.GlobalCommandOptions) {
Expand Down Expand Up @@ -270,8 +285,8 @@ func TestToolFirstRunMiddleware_EmitsSkipReason(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
clearCIVars(t)
t.Setenv(envKeySkipFirstRun, "")
os.Unsetenv(envKeySkipFirstRun)
// The experience is off unless explicitly opted in.
t.Setenv(envKeySkipFirstRun, "false")

// Reset usage attributes so we can verify the middleware
// affirmatively writes tool.firstrun.skip_reason in this path.
Expand Down Expand Up @@ -308,8 +323,8 @@ func TestToolFirstRunMiddleware_EmitsSkipReason(t *testing.T) {
// to first-run adoption analysis.
func TestToolFirstRunMiddleware_NoSkipReasonForChildAction(t *testing.T) {
clearCIVars(t)
t.Setenv(envKeySkipFirstRun, "")
os.Unsetenv(envKeySkipFirstRun)
// The experience is off unless explicitly opted in.
t.Setenv(envKeySkipFirstRun, "false")

tracing.ResetUsageAttributesForTest()

Expand Down Expand Up @@ -364,8 +379,8 @@ func TestToolFirstRunMiddleware_ShouldSkip_FirstRunCompletedValue(t *testing.T)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
clearCIVars(t)
t.Setenv(envKeySkipFirstRun, "")
os.Unsetenv(envKeySkipFirstRun)
// The experience is off unless explicitly opted in.
t.Setenv(envKeySkipFirstRun, "false")

cfg := config.NewEmptyConfig()
require.NoError(t, cfg.Set(configKeyFirstRunCompleted, tt.value))
Expand Down
3 changes: 3 additions & 0 deletions cli/azd/cmd/middleware/tool_update_check.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ func (m *ToolUpdateCheckMiddleware) isToolCommand() bool {
// crashing the CLI.
func (m *ToolUpdateCheckMiddleware) triggerBackgroundCheckIfNeeded(ctx context.Context) {
// Honour the same opt-out signal used by the first-run experience.
// Note: unlike the first-run experience — which is off unless the variable
// is explicitly "false" — background update checks keep the ordinary
// default: unset means run, "true" means skip. See envKeySkipFirstRun.
if skip, _ := strconv.ParseBool(os.Getenv(envKeySkipFirstRun)); skip {
return
}
Expand Down
2 changes: 1 addition & 1 deletion cli/azd/docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ integration.
| `AZD_FORCE_TTY` | If true, forces `azd` to write terminal-style output. |
| `AZD_IN_CLOUDSHELL` | If true, `azd` runs with Azure Cloud Shell specific behavior. |
| `AZD_SKIP_UPDATE_CHECK` | If true, skips the out-of-date update check output that is typically printed at the end of the command. |
| `AZD_SKIP_FIRST_RUN` | If true, skips the first-run tool setup experience and background tool update checks. Useful for CI/CD pipelines and automated environments. |
| `AZD_SKIP_FIRST_RUN` | Controls the first-run tool setup experience, which is **off by default**: set to `false` to opt in to it. If true, also skips background tool update checks, which are otherwise on by default. Useful for CI/CD pipelines and automated environments. |
| `AZD_CONTAINER_RUNTIME` | The container runtime to use (e.g., `docker`, `podman`). |
| `AZD_ALLOW_NON_EMPTY_FOLDER` | If set, allows `azd init` to run in a non-empty directory without prompting. |
| `AZD_BUILDER_IMAGE` | The builder docker image used to perform Dockerfile-less builds. |
Expand Down
4 changes: 2 additions & 2 deletions cli/azd/internal/tracing/fields/fields.go
Original file line number Diff line number Diff line change
Expand Up @@ -422,8 +422,8 @@ var (
var (
// ToolFirstRunSkipReasonKey records why the first-run experience was
// bypassed for this invocation.
// Example: "env_var", "no_prompt", "ci_cd", "non_interactive",
// "already_completed", "config_error"
// Example: "default_disabled", "env_var", "no_prompt", "ci_cd",
// "non_interactive", "already_completed", "config_error"
Comment on lines +425 to +426
ToolFirstRunSkipReasonKey = AttributeKey{
Key: attribute.Key("tool.firstrun.skip_reason"),
Classification: SystemMetadata,
Expand Down
Loading