ソースを参照

show GitHub Actions settings after Source settings

Anukul Sangwan 4 年 前
コミット
7f43c9d3a6

+ 10 - 9
cli/cmd/api/github_action.go

@@ -11,14 +11,15 @@ import (
 // CreateGithubActionRequest represents the accepted fields for creating
 // a Github action
 type CreateGithubActionRequest struct {
-	ReleaseID      uint   `json:"release_id" form:"required"`
-	GitRepo        string `json:"git_repo" form:"required"`
-	GitBranch      string `json:"git_branch"`
-	ImageRepoURI   string `json:"image_repo_uri" form:"required"`
-	DockerfilePath string `json:"dockerfile_path"`
-	FolderPath     string `json:"folder_path"`
-	GitRepoID      uint   `json:"git_repo_id" form:"required"`
-	RegistryID     uint   `json:"registry_id"`
+	ReleaseID            uint   `json:"release_id" form:"required"`
+	GitRepo              string `json:"git_repo" form:"required"`
+	GitBranch            string `json:"git_branch"`
+	ImageRepoURI         string `json:"image_repo_uri" form:"required"`
+	DockerfilePath       string `json:"dockerfile_path"`
+	FolderPath           string `json:"folder_path"`
+	GitRepoID            uint   `json:"git_repo_id" form:"required"`
+	RegistryID           uint   `json:"registry_id"`
+	ShouldCreateWorkflow bool   `json:"should_create_workflow"`
 }
 
 // CreateGithubAction creates a Github action with basic authentication
@@ -37,7 +38,7 @@ func (c *Client) CreateGithubAction(
 	req, err := http.NewRequest(
 		"POST",
 		fmt.Sprintf(
-			"%s/projects/%d/ci/actions?cluster_id=%d&name=%s&namespace=%s",
+			"%s/projects/%d/ci/actions/create?cluster_id=%d&name=%s&namespace=%s",
 			c.BaseURL,
 			projectID,
 			clusterID,

+ 0 - 10
dashboard/src/components/repo-selector/ActionDetails.tsx

@@ -142,16 +142,6 @@ export default class ActionDetails extends Component<PropsType, StateType> {
           />
         )}
         {this.renderRegistrySection()}
-        <SubtitleAlt>
-          <Bold>Note:</Bold> To auto-deploy each time you push changes, Porter
-          will write Github Secrets and a GitHub Actions file to your repo.
-          <Highlight
-            href="https://docs.getporter.dev/docs/auto-deploy-requirements#cicd-with-github-actions"
-            target="_blank"
-          >
-            Learn more
-          </Highlight>
-        </SubtitleAlt>
         <Br />
 
         <Flex>

+ 43 - 15
dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx

@@ -10,10 +10,16 @@ import { pushFiltered } from "shared/routing";
 
 import { hardcodedNames } from "shared/hardcodedNameDict";
 import SourcePage from "./SourcePage";
+import WorkflowPage from "./WorkflowPage";
 import SettingsPage from "./SettingsPage";
 import TitleSection from "components/TitleSection";
 
-import { ActionConfigType, PorterTemplate, StorageType } from "shared/types";
+import {
+  ActionConfigType,
+  FullActionConfigType,
+  PorterTemplate,
+  StorageType,
+} from "shared/types";
 
 type PropsType = RouteComponentProps & {
   currentTab?: string;
@@ -52,9 +58,17 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
   const [procfilePath, setProcfilePath] = useState(null);
   const [folderPath, setFolderPath] = useState(null);
   const [selectedRegistry, setSelectedRegistry] = useState(null);
+  const [shouldCreateWorkflow, setShouldCreateWorkflow] = useState(true);
 
-  const getGHActionConfig = (chartName: string) => {
-    let imageRepoUri = `${selectedRegistry.url}/${chartName}-${selectedNamespace}`;
+  const setRandomNameIfEmpty = () => {
+    if (!templateName) {
+      const randomTemplateName = randomWords({ exactly: 3, join: "-" });
+      setTemplateName(randomTemplateName);
+    }
+  };
+
+  const getFullActionConfig = (): FullActionConfigType => {
+    let imageRepoUri = `${selectedRegistry.url}/${templateName}-${selectedNamespace}`;
 
     // DockerHub registry integration is per repo
     if (selectedRegistry.service === "dockerhub") {
@@ -63,18 +77,18 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
 
     return {
       git_repo: actionConfig.git_repo,
-      git_branch: branch,
+      branch: branch,
       registry_id: selectedRegistry.id,
       dockerfile_path: dockerfilePath,
       folder_path: folderPath,
       image_repo_uri: imageRepoUri,
       git_repo_id: actionConfig.git_repo_id,
+      should_create_workflow: shouldCreateWorkflow,
     };
   };
 
   const handleSubmitAddon = (wildcard?: any) => {
     let { currentCluster, currentProject, setCurrentError } = context;
-    let name = templateName || randomWords({ exactly: 3, join: "-" });
     setSaveValuesStatus("loading");
 
     let values = {};
@@ -90,7 +104,7 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
           storage: StorageType.Secret,
           formValues: values,
           namespace: selectedNamespace,
-          name,
+          name: templateName,
         },
         {
           id: currentProject.id,
@@ -137,7 +151,6 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
 
   const handleSubmit = async (rawValues: any) => {
     let { currentCluster, currentProject, setCurrentError } = context;
-    let name = templateName || randomWords({ exactly: 3, join: "-" });
     setSaveValuesStatus("loading");
 
     // Convert dotted keys to nested objects
@@ -203,7 +216,7 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
             .createSubdomain(
               "<token>",
               {
-                release_name: name,
+                release_name: templateName,
               },
               {
                 id: currentProject.id,
@@ -227,9 +240,9 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
       }
     }
 
-    let githubActionConfig = null;
+    let githubActionConfig: FullActionConfigType = null;
     if (sourceType == "repo") {
-      githubActionConfig = getGHActionConfig(name);
+      githubActionConfig = getFullActionConfig();
     }
 
     api
@@ -241,7 +254,7 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
           storage: StorageType.Secret,
           formValues: values,
           namespace: selectedNamespace,
-          name,
+          name: templateName,
           githubActionConfig,
         },
         {
@@ -309,6 +322,21 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
       );
     }
 
+    setRandomNameIfEmpty();
+
+    if (currentPage === "workflow" && currentTab === "porter") {
+      const fullActionConfig = getFullActionConfig();
+      return (
+        <WorkflowPage
+          name={templateName}
+          fullActionConfig={fullActionConfig}
+          shouldCreateWorkflow={shouldCreateWorkflow}
+          setShouldCreateWorkflow={setShouldCreateWorkflow}
+          setPage={setCurrentPage}
+        />
+      );
+    }
+
     // Display main (non-source) settings page
     return (
       <SettingsPage
@@ -341,16 +369,16 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
   };
 
   let { currentTab } = props;
-  let { name } = props.currentTemplate;
-  if (hardcodedNames[name]) {
-    name = hardcodedNames[name];
+  let currentTemplateName = props.currentTemplate.name;
+  if (hardcodedNames[currentTemplateName]) {
+    currentTemplateName = hardcodedNames[currentTemplateName];
   }
 
   return (
     <StyledLaunchFlow>
       <TitleSection handleNavBack={props.hideLaunchFlow}>
         {renderIcon()}
-        New {name} {currentTab === "porter" ? null : "Instance"}
+        New {currentTemplateName} {currentTab === "porter" ? null : "Instance"}
       </TitleSection>
       {renderCurrentPage()}
       <Br />

+ 2 - 2
dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx

@@ -189,11 +189,11 @@ class SettingsPage extends Component<PropsType, StateType> {
         <BackButton
           width="155px"
           onClick={() => {
-            this.props.setPage("source");
+            this.props.setPage("workflow");
           }}
         >
           <i className="material-icons">first_page</i>
-          Source Settings
+          GitHub Actions
         </BackButton>
       );
     }

+ 1 - 1
dashboard/src/main/home/launch/launch-flow/SourcePage.tsx

@@ -270,7 +270,7 @@ class SourcePage extends Component<PropsType, StateType> {
         <SaveButton
           text="Continue"
           disabled={!this.checkSourceSelected()}
-          onClick={() => setPage("settings")}
+          onClick={() => setPage("workflow")}
           status={this.getButtonStatus()}
           makeFlush={true}
           helper={this.getButtonHelper()}

+ 170 - 0
dashboard/src/main/home/launch/launch-flow/WorkflowPage.tsx

@@ -0,0 +1,170 @@
+import React, { useContext, useEffect, useState } from "react";
+import { RouteComponentProps, withRouter } from "react-router";
+import { FullActionConfigType } from "../../../../shared/types";
+import api from "../../../../shared/api";
+import { Context } from "../../../../shared/Context";
+import styled from "styled-components";
+import YamlEditor from "../../../../components/YamlEditor";
+import Loading from "../../../../components/Loading";
+import Helper from "../../../../components/form-components/Helper";
+import CheckboxRow from "../../../../components/form-components/CheckboxRow";
+import SaveButton from "../../../../components/SaveButton";
+
+type PropsType = RouteComponentProps & {
+  name: string;
+  fullActionConfig: FullActionConfigType;
+  shouldCreateWorkflow: boolean;
+  setShouldCreateWorkflow: React.Dispatch<React.SetStateAction<boolean>>;
+  setPage: React.Dispatch<React.SetStateAction<string>>;
+};
+
+const WorkflowPage: React.FC<PropsType> = (props) => {
+  const context = useContext(Context);
+
+  const [isLoading, setIsLoading] = useState(true);
+  const [hasError, setHasError] = useState(false);
+  const [workflowYAML, setWorkflowYAML] = useState("");
+
+  useEffect(() => {
+    const { currentCluster, currentProject } = context;
+
+    api
+      .generateGHAWorkflow("<token>", props.fullActionConfig, {
+        name: props.name,
+        cluster_id: currentCluster.id,
+        project_id: currentProject.id,
+      })
+      .then((res) => {
+        setWorkflowYAML(res.data);
+        setIsLoading(false);
+      })
+      .catch((err) => setHasError(true))
+      .finally(() => setIsLoading(false));
+  }, []);
+
+  const renderWorkflow = () => {
+    if (isLoading) {
+      return (
+        <LoadingWrapper>
+          <Loading />
+        </LoadingWrapper>
+      );
+    } else if (hasError) {
+      return (
+        <Placeholder>
+          <i className="material-icons">error</i> Error retrieving workflow.
+        </Placeholder>
+      );
+    }
+    return <YamlEditor value={workflowYAML} readOnly={true} />;
+  };
+
+  const getButtonHelper = () => {
+    if (props.shouldCreateWorkflow) {
+      return "Both secrets and workflow will be created";
+    } else {
+      return "Only secrets will be created";
+    }
+  };
+
+  return (
+    <StyledWorkflowPage>
+      <BackButton width="155px" onClick={() => props.setPage("source")}>
+        <i className="material-icons">first_page</i>
+        Source Settings
+      </BackButton>
+      <Heading>GitHub Actions</Heading>
+      <Helper>
+        To auto-deploy each time you push changes, Porter will write GitHub
+        Secrets and this GitHub Actions workflow to your repository.
+      </Helper>
+      {renderWorkflow()}
+      <CheckboxRow
+        toggle={() => props.setShouldCreateWorkflow((x: boolean) => !x)}
+        checked={props.shouldCreateWorkflow}
+        label="Create workflow file"
+      />
+      <Helper>
+        You may copy the YAML to an existing workflow and uncheck this box to
+        prevent Porter from creating a new workflow file.
+      </Helper>
+      <Buffer />
+      <SaveButton
+        text="Continue"
+        makeFlush={true}
+        disabled={hasError}
+        onClick={() => props.setPage("settings")}
+        helper={getButtonHelper()}
+      />
+    </StyledWorkflowPage>
+  );
+};
+
+export default withRouter(WorkflowPage);
+
+const StyledWorkflowPage = styled.div`
+  position: relative;
+  margin-top: -5px;
+`;
+
+const Heading = styled.div<{ isAtTop?: boolean }>`
+  color: white;
+  font-weight: 500;
+  font-size: 16px;
+  margin-bottom: 5px;
+  margin-top: ${(props) => (props.isAtTop ? "10px" : "30px")};
+  display: flex;
+  align-items: center;
+`;
+
+const LoadingWrapper = styled.div`
+  padding: 200px;
+`;
+
+const Placeholder = styled.div`
+  padding: 200px;
+  width: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  color: #ffffff44;
+  font-size: 14px;
+
+  > i {
+    font-size: 18px;
+    margin-right: 12px;
+  }
+`;
+
+const Buffer = styled.div`
+  width: 100%;
+  height: 35px;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  cursor: pointer;
+  font-size: 13px;
+  margin-top: 25px;
+  height: 35px;
+  padding: 5px 13px;
+  padding-right: 15px;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+    margin-left: -2px;
+  }
+`;

+ 16 - 2
dashboard/src/shared/api.tsx

@@ -1,6 +1,6 @@
 import { baseApi } from "./baseApi";
 
-import { StorageType } from "./types";
+import { FullActionConfigType, StorageType } from "./types";
 
 /**
  * Generic api call format
@@ -246,6 +246,19 @@ const deleteSlackIntegration = baseApi<
   return `/api/projects/${pathParams.project_id}/slack_integrations/${pathParams.slack_integration_id}`;
 });
 
+const generateGHAWorkflow = baseApi<
+  FullActionConfigType,
+  {
+    cluster_id: number;
+    project_id: number;
+    name: string;
+  }
+>("POST", (pathParams) => {
+  const { name, cluster_id, project_id } = pathParams;
+
+  return `/api/projects/${project_id}/ci/actions/generate?cluster_id=${cluster_id}&name=${name}`;
+});
+
 const deployTemplate = baseApi<
   {
     templateName: string;
@@ -254,7 +267,7 @@ const deployTemplate = baseApi<
     storage: StorageType;
     namespace: string;
     name: string;
-    githubActionConfig?: any;
+    githubActionConfig?: FullActionConfigType;
   },
   {
     id: number;
@@ -1033,6 +1046,7 @@ export default {
   getClusterNodes,
   getClusterNode,
   getConfigMap,
+  generateGHAWorkflow,
   getGitRepoList,
   getGitRepos,
   getImageRepos,

+ 7 - 0
dashboard/src/shared/types.tsx

@@ -259,6 +259,13 @@ export interface ActionConfigType {
   git_repo_id: number;
 }
 
+export interface FullActionConfigType extends ActionConfigType {
+  dockerfile_path: string;
+  folder_path: string;
+  registry_id: number;
+  should_create_workflow: boolean;
+}
+
 export interface CapabilityType {
   github: boolean;
   provisioner: boolean;

+ 14 - 9
internal/forms/git_action.go

@@ -7,7 +7,8 @@ import (
 // CreateGitAction represents the accepted values for creating a
 // github action integration
 type CreateGitAction struct {
-	ReleaseID      uint   `json:"release_id" form:"required"`
+	Release *models.Release
+
 	GitRepo        string `json:"git_repo" form:"required"`
 	GitBranch      string `json:"git_branch"`
 	ImageRepoURI   string `json:"image_repo_uri" form:"required"`
@@ -15,12 +16,15 @@ type CreateGitAction struct {
 	FolderPath     string `json:"folder_path"`
 	GitRepoID      uint   `json:"git_repo_id" form:"required"`
 	RegistryID     uint   `json:"registry_id"`
+
+	ShouldCreateWorkflow bool `json:"should_create_workflow"`
+	ShouldGenerateOnly   bool
 }
 
 // ToGitActionConfig converts the form to a gorm git action config model
 func (ca *CreateGitAction) ToGitActionConfig(version string) (*models.GitActionConfig, error) {
 	return &models.GitActionConfig{
-		ReleaseID:            ca.ReleaseID,
+		ReleaseID:            ca.Release.Model.ID,
 		GitRepo:              ca.GitRepo,
 		GitBranch:            ca.GitBranch,
 		ImageRepoURI:         ca.ImageRepoURI,
@@ -33,11 +37,12 @@ func (ca *CreateGitAction) ToGitActionConfig(version string) (*models.GitActionC
 }
 
 type CreateGitActionOptional struct {
-	GitRepo        string `json:"git_repo"`
-	GitBranch      string `json:"git_branch"`
-	ImageRepoURI   string `json:"image_repo_uri"`
-	DockerfilePath string `json:"dockerfile_path"`
-	FolderPath     string `json:"folder_path"`
-	GitRepoID      uint   `json:"git_repo_id"`
-	RegistryID     uint   `json:"registry_id"`
+	GitRepo              string `json:"git_repo"`
+	GitBranch            string `json:"branch"`
+	ImageRepoURI         string `json:"image_repo_uri"`
+	DockerfilePath       string `json:"dockerfile_path"`
+	FolderPath           string `json:"folder_path"`
+	GitRepoID            uint   `json:"git_repo_id"`
+	RegistryID           uint   `json:"registry_id"`
+	ShouldCreateWorkflow bool   `json:"should_create_workflow"`
 }

+ 22 - 10
internal/integrations/ci/actions/actions.go

@@ -45,13 +45,16 @@ type GithubActions struct {
 
 	defaultBranch string
 	Version       string
+
+	ShouldGenerateOnly   bool
+	ShouldCreateWorkflow bool
 }
 
-func (g *GithubActions) Setup() (string, error) {
+func (g *GithubActions) Setup() ([]byte, error) {
 	client, err := g.getClient()
 
 	if err != nil {
-		return "", err
+		return nil, err
 	}
 
 	// get the repository to find the default branch
@@ -62,23 +65,32 @@ func (g *GithubActions) Setup() (string, error) {
 	)
 
 	if err != nil {
-		return "", err
+		return nil, err
 	}
 
 	g.defaultBranch = repo.GetDefaultBranch()
 
-	// create porter token secret
-	if err := g.createGithubSecret(client, g.getPorterTokenSecretName(), g.PorterToken); err != nil {
-		return "", err
+	if !g.ShouldGenerateOnly {
+		// create porter token secret
+		if err := g.createGithubSecret(client, g.getPorterTokenSecretName(), g.PorterToken); err != nil {
+			return nil, err
+		}
 	}
 
-	fileBytes, err := g.GetGithubActionYAML()
+	workflowYAML, err := g.GetGithubActionYAML()
 
 	if err != nil {
-		return "", err
+		return nil, err
+	}
+
+	if !g.ShouldGenerateOnly && g.ShouldCreateWorkflow {
+		_, err = g.commitGithubFile(client, g.getPorterYMLFileName(), workflowYAML)
+		if err != nil {
+			return workflowYAML, err
+		}
 	}
 
-	return g.commitGithubFile(client, g.getPorterYMLFileName(), fileBytes)
+	return workflowYAML, err
 }
 
 func (g *GithubActions) Cleanup() error {
@@ -161,7 +173,7 @@ func (g *GithubActions) GetGithubActionYAML() ([]byte, error) {
 		branch = g.defaultBranch
 	}
 
-	actionYAML := &GithubActionYAML{
+	actionYAML := GithubActionYAML{
 		On: GithubActionYAMLOnPush{
 			Push: GithubActionYAMLOnPushBranches{
 				Branches: []string{

+ 13 - 2
server/api/deploy_handler.go

@@ -50,6 +50,13 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	clusterID, err := strconv.ParseUint(vals["cluster_id"][0], 10, 64)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
 	getChartForm.PopulateRepoURLFromQueryParams(vals)
 
 	chart, err := loader.LoadChartPublic(getChartForm.RepoURL, getChartForm.Name, getChartForm.Version)
@@ -161,13 +168,17 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 	// if github action config is linked, call the github action config handler
 	if form.GithubActionConfig != nil {
 		gaForm := &forms.CreateGitAction{
-			ReleaseID:      release.ID,
+			Release: release,
+
 			GitRepo:        form.GithubActionConfig.GitRepo,
 			GitBranch:      form.GithubActionConfig.GitBranch,
 			ImageRepoURI:   form.GithubActionConfig.ImageRepoURI,
 			DockerfilePath: form.GithubActionConfig.DockerfilePath,
 			GitRepoID:      form.GithubActionConfig.GitRepoID,
 			RegistryID:     form.GithubActionConfig.RegistryID,
+
+			ShouldGenerateOnly:   false,
+			ShouldCreateWorkflow: form.GithubActionConfig.ShouldCreateWorkflow,
 		}
 
 		// validate the form
@@ -176,7 +187,7 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 
-		app.createGitActionFromForm(projID, release, form.ChartTemplateForm.Name, gaForm, w, r)
+		app.createGitActionFromForm(projID, clusterID, form.ChartTemplateForm.Name, gaForm, w, r)
 	}
 
 	w.WriteHeader(http.StatusOK)

+ 85 - 34
server/api/git_action_handler.go

@@ -20,6 +20,48 @@ const (
 	updateAppActionVersion = "v0.1.0"
 )
 
+// HandleGenerateGitAction returns the Github action that will be created in a repository
+// for a given release
+func (app *App) HandleGenerateGitAction(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 10, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+	name := vals["name"][0]
+
+	clusterID, err := strconv.ParseUint(vals["cluster_id"][0], 10, 64)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"release not found"},
+		}, w)
+	}
+
+	form := &forms.CreateGitAction{
+		ShouldGenerateOnly: true,
+	}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	_, workflowYAML := app.createGitActionFromForm(projID, clusterID, name, form, w, r)
+
+	w.WriteHeader(http.StatusOK)
+
+	if _, err := w.Write(workflowYAML); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
 // HandleCreateGitAction creates a new Github action in a repository for a given
 // release
 func (app *App) HandleCreateGitAction(w http.ResponseWriter, r *http.Request) {
@@ -53,7 +95,8 @@ func (app *App) HandleCreateGitAction(w http.ResponseWriter, r *http.Request) {
 	}
 
 	form := &forms.CreateGitAction{
-		ReleaseID: release.Model.ID,
+		Release:            release,
+		ShouldGenerateOnly: false,
 	}
 
 	// decode from JSON to form value
@@ -62,7 +105,7 @@ func (app *App) HandleCreateGitAction(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	gaExt := app.createGitActionFromForm(projID, release, name, form, w, r)
+	gaExt, _ := app.createGitActionFromForm(projID, clusterID, name, form, w, r)
 
 	w.WriteHeader(http.StatusCreated)
 
@@ -73,17 +116,17 @@ func (app *App) HandleCreateGitAction(w http.ResponseWriter, r *http.Request) {
 }
 
 func (app *App) createGitActionFromForm(
-	projID uint64,
-	release *models.Release,
+	projID,
+	clusterID uint64,
 	name string,
 	form *forms.CreateGitAction,
 	w http.ResponseWriter,
 	r *http.Request,
-) *models.GitActionConfigExternal {
+) (gaExt *models.GitActionConfigExternal, workflowYAML []byte) {
 	// validate the form
 	if err := app.validator.Struct(form); err != nil {
 		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
-		return nil
+		return
 	}
 
 	// if the registry was provisioned through Porter, create a repository if necessary
@@ -93,7 +136,7 @@ func (app *App) createGitActionFromForm(
 
 		if err != nil {
 			app.handleErrorDataRead(err, w)
-			return nil
+			return
 		}
 
 		_reg := registry.Registry(*reg)
@@ -107,30 +150,22 @@ func (app *App) createGitActionFromForm(
 
 		if err != nil {
 			app.handleErrorInternal(err, w)
-			return nil
+			return
 		}
 	}
 
-	// convert the form to a git action config
-	gitAction, err := form.ToGitActionConfig(updateAppActionVersion)
-
-	if err != nil {
-		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
-		return nil
-	}
-
-	repoSplit := strings.Split(gitAction.GitRepo, "/")
+	repoSplit := strings.Split(form.GitRepo, "/")
 
 	if len(repoSplit) != 2 {
 		app.handleErrorFormDecoding(fmt.Errorf("invalid formatting of repo name"), ErrProjectDecode, w)
-		return nil
+		return
 	}
 
 	session, err := app.Store.Get(r, app.ServerConf.CookieName)
 
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return nil
+		return
 	}
 
 	userID, _ := session.Values["user_id"].(uint)
@@ -142,7 +177,7 @@ func (app *App) createGitActionFromForm(
 			userID = tok.IBy
 		} else if tok == nil || tok.IBy == 0 {
 			http.Error(w, "no user id found in request", http.StatusInternalServerError)
-			return nil
+			return
 		}
 	}
 
@@ -155,7 +190,7 @@ func (app *App) createGitActionFromForm(
 
 	if err != nil {
 		app.handleErrorInternal(err, w)
-		return nil
+		return
 	}
 
 	// create the commit in the git repo
@@ -170,21 +205,35 @@ func (app *App) createGitActionFromForm(
 		Repo:                   *app.Repo,
 		GithubConf:             app.GithubProjectConf,
 		ProjectID:              uint(projID),
+		ClusterID:              uint(clusterID),
 		ReleaseName:            name,
-		GitBranch:              gitAction.GitBranch,
-		DockerFilePath:         gitAction.DockerfilePath,
-		FolderPath:             gitAction.FolderPath,
-		ImageRepoURL:           gitAction.ImageRepoURI,
+		GitBranch:              form.GitBranch,
+		DockerFilePath:         form.DockerfilePath,
+		FolderPath:             form.FolderPath,
+		ImageRepoURL:           form.ImageRepoURI,
 		PorterToken:            encoded,
-		ClusterID:              release.ClusterID,
-		Version:                gitAction.Version,
+		Version:                updateAppActionVersion,
+		ShouldGenerateOnly:     form.ShouldGenerateOnly,
+		ShouldCreateWorkflow:   form.ShouldCreateWorkflow,
 	}
 
-	_, err = gaRunner.Setup()
+	workflowYAML, err = gaRunner.Setup()
 
 	if err != nil {
 		app.handleErrorInternal(err, w)
-		return nil
+		return
+	}
+
+	if form.Release == nil {
+		return
+	}
+
+	// convert the form to a git action config
+	gitAction, err := form.ToGitActionConfig(gaRunner.Version)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
 	}
 
 	// handle write to the database
@@ -192,20 +241,22 @@ func (app *App) createGitActionFromForm(
 
 	if err != nil {
 		app.handleErrorDataWrite(err, w)
-		return nil
+		return
 	}
 
 	app.Logger.Info().Msgf("New git action created: %d", ga.ID)
 
 	// update the release in the db with the image repo uri
-	release.ImageRepoURI = gitAction.ImageRepoURI
+	form.Release.ImageRepoURI = gitAction.ImageRepoURI
 
-	_, err = app.Repo.Release.UpdateRelease(release)
+	_, err = app.Repo.Release.UpdateRelease(form.Release)
 
 	if err != nil {
 		app.handleErrorDataWrite(err, w)
-		return nil
+		return
 	}
 
-	return ga.Externalize()
+	gaExt = ga.Externalize()
+
+	return
 }

+ 15 - 1
server/router/router.go

@@ -394,7 +394,21 @@ func New(a *api.App) *chi.Mux {
 			// /api/projects/{project_id}/ci routes
 			r.Method(
 				"POST",
-				"/projects/{project_id}/ci/actions",
+				"/projects/{project_id}/ci/actions/generate",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleGenerateGitAction, l),
+						mw.URLParam,
+						mw.QueryParam,
+					),
+					mw.URLParam,
+					mw.WriteAccess,
+				),
+			)
+
+			r.Method(
+				"POST",
+				"/projects/{project_id}/ci/actions/create",
 				auth.DoesUserHaveProjectAccess(
 					auth.DoesUserHaveClusterAccess(
 						requestlog.NewHandler(a.HandleCreateGitAction, l),