Skip to content
Merged
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
52 changes: 52 additions & 0 deletions .github/workflows/create-experimental-branch.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Create Experimental Branch

on:
workflow_dispatch:
inputs:
feature-name:
description: 'Name of the feature (dash-case, e.g., foo-bar)'
required: true
type: string

jobs:
create-branch:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Validate feature name
run: |
FEATURE_NAME="${{ github.event.inputs.feature-name }}"
# Regex for kebab-case: lowercase letters, numbers, and dashes. Must not start/end with dash.
if [[ ! $FEATURE_NAME =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
echo "::error::Invalid feature name '$FEATURE_NAME'. It must be dash-case (e.g., foo-bar, no spaces, no camelCase)."
exit 1
fi

- name: Create branch using gh cli
run: |
FEATURE_NAME="${{ github.event.inputs.feature-name }}"
BRANCH_NAME="experimental/$FEATURE_NAME"

# We use the GitHub API (via gh cli) instead of standard git checkout/push for efficiency.
# In a large monorepo, checking out the entire repository just to create a branch is slow and resource-heavy.
# The API approach is instantaneous and doesn't require local disk space.

echo "Checking if branch $BRANCH_NAME exists..."
# Pre-flight check to avoid failing the ref creation with an "already exists" error from the API
if gh api "repos/${{ github.repository }}/branches/$BRANCH_NAME" --silent 2>/dev/null; then
echo "::error::Branch '$BRANCH_NAME' already exists."
exit 1
fi

echo "Getting master SHA..."
# Fetch the current tip of the master branch to use as the starting point
MASTER_SHA=$(gh api "repos/${{ github.repository }}/branches/master" --template '{{.commit.sha}}')

echo "Creating branch $BRANCH_NAME at $MASTER_SHA..."
# Create a new git reference (branch) pointing to the master's SHA
gh api "repos/${{ github.repository }}/git/refs" \
-f ref="refs/heads/$BRANCH_NAME" \
-f sha="$MASTER_SHA"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
122 changes: 122 additions & 0 deletions azure-pipelines.release-vnext-experimental.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
pr: none
trigger: none

parameters:
- name: dryRun
displayName: Dry Run Mode
type: boolean
default: true

# Customize build number to include major version
# Example: v9_20201022.1
name: 'v9_experimental_$(Date:yyyyMMdd)$(Rev:.r)'

variables:
- group: 'Github and NPM secrets'
- template: .devops/templates/variables.yml
parameters:
skipComponentGovernanceDetection: false
- name: release.vnext # Used to scope beachball to release only vnext packages
value: true
- name: tags
value: production,externalfacing

resources:
repositories:
- repository: 1esPipelines
type: git
name: 1ESPipelineTemplates/1ESPipelineTemplates
ref: refs/tags/release

extends:
template: v1/1ES.Official.PipelineTemplate.yml@1esPipelines
parameters:
pool:
name: Azure-Pipelines-1ESPT-ExDShared
image: windows-latest
os: windows # We need windows because compliance task only run on windows.
stages:
- stage: main
jobs:
- job: Release
pool:
name: '1ES-Host-Ubuntu'
image: '1ES-PT-Ubuntu-20.04'
os: linux
workspace:
clean: all
templateContext:
outputs:
- output: pipelineArtifact
targetPath: $(System.DefaultWorkingDirectory)
artifactName: output
steps:
- template: .devops/templates/tools.yml@self

- script: |
git config user.name "Fluent UI Build"
git config user.email "fluentui-internal@service.microsoft.com"
displayName: Configure git user (used by beachball)

- task: Bash@3
name: validation
inputs:
targetType: 'inline'
script: |
BRANCH="$(Build.SourceBranch)"
if [[ ! $BRANCH =~ refs/heads/experimental/ ]]; then
echo "##vso[task.logissue type=error]Branch '$BRANCH' must start with 'refs/heads/experimental/'"
exit 1
fi
FEATURE_NAME=${BRANCH#refs/heads/experimental/}
echo "##vso[task.setvariable variable=featureName;isOutput=true]$FEATURE_NAME"
echo "Feature name: $FEATURE_NAME"
displayName: Validate branch and extract feature name

- script: |
yarn install --frozen-lockfile
displayName: Install dependencies

# Deletes all existing changefiles so that only bump that happens is for nightly
- script: |
rm -f change/*
displayName: 'Delete existing changefiles'

# Bumps all v9 packages to a x.x.x-experimental.<feature>.<date>-<hash> version and checks in change files
# x.x.x is derived from the current version @fluentui/react-components package.json
- script: |
FEATURE_NAME=$(validation.featureName)
BASE_VERSION=$(node -p "require('./packages/react-components/react-components/package.json').version")
DATE=$(date +"%Y%m%d")
HASH=$(git rev-parse --short HEAD)
NEW_VERSION="${BASE_VERSION}-experimental.${FEATURE_NAME}.${DATE}-${HASH}"

echo "Target version: ${NEW_VERSION}"

yarn nx g @fluentui/workspace-plugin:version-bump --all --version "${NEW_VERSION}"
git add .
git commit -m "bump experimental versions to ${NEW_VERSION}"
yarn change --type prerelease --message "Release ${NEW_VERSION}" --dependent-change-type "prerelease"
displayName: 'Bump and commit experimental versions'

- script: |
FLUENT_PROD_BUILD=true yarn nx run-many -t build -p "tag:vNext" --exclude "tag:npm:private,tag:tools,tag:charting" --nxBail
displayName: build

- script: |
FLUENT_PROD_BUILD=true yarn nx run-many -t lint -p "tag:vNext" --exclude "tag:npm:private,tag:tools,tag:charting" --nxBail
displayName: lint

- script: |
FLUENT_PROD_BUILD=true yarn nx run-many -t test -p "tag:vNext" --exclude "tag:npm:private,tag:tools,tag:charting" --nxBail
displayName: test

- script: |
yarn publish:beachball -b origin/$(Build.SourceBranchName) -n $(npmToken) --no-push --tag experimental --config scripts/beachball/src/release-vNext.config.js
git reset --hard origin/$(Build.SourceBranchName)
displayName: Publish changes and bump versions
condition: and(succeeded(), not(${{ parameters.dryRun }}))

- template: .devops/templates/cleanup.yml@self
parameters:
checkForModifiedFiles: false
1 change: 1 addition & 0 deletions docs/react-v9/contributing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ This directory contains documentation on best practices and guidelines for contr

## Process & Release

- [Releases](../releases.md) - How to releases work
- [Release Cycle](./release-cycle.md) - Understanding the release process for v9 packages.
- [RFC Process](./rfc-process.md) - Process for proposing new features or significant changes.
- [Contributor License Agreement](./cla.md) - Information about the Contributor License Agreement (CLA).
Expand Down
59 changes: 59 additions & 0 deletions docs/react-v9/releases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Releases

## Stable

Please refer to ADO wiki

## Experimental

Experimental releases allow developers to publish feature branches to NPM for testing and validation with partners before merging into `master`. These releases are published under the `experimental` NPM tag and use a unique versioning scheme to avoid conflicts with official releases.

### Process

#### 1. Create an Experimental Branch

To ensure security and consistency, experimental releases must be triggered from branches following the `experimental/<feature-name>` pattern.

Use the **[Create Experimental Branch](https://github.com/microsoft/fluentui/actions/workflows/create-experimental-branch.yml)** GitHub Action:

- Go to the "Actions" tab in GitHub.
- Select the "Create Experimental Branch" workflow.
- Click "Run workflow".
- Enter the `feature-name` (must be in `dash-case`).

This will create a new branch from the current `master` tip.

#### 2. Trigger the Release Pipeline

Once you have pushed your changes to the experimental branch:

- Go to Azure DevOps and find the `v9_experimental` pipeline.
- Manually trigger a build, selecting your `experimental/<feature-name>` branch.
- Set the `dryRun` parameter to `false` if you want to publish to NPM.

### Versioning Scheme

Experimental versions follow this pattern:
`<base-version>-experimental.<feature-name>.<date>-<hash>`

- `<base-version>`: Current version of `@fluentui/react-components` (e.g., `9.72.9`).
- `<feature-name>`: The name provided when creating the branch.
- `<date>`: YYYYMMDD format.
- `<hash>`: Short commit hash.

Example: `9.72.9-experimental.my-feature.20240520-a1b2c3d`

### Usage

To install an experimental version, use the `experimental` tag or the specific version:

```bash
yarn add @fluentui/react-components@experimental
# OR
yarn add @fluentui/react-components@9.72.9-experimental.my-feature.20240520-a1b2c3d
```

> [!CAUTION]
> Experimental releases are not intended for production use.
>
> - They may/will contain BREAKING CHANGES and may be deleted from NPM or superseded at any time.
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,51 @@ describe('version-string-replace generator', () => {
expect(packageJson.beachball?.disallowedChangeTypes).toBeUndefined();
});

describe(`--version`, () => {
async function setupForVersionArgument(scope: 'all' | string) {
tree = setupDummyPackage(tree, {
name: 'react-components',
version: '9.10.20',
dependencies: {
'@proj/react-button': '^9.1.1',
},
projectConfiguration: { tags: ['vNext', 'platform:web'], sourceRoot: 'packages/react-components/src' },
});

tree = setupDummyPackage(tree, {
name: 'react-button',
version: '9.1.1',
projectConfiguration: { tags: ['vNext', 'platform:web'], sourceRoot: 'packages/react-button/src' },
});

if (scope === 'all') {
await generator(tree, { all: true, version: '9.0.0-experimental.foo.20220101-abc' });
} else {
await generator(tree, { name: 'react-button', version: '9.0.0-experimental.foo.20220101-abc' });
}

const suitePackageJson = readJson(tree, 'packages/react-components/package.json');
const reactButtonPackageJson = readJson(tree, 'packages/react-button/package.json');

return { suitePackageJson, reactButtonPackageJson };
}

it('should bump to explicit version', async () => {
const { reactButtonPackageJson, suitePackageJson } = await setupForVersionArgument('react-button');

expect(suitePackageJson.version).toBe('9.10.20');
expect(suitePackageJson.dependencies['@proj/react-button']).toBe('9.0.0-experimental.foo.20220101-abc');
expect(reactButtonPackageJson.version).toBe('9.0.0-experimental.foo.20220101-abc');
});
it('should bump all packages to <version>', async () => {
const { reactButtonPackageJson, suitePackageJson } = await setupForVersionArgument('all');

expect(suitePackageJson.version).toBe('9.0.0-experimental.foo.20220101-abc');
expect(suitePackageJson.dependencies['@proj/react-button']).toBe('9.0.0-experimental.foo.20220101-abc');
expect(reactButtonPackageJson.version).toBe('9.0.0-experimental.foo.20220101-abc');
});
});

describe('--all', () => {
beforeEach(() => {
tree = setupDummyPackage(tree, {
Expand Down
38 changes: 27 additions & 11 deletions tools/workspace-plugin/src/generators/version-bump/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ function runMigrationOnProject(tree: Tree, schema: ValidatedSchema, userLog: Use
}

updateJson(tree, packageJsonPath, (packageJson: PackageJson) => {
nextVersion = bumpVersion(packageJson, schema.bumpType, schema.prereleaseTag);
nextVersion = bumpVersion(packageJson, schema.bumpType, schema.prereleaseTag, schema.version);

// nightly releases should bypass beachball disallowed changetypes
if (
Expand Down Expand Up @@ -124,7 +124,7 @@ const bumpDependency = (options: {
const { dependencies, dependencyName, version, bumpType } = options;

const hasCaret = dependencies[dependencyName].includes('^');
const versionToBump = hasCaret && bumpType !== 'nightly' ? `^${version}` : version;
const versionToBump = hasCaret && bumpType && bumpType !== 'nightly' ? `^${version}` : version;
dependencies[dependencyName] = versionToBump;
};

Expand All @@ -140,6 +140,7 @@ function runBatchMigration(tree: Tree, schema: ValidatedSchema, userLog: UserLog
all: false,
bumpType: schema.bumpType,
prereleaseTag: schema.prereleaseTag,
version: schema.version,
exclude: schema.exclude,
},
userLog,
Expand All @@ -148,7 +149,16 @@ function runBatchMigration(tree: Tree, schema: ValidatedSchema, userLog: UserLog
});
}

function bumpVersion(packageJson: PackageJson, bumpType: ValidatedSchema['bumpType'], prereleaseTag?: string) {
function bumpVersion(
packageJson: PackageJson,
bumpType: ValidatedSchema['bumpType'],
prereleaseTag?: string,
version?: string,
) {
if (version) {
return version;
}

if (bumpType === 'nightly') {
// initialize the prerelease tag so that prerelease doesn't bump to 0.0.1
packageJson.version = '0.0.0-empty';
Expand All @@ -161,7 +171,7 @@ function bumpVersion(packageJson: PackageJson, bumpType: ValidatedSchema['bumpTy

if (bumpType === 'nightly') {
semverVersion.inc('prerelease', prereleaseTag);
} else {
} else if (bumpType) {
semverVersion.inc(bumpType, prereleaseTag);
}

Expand Down Expand Up @@ -190,9 +200,9 @@ export const validBumpTypes = [
'nightly',
] as const;

interface ValidatedSchema extends Required<Omit<VersionBumpGeneratorSchema, 'exclude'>> {
bumpType: (typeof validBumpTypes)[number];

interface ValidatedSchema extends Required<Omit<VersionBumpGeneratorSchema, 'exclude' | 'version' | 'bumpType'>> {
bumpType?: (typeof validBumpTypes)[number];
version?: string;
exclude: string[];
}

Expand All @@ -201,18 +211,24 @@ function validateSchema(tree: Tree, schema: VersionBumpGeneratorSchema) {
throw new Error('--name and --all are mutually exclusive');
}

const validateBumpType = (type: string): type is ValidatedSchema['bumpType'] => {
return validBumpTypes.includes(type as ValidatedSchema['bumpType']);
if (!schema.version && !schema.bumpType) {
throw new Error('Either --bumpType or --version must be provided');
}

const validateBumpType = (type?: string): type is ValidatedSchema['bumpType'] => {
return !!type && validBumpTypes.includes(type as NonNullable<ValidatedSchema['bumpType']>);
};
if (!validateBumpType(schema.bumpType)) {

if (schema.bumpType && !validateBumpType(schema.bumpType)) {
throw new Error(`${schema.bumpType} is not a valid bumpType, please use one of ${validBumpTypes}`);
}

const validatedSchema: ValidatedSchema = {
bumpType: schema.bumpType,
bumpType: schema.bumpType as ValidatedSchema['bumpType'],
prereleaseTag: schema.prereleaseTag ?? '',
all: schema.all ?? false,
name: schema.name ?? '',
version: schema.version,
exclude: schema.exclude ? schema.exclude.split(',') : [],
};

Expand Down
Loading