Kaynağa Gözat

Merge pull request #1013 from porter-dev/0.8.0-github-actions-updates

[0.8.0] GitHub Actions updates
abelanger5 4 yıl önce
ebeveyn
işleme
778dcb9280

+ 1 - 1
cli/cmd/api/domain.go

@@ -17,7 +17,7 @@ type CreateDNSRecordRequest struct {
 // CreateDNSRecordResponse is the DNS record that was created
 type CreateDNSRecordResponse models.DNSRecordExternal
 
-// CreateGithubAction creates a Github action with basic authentication
+// CreateDNSRecord creates a Github action with basic authentication
 func (c *Client) CreateDNSRecord(
 	ctx context.Context,
 	projectID, clusterID uint,

+ 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>

+ 169 - 242
dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx

@@ -1,4 +1,4 @@
-import React, { Component } from "react";
+import React, { useContext, useState } from "react";
 import styled from "styled-components";
 import _ from "lodash";
 import randomWords from "random-words";
@@ -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;
@@ -22,28 +28,6 @@ type PropsType = RouteComponentProps & {
   form: any;
 };
 
-type StateType = {
-  currentPage: string;
-  templateName: string;
-  sourceType: string;
-  valuesToOverride: any;
-
-  imageUrl: string;
-  imageTag: string;
-
-  actionConfig: ActionConfigType;
-  procfileProcess: string;
-  branch: string;
-  repoType: string;
-  dockerfilePath: string | null;
-  procfilePath: string | null;
-  folderPath: string | null;
-  selectedRegistry: any;
-
-  selectedNamespace: string;
-  saveValuesStatus: string;
-};
-
 const defaultActionConfig: ActionConfigType = {
   git_repo: "",
   image_repo_uri: "",
@@ -51,83 +35,61 @@ const defaultActionConfig: ActionConfigType = {
   git_repo_id: 0,
 };
 
-class LaunchFlow extends Component<PropsType, StateType> {
-  state = {
-    currentPage: "source",
-    templateName: "",
-    saveValuesStatus: "",
-    sourceType: "",
-    selectedNamespace: "default",
-    valuesToOverride: {} as any,
-
-    imageUrl: "",
-    imageTag: "",
-
-    actionConfig: { ...defaultActionConfig },
-    procfileProcess: "",
-    branch: "",
-    repoType: "",
-    dockerfilePath: null as string | null,
-    procfilePath: null as string | null,
-    folderPath: null as string | null,
-    selectedRegistry: null as any,
+const LaunchFlow: React.FC<PropsType> = (props) => {
+  const context = useContext(Context);
+
+  const [currentPage, setCurrentPage] = useState("source");
+  const [templateName, setTemplateName] = useState("");
+  const [saveValuesStatus, setSaveValuesStatus] = useState("");
+  const [sourceType, setSourceType] = useState("");
+  const [selectedNamespace, setSelectedNamespace] = useState("default");
+  const [valuesToOverride, setValuesToOverride] = useState({});
+
+  const [imageUrl, setImageUrl] = useState("");
+  const [imageTag, setImageTag] = useState("");
+
+  const [actionConfig, setActionConfig] = useState<ActionConfigType>({
+    ...defaultActionConfig,
+  });
+  const [procfileProcess, setProcfileProcess] = useState("");
+  const [branch, setBranch] = useState("");
+  const [repoType, setRepoType] = useState("");
+  const [dockerfilePath, setDockerfilePath] = useState(null);
+  const [procfilePath, setProcfilePath] = useState(null);
+  const [folderPath, setFolderPath] = useState(null);
+  const [selectedRegistry, setSelectedRegistry] = useState(null);
+  const [shouldCreateWorkflow, setShouldCreateWorkflow] = useState(true);
+
+  const setRandomNameIfEmpty = () => {
+    if (!templateName) {
+      const randomTemplateName = randomWords({ exactly: 3, join: "-" });
+      setTemplateName(randomTemplateName);
+    }
   };
 
-  createGHAction = (chartName: string, chartNamespace: string) => {
-    let { currentProject, currentCluster, setCurrentError } = this.context;
-    let {
-      actionConfig,
-      branch,
-      selectedRegistry,
-      dockerfilePath,
-      folderPath,
-    } = this.state;
-    let imageRepoUri = `${selectedRegistry.url}/${chartName}-${chartNamespace}`;
+  const getFullActionConfig = (): FullActionConfigType => {
+    let imageRepoUri = `${selectedRegistry.url}/${templateName}-${selectedNamespace}`;
 
     // DockerHub registry integration is per repo
     if (selectedRegistry.service === "dockerhub") {
       imageRepoUri = selectedRegistry.url;
     }
 
-    api
-      .createGHAction(
-        "<token>",
-        {
-          git_repo: actionConfig.git_repo,
-          git_branch: branch,
-          registry_id: selectedRegistry.id,
-          dockerfile_path: dockerfilePath,
-          folder_path: folderPath,
-          image_repo_uri: imageRepoUri,
-          git_repo_id: actionConfig.git_repo_id,
-        },
-        {
-          project_id: currentProject.id,
-          CLUSTER_ID: currentCluster.id,
-          RELEASE_NAME: chartName,
-          RELEASE_NAMESPACE: chartNamespace,
-        }
-      )
-      .then((res) => console.log(""))
-      .catch((err) => {
-        let parsedErr =
-          err?.response?.data?.errors && err.response.data.errors[0];
-        err = parsedErr || err.message || JSON.stringify(err);
-
-        this.setState({
-          saveValuesStatus: `Could not create GitHub Action: ${err}`,
-        });
-
-        setCurrentError(err);
-      });
+    return {
+      git_repo: actionConfig.git_repo,
+      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,
+    };
   };
 
-  onSubmitAddon = (wildcard?: any) => {
-    let { selectedNamespace } = this.state;
-    let { currentCluster, currentProject, setCurrentError } = this.context;
-    let name =
-      this.state.templateName || randomWords({ exactly: 3, join: "-" });
-    this.setState({ saveValuesStatus: "loading" });
+  const handleSubmitAddon = (wildcard?: any) => {
+    let { currentCluster, currentProject, setCurrentError } = context;
+    setSaveValuesStatus("loading");
 
     let values = {};
     for (let key in wildcard) {
@@ -138,38 +100,35 @@ class LaunchFlow extends Component<PropsType, StateType> {
       .deployAddon(
         "<token>",
         {
-          templateName: this.props.currentTemplate.name,
+          templateName: props.currentTemplate.name,
           storage: StorageType.Secret,
           formValues: values,
           namespace: selectedNamespace,
-          name,
+          name: templateName,
         },
         {
           id: currentProject.id,
           cluster_id: currentCluster.id,
-          name: this.props.currentTemplate.name.toLowerCase().trim(),
-          version: this.props.currentTemplate?.currentVersion || "latest",
+          name: props.currentTemplate.name.toLowerCase().trim(),
+          version: props.currentTemplate?.currentVersion || "latest",
           repo_url: process.env.ADDON_CHART_REPO_URL,
         }
       )
       .then((_) => {
-        // this.props.setCurrentView('cluster-dashboard');
-        this.setState({ saveValuesStatus: "successful" }, () => {
-          // redirect to dashboard
-          let dst =
-            this.props.currentTemplate.name === "job"
-              ? "/jobs"
-              : "/applications";
-          setTimeout(() => {
-            pushFiltered(this.props, dst, ["project_id"], {
-              cluster: currentCluster.name,
-            });
-          }, 500);
-          window.analytics.track("Deployed Add-on", {
-            name: this.props.currentTemplate.name,
-            namespace: selectedNamespace,
-            values: values,
+        // props.setCurrentView('cluster-dashboard');
+        setSaveValuesStatus("successful");
+        // redirect to dashboard
+        let dst =
+          props.currentTemplate.name === "job" ? "/jobs" : "/applications";
+        setTimeout(() => {
+          pushFiltered(props, dst, ["project_id"], {
+            cluster: currentCluster.name,
           });
+        }, 500);
+        window.analytics.track("Deployed Add-on", {
+          name: props.currentTemplate.name,
+          namespace: selectedNamespace,
+          values: values,
         });
       })
       .catch((err) => {
@@ -178,13 +137,11 @@ class LaunchFlow extends Component<PropsType, StateType> {
 
         err = parsedErr || err.message || JSON.stringify(err);
 
-        this.setState({
-          saveValuesStatus: err,
-        });
+        setSaveValuesStatus(err);
 
         setCurrentError(err);
         window.analytics.track("Failed to Deploy Add-on", {
-          name: this.props.currentTemplate.name,
+          name: props.currentTemplate.name,
           namespace: selectedNamespace,
           values: values,
           error: err,
@@ -192,17 +149,9 @@ class LaunchFlow extends Component<PropsType, StateType> {
       });
   };
 
-  onSubmit = async (rawValues: any) => {
-    let { currentCluster, currentProject, setCurrentError } = this.context;
-    let {
-      selectedNamespace,
-      templateName,
-      imageUrl,
-      imageTag,
-      sourceType,
-    } = this.state;
-    let name = templateName || randomWords({ exactly: 3, join: "-" });
-    this.setState({ saveValuesStatus: "loading" });
+  const handleSubmit = async (rawValues: any) => {
+    let { currentCluster, currentProject, setCurrentError } = context;
+    setSaveValuesStatus("loading");
 
     // Convert dotted keys to nested objects
     let values: any = {};
@@ -210,21 +159,22 @@ class LaunchFlow extends Component<PropsType, StateType> {
       _.set(values, key, rawValues[key]);
     }
 
-    let tag = imageTag;
-    if (imageUrl.includes(":")) {
-      let splits = imageUrl.split(":");
-      imageUrl = splits[0];
+    let url = imageUrl,
+      tag = imageTag;
+    if (url.includes(":")) {
+      let splits = url.split(":");
+      url = splits[0];
       tag = splits[1];
     } else if (!tag) {
       tag = "latest";
     }
 
     if (sourceType === "repo") {
-      if (this.props.currentTemplate?.name == "job") {
-        imageUrl = "public.ecr.aws/o1j4x7p4/hello-porter-job";
+      if (props.currentTemplate?.name == "job") {
+        url = "public.ecr.aws/o1j4x7p4/hello-porter-job";
         tag = "latest";
       } else {
-        imageUrl = "public.ecr.aws/o1j4x7p4/hello-porter";
+        url = "public.ecr.aws/o1j4x7p4/hello-porter";
         tag = "latest";
       }
     }
@@ -245,28 +195,28 @@ class LaunchFlow extends Component<PropsType, StateType> {
     }
 
     // don't overwrite for templates that already have a source (i.e. non-Docker templates)
-    if (imageUrl && tag) {
-      _.set(values, "image.repository", imageUrl);
+    if (url && tag) {
+      _.set(values, "image.repository", url);
       _.set(values, "image.tag", tag);
     }
 
     _.set(values, "ingress.provider", provider);
 
     // pause jobs automatically
-    if (this.props.currentTemplate?.name == "job") {
+    if (props.currentTemplate?.name == "job") {
       _.set(values, "paused", true);
     }
 
-    var url: string;
+    var external_domain: string;
     // check if template is docker and create external domain if necessary
-    if (this.props.currentTemplate.name == "web") {
+    if (props.currentTemplate.name == "web") {
       if (values?.ingress?.enabled && !values?.ingress?.custom_domain) {
-        url = await new Promise((resolve, reject) => {
+        external_domain = await new Promise((resolve, reject) => {
           api
             .createSubdomain(
               "<token>",
               {
-                release_name: name,
+                release_name: templateName,
               },
               {
                 id: currentProject.id,
@@ -280,128 +230,109 @@ class LaunchFlow extends Component<PropsType, StateType> {
               let parsedErr =
                 err?.response?.data?.errors && err.response.data.errors[0];
               err = parsedErr || err.message || JSON.stringify(err);
-              this.setState({
-                saveValuesStatus: `Could not create subdomain: ${err}`,
-              });
+              setSaveValuesStatus(`Could not create subdomain: ${err}`);
 
               setCurrentError(err);
             });
         });
 
-        values.ingress.porter_hosts = [url];
+        values.ingress.porter_hosts = [external_domain];
       }
     }
 
+    let githubActionConfig: FullActionConfigType = null;
+    if (sourceType === "repo") {
+      githubActionConfig = getFullActionConfig();
+    }
+
     api
       .deployTemplate(
         "<token>",
         {
-          templateName: this.props.currentTemplate.name,
-          imageURL: imageUrl,
+          templateName: props.currentTemplate.name,
+          imageURL: url,
           storage: StorageType.Secret,
           formValues: values,
           namespace: selectedNamespace,
-          name,
+          name: templateName,
+          githubActionConfig,
         },
         {
           id: currentProject.id,
           cluster_id: currentCluster.id,
-          name: this.props.currentTemplate.name.toLowerCase().trim(),
-          version: this.props.currentTemplate?.currentVersion || "latest",
+          name: props.currentTemplate.name.toLowerCase().trim(),
+          version: props.currentTemplate?.currentVersion || "latest",
           repo_url: process.env.APPLICATION_CHART_REPO_URL,
         }
       )
       .then((res: any) => {
-        if (sourceType === "repo") {
-          this.createGHAction(name, selectedNamespace);
-        }
-        // this.props.setCurrentView('cluster-dashboard');
-        this.setState({ saveValuesStatus: "successful" }, () => {
-          // redirect to dashboard with namespace
-          setTimeout(() => {
-            let dst =
-              this.props.currentTemplate.name === "job"
-                ? "/jobs"
-                : "/applications";
-            pushFiltered(this.props, dst, ["project_id"], {
-              cluster: currentCluster.name,
-            });
-          }, 1000);
-        });
+        // props.setCurrentView('cluster-dashboard');
+        setSaveValuesStatus("successful");
+        // redirect to dashboard with namespace
+        setTimeout(() => {
+          let dst =
+            props.currentTemplate.name === "job" ? "/jobs" : "/applications";
+          pushFiltered(props, dst, ["project_id"], {
+            cluster: currentCluster.name,
+          });
+        }, 1000);
       })
       .catch((err: any) => {
         let parsedErr =
           err?.response?.data?.errors && err.response.data.errors[0];
         err = parsedErr || err.message || JSON.stringify(err);
-        this.setState({
-          saveValuesStatus: `Could not deploy template: ${err}`,
-        });
+        setSaveValuesStatus(`Could not deploy template: ${err}`);
         setCurrentError(err);
       });
   };
 
-  renderCurrentPage = () => {
-    let { form, currentTab } = this.props;
-    let {
-      currentPage,
-      valuesToOverride,
-      templateName,
-      imageUrl,
-      imageTag,
-      actionConfig,
-      branch,
-      repoType,
-      dockerfilePath,
-      procfileProcess,
-      procfilePath,
-      folderPath,
-      selectedNamespace,
-      selectedRegistry,
-      saveValuesStatus,
-      sourceType,
-    } = this.state;
+  const renderCurrentPage = () => {
+    let { form, currentTab } = props;
 
     if (currentPage === "source" && currentTab === "porter") {
       return (
         <SourcePage
           sourceType={sourceType}
-          setSourceType={(x: string) => this.setState({ sourceType: x })}
+          setSourceType={setSourceType}
           templateName={templateName}
-          setPage={(x: string) => {
-            this.setState({ currentPage: x });
-          }}
-          setTemplateName={(x: string) => this.setState({ templateName: x })}
-          setValuesToOverride={(x: any) =>
-            this.setState({ valuesToOverride: x })
-          }
+          setPage={setCurrentPage}
+          setTemplateName={setTemplateName}
+          setValuesToOverride={setValuesToOverride}
           imageUrl={imageUrl}
-          setImageUrl={(x: string) => this.setState({ imageUrl: x })}
+          setImageUrl={setImageUrl}
           imageTag={imageTag}
-          setImageTag={(x: string) => this.setState({ imageTag: x })}
+          setImageTag={setImageTag}
           actionConfig={actionConfig}
-          setActionConfig={(x: ActionConfigType) =>
-            this.setState({ actionConfig: x })
-          }
+          setActionConfig={setActionConfig}
           branch={branch}
-          setBranch={(x: string) => this.setState({ branch: x })}
+          setBranch={setBranch}
           procfileProcess={procfileProcess}
-          setProcfileProcess={(x: string) =>
-            this.setState({ procfileProcess: x })
-          }
+          setProcfileProcess={setProcfileProcess}
           repoType={repoType}
-          setRepoType={(x: string) => this.setState({ repoType: x })}
+          setRepoType={setRepoType}
           dockerfilePath={dockerfilePath}
-          setDockerfilePath={(x: string) =>
-            this.setState({ dockerfilePath: x })
-          }
+          setDockerfilePath={setDockerfilePath}
           folderPath={folderPath}
-          setFolderPath={(x: string) => this.setState({ folderPath: x })}
+          setFolderPath={setFolderPath}
           procfilePath={procfilePath}
-          setProcfilePath={(x: string) => this.setState({ procfilePath: x })}
+          setProcfilePath={setProcfilePath}
           selectedRegistry={selectedRegistry}
-          setSelectedRegistry={(x: string) =>
-            this.setState({ selectedRegistry: x })
-          }
+          setSelectedRegistry={setSelectedRegistry}
+        />
+      );
+    }
+
+    setRandomNameIfEmpty();
+
+    if (currentPage === "workflow" && currentTab === "porter") {
+      const fullActionConfig = getFullActionConfig();
+      return (
+        <WorkflowPage
+          name={templateName}
+          fullActionConfig={fullActionConfig}
+          shouldCreateWorkflow={shouldCreateWorkflow}
+          setShouldCreateWorkflow={setShouldCreateWorkflow}
+          setPage={setCurrentPage}
         />
       );
     }
@@ -409,25 +340,24 @@ class LaunchFlow extends Component<PropsType, StateType> {
     // Display main (non-source) settings page
     return (
       <SettingsPage
-        onSubmit={currentTab === "porter" ? this.onSubmit : this.onSubmitAddon}
+        onSubmit={currentTab === "porter" ? handleSubmit : handleSubmitAddon}
         saveValuesStatus={saveValuesStatus}
         selectedNamespace={selectedNamespace}
-        setSelectedNamespace={(x: string) =>
-          this.setState({ selectedNamespace: x })
-        }
+        setSelectedNamespace={setSelectedNamespace}
         templateName={templateName}
-        setTemplateName={(x: string) => this.setState({ templateName: x })}
+        setTemplateName={setTemplateName}
         hasSource={currentTab === "porter"}
-        setPage={(x: string) => this.setState({ currentPage: x })}
+        sourceType={sourceType}
+        setPage={setCurrentPage}
         form={form}
         valuesToOverride={valuesToOverride}
-        clearValuesToOverride={() => this.setState({ valuesToOverride: null })}
+        clearValuesToOverride={() => setValuesToOverride(null)}
       />
     );
   };
 
-  renderIcon = () => {
-    let icon = this.props.currentTemplate?.icon;
+  const renderIcon = () => {
+    let icon = props.currentTemplate?.icon;
     if (icon) {
       return <Icon src={icon} />;
     }
@@ -439,27 +369,24 @@ class LaunchFlow extends Component<PropsType, StateType> {
     );
   };
 
-  render() {
-    let { currentTab } = this.props;
-    let { name } = this.props.currentTemplate;
-    if (hardcodedNames[name]) {
-      name = hardcodedNames[name];
-    }
-
-    return (
-      <StyledLaunchFlow>
-        <TitleSection handleNavBack={this.props.hideLaunchFlow}>
-          {this.renderIcon()}
-          New {name} {currentTab === "porter" ? null : "Instance"}
-        </TitleSection>
-        {this.renderCurrentPage()}
-        <Br />
-      </StyledLaunchFlow>
-    );
+  let { currentTab } = props;
+  let currentTemplateName = props.currentTemplate.name;
+  if (hardcodedNames[currentTemplateName]) {
+    currentTemplateName = hardcodedNames[currentTemplateName];
   }
-}
 
-LaunchFlow.contextType = Context;
+  return (
+    <StyledLaunchFlow>
+      <TitleSection handleNavBack={props.hideLaunchFlow}>
+        {renderIcon()}
+        New {currentTemplateName} {currentTab === "porter" ? null : "Instance"}
+      </TitleSection>
+      {renderCurrentPage()}
+      <Br />
+    </StyledLaunchFlow>
+  );
+};
+
 export default withRouter(LaunchFlow);
 
 const Br = styled.div`

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

@@ -19,6 +19,7 @@ import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 type PropsType = WithAuthProps & {
   onSubmit: (x?: any) => void;
   hasSource: boolean;
+  sourceType: string;
   setPage: (x: string) => void;
   form: any;
   valuesToOverride: any;
@@ -182,18 +183,24 @@ class SettingsPage extends Component<PropsType, StateType> {
   };
 
   renderHeaderSection = () => {
-    let { hasSource, templateName, setTemplateName } = this.props;
+    let {
+      hasSource,
+      sourceType,
+      templateName,
+      setPage,
+      setTemplateName,
+    } = this.props;
 
     if (hasSource) {
+      const [pageKey, pageName] =
+        sourceType === "repo"
+          ? ["workflow", "GitHub Actions"]
+          : ["source", "Source Settings"];
+
       return (
-        <BackButton
-          width="155px"
-          onClick={() => {
-            this.props.setPage("source");
-          }}
-        >
+        <BackButton width="155px" onClick={() => setPage(pageKey)}>
           <i className="material-icons">first_page</i>
-          Source Settings
+          {pageName}
         </BackButton>
       );
     }

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

@@ -28,7 +28,9 @@ type PropsType = RouteComponentProps & {
   setImageTag: (x: string) => void;
 
   actionConfig: ActionConfigType;
-  setActionConfig: (x: ActionConfigType) => void;
+  setActionConfig: (
+    x: ActionConfigType | ((prevState: ActionConfigType) => ActionConfigType)
+  ) => void;
   procfileProcess: string;
   setProcfileProcess: (x: string) => void;
   branch: string;
@@ -162,7 +164,10 @@ class SourcePage extends Component<PropsType, StateType> {
           actionConfig={actionConfig}
           branch={branch}
           setActionConfig={(actionConfig: ActionConfigType) => {
-            setActionConfig(actionConfig);
+            setActionConfig((currentActionConfig: ActionConfigType) => ({
+              ...currentActionConfig,
+              ...actionConfig,
+            }));
             setImageUrl(actionConfig.image_repo_uri);
             /*
             setParentState({ actionConfig }, () =>
@@ -173,10 +178,11 @@ class SourcePage extends Component<PropsType, StateType> {
           procfileProcess={procfileProcess}
           setProcfileProcess={(procfileProcess: string) => {
             setProcfileProcess(procfileProcess);
-            setValuesToOverride({
+            setValuesToOverride((v: any) => ({
+              ...v,
               "container.command": procfileProcess || "",
               showStartCommand: !procfileProcess,
-            });
+            }));
           }}
           setBranch={setBranch}
           setDockerfilePath={setDockerfilePath}
@@ -223,6 +229,16 @@ class SourcePage extends Component<PropsType, StateType> {
     }
   };
 
+  handleContinue = () => {
+    const { sourceType, setPage } = this.props;
+
+    if (sourceType === "repo") {
+      setPage("workflow");
+    } else {
+      setPage("settings");
+    }
+  };
+
   render() {
     let { templateName, setTemplateName, setPage } = this.props;
 
@@ -266,7 +282,7 @@ class SourcePage extends Component<PropsType, StateType> {
         <SaveButton
           text="Continue"
           disabled={!this.checkSourceSelected()}
-          onClick={() => setPage("settings")}
+          onClick={this.handleContinue}
           status={this.getButtonStatus()}
           makeFlush={true}
           helper={this.getButtonHelper()}

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

@@ -0,0 +1,184 @@
+import React, { useContext, useEffect, useState } from "react";
+import { RouteComponentProps } 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 = {
+  name: string;
+  fullActionConfig: FullActionConfigType;
+  shouldCreateWorkflow: boolean;
+  setShouldCreateWorkflow: (x: (prevState: boolean) => boolean) => void;
+  setPage: (x: string) => void;
+};
+
+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.
+        <GitHubActionLink show={!props.shouldCreateWorkflow}>
+          The GitHub Action can be found at{" "}
+          <a
+            href="https://github.com/porter-dev/porter-update-action"
+            target="_blank"
+          >
+            porter-dev/porter-update-action
+          </a>
+        </GitHubActionLink>
+      </Helper>
+      <Buffer />
+      <SaveButton
+        text="Continue"
+        makeFlush={true}
+        disabled={hasError}
+        onClick={() => props.setPage("settings")}
+        helper={getButtonHelper()}
+      />
+    </StyledWorkflowPage>
+  );
+};
+
+export default 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;
+  }
+`;
+
+const GitHubActionLink = styled.p`
+  visibility: ${(props: { show: boolean }) =>
+    props.show ? "visible" : "hidden"};
+`;

+ 16 - 23
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
@@ -113,27 +113,6 @@ const createGCR = baseApi<
   return `/api/projects/${pathParams.project_id}/provision/gcr`;
 });
 
-const createGHAction = baseApi<
-  {
-    git_repo: string;
-    git_branch: string;
-    registry_id: number;
-    image_repo_uri: string;
-    dockerfile_path: string;
-    folder_path: string;
-    git_repo_id: number;
-  },
-  {
-    project_id: number;
-    CLUSTER_ID: number;
-    RELEASE_NAME: string;
-    RELEASE_NAMESPACE: string;
-  }
->("POST", (pathParams) => {
-  let { project_id, CLUSTER_ID, RELEASE_NAME, RELEASE_NAMESPACE } = pathParams;
-  return `/api/projects/${project_id}/ci/actions?cluster_id=${CLUSTER_ID}&name=${RELEASE_NAME}&namespace=${RELEASE_NAMESPACE}`;
-});
-
 const createGKE = baseApi<
   {
     gcp_integration_id: number;
@@ -294,6 +273,19 @@ const getNotificationConfig = baseApi<
   return `/api/projects/${pathParams.project_id}/releases/${pathParams.name}/notifications`;
 });
 
+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;
@@ -302,6 +294,7 @@ const deployTemplate = baseApi<
     storage: StorageType;
     namespace: string;
     name: string;
+    githubActionConfig?: FullActionConfigType;
   },
   {
     id: number;
@@ -1053,7 +1046,6 @@ export default {
   createEmailVerification,
   createGCPIntegration,
   createGCR,
-  createGHAction,
   createGKE,
   createInvite,
   createNamespace,
@@ -1092,6 +1084,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;

+ 1 - 0
dashboard/webpack.config.js

@@ -78,6 +78,7 @@ module.exports = () => {
       publicPath: "/",
     },
     devServer: {
+      port: env["PORT"],
       historyApiFallback: true,
       disableHostCheck: true,
       host: "0.0.0.0",

+ 10 - 3
docs/deploy/applications/deploying-from-git-repo.md

@@ -16,7 +16,7 @@ Let's get started!
 > 
 > Porter will set up CI/CD with [Github Actions](https://github.com/features/actions) to automatically build and deploy new versions of your code. You can learn more about how Porter uses Github Actions [here](https://docs.getporter.dev/docs/auto-deploy-requirements#cicd-with-github-actions).
 
-![Github Actions](https://files.readme.io/0660e91-Screen_Shot_2021-03-17_at_7.20.44_PM.png "Screen Shot 2021-03-17 at 7.20.44 PM.png")
+![Select Repository](https://files.readme.io/0660e91-Screen_Shot_2021-03-17_at_7.20.44_PM.png "Screen Shot 2021-03-17 at 7.20.44 PM.png")
 
 3. After returning to the **Launch** tab you will be prompted to select a repository and source folder. Select the root folder of your service (this is usually where you run a start command like `npm start` or `python -m flask run`) and click **Continue**. If you have an existing Dockerfile, you can select it directly instead of using a folder. 
 
@@ -24,12 +24,19 @@ Let's get started!
 > 
 > If you specify a folder in your repo to use as source, Porter will autodetect the language runtime and build your application using Cloud Native Buildpacks. For more details refer to our guide on [requirements for auto build](https://docs.getporter.dev/docs/auto-deploy-requirements).
 
-4. Select "Continue" once your source has been connected. Under **Additional Settings**, you can configure remaining options like your service's port and computing resources. Once you're ready, click the **Deploy** button to launch. You will be redirected to the cluster dashboard where you should see your newly deployed service.
+4. Click **Continue** once your source has been connected. This will take you to the **GitHub Actions** page, where you can see a workflow that will be created in the selected repository for automatically deploying new changes as they are pushed.  
+You can skip the creation of this workflow using the **Create workflow file** toggle, in case you wish to manually add the [`porter-update-action`](https://github.com/porter-dev/porter-update-action) to a different workflow of your choice.  
+You can proceed further by clicking **Continue** after this step.
+
+
+![GitHub Actions page](https://user-images.githubusercontent.com/44864521/129893348-44d63d54-115b-436b-bc41-48c6d8c94dc2.png)
+
+5. Under **Additional Settings**, you can configure remaining options like your service's port and computing resources. Once you're ready, click the **Deploy** button to launch. You will be redirected to the cluster dashboard where you should see your newly deployed service.
 
 ![Deployed service](https://files.readme.io/4f731ca-Screen_Shot_2021-03-17_at_7.53.40_PM.png "Screen Shot 2021-03-17 at 7.53.40 PM.png")
 
 5. The first time your service is being built, your deployment will use a placeholder Docker image until the GitHub Action has completed. You can monitor the status of the generated GitHub Action by checking the **Actions** tab in your linked repository.
 
-![Actions tab](https://files.readme.io/ffe7b14-d1046ba-Screen_Shot_2021-02-26_at_11.33.55_AM.png "Screen_Shot_2021-02-26_at_11.33.55_AM.png")
+![Actions tab on GitHub repository](https://files.readme.io/ffe7b14-d1046ba-Screen_Shot_2021-02-26_at_11.33.55_AM.png "Screen_Shot_2021-02-26_at_11.33.55_AM.png")
 
 After the GitHub Action has finished running, you can refresh the Porter dashboard. The new version of your service should have been successfully deployed.

+ 14 - 10
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,12 +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"`
-	BuildEnv       map[string]string `json:"env"`
-	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"`
 }

+ 1 - 1
internal/forms/release.go

@@ -128,7 +128,7 @@ type InstallChartTemplateForm struct {
 	*ChartTemplateForm
 
 	// optional git action config
-	GithubActionConfig *CreateGitActionOptional `json:"github_action,omitempty"`
+	GithubActionConfig *CreateGitActionOptional `json:"githubActionConfig,omitempty"`
 }
 
 // UpdateImageForm represents the accepted values for updating a Helm release's image

+ 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)

+ 86 - 34
server/api/git_action_handler.go

@@ -20,6 +20,49 @@ 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)
+		return
+	}
+
+	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 +96,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 +106,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 +117,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 +137,7 @@ func (app *App) createGitActionFromForm(
 
 		if err != nil {
 			app.handleErrorDataRead(err, w)
-			return nil
+			return
 		}
 
 		_reg := registry.Registry(*reg)
@@ -107,30 +151,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 +178,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 +191,7 @@ func (app *App) createGitActionFromForm(
 
 	if err != nil {
 		app.handleErrorInternal(err, w)
-		return nil
+		return
 	}
 
 	// create the commit in the git repo
@@ -170,21 +206,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 +242,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),