diff --git a/docs/docs/cmd/spfx/project/project-github-workflow-add.mdx b/docs/docs/cmd/spfx/project/project-github-workflow-add.mdx
new file mode 100644
index 00000000000..5906c64a716
--- /dev/null
+++ b/docs/docs/cmd/spfx/project/project-github-workflow-add.mdx
@@ -0,0 +1,94 @@
+import Global from '/docs/cmd/_global.mdx';
+
+# spfx project github workflow add
+
+Adds a GitHub workflow for a SharePoint Framework project
+
+## Usage
+
+```sh
+m365 spfx project github workflow add [options]
+```
+
+## Options
+
+```md definition-list
+`-n, --name [name]`
+: Name of the workflow that will be created. If none is specified a default name will be used 'Deploy Solution ${name of sppkg file}'
+
+`-b, --branchName [branchName]`
+: Specify the branch name which should trigger the workflow on push. If none is specified a default will be used which is 'main'
+
+`-m, --manuallyTrigger`
+: When specified a manual trigger option will be added to the workflow: `workflow_dispatch`
+
+`-l, --loginMethod [loginMethod]`
+: Specify the login method used for the login action. Possible options are: `user`, `application`. Default `application`'
+
+`-s, --scope [scope]`
+: Scope of the app catalog: `tenant`, `sitecollection`. Default is `tenant`
+
+`-u, --siteUrl [siteUrl]`
+: The URL of the site collection where the solution package will be added. Required if scope is set to `sitecollection`
+
+`--skipFeatureDeployment`
+: When specified and the app supports tenant-wide deployment, deploy it to the whole tenant
+
+`--overwrite`
+: When specified the workflow will overwrite the existing .sppkg if it is already deployed in the app catalog.
+```
+
+
+
+## Remarks
+
+The `spfx project github workflow add` will create a workflow .yml file in the `.github/workflows` directory in your project. If such directory does not exist the command will automatically create it.
+
+For the `application` login method the command does not register AAD application nor create the required certificate. In order for you to proceed you will need to first obtain (create) a self-signed certificate and register a new AAD application with certificate authentication. After that in GitHub repo settings, you will need to create the following secrets:
+
+- `APP_ID` - client id of the registered AAD application
+- `CERTIFICATE_ENCODED` - application's encoded certificate
+- `CERTIFICATE_PASSWORD` - certificate password. This applies only if the certificate is encoded which is the recommended approach
+
+This use case is perfect in a production context as it does not create any dependencies on an account
+
+For the `user` login method you will need to create the following secrets in GitHub repo settings:
+
+- `ADMIN_USERNAME` - username
+- `ADMIN_PASSWORD` - password
+
+This method is perfect to test your workflow, in a dev context, for personal usage. It will not work for accounts with MFA.
+
+:::info
+
+Run this command in the SPFx solution folder.
+
+:::
+
+## Examples
+
+Adds a GitHub workflow for a SharePoint Framework project with `application` login method triggered on push to main
+
+```sh
+m365 spfx project github workflow add
+```
+
+Adds a GitHub workflow for a SharePoint Framework project with `user` login method triggered on push to main and when manually triggered.
+
+```sh
+m365 spfx project github workflow add --manuallyTrigger --loginMethod "user"
+```
+
+Adds a GitHub workflow for a SharePoint Framework project with deployment to a site collection app catalog
+
+```sh
+m365 spfx project github workflow add --scope "sitecollection" --siteUrl "https://some.sharepoint.com/sites/someSite"
+```
+
+## Response
+
+The command won't return a response on success.
+
+## More information
+
+- Automate your CI/CD workflow using CLI for Microsoft 365 GitHub Actions: [https://pnp.github.io/cli-microsoft365/user-guide/github-actions](https://pnp.github.io/cli-microsoft365/user-guide/github-actions)
diff --git a/docs/src/config/sidebars.js b/docs/src/config/sidebars.js
index d1dfada4b4d..fe12a2b0b96 100644
--- a/docs/src/config/sidebars.js
+++ b/docs/src/config/sidebars.js
@@ -1728,6 +1728,11 @@ const sidebars = {
label: 'project externalize',
id: 'cmd/spfx/project/project-externalize'
},
+ {
+ type: 'doc',
+ label: 'project github workflow add',
+ id: 'cmd/spfx/project/project-github-workflow-add'
+ },
{
type: 'doc',
label: 'project permissions grant',
diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json
index 9dc3b368a5d..09fe9067fb9 100644
--- a/npm-shrinkwrap.json
+++ b/npm-shrinkwrap.json
@@ -33,7 +33,8 @@
"strip-json-comments": "^3.1.1",
"typescript": "^4.9.5",
"update-notifier": "^5.1.0",
- "uuid": "^9.0.0"
+ "uuid": "^9.0.0",
+ "yaml": "^2.3.1"
},
"bin": {
"m365": "dist/index.js",
@@ -6037,6 +6038,14 @@
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
},
+ "node_modules/yaml": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz",
+ "integrity": "sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/yargs": {
"version": "16.2.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
@@ -10458,6 +10467,11 @@
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
},
+ "yaml": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz",
+ "integrity": "sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ=="
+ },
"yargs": {
"version": "16.2.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
diff --git a/package.json b/package.json
index c7831a8bf5b..0932d93a32f 100644
--- a/package.json
+++ b/package.json
@@ -251,7 +251,8 @@
"strip-json-comments": "^3.1.1",
"typescript": "^4.9.5",
"update-notifier": "^5.1.0",
- "uuid": "^9.0.0"
+ "uuid": "^9.0.0",
+ "yaml": "^2.3.1"
},
"devDependencies": {
"@microsoft/microsoft-graph-types": "^2.38.0",
diff --git a/scripts/copy-files.js b/scripts/copy-files.js
index a653dab6a72..e88129ae242 100644
--- a/scripts/copy-files.js
+++ b/scripts/copy-files.js
@@ -51,4 +51,4 @@ const spfxPackageGenerateCmdDir = 'dist/m365/spfx/commands/package/package-gener
const spfxPackageGenerateAssetsDir = 'dist/m365/spfx/commands/package/package-generate/assets';
mkdirNotExistsSync(spfxPackageGenerateCmdDir);
mkdirNotExistsSync(spfxPackageGenerateAssetsDir);
-getFilePaths(spfxPackageGenerateAssetsSourceDir).forEach(file => copyFile(file, spfxPackageGenerateAssetsSourceDir, spfxPackageGenerateAssetsDir));
\ No newline at end of file
+getFilePaths(spfxPackageGenerateAssetsSourceDir).forEach(file => copyFile(file, spfxPackageGenerateAssetsSourceDir, spfxPackageGenerateAssetsDir));
diff --git a/src/m365/spfx/commands.ts b/src/m365/spfx/commands.ts
index d8b54ae104f..04c864c549d 100644
--- a/src/m365/spfx/commands.ts
+++ b/src/m365/spfx/commands.ts
@@ -5,6 +5,7 @@ export default {
PACKAGE_GENERATE: `${prefix} package generate`,
PROJECT_DOCTOR: `${prefix} project doctor`,
PROJECT_EXTERNALIZE: `${prefix} project externalize`,
+ PROJECT_GITHUB_WORKFLOW_ADD: `${prefix} project github workflow add`,
PROJECT_PERMISSIONS_GRANT: `${prefix} project permissions grant`,
PROJECT_RENAME: `${prefix} project rename`,
PROJECT_UPGRADE: `${prefix} project upgrade`
diff --git a/src/m365/spfx/commands/project/DeployWorkflow.ts b/src/m365/spfx/commands/project/DeployWorkflow.ts
new file mode 100644
index 00000000000..ab91878d13d
--- /dev/null
+++ b/src/m365/spfx/commands/project/DeployWorkflow.ts
@@ -0,0 +1,56 @@
+import { gitHubWorkflow } from "./project-github-workflow-model";
+
+export const workflow: gitHubWorkflow = {
+ name: "Deploy Solution {{ name }}",
+ on: {
+ push: {
+ branches: [
+ "main"
+ ]
+ }
+ },
+ jobs: {
+ "build-and-deploy": {
+ "runs-on": "ubuntu-latest",
+ steps: [
+ {
+ name: "Checkout",
+ uses: "actions/checkout@v3.5.3"
+ },
+ {
+ name: "Use Node.js 16.x",
+ uses: "actions/setup-node@v3.7.0",
+ with: {
+ "node-version": "16.x"
+ }
+ },
+ {
+ name: "Run npm ci",
+ run: "npm ci"
+ },
+ {
+ name: "Bundle & Package",
+ run: "gulp bundle --ship\ngulp package-solution --ship\n"
+ },
+ {
+ name: "CLI for Microsoft 365 Login",
+ uses: "pnp/action-cli-login@v2.2.2",
+ with: {
+ "CERTIFICATE_ENCODED": "${{ secrets.CERTIFICATE_ENCODED }}",
+ "CERTIFICATE_PASSWORD": "${{ secrets.CERTIFICATE_PASSWORD }}",
+ "APP_ID": "${{ secrets.APP_ID }}"
+ }
+ },
+ {
+ name: "CLI for Microsoft 365 Deploy App",
+ uses: "pnp/action-cli-deploy@v3.0.1",
+ with: {
+ "APP_FILE_PATH": "sharepoint/solution/{{ solutionName }}.sppkg",
+ "SKIP_FEATURE_DEPLOYMENT": false,
+ "OVERWRITE": false
+ }
+ }
+ ]
+ }
+ }
+};
\ No newline at end of file
diff --git a/src/m365/spfx/commands/project/project-github-workflow-add.spec.ts b/src/m365/spfx/commands/project/project-github-workflow-add.spec.ts
new file mode 100644
index 00000000000..71c07126d5d
--- /dev/null
+++ b/src/m365/spfx/commands/project/project-github-workflow-add.spec.ts
@@ -0,0 +1,185 @@
+import * as assert from 'assert';
+import * as fs from 'fs';
+import Command, { CommandError } from '../../../../Command';
+import { Cli } from '../../../../cli/Cli';
+import { CommandInfo } from '../../../../cli/CommandInfo';
+import { Logger } from '../../../../cli/Logger';
+import { telemetry } from '../../../../telemetry';
+import { pid } from '../../../../utils/pid';
+import { session } from '../../../../utils/session';
+import { sinonUtil } from '../../../../utils/sinonUtil';
+import commands from '../../commands';
+import sinon = require('sinon');
+import path = require('path');
+const command: Command = require('./project-github-workflow-add');
+
+describe(commands.PROJECT_GITHUB_WORKFLOW_ADD, () => {
+ let log: any[];
+ let logger: Logger;
+ let commandInfo: CommandInfo;
+ const projectPath: string = 'test-project';
+
+ before(() => {
+ sinon.stub(telemetry, 'trackEvent').returns();
+ sinon.stub(pid, 'getProcessName').callsFake(() => '');
+ sinon.stub(session, 'getId').callsFake(() => '');
+ commandInfo = Cli.getCommandInfo(command);
+ });
+
+ beforeEach(() => {
+ log = [];
+ logger = {
+ log: (msg: string) => {
+ log.push(msg);
+ },
+ logRaw: (msg: string) => {
+ log.push(msg);
+ },
+ logToStderr: (msg: string) => {
+ log.push(msg);
+ }
+ };
+ });
+
+ afterEach(() => {
+ sinonUtil.restore([
+ (command as any).getProjectRoot,
+ fs.existsSync,
+ fs.readFileSync,
+ fs.writeFileSync
+ ]);
+ });
+
+ after(() => {
+ sinon.restore();
+ });
+
+ it('has correct name', () => {
+ assert.strictEqual(command.name, commands.PROJECT_GITHUB_WORKFLOW_ADD);
+ });
+
+ it('has a description', () => {
+ assert.notStrictEqual(command.description, null);
+ });
+
+ it('fails validation if loginMethod is not valid type', async () => {
+ const actual = await command.validate({ options: { loginMethod: 'abc' } }, commandInfo);
+ assert.notStrictEqual(actual, true);
+ });
+
+ it('fails validation if scope is not valid type', async () => {
+ const actual = await command.validate({ options: { scope: 'abc' } }, commandInfo);
+ assert.notStrictEqual(actual, true);
+ });
+
+ it('fails validation if scope is sitecollection but the siteUrl was not defined', async () => {
+ const actual = await command.validate({ options: { scope: 'sitecollection' } }, commandInfo);
+ assert.notStrictEqual(actual, true);
+ });
+
+ it('fails validation if siteUrl is not valid', async () => {
+ const actual = await command.validate({ options: { scope: 'sitecollection', siteUrl: 'abc' } }, commandInfo);
+ assert.notStrictEqual(actual, true);
+ });
+
+ it('passes validation if all required properties are provided', async () => {
+ const actual = await command.validate({ options: { scope: 'sitecollection', siteUrl: 'https://contoso.sharepoint.com/sites/project' } }, commandInfo);
+ assert.strictEqual(actual, true);
+ });
+
+ it('shows error if the project path couldn\'t be determined', async () => {
+ sinon.stub(command as any, 'getProjectRoot').returns(null);
+
+ await assert.rejects(command.action(logger, { options: {} } as any),
+ new CommandError(`Couldn't find project root folder`, 1));
+ });
+
+ it('creates a default workflow (debug)', async () => {
+ sinon.stub(command as any, 'getProjectRoot').returns(path.join(process.cwd(), projectPath));
+
+ sinon.stub(fs, 'existsSync').callsFake((fakePath) => {
+ if (fakePath.toString().endsWith('.github')) {
+ return true;
+ }
+ else if (fakePath.toString().endsWith('workflows')) {
+ return true;
+ }
+
+ return false;
+ });
+
+ sinon.stub(fs, 'readFileSync').callsFake((path, options) => {
+ if (path.toString().endsWith('package.json') && options === 'utf-8') {
+ return '{"name": "test"}';
+ }
+
+ return '';
+ });
+
+ const writeFileSyncStub: sinon.SinonStub = sinon.stub(fs, 'writeFileSync').resolves({});
+
+ await command.action(logger, { options: { debug: true } } as any);
+ assert(writeFileSyncStub.calledWith(path.join(process.cwd(), projectPath, '/.github', 'workflows', 'deploy-spfx-solution.yml')), 'workflow file not created');
+ });
+
+ it('creates a default workflow with specifying options', async () => {
+ sinon.stub(command as any, 'getProjectRoot').returns(path.join(process.cwd(), projectPath));
+
+ sinon.stub(fs, 'existsSync').callsFake((fakePath) => {
+ if (fakePath.toString().endsWith('workflows')) {
+ return true;
+ }
+
+ return false;
+ });
+
+ sinon.stub(fs, 'readFileSync').callsFake((path, options) => {
+ if (path.toString().endsWith('package.json') && options === 'utf-8') {
+ return '{"name": "test"}';
+ }
+
+ return '';
+ });
+
+ sinon.stub(fs, 'mkdirSync').callsFake((path, options) => {
+ if (path.toString().endsWith('.github') && (options as fs.MakeDirectoryOptions).recursive) {
+ return `${projectPath}/.github`;
+ }
+
+ return '';
+ });
+
+ const writeFileSyncStub: sinon.SinonStub = sinon.stub(fs, 'writeFileSync').resolves({});
+
+ await command.action(logger, { options: { name: 'test', branchName: 'dev', manuallyTrigger: true, skipFeatureDeployment: true, overwrite: true, loginMethod: 'user', scope: 'sitecollection' } } as any);
+ assert(writeFileSyncStub.calledWith(path.join(process.cwd(), projectPath, '/.github', 'workflows', 'deploy-spfx-solution.yml')), 'workflow file not created');
+ });
+
+ it('handles unexpected error', async () => {
+ sinon.stub(command as any, 'getProjectRoot').returns(path.join(process.cwd(), projectPath));
+
+ sinon.stub(fs, 'readFileSync').callsFake((path, options) => {
+ if (path.toString().endsWith('package.json') && options === 'utf-8') {
+ return '{"name": "test"}';
+ }
+
+ return '';
+ });
+
+ sinon.stub(fs, 'existsSync').callsFake((fakePath) => {
+ if (fakePath.toString().endsWith('.github')) {
+ return true;
+ }
+ else if (fakePath.toString().endsWith('workflows')) {
+ return true;
+ }
+
+ return false;
+ });
+
+ sinon.stub(fs, 'writeFileSync').callsFake(() => { throw 'error'; });
+
+ await assert.rejects(command.action(logger, { options: {} } as any),
+ new CommandError('error'));
+ });
+});
\ No newline at end of file
diff --git a/src/m365/spfx/commands/project/project-github-workflow-add.ts b/src/m365/spfx/commands/project/project-github-workflow-add.ts
new file mode 100644
index 00000000000..2c382c0b4d8
--- /dev/null
+++ b/src/m365/spfx/commands/project/project-github-workflow-add.ts
@@ -0,0 +1,196 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import * as yaml from 'yaml';
+import { CommandArgs, CommandError } from '../../../../Command';
+import GlobalOptions from '../../../../GlobalOptions';
+import { Logger } from '../../../../cli/Logger';
+import { fsUtil } from '../../../../utils/fsUtil';
+import { validation } from '../../../../utils/validation';
+import commands from '../../commands';
+import { workflow } from './DeployWorkflow';
+import { BaseProjectCommand } from './base-project-command';
+import { gitHubWorkflow, gitHubWorkflowStep } from './project-github-workflow-model';
+
+class SpfxProjectGithubWorkflowAddCommand extends BaseProjectCommand {
+ private static loginMethod: string[] = ['application', 'user'];
+ private static scope: string[] = ['tenant', 'sitecollection'];
+ public static ERROR_NO_PROJECT_ROOT_FOLDER: number = 1;
+
+ public get name(): string {
+ return commands.PROJECT_GITHUB_WORKFLOW_ADD;
+ }
+
+ public get description(): string {
+ return 'Adds a GitHub workflow for a SharePoint Framework project.';
+ }
+
+ constructor() {
+ super();
+
+ this.#initTelemetry();
+ this.#initOptions();
+ this.#initValidators();
+ }
+
+ #initTelemetry(): void {
+ this.telemetry.push((args: CommandArgs) => {
+ Object.assign(this.telemetryProperties, {
+ name: typeof args.options.name !== 'undefined',
+ branchName: typeof args.options.branchName !== 'undefined',
+ manuallyTrigger: !!args.options.manuallyTrigger,
+ loginMethod: typeof args.options.loginMethod !== 'undefined',
+ scope: typeof args.options.scope !== 'undefined',
+ skipFeatureDeployment: !!args.options.skipFeatureDeployment,
+ overwrite: !!args.options.overwrite
+ });
+ });
+ }
+
+ #initOptions(): void {
+ this.options.unshift(
+ {
+ option: '-n, --name [name]'
+ },
+ {
+ option: '-b, --branchName [branchName]'
+ },
+ {
+ option: '-m, --manuallyTrigger'
+ },
+ {
+ option: '-l, --loginMethod [loginMethod]',
+ autocomplete: SpfxProjectGithubWorkflowAddCommand.loginMethod
+ },
+ {
+ option: '-s, --scope [scope]',
+ autocomplete: SpfxProjectGithubWorkflowAddCommand.scope
+ },
+ {
+ option: '-u, --siteUrl [siteUrl]'
+ },
+ {
+ option: '--skipFeatureDeployment'
+ },
+ {
+ option: '--overwrite'
+ }
+ );
+ }
+
+ #initValidators(): void {
+ this.validators.push(
+ async (args: CommandArgs) => {
+ if (args.options.scope && args.options.scope === 'sitecollection') {
+ if (!args.options.siteUrl) {
+ return `siteUrl option has to be defined when scope set to ${args.options.scope}`;
+ }
+
+ const isValidSharePointUrl: boolean | string = validation.isValidSharePointUrl(args.options.siteUrl);
+ if (isValidSharePointUrl !== true) {
+ return isValidSharePointUrl;
+ }
+ }
+
+ if (args.options.loginMethod && SpfxProjectGithubWorkflowAddCommand.loginMethod.indexOf(args.options.loginMethod) < 0) {
+ return `${args.options.loginMethod} is not a valid login method. Allowed values are ${SpfxProjectGithubWorkflowAddCommand.loginMethod.join(', ')}`;
+ }
+
+ if (args.options.scope && SpfxProjectGithubWorkflowAddCommand.scope.indexOf(args.options.scope) < 0) {
+ return `${args.options.scope} is not a valid scope. Allowed values are ${SpfxProjectGithubWorkflowAddCommand.scope.join(', ')}`;
+ }
+
+ return true;
+ }
+ );
+ }
+
+ public async commandAction(logger: Logger, args: CommandArgs): Promise {
+ this.projectRootPath = this.getProjectRoot(process.cwd());
+ if (this.projectRootPath === null) {
+ throw new CommandError(`Couldn't find project root folder`, SpfxProjectGithubWorkflowAddCommand.ERROR_NO_PROJECT_ROOT_FOLDER);
+ }
+
+ const solutionPackageJsonFile: string = path.join(this.projectRootPath, 'package.json');
+ const packageJson: string = fs.readFileSync(solutionPackageJsonFile, 'utf-8');
+ const solutionName = JSON.parse(packageJson).name;
+
+ if (this.debug) {
+ logger.logToStderr(`Adding GitHub workflow in the current SPFx project`);
+ }
+
+ try {
+ this.updateWorkflow(solutionName, workflow, args.options);
+ this.saveWorkflow(workflow);
+ }
+ catch (error: any) {
+ throw new CommandError(error);
+ }
+ }
+
+ private saveWorkflow(workflow: gitHubWorkflow): void {
+ const githubPath: string = path.join(this.projectRootPath as string, '.github');
+ fsUtil.ensureDirectory(githubPath);
+
+ const workflowPath: string = path.join(githubPath, 'workflows');
+ fsUtil.ensureDirectory(workflowPath);
+
+ const workflowFile: string = path.join(workflowPath, 'deploy-spfx-solution.yml');
+ fs.writeFileSync(path.resolve(workflowFile), yaml.stringify(workflow), 'utf-8');
+ }
+
+ private updateWorkflow(solutionName: string, workflow: gitHubWorkflow, options: GlobalOptions): void {
+ workflow.name = workflow.name.replace('{{ name }}', options.name ?? solutionName);
+
+ if (options.branchName) {
+ workflow.on.push.branches[0] = options.branchName;
+ }
+
+ if (options.manuallyTrigger) {
+ // eslint-disable-next-line camelcase
+ workflow.on.workflow_dispatch = null;
+ }
+
+ if (options.skipFeatureDeployment) {
+ this.getDeployAction(workflow).with!.SKIP_FEATURE_DEPLOYMENT = true;
+ }
+
+ if (options.overwrite) {
+ this.getDeployAction(workflow).with!.OVERWRITE = true;
+ }
+
+ if (options.loginMethod === 'user') {
+ const loginAction = this.getLoginAction(workflow);
+ loginAction.with = {
+ ADMIN_USERNAME: '${{ secrets.ADMIN_USERNAME }}',
+ ADMIN_PASSWORD: '${{ secrets.ADMIN_PASSWORD }}'
+ };
+ }
+
+ if (options.scope === 'sitecollection') {
+ const deployAction = this.getDeployAction(workflow);
+ deployAction.with!.SCOPE = 'sitecollection';
+ deployAction.with!.SITE_COLLECTION_URL = options.siteUrl;
+ }
+
+ if (solutionName) {
+ const deployAction = this.getDeployAction(workflow);
+ deployAction.with!.APP_FILE_PATH = deployAction.with!.APP_FILE_PATH!.replace('{{ solutionName }}', solutionName);
+ }
+ }
+
+ private getLoginAction(workflow: gitHubWorkflow): gitHubWorkflowStep {
+ const steps = this.getWorkFlowSteps(workflow);
+ return steps.find(step => step.uses && step.uses.indexOf('action-cli-login') >= 0)!;
+ }
+
+ private getDeployAction(workflow: gitHubWorkflow): gitHubWorkflowStep {
+ const steps = this.getWorkFlowSteps(workflow);
+ return steps.find(step => step.uses && step.uses.indexOf('action-cli-deploy') >= 0)!;
+ }
+
+ private getWorkFlowSteps(workflow: gitHubWorkflow): gitHubWorkflowStep[] {
+ return workflow.jobs['build-and-deploy'].steps;
+ }
+}
+
+module.exports = new SpfxProjectGithubWorkflowAddCommand();
\ No newline at end of file
diff --git a/src/m365/spfx/commands/project/project-github-workflow-model.ts b/src/m365/spfx/commands/project/project-github-workflow-model.ts
new file mode 100644
index 00000000000..b47b1399507
--- /dev/null
+++ b/src/m365/spfx/commands/project/project-github-workflow-model.ts
@@ -0,0 +1,34 @@
+export interface gitHubWorkflow {
+ name: string,
+ on: {
+ push: {
+ branches: string[];
+ },
+ workflow_dispatch?: any
+ },
+ jobs: {
+ "build-and-deploy": {
+ "runs-on": string,
+ steps: gitHubWorkflowStep[]
+ }
+ }
+}
+
+export interface gitHubWorkflowStep {
+ name?: string,
+ run?: string,
+ uses?: string,
+ with?: {
+ "node-version"?: string,
+ CERTIFICATE_ENCODED?: string,
+ CERTIFICATE_PASSWORD?: string,
+ ADMIN_USERNAME?: string,
+ ADMIN_PASSWORD?: string,
+ APP_ID?: string,
+ APP_FILE_PATH?: string,
+ SKIP_FEATURE_DEPLOYMENT?: boolean,
+ OVERWRITE?: boolean,
+ SCOPE?: string,
+ SITE_COLLECTION_URL?: string
+ }
+}
\ No newline at end of file
diff --git a/src/utils/fsUtil.ts b/src/utils/fsUtil.ts
index 9f18f5f56ab..96119e02f74 100644
--- a/src/utils/fsUtil.ts
+++ b/src/utils/fsUtil.ts
@@ -110,5 +110,11 @@ export const fsUtil = {
getRemoveCommand(command: string, shell: string): string {
return (removeFileCommands as any)[shell][command];
+ },
+
+ ensureDirectory(path: string): void {
+ if (!fs.existsSync(path)) {
+ fs.mkdirSync(path, { recursive: true });
+ }
}
};
\ No newline at end of file