diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 496ff96b14..6f27864fc1 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -45,11 +45,22 @@ on: type: boolean default: false description: "[Dev/Testing only] Skip branch validation." + skipdockerbuild: + type: boolean + default: false + description: "Skip triggering the VscUse ATK docker build after CD (e.g. when the caller builds its own image)." permissions: actions: write contents: write +# Production CD runs (dev / release/*) use a unique group so concurrency is a no-op and +# real releases / nightly are never cancelled or queued. Other refs (feature/PR branches +# used by the smoke pipeline) are grouped per-branch so a re-dispatch cancels the stale run. +concurrency: + group: ${{ format('cd-{0}', (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/heads/release/')) && github.run_id || github.ref_name) }} + cancel-in-progress: true + jobs: cd: runs-on: ubuntu-latest @@ -576,7 +587,7 @@ jobs: trigger-docker-build: needs: cd - if: ${{ success() }} + if: ${{ success() && !inputs.skipdockerbuild }} runs-on: ubuntu-latest steps: - name: Trigger Docker Build Workflow diff --git a/.github/workflows/dev-smoke-test-pipeline.yml b/.github/workflows/dev-smoke-test-pipeline.yml index defe793e09..c097ee8d64 100644 --- a/.github/workflows/dev-smoke-test-pipeline.yml +++ b/.github/workflows/dev-smoke-test-pipeline.yml @@ -13,6 +13,12 @@ on: type: string default: "" +# Cancel an in-progress smoke run (and let its children cancel too) when a newer +# run starts for the same branch. Distinct branches (different PRs) never collide. +concurrency: + group: dev-smoke-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + jobs: run-cd: runs-on: ubuntu-latest @@ -44,16 +50,18 @@ jobs: const owner = context.repo.owner; const repo = context.repo.repo; const sourceBranch = '${{ github.head_ref || github.ref_name }}'; - const cdDispatchBranch = 'neil/cdprivatepipeline'; - // Trigger CD workflow on private CD branch, build sourceBranch via external_ref + // Trigger CD workflow directly on the reference (source) branch await github.rest.actions.createWorkflowDispatch({ owner, repo, workflow_id: 'cd.yml', - ref: cdDispatchBranch, + ref: sourceBranch, inputs: { - external_ref: sourceBranch + preid: 'alpha', + skipmarkdowncheck: 'true', + SkipBranchCheck: 'true', + skipdockerbuild: 'true' } }); @@ -63,7 +71,7 @@ jobs: owner, repo, workflow_id: 'cd.yml', - branch: cdDispatchBranch, + branch: sourceBranch, per_page: 5 }); @@ -133,7 +141,8 @@ jobs: ref: 'dev', inputs: { tag: '${{ needs.run-cd.outputs.tag_name }}', - run_id: '${{ needs.run-cd.outputs.run_id }}' + run_id: '${{ needs.run-cd.outputs.run_id }}', + source_ref: '${{ github.head_ref || github.ref_name }}' } }); @@ -224,12 +233,13 @@ jobs: script: | const owner = context.repo.owner; const repo = context.repo.repo; + const sourceBranch = '${{ github.head_ref || github.ref_name }}'; await github.rest.actions.createWorkflowDispatch({ owner, repo, workflow_id: 'ui-test-vscuse-template.yml', - ref: 'dev', + ref: sourceBranch, inputs: { image_tag: '${{ needs.run-cd.outputs.tag_name }}', 'email-receiver': '${{ inputs.email }}', @@ -244,7 +254,7 @@ jobs: owner, repo, workflow_id: 'ui-test-vscuse-template.yml', - branch: 'dev', + branch: sourceBranch, per_page: 5 }); diff --git a/.github/workflows/ui-test-vscuse-template.yml b/.github/workflows/ui-test-vscuse-template.yml index c2c212bd23..5ac065d900 100644 --- a/.github/workflows/ui-test-vscuse-template.yml +++ b/.github/workflows/ui-test-vscuse-template.yml @@ -38,6 +38,13 @@ on: permissions: actions: read +# Production runs (dev / release/*) use a unique group so concurrency is a no-op (nightly is +# never cancelled); feature/PR branches dispatched by the smoke pipeline are grouped per-branch +# so a re-dispatch cancels the stale UI-test run. +concurrency: + group: ${{ format('uitest-vscuse-template-{0}', (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/heads/release/')) && github.run_id || github.ref_name) }} + cancel-in-progress: true + jobs: run: uses: ./.github/workflows/ui-test-vscuse-common.yml diff --git a/.github/workflows/vscuse-atk-docker-build.yml b/.github/workflows/vscuse-atk-docker-build.yml index 26c47e2ec8..05986f38c9 100644 --- a/.github/workflows/vscuse-atk-docker-build.yml +++ b/.github/workflows/vscuse-atk-docker-build.yml @@ -28,10 +28,20 @@ on: description: 'Whether to trigger UI test after build' type: boolean default: false + source_ref: + description: 'Source branch this build belongs to. Used only to group concurrency so a re-dispatch for the same branch cancels the stale build. Leave empty for release/manual builds.' + required: false + default: '' env: REGISTRY: ghcr.io IMAGE_NAME: officedev/vscuse-atk-vscode +# When dispatched with a source_ref (smoke pipeline) group per-branch so a re-dispatch cancels +# the stale build. Without it (CD / manual / release) the run_id keeps the group unique → no-op. +concurrency: + group: ${{ format('vscuse-atk-docker-{0}', github.event.inputs.source_ref || github.run_id) }} + cancel-in-progress: true + jobs: build-and-push: runs-on: ubuntu-latest diff --git a/packages/fx-core/src/question/scaffold/constructNode.ts b/packages/fx-core/src/question/scaffold/constructNode.ts index 0fdc14e393..52b49ad161 100644 --- a/packages/fx-core/src/question/scaffold/constructNode.ts +++ b/packages/fx-core/src/question/scaffold/constructNode.ts @@ -2,7 +2,7 @@ // Licensed under the MIT license. import { IQTreeNode, OptionItem, Platform, SingleSelectQuestion } from "@microsoft/teamsfx-api"; -import { featureFlagManager } from "../../common/featureFlags"; +import { FeatureFlag, FeatureFlags, featureFlagManager } from "../../common/featureFlags"; import { getLocalizedString } from "../../common/localizeUtils"; import { apiSpecNode, @@ -16,7 +16,11 @@ import { GCConnectionIdQuestion, GCNameQuestion } from "../create"; import { QuestionNames } from "../questionNames"; function isFeatureEnabled(flagName: string): boolean { - return featureFlagManager.getBooleanValue({ name: flagName, defaultValue: "false" }); + const flag = (Object.values(FeatureFlags) as FeatureFlag[]).find((f) => f.name === flagName) ?? { + name: flagName, + defaultValue: "false", + }; + return featureFlagManager.getBooleanValue(flag); } export function constructNode( @@ -117,7 +121,6 @@ function resolveNodeReference( // TypeScript-defined complex nodes (lazy import to avoid circular dependency) case "mcpServerTypeNode": { - // eslint-disable-next-line @typescript-eslint/no-var-requires const { MCPServerTypeNode } = require("./vsc/teamsProjectTypeNode"); node = MCPServerTypeNode(); break; diff --git a/packages/fx-core/tests/question/scaffold.test.ts b/packages/fx-core/tests/question/scaffold.test.ts index 2ba5f0356b..680d0dd4a8 100644 --- a/packages/fx-core/tests/question/scaffold.test.ts +++ b/packages/fx-core/tests/question/scaffold.test.ts @@ -10,7 +10,7 @@ import { SingleSelectQuestion, StringValidation, } from "@microsoft/teamsfx-api"; -import { featureFlagManager, FeatureFlags } from "../../src/common/featureFlags"; +import { featureFlagManager, FeatureFlagName, FeatureFlags } from "../../src/common/featureFlags"; import { getLocalizedString } from "../../src/common/localizeUtils"; import { AppDefinition } from "../../src/component/driver/teamsApp/interfaces/appdefinitions/appDefinition"; import { Bot } from "../../src/component/driver/teamsApp/interfaces/appdefinitions/bot"; @@ -26,7 +26,16 @@ import { } from "../../src/question/scaffold/commonNodes"; import { constructNode } from "../../src/question/scaffold/constructNode"; import { scaffoldQuestionForVS } from "../../src/question/scaffold/vs/createRootNode"; -import { ActionStartOptions } from "../../src/question/scaffold/vsc/CapabilityOptions"; +import { + ActionStartOptions, + BotCapabilityOptions, + CustomCopilotRagOptions, + MeArchitectureOptions, + MeCapabilityOptions, + NotificationBotOptions, + TabCapabilityOptions, + TeamsAgentCapabilityOptions, +} from "../../src/question/scaffold/vsc/CapabilityOptions"; import { ProjectTypeOptions } from "../../src/question/scaffold/vsc/ProjectTypeOptions"; import { createFromTdpNode, @@ -48,15 +57,6 @@ import { AppPackageFolderName } from "@microsoft/teamsfx-api"; import fs from "fs-extra"; import path from "path"; import { assert, vi } from "vitest"; -import { - BotCapabilityOptions, - CustomCopilotRagOptions, - MeArchitectureOptions, - MeCapabilityOptions, - NotificationBotOptions, - TabCapabilityOptions, - TeamsAgentCapabilityOptions, -} from "../../src/question/scaffold/vsc/CapabilityOptions"; import { botProjectTypeNode, CreateNewPluginManifestSentinel, @@ -1616,6 +1616,123 @@ describe("constructNode", () => { assert.equal(options.length, 2); }); + it("should include a true-default feature-flagged option when env var is unset", () => { + delete process.env[FeatureFlagName.OpenPluginImportExport]; + const json = JSON.stringify({ + data: { + title: "test.title", + name: "test", + type: "singleSelect", + options: [ + { id: "always-visible", label: "Always" }, + { id: "flagged", label: "Flagged", featureFlag: FeatureFlagName.OpenPluginImportExport }, + ], + }, + }); + + const node = constructNode(json); + const data = node.data as SingleSelectQuestion; + const options = data.staticOptions as OptionItem[]; + assert.equal(options.length, 2); + assert.isTrue(options.some((o) => o.id === "flagged")); + }); + + it("should exclude a false-default feature-flagged option when env var is unset", () => { + delete process.env[FeatureFlagName.AgentSkillsManifest]; + const json = JSON.stringify({ + data: { + title: "test.title", + name: "test", + type: "singleSelect", + options: [ + { id: "always-visible", label: "Always" }, + { id: "flagged", label: "Flagged", featureFlag: FeatureFlagName.AgentSkillsManifest }, + ], + }, + }); + + const node = constructNode(json); + const data = node.data as SingleSelectQuestion; + const options = data.staticOptions as OptionItem[]; + assert.equal(options.length, 1); + assert.equal(options[0].id, "always-visible"); + }); + + it("should let an explicit env override win for a true-default flag", () => { + process.env[FeatureFlagName.OpenPluginImportExport] = "false"; + try { + const json = JSON.stringify({ + data: { + title: "test.title", + name: "test", + type: "singleSelect", + options: [ + { id: "always-visible", label: "Always" }, + { + id: "flagged", + label: "Flagged", + featureFlag: FeatureFlagName.OpenPluginImportExport, + }, + ], + }, + }); + + const node = constructNode(json); + const data = node.data as SingleSelectQuestion; + const options = data.staticOptions as OptionItem[]; + assert.equal(options.length, 1); + assert.equal(options[0].id, "always-visible"); + } finally { + delete process.env[FeatureFlagName.OpenPluginImportExport]; + } + }); + + it("should let an explicit env override win for a false-default flag", () => { + process.env[FeatureFlagName.AgentSkillsManifest] = "true"; + try { + const json = JSON.stringify({ + data: { + title: "test.title", + name: "test", + type: "singleSelect", + options: [ + { id: "always-visible", label: "Always" }, + { id: "flagged", label: "Flagged", featureFlag: FeatureFlagName.AgentSkillsManifest }, + ], + }, + }); + + const node = constructNode(json); + const data = node.data as SingleSelectQuestion; + const options = data.staticOptions as OptionItem[]; + assert.equal(options.length, 2); + assert.isTrue(options.some((o) => o.id === "flagged")); + } finally { + delete process.env[FeatureFlagName.AgentSkillsManifest]; + } + }); + + it("should exclude an unknown feature flag when env var is unset", () => { + delete process.env["TEAMSFX_UNKNOWN_FLAG"]; + const json = JSON.stringify({ + data: { + title: "test.title", + name: "test", + type: "singleSelect", + options: [ + { id: "always-visible", label: "Always" }, + { id: "flagged", label: "Flagged", featureFlag: "TEAMSFX_UNKNOWN_FLAG" }, + ], + }, + }); + + const node = constructNode(json); + const data = node.data as SingleSelectQuestion; + const options = data.staticOptions as OptionItem[]; + assert.equal(options.length, 1); + assert.equal(options[0].id, "always-visible"); + }); + it("should handle group type nodes", () => { const json = JSON.stringify({ data: { type: "group", name: "test-group" },