Skip to content
Closed
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
27 changes: 6 additions & 21 deletions .github/scripts/v4/publish-v4-channel.sh
Original file line number Diff line number Diff line change
Expand Up @@ -32,29 +32,14 @@ SHA="${4:?Need the commit sha to anchor the release.}"
METADATA_ZIP="${5:-}"

RAW_VERSION="${TEMPLATE_TAG#templates@}"
RAW_MINOR="$(echo "$RAW_VERSION" | cut -d. -f2)"

# Clean-version invariant. The v4 channel only ever holds clean versions that a
# `~major.minor` range can resolve, keyed on the minted version's odd/even minor:
# - ODD minor = prerelease line (6.11.x): published under a clean, date-stamped
# version (6.11.<date>) computed by computeV4PublishVersion and recorded as
# templates-config.json v4.localVersion by the preceding "sync v4 template
# config" step. We reuse that single source of truth here.
# - EVEN minor = stable line (6.10.x): already clean, published as-is.
# An EVEN-minor version that still carries a prerelease suffix means a preview
# lane was minted on a stable branch (the exact 6.10.3-beta.<date> misfire) —
# refuse it, since stripping the suffix would collide with the real stable 6.10.3.
case "$RAW_VERSION" in
*-*)
if [ $((RAW_MINOR % 2)) -eq 0 ]; then
echo "Refusing v4 channel publish: '$RAW_VERSION' is a prerelease-suffixed even-minor (stable) version — a preview lane minted on a stable branch." >&2
exit 1
fi
;;
esac

# Reuse the clean publish version the sync step already computed (single source
# of truth), falling back to the raw version for an even-minor stable release.
# `~major.minor` range can resolve. The clean publish version was computed by
# computeV4PublishVersion and recorded as templates-config.json v4.localVersion
# by the preceding "sync v4 template config" step; we reuse that single source
# of truth. A preview (-beta.<date> suffix) maps to the odd-minor line
# (6.11.<date>) — bumped from an even-minor stable base when needed, mirroring
# the VSIX vsc-version.sh mints; a stable version is already clean.
CONFIG_FILE="packages/fx-core/src/common/templates-config.json"
VERSION="$(node -p "(require('./$CONFIG_FILE').v4 || {}).localVersion || '$RAW_VERSION'")"

Expand Down
25 changes: 13 additions & 12 deletions .github/scripts/v4/sync-v4-template-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,21 +48,21 @@ function computeBundled(goproduct) {
return !goproduct;
}

// Clean, suffix-free version published to the v4 channel: odd minor (prerelease)
// stamps the build date into the patch (6.11.<date>, read from the -beta.<date>
// preid); even minor (stable) uses major.minor.patch as-is.
// Clean, suffix-free version published to the v4 channel: mirrors the VSIX
// version vsc-version.sh mints. A preview (-beta.<date> suffix) targets the
// odd-minor line, date-stamped patch (6.11.<date>) — an even-minor stable base
// bumps to the next odd minor, like the VSIX; stable (no date) is as-is.
function computeV4PublishVersion(version) {
const parsed = semver.parse(version);
if (parsed === null) {
throw new Error(`Cannot compute v4 publish version: "${version}" is not valid SemVer.`);
}
if (parsed.minor % 2 === 1) {
const dateStamp = parsed.prerelease.find(
(segment) => typeof segment === "number" && segment >= 1000000000
);
if (dateStamp !== undefined) {
return `${parsed.major}.${parsed.minor}.${dateStamp}`;
}
const dateStamp = parsed.prerelease.find(
(segment) => typeof segment === "number" && segment >= 1000000000
);
if (dateStamp !== undefined) {
const minor = parsed.minor % 2 === 0 ? parsed.minor + 1 : parsed.minor;
return `${parsed.major}.${minor}.${dateStamp}`;
}
return `${parsed.major}.${parsed.minor}.${parsed.patch}`;
}
Expand All @@ -79,10 +79,11 @@ function computeRange(version, previousRange) {
}

function computeV4TemplateConfig(input) {
const localVersion = computeV4PublishVersion(input.version);
return {
range: computeRange(input.version, input.previousRange),
range: computeRange(localVersion, input.previousRange),
bundled: computeBundled(input.goproduct),
localVersion: computeV4PublishVersion(input.version),
localVersion,
};
}

Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/e2e-test-same-tenant.yml
Original file line number Diff line number Diff line change
Expand Up @@ -178,13 +178,13 @@ jobs:
run: |
npm install -g @microsoft/m365agentstoolkit-cli@${{ github.event.inputs.cli-version }}

- name: install cli for schedule
if: ${{ github.event_name == 'schedule' || github.event_name == 'pull_request' || (github.event.ref == 'refs/heads/dev' && github.event_name == 'workflow_dispatch' && github.event.inputs.cli-version == '') }}
- name: install published cli
if: ${{ github.event_name == 'schedule' || (github.event.ref == 'refs/heads/dev' && github.event_name == 'workflow_dispatch' && github.event.inputs.cli-version == '') }}
run: |
npm install -g @microsoft/m365agentstoolkit-cli@alpha

- name: link cli for workflow_dispatch
if: ${{ github.event_name == 'workflow_dispatch' && github.event.ref != 'refs/heads/dev' && github.event.inputs.cli-version == '' }}
- name: link cli for pull_request or workflow_dispatch
if: ${{ github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && github.event.ref != 'refs/heads/dev' && github.event.inputs.cli-version == '') }}
run: |
pnpm link --global

Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/e2e-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -241,13 +241,13 @@ jobs:
run: |
npm install -g @microsoft/m365agentstoolkit-cli@${{ github.event.inputs.cli-version }}

- name: install cli for schedule
if: ${{ github.event_name == 'schedule' || github.event_name == 'pull_request' || ( github.event.ref == 'refs/heads/dev' && github.event_name == 'workflow_dispatch' && github.event.inputs.cli-version == '' ) }}
- name: install published cli
if: ${{ github.event_name == 'schedule' || ( github.event.ref == 'refs/heads/dev' && github.event_name == 'workflow_dispatch' && github.event.inputs.cli-version == '' ) }}
run: |
npm install -g @microsoft/m365agentstoolkit-cli@alpha

- name: link cli for workflow_dispatch
if: ${{ github.event_name == 'workflow_dispatch' && github.event.ref != 'refs/heads/dev' && github.event.inputs.cli-version == '' }}
- name: link cli for pull_request or workflow_dispatch
if: ${{ github.event_name == 'pull_request' || ( github.event_name == 'workflow_dispatch' && github.event.ref != 'refs/heads/dev' && github.event.inputs.cli-version == '' ) }}
run: |
pnpm link --global

Expand Down
73 changes: 73 additions & 0 deletions .github/workflows/test-v4-channel.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: Test v4 template channel
run-name: test-v4-${{ inputs.rawVersion }}

# Manual: build templates from a branch/sha and publish ONE v4 package to the
# real channel (templates-v4@<ver> + template-v4-tag-list) so dev can test v4.
# Safe because v4 is not enabled in prod yet. No version mint, no npm/vsix/v3.
on:
workflow_dispatch:
inputs:
rawVersion:
description: "Raw templates version to mint from (even-minor preview bumps to 6.11.<date>)."
required: true
default: "6.11.0-beta.2026062908.0"
ref:
description: "Branch/sha to build template content from."
required: true
default: ""
production:
description: "true = online channel config; false = bundled floor."
required: true
default: "true"

permissions:
contents: write

jobs:
test-publish:
runs-on: ubuntu-latest
steps:
- id: generate-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ vars.APP_GITHUB_APP_ID }}
private-key: ${{ secrets.APP_GITHUB_APP_PRIVATE_KEY }}

- uses: actions/checkout@v3
with:
fetch-depth: 0
token: ${{ steps.generate-token.outputs.token }}
ref: ${{ github.event.inputs.ref }}

- uses: actions/setup-node@v3
with:
node-version: 22
- uses: pnpm/action-setup@v4

- name: Setup project
run: npm run setup

- name: Pin templates version
run: |
jq --arg v "${{ github.event.inputs.rawVersion }}" '.version=$v' \
templates/package.json > templates/tmp.json
mv templates/tmp.json templates/package.json

- name: Build templates (v4 zip + metadata)
run: cd templates && npm run build

- name: Sync v4 template config
env:
PRODUCTION: ${{ github.event.inputs.production }}
run: node .github/scripts/v4/sync-v4-template-config.js

- name: Publish to v4 channel
env:
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
run: |
bash .github/scripts/v4/publish-v4-channel.sh \
"templates@${{ github.event.inputs.rawVersion }}" \
"${{ github.workspace }}/templates/build/v4/templates.zip" \
"${{ runner.temp }}" \
"${{ github.sha }}" \
"${{ github.workspace }}/templates/build/metadata.zip"
2 changes: 1 addition & 1 deletion packages/fx-core/src/common/featureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export class FeatureFlags {
};
static readonly V4Enabled = {
name: FeatureFlagName.V4Enabled,
defaultValue: "false",
defaultValue: "true",
};
static readonly MCPForDADT = {
name: FeatureFlagName.MCPForDADT,
Expand Down
40 changes: 30 additions & 10 deletions packages/fx-core/src/v4/distribution/templateConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,35 @@ export function computeBundled(goproduct: boolean): boolean {
return !goproduct;
}

/** Compute the clean, suffix-free version published to the v4 channel. */
/**
* The clean, suffix-free version published to the v4 channel for a minted
* version. It mirrors the VSIX version `vsc-version.sh` mints so the template
* channel and the shipped extension always share one version:
*
* - PREVIEW (any `-beta.<date>`-suffixed version): the odd-minor prerelease
* line, date-stamped patch (`6.11.<YYYYMMDDHH>`). An even-minor base (lerna
* `prerelease` keeps the stable `6.10.x` minor) is bumped to the next odd
* minor, exactly as `vsc-version.sh` bumps the VSIX from `6.10.x` to
* `6.11.<date>`; an already-odd base keeps its minor. The date is read from
* the `-beta.<date>` preid, so every preview build gets a unique version
* that satisfies `~major.minor`.
* - STABLE (no date-stamped suffix): `major.minor.patch` as-is; the stable
* lane already mints a clean version.
*
* This is the v4-channel counterpart of the clean `templates@<major>.<minor>.<date>`
* pattern the v3 channel uses for prereleases.
*/
export function computeV4PublishVersion(version: string): string {
const parsed = semver.parse(version);
if (parsed === null) {
throw new Error(`Cannot compute v4 publish version: "${version}" is not valid SemVer.`);
}
if (parsed.minor % 2 === 1) {
const dateStamp = parsed.prerelease.find(
(segment): segment is number => typeof segment === "number" && segment >= 1_000_000_000
);
if (dateStamp !== undefined) {
return `${parsed.major}.${parsed.minor}.${dateStamp}`;
}
const dateStamp = parsed.prerelease.find(
(segment): segment is number => typeof segment === "number" && segment >= 1_000_000_000
);
if (dateStamp !== undefined) {
const minor = parsed.minor % 2 === 0 ? parsed.minor + 1 : parsed.minor;
return `${parsed.major}.${minor}.${dateStamp}`;
}
return `${parsed.major}.${parsed.minor}.${parsed.patch}`;
}
Expand All @@ -59,9 +75,13 @@ export function computeRange(version: string, previousRange: string): string {

/** Compute the full v4 distribution config block for `templates-config.json`. */
export function computeV4TemplateConfig(input: V4TemplateConfigInput): V4TemplateConfig {
const localVersion = computeV4PublishVersion(input.version);
return {
range: computeRange(input.version, input.previousRange),
// Range follows the published version, not the raw minted minor: a preview
// minted on a stable `6.10.x` base publishes as `6.11.<date>`, so the range
// must widen to `~6.11` to resolve it.
range: computeRange(localVersion, input.previousRange),
bundled: computeBundled(input.goproduct),
localVersion: computeV4PublishVersion(input.version),
localVersion,
};
}
38 changes: 5 additions & 33 deletions packages/fx-core/src/v4/validation/validateTemplatePackage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const SOURCE = "Scaffold";
/** Package namespace. */
export type PackageKind = "create" | "modify";

/** Validation mode; only `load` compares this engine against `minEngineVersion`. */
/** Validation mode; retained for signature parity. The engine gate is disabled. */
export type ValidateMode = "build" | "load";

/** One content file's path plus extracted `{{token}}` names. */
Expand Down Expand Up @@ -112,33 +112,11 @@ function v4RouteIds(selectorData: unknown): string[] {
return ids;
}

/** Compare `major.minor.patch` numerically: <0 / 0 / >0. */
function compareSemver(a: string, b: string): number {
const pa = parseSemver(a);
const pb = parseSemver(b);
for (let i = 0; i < 3; i++) {
if (pa[i] !== pb[i]) {
return pa[i] < pb[i] ? -1 : 1;
}
}
return 0;
}

function parseSemver(v: string): number[] {
const parts = v.split(".");
const nums: number[] = [];
for (let i = 0; i < 3; i++) {
const n = Number.parseInt(parts[i] ?? "0", 10);
nums.push(Number.isNaN(n) ? 0 : n);
}
return nums;
}

/** Validate one `<kind>/<id>` package before any content is rendered. */
export function validateTemplatePackage(
kind: PackageKind,
id: string,
mode: ValidateMode,
_mode: ValidateMode,
port: TemplatePackagePort
): Result<ValidatedPackage, FxError> {
const pkg = `${kind}/${id}`;
Expand Down Expand Up @@ -282,14 +260,8 @@ export function validateTemplatePackage(
);
}

if (mode === "load" && compareSemver(port.engineVersion(), minEngineVersion) < 0) {
return err(
userError(
VALIDATE_ENGINE_TOO_OLD,
`${pkg}: requires engine ${minEngineVersion}, but this engine is ${port.engineVersion()}; upgrade the engine (no silent fallback)`
)
);
}

// The engine-version reverse gate is disabled: minEngineVersion is still
// mandatory (recorded for telemetry) but never blocks load, so v4 packages
// run regardless of the engine SemVer.
return ok({ descriptor, minEngineVersion, contentFiles });
}
17 changes: 15 additions & 2 deletions packages/fx-core/tests/v4/distribution/templateConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ describe("templateConfig (v4 build-time)", () => {
assert.strictEqual(computeV4PublishVersion("6.10.3"), "6.10.3");
});

it("even-minor suffixed (preview minted on stable branch) → stripped (publish-time guard refuses it separately)", () => {
assert.strictEqual(computeV4PublishVersion("6.10.3-beta.2026061609.0"), "6.10.3");
it("even-minor preview (lerna keeps the stable minor) → bumps to the next odd minor, date-stamped, like the VSIX", () => {
assert.strictEqual(computeV4PublishVersion("6.10.4-beta.2026062608.0"), "6.11.2026062608");
});

it("throws on a non-SemVer version (no silent fallback)", () => {
Expand Down Expand Up @@ -104,6 +104,19 @@ describe("templateConfig (v4 build-time)", () => {
});
});

it("even-minor preview shipping (goproduct=true) → bumps to ~6.11 and 6.11.<date>, matching the VSIX", () => {
const config = computeV4TemplateConfig({
version: "6.10.4-beta.2026062608.0",
goproduct: true,
previousRange: "~6.10",
});
assert.deepEqual(config, {
range: "~6.11",
bundled: false,
localVersion: "6.11.2026062608",
});
});

it("patch within the current range keeps the range stable (reproducibility)", () => {
const config = computeV4TemplateConfig({
version: "6.10.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
ContentFile,
TemplatePackagePort,
VALIDATE_DANGLING_ROUTE,
VALIDATE_ENGINE_TOO_OLD,
VALIDATE_KIND_OVERLAP,
VALIDATE_MIN_ENGINE_MISSING,
VALIDATE_PLACEHOLDER_DRIFT,
Expand Down Expand Up @@ -346,7 +345,7 @@ describe("v4/validation/validateTemplatePackage", () => {
assert.isTrue(res.isOk());
});

it("AC-18: load, engine 6.11.0 < minEngineVersion 6.11.3 -> UserError (upgrade engine)", () => {
it("AC-18: engine gate disabled - engine 6.11.0 < minEngineVersion 6.11.3 still ok", () => {
const parts = validParts();
parts.engineVersion = "6.11.0";
parts.descriptor = {
Expand All @@ -358,14 +357,10 @@ describe("v4/validation/validateTemplatePackage", () => {
replaceMap: [{ var: "MCPNamespace", const: "ns" }],
};
const res = validateTemplatePackage("create", "mcp-server", "load", makePort(parts));
assert.isTrue(res.isErr());
const e = res._unsafeUnwrapErr();
assert.instanceOf(e, UserError);
assert.equal(e.name, VALIDATE_ENGINE_TOO_OLD);
assert.include(e.message, "6.11.3");
assert.isTrue(res.isOk());
});

it("AC-19: per-package gate separates siblings in one artifact (mcp-server ok, foo too-old)", () => {
it("AC-19: engine gate disabled - siblings both ok regardless of minEngineVersion", () => {
const okParts = validParts();
okParts.engineVersion = "6.11.0";

Expand All @@ -389,8 +384,7 @@ describe("v4/validation/validateTemplatePackage", () => {
const resOk = validateTemplatePackage("create", "mcp-server", "load", makePort(okParts));
const resFoo = validateTemplatePackage("create", "foo", "load", makePort(foo));
assert.isTrue(resOk.isOk());
assert.isTrue(resFoo.isErr());
assert.equal(resFoo._unsafeUnwrapErr().name, VALIDATE_ENGINE_TOO_OLD);
assert.isTrue(resFoo.isOk());
});

it("AC-20: a malformed package fails identically under build and load", () => {
Expand Down
Loading
Loading